惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
MyScale Blog
MyScale Blog
Jina AI
Jina AI
爱范儿
爱范儿
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
I
Intezer
The Cloudflare Blog
T
Threat Research - Cisco Blogs
G
Google Developers Blog
Stack Overflow Blog
Stack Overflow Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
D
Docker
AI
AI
Scott Helme
Scott Helme
Attack and Defense Labs
Attack and Defense Labs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
LangChain Blog
Recent Announcements
Recent Announcements
Security Latest
Security Latest
Hugging Face - Blog
Hugging Face - Blog
W
WeLiveSecurity
Last Week in AI
Last Week in AI
Security Archives - TechRepublic
Security Archives - TechRepublic
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
S
Securelist
S
Security Affairs
Project Zero
Project Zero
博客园 - 叶小钗
Google DeepMind News
Google DeepMind News
T
Tor Project blog
A
About on SuperTechFans
V2EX - 技术
V2EX - 技术
宝玉的分享
宝玉的分享
T
Tenable Blog
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
Simon Willison's Weblog
Simon Willison's Weblog
Forbes - Security
Forbes - Security
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V
V2EX
AWS News Blog
AWS News Blog
The GitHub Blog
The GitHub Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
P
Privacy & Cybersecurity Law Blog
阮一峰的网络日志
阮一峰的网络日志
I
InfoQ
C
CXSECURITY Database RSS Feed - CXSecurity.com
H
Hacker News: Front Page
美团技术团队

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Code Review Horror Stories. Часть 1: Concurrency & Memory в Go-сервисе
Zmey56 · 2026-05-04 · via Все публикации подряд на Хабре

Средний

11 мин

6.9K

Продолжение прошлой статьи про ошибки на Go-собесах. В тот раз — про лайв-кодинг. Теперь — про code review: когда дают готовый сервис на ~150 строк и говорят “найди что не так, у тебя 30 минут”.

Разберём по косточкам реальный код с собеседования — микросервис трекинга рекламных кликов. Багов набралось 21, поэтому делю на две части. Первая — про самое страшное: concurrency, гонки, утечки памяти и горутин. Это то, что роняет сервис в проде. Часть 2 — про API design, ошибки и graceful shutdown — выйдет следом. Актуально для Go 1.26.

Из 21 бага на собесе я нашёл 18. Три самых тонких пропустил — потом, дома, перечитал спокойно и выписал. В этой части про concurrency пропустил один — TOCTOU race в дедупликации. Остальные семь — поймал. Расскажу как искал и какими красными флагами зацепился.


Контекст: что за код?

Дали типовой ad-tech сервис: HTTP endpoint принимает клик пользователя по баннеру, генерирует tracking link, кладёт в in-memory storage, паблишит в очередь. Дедупликация по (user_uuid, campaign_uuid) за последние 5 секунд (чтобы один пользователь не накручивал клики).

Stack: Gin, sync.RWMutex, чистый Go без БД. Вроде бы простенько. Вот основные структуры:

type CampaignClick struct {
    ClickUUID    string
    UserUUID     string
    CampaignUUID string
    Country      string
    TrackingLink string
    CreatedAt    time.Time
}

type InMemoryClickRepository struct {
    mu     sync.RWMutex
    clicks []*CampaignClick
}

type Publisher struct {
    topic string
    out   chan *CampaignClick
}

Интервьюер мне такой: “Тут есть несколько проблем. Найди как можно больше за 30 минут”. Я думаю — ну сейчас покажу класс. И, к моему собственному удивлению, действительно показал: 18 из 21. Дальше — как именно я их искал, и какие три всё-таки пропустил.


1. “Mutex есть, но он не работает”

Открываю код, вижу:

type InMemoryClickRepository struct {
    mu     sync.RWMutex
    clicks []*CampaignClick
}

func (r *InMemoryClickRepository) Save(c *CampaignClick) error {
    r.clicks = append(r.clicks, c)
    return nil
}

Первое, на что я всегда смотрю в репозитории, — все ли write-методы берут Lock. Проверяю Save — там пусто. Никакого r.mu.Lock(). Mutex объявлен в структуре, в GetRecent используется, а в Save забыли. Классика жанра.

Озвучиваю интервьюеру: “Тут data race — Save пишет в slice без Lock, при этом GetRecent читает под RLock. Если одна горутина делает append (а он может перевыделить underlying array), а вторая в этот момент читает — получаем race на underlying array”. Интервьюер кивает, ставит галочку.

Дополнительно: даже без перевыделения массива у нас простая non-atomic запись в slice header (3 машинных слова: pointer, len, cap) одновременно с чтением. На amd64 это видно go test -race сразу, а в production — будет всплывать раз в сутки в виде “internal error” или невидимо рушить инварианты.

Правильно:

func (r *InMemoryClickRepository) Save(c *CampaignClick) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.clicks = append(r.clicks, c)
    return nil
}

Мораль: Если видишь sync.Mutex в структуре — проверь ВСЕ методы, где меняется состояние. Один забытый Lock() обнуляет всю защиту. И да, всегда гоняй go test -race в CI. Это не опционально.


2. Goroutine Leak — самая красивая засада

Дальше натыкаюсь на этот шедевр:

type Publisher struct {
    topic string
    out   chan *CampaignClick
}

func NewPublisher(topic string, buffer int) *Publisher {
    return &Publisher{
        topic: topic,
        out:   make(chan *CampaignClick, buffer),
    }
}

func (p *Publisher) Publish(c *CampaignClick) {
    select {
    case p.out <- c:
    default:
    }
}

И потом в handler’е:

go c.publisher.Publish(click)

Тут у меня сразу сработала привычка: вижу chan в структуре — ищу <- где-то ещё в коде, чтобы понять, кто consumer. Грепаю publisher.out по файлу — ни одного receive’а. Нет ни горутины-consumer’а, ни даже метода Run()/Start().

Говорю интервьюеру: “Канал есть, producer есть, а consumer’а нет. Сообщения накопятся до буфера на 100 элементов, дальше default сработает — и мы будем молча терять клики. Метрики на это нет, ошибки нет, в логах пусто. В ad-tech это деньги, которые улетают в /dev/null”. Это второй пойманный баг.

То есть: канал на 100 элементов, заполняется до упора, потом default срабатывает и сообщения просто молча теряются. Сервис трекает клики (это деньги в ad-tech!), но половину из них выбрасывает в /dev/null. И никто не узнает, потому что ошибки нет, метрики нет, ничего нет.

Плюс к этому — go c.publisher.Publish(click) запускает горутину, которая кладёт элемент в буфер. Это в 99% случаев лишнее: Publish неблокирующий, его можно вызвать синхронно. Лишняя горутина = лишний overhead на scheduler. А если к моменту запуска горутины процесс получит SIGTERM (см. graceful shutdown во второй части) — горутина просто умрёт на полпути.

Правильно:

type Publisher struct {
    topic string
    out   chan *CampaignClick
    done  chan struct{}
}

func NewPublisher(topic string, buffer int) *Publisher {
    p := &Publisher{
        topic: topic,
        out:   make(chan *CampaignClick, buffer),
        done:  make(chan struct{}),
    }
    go p.run() // запускаем consumer!
    return p
}

func (p *Publisher) run() {
    for c := range p.out {
        if err := sendToBroker(p.topic, c); err != nil {
            log.Printf("publish failed: %v", err)
            // metric: dropped_messages++
        }
    }
    close(p.done)
}

func (p *Publisher) Publish(c *CampaignClick) error {
    select {
    case p.out <- c:
        return nil
    default:
        return errors.New("publisher buffer full") // хотя бы знаем!
    }
}

func (p *Publisher) Close(ctx context.Context) error {
    close(p.out)
    select {
    case <-p.done:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

В Go 1.26 завезли экспериментальный профайлер GOEXPERIMENT=goroutineleakprofile — он ловит ровно такие сценарии. Запускайте на staging и смотрите, что течёт. Собес проходил еще на Go 1.24

Мораль: Каждый chan должен иметь и producer’а, и consumer’а. Если канал создан, но никто из него не читает — это либо memory leak (если канал небуфкризированный — горутины висят на send), либо тихая потеря данных (если буферизованный + default). И никогда, НИКОГДА не глотай ошибку публикации без метрики.


3. Publisher по значению — копирование канала

Смотрю в main:

publisher := NewPublisher("clicks", 100)  // возвращает *Publisher
ctrl := NewController(repo, *publisher)   // разыменовываем!

И сигнатура:

func NewController(repo *InMemoryClickRepository, publisher Publisher) *ClickController {
    return &ClickController{
        repo:      repo,
        publisher: publisher,  // копия!
    }
}

Publisher передаётся по значению, без указателя. Цепляюсь сразу: внутри Publisherchan *CampaignClick, при копировании мы копируем header канала. Сейчас работает, но это работает случайно — стоит добавить любое мутабельное поле (счётчик, mutex), и копия начнёт жить своей жизнью.

Каналы в Go — это reference type, при копировании структуры мы копируем сам header канала, а не содержимое. То есть формально оба Publisher’а указывают на один канал. Но это работает случайно. Стоит добавить в Publisher любое мутабельное поле (счётчик, mutex, slice статистики) — и копия начинает жить своей жизнью. Изменения в одной копии не видны в другой. Hello debugging from hell.

Плюс: если кто-то добавит метод, который меняет состояние:

func (p *Publisher) IncrementSent() {
    p.sent++ // в КОПИИ структуры!
}

— это просто не будет работать. И ты будешь два дня гадать почему.

Правильно — всегда передавать по указателю, если в структуре есть mutex, channel, slice или map:

func NewController(repo *InMemoryClickRepository, publisher *Publisher) *ClickController {
    return &ClickController{
        repo:      repo,
        publisher: publisher,
    }
}

// в main:
ctrl := NewController(repo, publisher)  // без звёздочки

Ещё лучше — принимать interface, а не конкретный тип:

type ClickPublisher interface {
    Publish(c *CampaignClick) error
}

func NewController(repo *InMemoryClickRepository, publisher ClickPublisher) *ClickController {
    // тестировать в 10 раз проще, моки делаются за минуту
}

Мораль: Структуры с каналами/мьютексами/слайсами — всегда по указателю. go vet поймает копирование структуры с sync.Mutex, но не с каналом — это блайнд-спот линтера. Если код удобно мокать — он удобно тестируется. Прячь конкретные типы за интерфейсами.


4. Memory Leak в “in-memory storage”

func (r *InMemoryClickRepository) PurgeOld() {
    r.mu.Lock()
    defer r.mu.Unlock()
    cutoff := time.Now().Add(-5 * time.Minute)
    for i := len(r.clicks) - 1; i >= 0; i-- {
        if r.clicks[i].CreatedAt.Before(cutoff) {
            r.clicks = append(r.clicks[:i], r.clicks[i+1:]...)
        }
    }
}

Метод есть. Но фишка: никто его не вызывает. Перегрепал весь main.go — PurgeOld упомянут только в самом определении.

Что в итоге: при 1000 RPS за час сервис накопит 3.6 млн структур, за день — 86 млн. Через неделю pod падает с OOM. И это в production, в субботу ночью, когда никто не видит.

Правильно — запускать в отдельной горутине через ticker:

func (r *InMemoryClickRepository) StartPurger(ctx context.Context, interval time.Duration) {
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                r.PurgeOld()
            }
        }
    }()
}

И вызвать из main:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repo.StartPurger(ctx, 1*time.Minute)

Мораль: Если ты написал cleanup-метод — ОБЯЗАТЕЛЬНО запусти его. И поставь алерт на размер in-memory структур (через expvar или Prometheus). “In-memory” не значит “infinity-memory”.


5. PurgeOld: держим Lock на O(N²) операции

К тому же PurgeOld написан неэффективно. Смотрите внимательнее:

for i := len(r.clicks) - 1; i >= 0; i-- {
    if r.clicks[i].CreatedAt.Before(cutoff) {
        r.clicks = append(r.clicks[:i], r.clicks[i+1:]...)
    }
}

Каждый append(r.clicks[:i], r.clicks[i+1:]...) для удаления одного элемента — это O(N) копирование оставшегося хвоста. Если протухло K элементов из N — получаем O(K·N). На 1М кликов с 100K протухших — это 100 миллиардов операций. Под Lock.

И всё это время никто не может ни читать, ни писать. Сервис фактически зависает на длительности cleanup. RPS просаживается, latency взлетает, k8s начинает рестартить под из-за readiness-проб.

Правильно — две стадии: найти точку среза, сделать один срез:

func (r *InMemoryClickRepository) PurgeOld(maxAge time.Duration) {
    cutoff := time.Now().Add(-maxAge)
    
    r.mu.Lock()
    defer r.mu.Unlock()
    
    // clicks отсортированы по времени вставки (Save под Lock)
    // ищем первый "свежий" элемент через бинарный поиск
    idx := sort.Search(len(r.clicks), func(i int) bool {
        return r.clicks[i].CreatedAt.After(cutoff)
    })
    
    if idx == 0 {
        return // нечего удалять
    }
    
    // один срез, O(N - idx) копирование
    r.clicks = append(r.clicks[:0], r.clicks[idx:]...)
}

Бонус — для совсем большой нагрузки можно делать swap-and-truncate, чтобы освободить старый underlying array:

newClicks := make([]*CampaignClick, len(r.clicks)-idx)
copy(newClicks, r.clicks[idx:])
r.clicks = newClicks  // GC заберёт старый массив

Мораль: Lock — это бутылочное горлышко. Внутри Lock держи только работу с защищённым стейтом, и делай её за O(log N) или O(N), не за O(N²). Если cleanup тяжёлый — выноси работу за Lock, под Lock делай только финальный swap.


6. TOCTOU race в дедупликации — тот самый, что я пропустил на интервью

А вот этот баг я проворонил. Прочитал handler, посмотрел: “ага, проверка в кеше, если нет — создаём”, всё логично. Тиканье таймера в голове — иду дальше. И только дома, перечитывая, понял, чего не увидел. Смотрите на handler:

if _, cached, ok := c.repo.GetRecent(req.UserUUID, req.CampaignUUID, c.cacheTTL); ok {
    ctx.JSON(http.StatusOK, ClickResponse{...cached...})
    return
}

click := &CampaignClick{...}
c.repo.Save(click)

Классический TOCTOU (Time Of Check vs Time Of Use). Между GetRecent и Save другая горутина может успеть сохранить дубликат. Получаем два клика для одного (user, campaign) за окно дедупликации. В ad-tech это значит — рекламодатель платит дважды за одного юзера.

Почему пропустил: глаз зацепился за RWMutex внутри репозитория — мозг успокоился (“там же synchronization”). А внутри handler’а между двумя вызовами этого репозитория Lock’а нет — и быть не может, потому что репозиторий не знает про composite-операции “сначала проверь, потом сохрани”.

Сценарий:

  1. Goroutine A: GetRecent(user1, camp1) → не нашёл

  2. Goroutine B: GetRecent(user1, camp1) → не нашёл

  3. Goroutine A: Save(click1)

  4. Goroutine B: Save(click2) ← дубликат!

Правильно — атомарная операция GetOrCreate на стороне репозитория:

func (r *InMemoryClickRepository) GetOrCreate(
    userUUID, campaignUUID string,
    maxAge time.Duration,
    factory func() *CampaignClick,
) (click *CampaignClick, created bool) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    cutoff := time.Now().Add(-maxAge)
    for i := len(r.clicks) - 1; i >= 0; i-- {
        cc := r.clicks[i]
        if cc.UserUUID == userUUID && cc.CampaignUUID == campaignUUID && cc.CreatedAt.After(cutoff) {
            return cc, false
        }
    }
    
    newClick := factory()
    r.clicks = append(r.clicks, newClick)
    return newClick, true
}

Один Lock на check + create. Дубликаты исключены by design.

Мораль: Если у тебя есть последовательность “проверь — потом сделай” — это всегда race condition между двумя горутинами. Объединяй в одну атомарную операцию, либо используй distributed lock (если несколько подов).


7. O(N) поиск — пока 100 кликов, потом 100 миллионов

Смотрю на GetRecent:

for i := len(r.clicks) - 1; i >= 0; i-- {
    cc := r.clicks[i]
    if cc.UserUUID == userUUID && cc.CampaignUUID == campaignUUID && cc.CreatedAt.After(cutoff) {
        return cc, true
    }
}

Каждый запрос — линейный проход по всему слайсу. На 1000 кликов — норм. На 100M (см. п.4 про утечку) — каждый запрос становится секундой. Сервис ложится. Под Lock держим тоже долго.

Правильно — map по составному ключу:

type clickKey struct {
    userUUID, campaignUUID string
}

type InMemoryClickRepository struct {
    mu      sync.RWMutex
    byKey   map[clickKey]*CampaignClick // O(1) lookup
    ordered []*CampaignClick             // для PurgeOld
}

func (r *InMemoryClickRepository) GetRecent(userUUID, campaignUUID string, maxAge time.Duration) (*CampaignClick, bool) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    cc, ok := r.byKey[clickKey{userUUID, campaignUUID}]
    if !ok {
        return nil, false
    }
    if cc.CreatedAt.After(time.Now().Add(-maxAge)) {
        return cc, true
    }
    return nil, false
}

PurgeOld теперь должен подчищать обе структуры синхронно. Чуть сложнее, но lookup O(1).

Мораль: “In-memory” — не значит “бесконечно быстро”. Структура данных решает. Линейный поиск по 100M элементов будет медленнее, чем запрос в индексированный PostgreSQL.


8. go publisher.Publish(click) — fire-and-forget без отмены

Возвращаемся к handler:

c.repo.Save(click)
go c.publisher.Publish(click)  // fire and forget

Помимо багов из пункта #2, тут есть отдельная проблема: горутина запускается без context’а и без какой-либо связи с lifecycle сервиса. Когда придёт SIGTERM:

  1. http.Server.Shutdown(ctx) дожидается in-flight запросов

  2. Но эти запущенные go publisher.Publish() горутины — никем не учитываются

  3. Процесс завершается, горутины умирают на полпути

В нашем случае Publish сейчас просто пишет в канал — это быстро. Но завтра кто-то заменит реализацию на синхронный вызов в Kafka — и тогда in-flight publish’и при shutdown будут теряться.

Правильно: использовать errgroup с контекстом сервиса либо принимать context.Context в Publish:

func (p *Publisher) Publish(ctx context.Context, c *CampaignClick) error {
    select {
    case p.out <- c:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    default:
        return ErrBufferFull
    }
}

И в handler — синхронно:

if err := c.publisher.Publish(ctx.Request.Context(), click); err != nil {
    log.Printf("publish failed: %v", err)
    // не падаем — клик уже сохранён, publish можно отретраить из репозитория
}

Мораль: Fire-and-forget горутины (go someFunc()) — это долговая расписка, которую кто-то рано или поздно предъявит. Каждая такая горутина должна либо иметь привязку к lifecycle (через context/wg/errgroup), либо быть явно задокументирована как “best-effort, may be lost”.


Итого Часть 1: 8 проблем concurrency & memory

#

Проблема

Тип

1

Забытый Lock в Save

Data race

2

Канал без consumer’а

Goroutine leak / data loss

3

Publisher по значению

Channel duplication

4

PurgeOld не вызывается

Memory leak

5

PurgeOld O(N²) под Lock

Latency spike

6

TOCTOU race в дедупликации

Race condition

7

O(N) поиск в горячем пути

Performance

8

go publisher.Publish() без context

Fire-and-forget leak

Из этих восьми багов на интервью я нашёл семь. Пропустил только #6 — TOCTOU race в дедупликации. Остальные семь поймал, потому что прошёлся по чеклисту: где Lock на write-методах? где consumer для каждого канала? где cleanup для in-memory storage? где O(N) под Lock? Это рутина — не “увидел и удивился”, а “проверил и сверил”.

TOCTOU же выпал, потому что я мысленно “доверился” RWMutex внутри репозитория — а композитная операция “check then save” живёт уровнем выше, в handler’е.

Во второй части — ещё 13 багов. Из них на интервью пропустил два (package main/compile error и отсутствие slog). Расскажу, какие зацепки помогли поймать остальные 11.

🆕 Что нового в Go 1.26 для борьбы с такими багами

  • GOEXPERIMENT=goroutineleakprofile — экспериментальный профайлер, ловит ровно проблемы из бага #2. Запускайте на staging.

  • Green Tea GC by default — на 10-40% меньше overhead на GC. Особенно заметно на short-lived объектах (как наш ad-tech).

  • Стек-аллокация для slice backing stores в большем числе случаев — компилятор стал умнее с escape-анализом.


Напоминаю про свой telegram канал который я начал на прошлой неделе. Опять мне напихают что рекламой делюсь, но даю все равно ссылку t.me/go_interview_prep_ru. В нем собираюсь:

📝 Подробными разборами задач с реальных собесов
🧠 Еженедельными Go quiz с объяснениями
💡 Инсайдами про процессы топовых компаний
🔧 Практическими советами для Senior уровня
💰 Данными по зарплатам и market trends


П.С.: А как у вас с code review на собесах? Какие самые жёсткие куски кода давали? Делитесь в комментариях — соберу подборку для следующей статьи.

П.П.С.: Часть 2 уже в работе — там про API design, валидацию, graceful shutdown и тестовую горутину, которая не тестирует ничего. Stay tuned.

П.П.П.С: Дописал так как увидел что кто то подумал - текст не сгенерированный, а реальный пример из компании где проходил собеседование. Но на Ваше усмотрение - так как обычно после этого еще больше напихают.