Очереди, потоки, события
Порядок сообщений: полный порядок — налог на throughput, частичный — честная сделка
Сервис кошелька шлёт balance.credit, затем balance.debit по одному аккаунту. В staging, с одним consumer, это работает месяцами. В prod ты масштабируешь до восьми consumers ради throughput — и debit прилетает первым: клиент на миг уходит в минус, срабатывает антифрод-правило, аккаунт замораживают. В коде ничего не менялось. Ты просто убрал случайную однопоточную упорядоченность, которую staging давал бесплатно, и обнаружил, что очередь и не обещала порядок между consumers.
У полного порядка ровно одна форма, и она медленная
«Обрабатывать всё ровно в том порядке, в котором произвели» звучит как флаг в конфиге. Это не так. Единственный способ получить единую глобальную последовательность — единственная точка сериализации: одна partition, записанная одним порядком producer, вычитываемая одним consumer за раз. Как только два consumers могут работать параллельно, единого таймлайна больше нет — сообщения 5 и 6 гонятся, и побеждает тот handler, что закончит первым. Полный порядок и параллелизм физически противоположны: нужный тебе порядок — это очередь из одного.
В этом и есть налог на throughput. SQS FIFO с одной message group упирается в 300 сообщений/секунду (3 000 с батчингом по 10), потому что всё в одной группе выдаётся по одному за раз — SQS не отдаст следующее сообщение группы, пока предыдущее не удалено. Kafka с одной partition ограничен throughput этой единственной partition и одним consumer в группе; добавление consumers не помогает, потому что partition назначается ровно одному consumer. Полный порядок ты купил своей масштабируемостью.
Частичный порядок — это то, что тебе на самом деле нужно
Прозрение сеньора в том, что полный порядок над всем почти никогда не нужен — нужен порядок внутри сущности. Кошельку всё равно, упорядочен ли credit аккаунта A относительно debit аккаунта B; они независимы. Ему критично, чтобы события самого A оставались в последовательности. Это и есть частичный порядок: сообщения с одним ключом FIFO друг относительно друга, а разные ключи свободно параллелятся.
Именно эту модель дают обе крупные системы:
- Kafka упорядочивает внутри partition. Выбери ключ partition (id аккаунта), и Kafka хэширует его в partition; все события этого ключа попадают в одну partition, по порядку, на полном throughput partition. Разные ключи разбросаны по partitions и consumers.
- SQS FIFO упорядочивает внутри
MessageGroupId— порядок строго в пределах группы, никогда между группами. Одна группа = строгий FIFO и выдача по одному; разные группы обрабатываются параллельно. Поэтому совокупный throughput растёт с числом message groups: high-throughput режим достигает десятков тысяч сообщений/секунду только за счёт распределения нагрузки по многим группам и батчинга, а не ускорения отдельной группы.
Выбери ключом сущность, которая должна оставаться согласованной — на аккаунт, на агрегат, на пользователя — и получишь порядок ровно там, где он важен, и параллелизм везде остальном.
| Модель | Точка сериализации | Throughput | Когда применять |
|---|---|---|---|
| Полный порядок | Одна partition / один consumer | 1 группа: ~300 msg/s (SQS), одна partition (Kafka) | Редко — единый глобальный ledger, который реально обязан сериализоваться |
| Частичный порядок (FIFO по ключу) | По одной на ключ/partition/группу | Масштабируется числом ключей; полный throughput partition | Почти всегда — порядок внутри сущности, параллелизм между сущностями |
| Без порядка | Нет | Максимум — каждый consumer полностью независим | Коммутативная/идемпотентная работа, где порядок не важен |
Как порядок тихо ломается в проде
Частичный порядок не бесплатен — он держится, лишь пока истинны три вещи, и прод обожает ломать каждую.
1. Ты добавляешь partitions. Kafka сопоставляет ключ с partition хэшем по модулю числа partitions. Увеличь partitions с 6 до 12 — и тот же ключ теперь хэшится в другую partition. События ключа в полёте всё ещё вычитываются из старой partition, пока новые валятся в новую — на какое-то окно две partitions держат события одного ключа, и два consumers обрабатывают их параллельно. Репартиционирование — классическое событие порчи порядка; поэтому команды закладывают partitions с запасом наперёд, а не растят их.
2. Ретраи и DLQ вставляют сообщения вне очереди. Handler падает на сообщении 5, оно уходит в dead-letter queue, и позже кто-то его реплеит — но сообщения 6, 7, 8 уже закоммичены. Теперь 5 прилетает после своих преемников. Даже внутри одной partition ретрай, переотправляющий в хвост, переупорядочивает относительно всего, что произвели за это время. Реплей DLQ на полной скорости тоже переупорядочивает целый батч относительно живого трафика — поэтому реплеят с ограничением скорости, а не всё разом.
3. Producer переупорядочивает под ретраем. Этот кусает у источника. В Kafka, если enable.idempotence равно false, а max.in.flight.requests.per.connection больше 1, упавший и ретраящийся батч может приземлиться после более позднего, который успел — тихо меняя местами два сообщения в одной partition. KIP-185 сделал идемпотентный producer дефолтом именно чтобы это закрыть: брокер ведёт sequence number на producer и partition, отбрасывает дубликаты и отвергает батчи вне последовательности, сохраняя порядок даже при до 5 запросах в полёте.
Почему это работает
«At-least-once доставка» и «упорядоченность» — независимые свойства, и большинство очередей дают at-least-once. Значит, сообщение может прийти дважды, и передоставка может прилететь после более новых сообщений. Так что даже корректно партиционированный FIFO-поток по ключу может показать тебе [5, 6, 5] после передоставки 5. Гарантии порядка описывают счастливый путь; путь «дубль и переупорядочивание» — тот, который твой handler обязан пережить.
Паттерны, которые делают порядок переживаемым
Сделать распределённую систему идеально упорядоченной дёшево нельзя, поэтому сеньоры проектируют так, чтобы эпизодический беспорядок был безвреден. Четыре хода, примерно по убыванию рычага:
- Партиционируй по сущности, которой нужен порядок. Выбери ключом границу согласованности —
accountId, id агрегата,userId. Это фундамент; остальные три закрывают оставленные им зазоры. - Сделай handler идемпотентным. Сохраняй id сообщения (или ключ идемпотентности) и пропускай всё, что уже видел. Теперь at-least-once передоставка — это no-op вместо двойного списания, а реплей DLQ безопасен.
- Делай эффекты коммутативными, где можешь. Если операция —
set balance = X, а неadd X, порядок перестаёт иметь значение — побеждает последняя запись, и повторное применение безвредно. Коммутативные или last-write-wins эффекты превращают проблему порядка в не-проблему. - Носи sequence number / version и отбрасывай устаревшие апдейты. Штампуй каждое событие монотонной версией на сущность; consumer помнит наивысшую применённую версию и выбрасывает всё ниже. Апдейт вне порядка или реплейнутый — детектируется и дропается. Это single-flight на ключ плюс проверка версии — сильнейшая защита, когда эффекты не коммутативны.
Сервис профиля потребляет события user.updated. Параллельные consumers + at-least-once очередь означают, что апдейты одного пользователя могут прийти вне порядка или дважды. Выбери дизайн.
Почему масштабирование Kafka-топика с одного consumer до восьми ломает порядок, который ты видел в staging?
С Kafka-producer какая комбинация может тихо переупорядочить два сообщения в одной partition?
Расставь решения сеньора для дизайна упорядоченного consumer, сначала самый сильный рычаг:
- 1 Определи границу согласованности: внутри какой сущности её события должны оставаться упорядочены (аккаунт, агрегат, пользователь)?
- 2 Партиционируй / задай MessageGroupId по этому ключу, чтобы его события были FIFO по ключу
- 3 Сделай handler идемпотентным по id сообщения, чтобы at-least-once передоставка и реплей DLQ были безопасны
- 4 Предпочитай коммутативные / last-write-wins эффекты, чтобы остаточный беспорядок был безвреден
- 5 Штампуй версию на сущность и дропай любой апдейт старше последнего применённого
- 01Коллега говорит: «просто включим FIFO и получим упорядоченную обработку». Объясни, что FIFO реально даёт и чего стоит.
- 02Почему добавление partitions в Kafka-топик рискует испортить порядок по ключу, и как команды этого избегают?
У глобального полного порядка ровно одна форма — единственная точка сериализации, одна partition, вычитываемая одним consumer, — и она упирает throughput примерно в 300 сообщений/секунду на группу SQS FIFO или одну partition Kafka. Он почти никогда не нужен. Нужен частичный порядок: FIFO по ключу, где сообщения с одним ключом (аккаунт, агрегат, пользователь) остаются в последовательности, а разные ключи параллелятся по partitions и consumers. Этот порядок хрупок в проде: он ломается, когда добавляешь partitions и ключ перехэшируется, когда ретраи или реплеи DLQ вставляют сообщение позади более новых, и когда неидемпотентный producer с несколькими запросами в полёте даёт ретрайнутому батчу обогнать более поздний — поэтому идемпотентный producer с sequence numbers на partition стал дефолтом Kafka. Долговечный дизайн предполагает эпизодический беспорядок и переживает его: партиционируй по нужной сущности, делай handlers идемпотентными, чтобы передоставка была no-op, предпочитай коммутативные или last-write-wins эффекты и штампуй версию на сущность, чтобы апдейты вне порядка детектировались и дропались. Порядок там, где он важен, параллелизм везде остальном.