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

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

От Nagle до io_uring: эволюция пакетирования

Суть Один паттерн тянется от Nagle (1984) через Kafka linger.ms до io_uring: амортизировать фиксированную стоимость операции на N операций. Рычаг тот же; меняются лишь фиксированная стоимость (заголовок, round-trip, syscall) и настройка окна.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 15 min

В 1984 году Джон Нагл смотрел, как одно нажатие клавиши в telnet покидает хост 41-байтовым пакетом: один байт полезных данных, обёрнутый в 40-байтовый TCP/IP-заголовок. На перегруженном ARPANET это были 97.5% потерь, и тысячи таких «крошек» (tinygrams) плавили каналы. Его правка — три строки логики TCP. Сорок лет спустя вы крутите ровно тот же рычаг каждый раз, когда задаёте linger.ms в Kafka или пакуете SQE в один io_uring_enter. Фиксированная стоимость сменила имя; приём не менялся.

Идея пакетирования вне времени: когда фиксированная стоимость на операцию доминирует над переменной стоимостью данных — заголовок TCP, сетевой round-trip, переключение контекста syscall, захват соединения с БД — группируйте операции, чтобы платить фиксированную стоимость один раз вместо N раз. Всё в этом уроке — одна эта идея, применённая на разных слоях стека на протяжении четырёх десятилетий. Единственная настоящая ручка проектирования — окно: как долго ждать или насколько заполниться, прежде чем сбросить.

Алгоритм Нагле: исходный компромисс пакетирования

RFC 896 (1984) ввёл то, что мы теперь зовём алгоритмом Нагле. Правило короткое: пока в полёте есть неподтверждённые данные, придержи любой новый маленький сегмент и объедини его с последующими записями; сбрасывай немедленно лишь когда ACK очистит данные в полёте или когда наберётся целый MSS байт. Мотивация — жёсткая арифметика: 1-байтовое нажатие telnet становилось 41-байтовым пакетом, так что 40 из каждого 41 байта на проводе были заголовком. Правило Нагле превращало серию нажатий в один пакет на round-trip вместо одного пакета на клавишу.

Цена ложится на интерактивный трафик и трафик «запрос/ответ». Если приложение делает маленький write() и затем ждёт ответа, Нагле может усесться на этот последний маленький сегмент в надежде на новые данные, которых не будет — и в итоге ждёт ACK, добавляя до полного round-trip мёртвого времени. Путь отступления — опция сокета TCP_NODELAY, отключающая алгоритм, так что каждая запись уходит немедленно. Поэтому HTTP/2, gRPC, клиенты Redis и по сути любой современный RPC-стек ставят TCP_NODELAY при подключении и делают собственное пакетирование на уровне приложения, где границы сообщений действительно известны.

Дедлок Нагле + delayed-ACK (постмортем-момент)

Знаменитый отказ — не Нагле сам по себе, а Нагле во взаимодействии с TCP delayed ACK. Delayed ACK — зеркало Нагле на стороне получателя: вместо ACK на каждый сегмент получатель ждёт (по умолчанию в Linux до ~40мс), надеясь подсадить ACK на ответ или объединить его со следующим. Теперь сложите оба. Отправитель пишет ответ чуть больше одного MSS: первый полный сегмент уходит, но маленький хвостовой сегмент держит Нагле, потому что первый ещё не подтверждён. Получатель получил первый сегмент, но держит свой ACK под delayed-ACK, ожидая подсадки. Ни одна сторона не двинется. Дедлок разрывается лишь когда срабатывает 40мс таймер delayed-ACK.

Симптом в проде безошибочен и бесит: протокол, который должен делать тысячи транзакций в секунду, загадочно упирается в ~25/сек, а гистограммы латентности всплескивают на подозрительно круглых 40мс (или 200мс на некоторых стеках). Фраза Марка Брукера — «Это всегда TCP_NODELAY. Каждый чёртов раз.» — фольклор не зря. Правка — одна опция сокета; диагноз — вот что трудно, потому что эти 40мс не платит ничей CPU, и они проявляются лишь как стояние по настенным часам.

Граница Парето латентность-пропускная способность

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

Рабочая точкаОкноЛатентность на элементПропускная способность
Без пакетированияокно = 0Минимум (шлём сразу)Упёрта в фиксированную стоимость на операцию
Рабочая точка SLOнаибольшее окно с p99 < SLOУ потолка SLOПочти максимум под этим потолком
Бесконечное пакетированиеокно = latency = ∞Неограниченно (первый элемент не сбрасывается)Максимум теоретический

Старший рабочий процесс — не «выбери число», а: сначала определи потолок латентности SLO (скажем, p99 < 50ms), затем найди наибольшее окно пакета, которое всё ещё умещается под ним, потому что это окно даёт максимум пропускной способности, который можно купить, не ломая контракт. Статические системы крутят эту ручку один раз и живут с этим. Адаптивные системы отслеживают кривую в рантайме: при лёгкой нагрузке сжимают окно к нулю (важна латентность, и пакетировать всё равно нечего); при тяжёлой — дают пакетам наполниться (важна пропускная способность, а элементы прибывают так быстро, что ожидание дёшево). Kafka 4.0 тихо закодировала эту мудрость: дефолт продюсера linger.ms сдвинулся с 0 на 5ms, потому что выигрыш в эффективности от более полных пакетов обычно окупает 5мс ожидания — нередко давая меньшую сквозную латентность, а не большую, за счёт сокращения накладных расходов на запрос.

Coalescing пакетов и deduplication запросов

Есть более острый вариант для нагрузок кеш/поиск, где многим вызывающим нужен один и тот же результат, а не просто пропускная способность. Когда конкурентные запросы промахиваются мимо кеша по одному ключу, их можно схлопнуть в единственную загрузку в полёте вместо N дублирующих. Работник А промахивается по ключу K и стартует запрос к БД; работник Б (и В, и Г…) тоже промахиваются по K, видят, что запрос А уже в полёте, и прицепляются к нему вместо собственного. Один результат раздаётся всем.

Это лекарство от cache stampede / thundering herd: горячий ключ протух, 100 запросов прибывают в одну миллисекунду, и без coalescing все 100 долбят БД разом — нередко достаточно, чтобы её уронить ровно когда трафик на пике. С coalescing эти 100 промахов становятся 1 запросом и 99 «попутчиками». Реализации повсюду под разными именами: golang.org/x/sync/singleflight в Go, AsyncLoadingCache Caffeine в Java, request collapsing в Varnish и большинстве CDN. DataLoader из GraphQL идёт ещё на шаг дальше, комбинируя coalescing с пакетной загрузкой по окну: каждый уникальный ключ, запрошенный в один tick, дедуплицируется и уникальные ключи связываются в один пакетный backend-запрос, что заодно — как DataLoader убивает проблему N+1 запросов.

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

Coalescing и Нагле выглядят по-разному, но у них общий хребет. Нагле сливает записи во времени на одном соединении, амортизируя стоимость заголовка. Singleflight сливает чтения между вызывающими по одному ключу, амортизируя backend-запрос. Оба отвечают на один вопрос — «несколько маленьких вещей хотят одну дорогую операцию; может ли один поход обслужить всех?» — просто по разным измерениям (время против идентичности).

Почему важна эта родословная

Весь смысл видеть Нагле, Kafka и io_uring одной семьёй в том, что метод настройки переносится. Каждое — лишь другая фиксированная стоимость, обёрнутая в тот же рычаг, поэтому диагностический вопрос всякий раз одинаков.

ЭпохаСистемаАмортизируемая фиксированная стоимостьРучка окна
1984Nagle / RFC 89640-байтовый TCP/IP-заголовок на сегментACK по данным в полёте или полный MSS
2011Kafka producer linger.msсетевой round-trip + запрос к брокеруlinger.ms + batch.size
2019io_uring (Linux 5.1)syscall + переключение контекста в ядроSQE в очереди до одного io_uring_enter

io_uring — чистейшее современное эхо: вместо одного syscall на каждый I/O вы заполняете кольцо записей очереди отправки (SQE) в разделяемой памяти и отправляете весь пакет одним io_uring_enter — амортизируя пересечение границы ядра на множество операций, ровно как Нагле амортизировал заголовок на множество нажатий. Тот же рычаг, новая фиксированная стоимость. Когда видишь паттерн, работа всегда одна и та же в три шага: измерь фиксированную стоимость против переменной, подтверди, что фиксированная действительно доминирует, затем подбери окно к наибольшему значению, какое позволяет твой SLO по латентности.

Викторина

Сервис запрос/ответ поверх TCP загадочно упирается в ~25 транзакций/сек, а латентности сгруппированы ровно на 40мс. Какая причина наиболее вероятна?

Викторина

Горячий ключ кеша протух, и 100 конкурентных запросов промахиваются по нему в одну миллисекунду. Что делает coalescing запросов (singleflight)?

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

Упорядочьте старший рабочий процесс настройки любого окна пакетирования:

  1. 1 Измерь фиксированную стоимость на операцию против переменной стоимости данных
  2. 2 Подтверди, что фиксированная стоимость действительно доминирует (иначе пакетирование даст мало)
  3. 3 Сначала определи потолок латентности SLO (например, p99 ниже 50мс)
  4. 4 Найди наибольшее окно пакета, которое всё ещё умещается под этим потолком
  5. 5 Реши: статика или адаптив — фиксированная ручка либо сжатие/рост окна с нагрузкой
Выбери лучший вариант

Низконагруженный внутренний RPC-сервис делает маленькие вызовы запрос/ответ и упирается в фиксированный пол латентности ~40мс на вызов. Выберите правку, которую защитит сеньор.

Вспомните перед уходом
  1. 01
    Какую проблему решил алгоритм Нагле, каков механизм и как возникает классический дедлок?
  2. 02
    Объясните родословную Nagle → Kafka linger.ms → io_uring как один паттерн и рабочий процесс настройки окна.
Итог

Пакетирование — один рычаг, применённый сквозь четыре десятилетия: когда фиксированная стоимость на операцию (заголовок TCP, round-trip, syscall) доминирует над переменной стоимостью данных, группируй операции, чтобы платить её один раз. Алгоритм Нагле (1984) сливал мелкие TCP-записи до ACK или полного MSS, а его знаменитый дедлок с delayed ACK прибивает латентность к 40мс, пока TCP_NODELAY его не отключит. Kafka linger.ms и пакетная отправка SQE в io_uring — тот же приём на более высоких слоях, поэтому рабочий процесс настройки не меняется: измерь фиксированную против переменной стоимости, определи потолок латентности SLO, затем возьми наибольшее окно под ним — статически или адаптивно. Coalescing запросов (singleflight, Caffeine, DataLoader) применяет идею между вызывающими, а не во времени, схлопывая давку одинаковых промахов кеша в единственную backend-загрузку.

Связанные уроки
встречается в260
Продолжить восхождение ↑Backpressure, изоляция сбоев и безопасность батчей в продакшене
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.