awesome-everything EN
↑ Обратно к восхождению

Производительность

Окно батчинга: размер и время ожидания

Суть У каждой системы батчинга две ручки: max-size (байты или записи) и max-wait (время). Что сработает первым — то и шлёт батч; и какая ручка сработала, говорит, упёрлись вы в throughput или в латентность.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 10 min

Вы поднимаете linger.ms у Kafka-продюсера с 0 до 10 — и throughput взлетает в 10 раз ценой всего 10мс лишней латентности. Apache сделал тот же выбор за всех: в Kafka 4.0 (март 2025) дефолт linger.ms сменили с 0 на 5мс после многих лет на нуле. Аргумент команды был жёстким — погоня за немедленностью на стороне отправителя не даёт глобально низкую латентность. Крошечная искусственная задержка покупает эффективность батчинга, которая часто снижает сквозную латентность. Чтобы понять, почему задержка в 5мс делает систему быстрее, нужно разобрать двумерное окно под капотом.

Два триггера, а не одна ручка

У любой системы батчинга, которую вы будете тюнить, ровно два лимита: max-size (байты или число записей) и max-wait (таймер). Элементы копятся в буфере. Батч сбрасывается в тот миг, когда сработал любой из лимитов — буфер дорос до max-size или таймер дошёл до max-wait. Это OR, а не AND. В Kafka это batch.size и linger.ms; в Redis-пайплайнинге — буфер клиента и то, сколько вы даёте ему наполняться; в bulk-insert БД — строк на запрос и интервал сброса. Везде одна и та же форма.

Наивный инстинкт — выдать одну ручку («просто задай размер батча»), и каждый сеньор видел, как этот инстинкт поднимал кого-то в 3 ночи. Нужны обе, потому что у каждой в одиночку есть провал, который закрывает другая.

Почему один размер застревает, а одно время переполняет

Убери max-wait, оставь только max-size: теперь медленный продюсер берёт батч в заложники. Трафик проседает в 2 ночи, элементы капают, батч никогда не дорастает до batch.size и потому не сбрасывается. Первое сообщение полупустого батча может лежать секундами — неограниченная латентность, которая растёт обратно пропорционально нагрузке. Это классический head-of-line stall: лекарство — таймер, который говорит «шли что есть».

Убери max-size, оставь только max-wait: теперь быстрый продюсер собирает монстра. Прилетает Чёрная пятница, элементы хлынули, и за окно в 50мс буфер раздувается до того, что downstream не проглотит — запрос больше брокерского message.max.bytes, пакет фрагментируется, транзакция разносит WAL, массив роняет консьюмер по OOM. Лекарство — лимит размера, который говорит «шли, пока не разросся». Нужны обе, потому что они ломаются в противоположные стороны: размер защищает throughput, время защищает латентность, и убрав любую, вы возвращаете тот баг, который другая и закрывала.

Режим нагрузкиЧто срабатывает первымЧто это значитРычаг тюнинга
Высокая нагрузкаmax-size (буфер наполняется первым)Упёрлись в throughput; таймер не успеваетПоднять batch.size — меньше, но толще сбросы
Низкая нагрузкаmax-wait (таймер срабатывает первым)Упёрлись в латентность; батчи мелкие, доминирует таймерПодстроить linger.ms под SLO латентности
Ровно на безубыточностиЛюбой, примерно вместеОкно хорошо подогнано под текущий трафикОставить; перепроверить при смене формы трафика

Математика ускорения, по шагам

Смоделируем любую операцию на элемент как фиксированную стоимость F (накладные на вызов — syscall, сетевой round-trip, begin/commit транзакции) плюс переменную стоимость V*n (работа пропорционально payload размера n). N элементов по отдельности стоят:

N * (F + V*n)

Те же элементы одним батчем платят фиксированную стоимость один раз и ту же переменную работу:

F + V*(N*n)

Ускорение — это отношение:

speedup = (N*F + N*V*n) / (F + V*N*n)

Смотрим на две крайности. Когда доминирует фиксированная стоимость — F > V*n, то есть маленькие payload, где накладные на вызов и есть вся история — член N*F забивает всё и speedup → N. Сбатчили 100 элементов — стали быстрее в ~100 раз. Когда доминирует переменная — F < V*n, большие payload, где вы и так платите в основном за байты — член V*N*n забивает всё и speedup → 1: батчинг не даёт ничего, потому что не было фиксированной стоимости для амортизации. Перелом, точка безубыточности — это F = V*n: когда переменная стоимость одного элемента равна фиксированным накладным, батчинг начинает окупаться.

Конкретное число

Возьмём сетевую операцию: пакеты по 1КБ, round-trip 50мкс на вызов как фиксированная стоимость и батч из 100 элементов. По одному round-trip’ы стоят 100 * 50мкс = 5мс. Батчем вы платите round-trip один раз плюс байты — пусть ~150мкс всего. Это 5мс до 150мкс, ~33x ускорение, потому что здесь F (RTT 50мкс) сильно перевешивает V*n (время передачи на КБ). Ровно в этом режиме живёт Redis-пайплайнинг: опубликованный бенчмарк шлёт 10 000 PING за 1.185с без пайплайна и за 0.250с с пайплайном — ~5x — и разрыв растёт по мере роста RTT относительно работы на команду. Версия с syscall-ами та же по духу: syscall стоит ~1–5мкс, поэтому схлопывание 300k syscall-ов в ~4k через большие буферизованные записи делает накладные на вызов фактически нулевыми.

Почему это работает

Заметьте: безубыточность — на элемент, а не на батч. Если payload одного элемента уже стоит больше фиксированных накладных (V*n > F), никакой размер батча вас не спасёт — вы прочно в режиме speedup → 1, и батчинг только добавляет латентность. Вот почему батчить крошечные сообщения (логи, метрики, лукапы по ключу) — огромный выигрыш, а батчить уже большие блобы (видео-чанки, большие загрузки файлов) — почти бессмысленно.

Нагрузка решает, какой триггер главный

Поведение окна не статично — оно смещается с трафиком. При высокой нагрузке элементы хлынули, и буфер достигает max-size задолго до таймера; размер — доминирующий триггер, вы упёрлись в throughput. При низкой нагрузке ручеёк не наполняет буфер, поэтому таймер срабатывает первым; доминирует время, вы упёрлись в латентность. Практическая диагностика: смотрите средний размер батча против заданного max-size. Если батчи стабильно сбрасываются у max-size — побеждает размер, поднимайте его. Если сбрасываются мелкими и по таймеру — побеждает время, и поднимать linger.ms помогает только пока батч не начнёт наполняться раньше истечения таймера.

Разбор живой системы: max-size 10000 байт, max-wait 5мс, и вы видите батчи в среднем 8000 байт каждые 4мс. Таймер срабатывает первым (4ms < 5ms), но буфер не полон (8000 < 10000) — умеренная нагрузка, доминирует время. Поднятие max-wait увеличит батчи и throughput, но только до точки, где буфер наполняется раньше таймера.

Расставь шаги по порядку

Расставьте по порядку, что происходит с одним элементом в окне батчинга «размер + время»:

  1. 1 Элемент приходит и дописывается в буфер в памяти
  2. 2 Система проверяет: довёл ли он буфер до max-size?
  3. 3 Если не полон — таймер max-wait продолжает идти для самого старого элемента буфера
  4. 4 Что достигнуто первым — полный буфер ИЛИ истёкший таймер — запускает сброс
  5. 5 Весь батч уходит одной операцией, платя фиксированную стоимость один раз

Разумные дефолты и как реально тюнить

Разумные стартовые точки — max-wait 10–100мс и max-size 64КБ–1МБ; high-throughput-рецепт Kafka — batch.size 64КБ–256КБ с linger.ms 20–100мс, а сбалансированный продакшен-конфиг вроде batch.size=32768, linger.ms=10, compression.type=lz4, acks=1 даёт ~25k msg/s при латентности под 20мс. Но дефолты — стартовая линия, а не ответ. Сеньорский ход — выводить ожидание из SLO латентности, а не из жажды максимального throughput: берите наибольший linger.ms, который терпит ваш бюджет p99, затем подбирайте буфер так, чтобы он наполнялся у этого таймера на пиковой ожидаемой нагрузке. Дальше — проверяйте так, как нельзя на доске: проигрывайте реальный продакшен-трафик в staging, прогоняйте обе ручки и читайте фактическое распределение размеров батча и хвостовую латентность. Синтетическая равномерная нагрузка врёт; продакшен бёрстовый, и только реплей покажет, какой триггер доминирует на вашей реальной форме трафика.

Викторина

Ваша система батчинга использует только max-size (без таймера). Ночью трафик падает. Какой провал?

Викторина

Для payload по 4КБ, где фиксированная стоимость вызова ~50мкс, а передача на КБ ~40мкс, даст ли большой батч сильное ускорение?

Выбери лучший вариант

У сервиса подтверждения платежей строгий SLO p99 латентности 25мс, но на пике нужен и высокий throughput. Как настроить окно батчинга?

Вспомните перед уходом
  1. 01
    Почему системе батчинга нужны одновременно лимит размера и лимит времени, и что ломается при удалении каждого?
  2. 02
    Выведите формулу ускорения и объясните, где она идёт к N, а где к 1.
  3. 03
    max-size=10000 байт, max-wait=5мс, наблюдаемые батчи в среднем 8000 байт каждые 4мс. Что это говорит и что менять?
Итог

Двумерное окно — max-size плюс max-wait — ядро любой системы батчинга, и два лимита это OR: что сработает первым, то и шлёт батч. Нужны оба, потому что они ломаются в противоположные стороны: только размер застревает на медленном продюсере (батч не наполняется, латентность не ограничена), а только время даёт быстрому продюсеру собрать батч слишком большой для downstream. Какой триггер сработал, ещё и диагностирует режим — доминирует размер значит упёрлись в throughput (высокая нагрузка), доминирует время значит упёрлись в латентность (низкая). Математика ускорения — (N*F + N*V*n) / (F + V*N*n): идёт к N, когда доминирует фиксированная стоимость (F > V*n, маленькие payload), и к 1, когда доминирует переменная (F < V*n, большие payload), с безубыточностью на элемент при F = V*n — пакеты 1КБ через RTT 50мкс батчем ~33x. Тюньте, выводя max-wait из SLO латентности, подбирая буфер под наполнение у этого таймера на пике, затем проверяйте реплеем продакшен-трафика в staging — а не погоней за максимальным throughput на доске.

Связанные уроки
встречается в260
Продолжить восхождение ↑Batching в Kafka и Postgres
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources5
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05

Trademarks belong to their respective owners. Editorial reference only.