AdTechSep 10, 202610 мин чтения

Паузы GC в Go на RTB-биддере: mark assist, дедлайны и решение о Rust

Ставки в реальном времени (RTB)RustСборка мусораХвост задержки
Ошибка загрузки изображения

Go-биддер, который теряет аукционы на таймауте, пока его среднее выглядит здоровым, — это две проблемы под одним симптомом: дедлайн, который принадлежит бирже и покрывает путь туда и обратно, и сборщик, списывающий mark assist на горутину, считающую ставку. Здесь — как делится дедлайн, какие ручки Go окупаются и чего не чинит горячий путь на Rust.

Биддер, который теряет аукционы на таймауте, пока его среднее выглядит здоровым, описан не той цифрой. Дедлайн принадлежит бирже и покрывает сеть в обе стороны. Сборщик — свойство процесса, поэтому его пауза списывается сразу на все соединения, а всплеск на одном отдельном соединении требует другого объяснения.

«Переписать на Rust или тюнить рантайм» — это выбор между двумя ответами на вопрос, который никто не задал: какая часть дедлайна тратится и на что. Дальше бюджет отделён от сборщика, сборщик от планировщика, а переписывание — от компонента, который его заслуживает.

Короткий ответ структурный. Дедлайн задаёт биржа, и он включает сеть, поэтому первое исправление раскладывает p99 одного соединения на время в обработчике, время ожидания процессора и время в сети. Что amBrain может подтвердить публично: мы построили RTBBidder — demand-side платформу, сделанную с нуля, где каждое решение по ставке проверяет десятки условий таргетинга на показ. Цифры задержки, которые мы публикуем как измеренные, взяты с торговых путей, а не с рекламного биддера, и ни одна цифра ниже не измерена на нашем биддере.

Дедлайн принадлежит бирже, и он покрывает путь туда и обратно

Спецификация OpenRTB определяет tmax как максимальное время в миллисекундах, которое биржа отводит на получение ставок, включая задержку интернета, и говорит, что это значение отменяет любые прежние указания. Бюджет — это путь туда и обратно, который приезжает внутри каждого запроса.

Порог, по которому вас судят, — не тот, что на вашем дашборде. Документация Google Authorized Buyers требует, чтобы 85 процентов ответов приходили внутри дедлайна по замеру в торговой локации, и троттлит биддеров, которые в него не укладываются. Между их часами и вашими лежит всё, что не является вычислением:

  • Путь до биржи и обратно, который сначала география и пиринг, а уже потом инженерия
  • Установка соединения, когда отваливается keepalive: свежий TLS-хендшейк внутри бюджета аукциона — это проигранный аукцион
  • Время в accept-очереди до того, как обработчик увидит запрос, — оно растёт ровно тогда, когда вы загружены сильнее всего
  • Десериализация, стоимость которой задаётся тем, сколько из запроса вы превращаете в объекты, а не его размером

Поэтому внутренний дедлайн стоит ниже tmax ровно на то, во что, по вашей же гистограмме, обходится ответ на этом соединении, и выводится заново для каждой биржи, а не задаётся один раз на весь флот. Дедлайн — не план по ёмкости: работа, отменённая на дедлайне, свой CPU уже потратила.

При перегрузке это означает платить полную цену за ответы, которые никто не считает, поэтому недостающее исправление — контроль допуска: прочитайте tmax, сравните его с задержкой в очереди, которую вы измеряете, и отвечайте no-bid, когда арифметика не сходится. Быстрый no-bid идёт в зачёт 85 процентов; опоздавшая ставка — нет.

Один сборщик обслуживает все соединения, поэтому одно отдельное соединение — это другой отказ

Сборщик мусора — свойство процесса, поэтому цикл, запущенный на любом соединении, списывается на все соединения. Первым промахивается то, у которого самый жёсткий tmax и самый тяжёлый запрос. Исключите то, что даёт ту же картину без сборщика:

  • Слишком мало соединений от одной биржи: HTTP/1.1 несёт по одному запросу за раз, поэтому QPS на соединение, умноженное на время обработчика, вблизи единицы ставит очередь на само соединение
  • Одно соединение HTTP/2, где один потерянный пакет тормозит все потоки, которые его делят, — то самое head-of-line blocking, которое RFC 9114 в 2022 называет причиной существования HTTP/3
  • Отваливающийся keepalive — чаще дефолт, чем поломка: http.Server.IdleTimeout при нуле откатывается к ReadTimeout, тогда как Google просит idle timeout в 2.5 минуты, а nginx закрывает на 75 секундах
  • Синхронный поход за фичами, который запускают лишь некоторые биржи: там хвост принадлежит удалённому хранилищу, а не вам

Ни один из четырёх не чинится настройкой сборщика, и у разделения есть инструменты. CPU-профиль разделяет счета сборщика по символам: runtime.gcAssistAlloc — списания на обработчик, runtime.gcBgMarkWorker — фоновая маркировка. Время stop-the-world — это /sched/pauses/total/gc:seconds, ожидание в состоянии runnable — /sched/latencies:seconds, а accept-очередь читается вне процесса через ListenOverflows.

Какое исправление в Go вам нужно, решает одно различие. Пауза stop-the-world списывается сразу на все горутины, поэтому выглядит как ровный всплеск на всех соединениях. Mark assist списывается на горутину, которая аллоцировала, поэтому попадает на запросы, аллоцировавшие больше всех. Assist снижают две вещи: меньше байт на bid request или более длинный цикл, в котором фоновые воркеры покрывают больше маркировки. Смену состава трафика переживает только первая.

Mark assist — это счёт, который оплачивает ваш bid request

Сборщик Go конкурентный, и официальное руководство прямо говорит, что длина паузы не растёт с размером кучи, поэтому переходы stop-the-world коротки. Значимый источник — assist: горутины помогают сборщику, когда аллокации идут быстро, потому что фоновой маркировке достаётся фиксированная четверть процессоров, а нехватка списывается на того, кто аллоцирует.

Темп превращает это в порог, а не в наклон. Темп аллокаций — это QPS, умноженное на байты на bid request, против фиксированной фоновой доли, поэтому код, который ни разу не уходит в assist на пятой части вашего трафика, при 100K QPS может уходить в assist почти на каждом запросе.

Стоимость маркировки пропорциональна живому графу указателей, а не мусору, и биддер держит неудачную для этого форму: индексы кампаний, сегменты аудитории, кеши частоты. Discord опубликовал такой же вывод в 2020: сборщик сканировал весь LRU-кеш, чтобы решить, свободна ли память. Под одной фразой прячется пять механизмов:

  • Переходы stop-the-world: ровный всплеск на каждом соединении в один и тот же момент, редко достаточно длинный, чтобы сам по себе проиграть аукцион
  • Mark assist: в трейсе паузы нет вовсе, только выросшее время обработчика — читайте его по доле assist в GC CPU
  • Стоимость маркировки: GC CPU растёт, когда растёт живая куча, хотя аллокации не выросли; это сдвигают плоские массивы, а уменьшение мусора — нет
  • Конкуренция за планировщик: запрос стоит в состоянии runnable и не исполняется — это показывает метрика задержки планировщика, а метрика пауз не покажет
  • Принудительный цикл: всплеск с периодом примерно в две минуты на тихом инстансе — он указывает на нижнюю границу сборки, а не на ваш трафик

Читайте это как пять отдельных счетов. Ровно один закрывается настройкой сборщика, и ни один — сменой языка до того, как разделение измерено.

Замкнутый контур стирает те самые улики, которые были нужны

Гил Тене назвал этот отказ coordinated omission: измеряющая система согласуется с системой под тестом так, что выбросы просто не попадают в замер, потому что замкнутый контур ждёт ответа и перестаёт слать во время затыка. ScyllaDB опубликовала в 2021 сравнение, где одна нагрузка показала p99 в 249 микросекунд в замкнутом контуре и 665 ms под разомкнутой нагрузкой с коррекцией — расхождение примерно в 2,700 раз.

  • Нагрузка с разомкнутым контуром на фиксированном темпе, где задержка считается от намеченного времени отправки, а не от момента, когда запрос ушёл
  • Коррекция в стиле HdrHistogram всякий раз, когда генератор не ставит в очередь запросы, которые не смог отправить
  • p99 и p99.9 вместо среднего, с учётом собственного fan-out: Дин и Барросо показали в 2013, что обращение к 100 серверам при p99 в одну секунду оставляет 63 процента запросов медленными
  • Профиль трафика, скопированный с той биржи, которая болит, прогнанный дольше интервала принудительной сборки и под той же квотой cgroup

Генератор нагрузки, который ждёт ответа, перестаёт слать запросы ровно во время того затыка, который он и был построен найти, а потом усредняет эту тишину в результат. Перцентиль, который он печатает после, описывает генератор, а не ваш биддер.

Сначала аллоцировать меньше, потом крутить три ручки

Назовите цифру остановки до того, как начнёте тюнить, потому что переписывание, решённое от усталости, — не решение. Цифры две, а не одна: аллокации кучи на запрос, которые двигают assist, и живая куча, которая двигает маркировку.

Порядок такой: сначала источник, потом потолок, а не сначала самый большой эффект. Начинайте там, где рождается assist: escape analysis на пути ставки, переиспользуемые буферы вместо новых аллокаций и кодек, который читает нужные вам поля, а не материализует свежий граф объектов. Держите его слайсы короткоживущими: слайс в буфер запроса удерживает весь этот буфер на всё время жизни ставки.

  • sync.Pool снимает давление и ничего не обещает: элемент может быть удалён в любой момент без уведомления, а объект в пуле всё равно маркируется между возвратом и вытеснением
  • Счёт выставляет живой набор: перестройте индексы кампаний и сегментов в плоские массивы вне горячего пути, чтобы маркируемый граф перестал расти вместе с числом кампаний
  • GOGC меняет память на CPU сборщика по курсу, который руководство называет прямо: удвоение примерно вдвое снижает расход GC на CPU, и вместе с ним падает доля assist
  • GOMEMLIMIT — это тот же обмен, но против потолка, мягкого по замыслу, потому что жёсткий лимит превращает всплеск кучи в бесконечный затык
  • GOMAXPROCS внутри контейнера: Go 1.25 читает лимит CPU из cgroup и явно не читает CPU requests, поэтому под с requests и без лимита сохраняет старое поведение

У подхода есть потолок: Uber сообщил в 2021, что настройка GOGC относительно лимита памяти контейнера вернула около 70,000 ядер на его критичных для бизнеса сервисах. Это результат по стоимости, а не по перцентилю.

Что даёт горячий путь на Rust и чем вы за это платите

RTB House в июне 2025 описала JVM-сервис ставок, где дробление на микросервисы дало большой объём мелких запросов: добавленная задержка должна была уложиться в 7 ms при среднем запросе около 2.5 ms, а 98-й и 99-й перцентили ломались на частых паузах G1. Они перешли на поколенческий ZGC и заплатили памятью.

Заметьте, что потребовалось для этой починки: второй сборщик, на который можно переключиться. В Go он один и не подключаемый, поэтому рычаги Go — темп аллокаций, форма живого набора и GOGC против GOMEMLIMIT. Горячий путь на Rust убирает вместо этого конкретные вещи: нет assist, нет фоновой маркировки, нет принудительного цикла. Список того, что никуда не денется, длиннее, чем ждёт большинство команд:

  • Аллокатор, плюс page faults и размещение по NUMA: стандартная библиотека Rust прямо говорит, что глобальный аллокатор по умолчанию не специфицирован, а malloc под многими потоками — источник хвоста
  • Планирование: асинхронный рантайм с пулом воркеров воспроизводит эффекты планировщика Go в тот же момент, когда блокирующая задача попадает на воркер
  • Учёт памяти, поскольку GOMEMLIMIT покрывает только память рантайма Go: аллокатор Rust в том же процессе лежит вне вашего потолка, и контроль переходит к OOM killer
  • Люди: кто дежурит по горячему пути ночью и во что обходятся два тулчейна в одном репозитории
  • Граница, если Go остаётся снаружи: Cockroach Labs в 2015 замерила вызов cgo в 171 ns против 1.83 ns у вызова Go, и переживает годы именно это соотношение

Если хвост живёт в разборе запроса, в соединении с биржей или в очереди планировщика, Rust не вернёт ни одной из этих миллисекунд, а переписывание не того компонента тратит квартал ради той же доли таймаутов.

Поэтому переносите не сервис, а самый маленький кусок, которому принадлежат аллокации: цикл оценки показа и его индексы, отбор кандидатов, таргетинг, обращения за частотой и бюджетом, скоринг. Считайте цену границы за одно пересечение: один раз на bid request с плоским буфером и никогда не по разу на правило таргетинга. Проверяйте на отдельных инстансах, которым подают зеркальный поток, и никогда внутри тестируемого процесса, где теневой путь удваивает обе величины, которые вы измеряете.

Приёмочный тест, который работает на любом из путей: прогоните короткое окно с выключенным сборщиком, под потолком памяти, который вы контролируете, и запишите p99.9 внутри обработчика. Прогоняйте на одном инстансе под долей трафика и с автоматическим откатом, потому что при выключенном GOGC всплеск кучи в потолок загоняет рантайм в циклы подряд, а руководство говорит, что этот затык может быть бесконечным. Читайте результат, только если ограничитель GC CPU ни разу не включился: как только он включился, перцентиль описывает ограничитель.

Компания, которая это чинит, сначала просит отчёт по таймаутам

У второй половины вопроса — кто делает такую работу — есть проверка, которой не нужен список подрядчиков. Компания, которая чинит задержку, вызванную GC, ведёт себя не так, как та, которая продаёт переписывание:

  • Просит распределение tmax и отчёт по таймаутам в разрезе бирж раньше, чем просит репозиторий
  • Называет разделение — обработчик, планировщик, сеть — и то, какому из трёх, по его ожиданию, принадлежат миллисекунды, раньше, чем предлагает язык
  • Приносит свой генератор с разомкнутым контуром и свой профиль трафика и не принимает перцентиль из замкнутого контура как доказательство
  • Заранее называет критерий выхода: какая биржа, какой перцентиль, какой запас относительно её tmax, сколько памяти на тысячу запросов
  • Может потом закрыть горячий путь людьми, потому что переписанный код, по которому ночью никто не дежурит, — это второй инцидент

Ни один из них не требует доверия: каждый — документ, который можно запросить в первом же разговоре, и если он приходит расплывчатым, значит диагностику пропускают.

Поэтому первый вопрос не в том, какой язык. Он в том, какому из трёх слагаемых — обработчику, планировщику или сети — принадлежат недостающие миллисекунды на том соединении, которое отваливается по таймауту, и сдвигаются ли байты, аллоцированные на bid request, когда вы за это беретесь.

Что amBrain может подтвердить публично: amBrain — компания по разработке ПО, специализирующаяся на торговых платформах, matching engine, системах real-time bidding и инженерии казино-платформ; мы пишем софт с 2019 года, а в AdTech делаем разработку DSP, платформ real-time bidding и ad exchange. Мы работаем в трёх форматах: полная разработка, выделенная команда или наши инженеры в вашей команде. Если вы взвешиваете переписывание против прохода по тюнингу, стоящий разговор сначала делает разделение и только потом выбирает язык.

На столе похожая архитектура?

Принесите текущую архитектуру и тот сценарий отказа, который вас беспокоит, — разберём его вместе за полчаса.

Похожие статьи

Ошибка загрузки изображения
AdTech
Sep 10, 202610 мин чтения

Измерение рекламы теряет события на пике: стыки, дублирующиеся ключи и джойн с биддером

Читать
Ошибка загрузки изображения
AdTech
Mar 5, 20267 мин чтения

Как AI меняет programmatic-рекламу в 2026 году

Читать
Ошибка загрузки изображения
AdTech
Feb 14, 20266 мин чтения

Таргетинг с приоритетом приватности: AdTech без сторонних cookie

Читать