Poison pill: как одно сообщение кладёт весь прод

Самое неприятное в Kafka — сообщение, которое нельзя обработать, но и пропустить тоже нельзя. Оно приходит в consumer. Consumer падает. Сообщение остаётся в очереди. Consumer пытается прочитать его снова. Снова падает. Ещё retry. И ещё. А сообщения после него просто стоят и ждут своей очереди.

Это и есть тот самый poison pill. Причём выглядит он обычно совсем безобидно. Обычный JSON, который из-за бага в producer, несовместимой версии схемы или просто кривых данных внезапно становится необрабатываемым.

Допустим, consumer читает сообщения последовательно:

1 → OK 2 → OK 3 → ERROR 4 → ? 5 → ? 6 → ?

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

Можно сделать бесконечный retry. Тогда consumer будет долбиться в одну и ту же ошибку, жрать CPU и засыпать логи одинаковыми stack trace. Можно сделать ack и сказать: «Ну, и ОК». Тогда сообщение потеряется. Оба варианта так себе.

Поэтому проблемные сообщения обычно отправляют в DLQ — Dead Letter Queue. Идея простая: если сообщение после определённого количества попыток или из-за фатальной ошибки обработать не получается, перестаём мучить им основной consumer и складываем его отдельно.

А дальше уже можно спокойно разобраться:

  • что именно сломалось;
  • почему сообщение стало невалидным;
  • нужно ли чинить код или сами данные;
  • можно ли после исправления сделать replay.

И здесь возникает следующий вопрос: а где вообще хранить эту DLQ?Первое, что приходит в голову, — Kafka.

У нас уже есть Kafka, значит, создаём отдельный топик:

events ↓ consumer ↓ error ↓ events_dlq

Работает. Но не всегда удобно.

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

Если DLQ лежит в Kafka, внезапно появляется ещё одна задача: написать consumer, который всё это прочитает, отфильтрует и каким-то образом покажет.

Если DLQ лежит в PostgreSQL, запрос может выглядеть примерно так:

SELECT message_value->>'event_type', COUNT(*) FROM dead_letter_queue WHERE created_at > NOW() - INTERVAL '7 days' GROUP BY message_value->>'event_type';

И всё.

При этом рядом можно хранить error_message, retry_count, время появления сообщения, статус обработки, ответственного и любые другие поля, которые пригодятся во время расследования.

Можно найти конкретное сообщение. Можно посмотреть историю его обработки. Можно сделать replay. Можно подключить Grafana или другой инструмент и посмотреть, что вообще происходит с DLQ.

Если PostgreSQL у вас уже есть, это довольно практичный вариант. Но здесь есть важное «но». Я бы не сказал, что PostgreSQL всегда лучше Kafka.

Если в DLQ летят миллионы сообщений в секунду, она растёт на терабайты в день и используется просто как временный буфер для автоматических retry — Kafka выглядит гораздо логичнее.

PostgreSQL хорошо подходит для другого сценария: когда DLQ — это не просто склад сообщений, а ещё и рабочее место для расследования и восстановления.

И вот здесь начинается самое интересное.

Создать таблицу dead_letter_queue — действительно дело пяти минут. Сделать так, чтобы она через полгода не превратилась в новый источник проблем, — уже совсем другая история.

Нужно решить, какие ошибки действительно отправляются в DLQ, а какие должны идти в retry. Что делать, если сама PostgreSQL недоступна в момент записи ошибки? Как удалять старые сообщения, чтобы таблица не росла бесконечно? Как делать replay так, чтобы не удалить сообщение из DLQ раньше времени и случайно не получить потерю данных?

Короче, DLQ — это не «корзина для мусора». Это отдельный контур обработки ошибок. И его тоже нужно нормально проектировать.

В следующей части покажу, как собрать небольшой демо-стенд на Go + PostgreSQL и руками пройти весь этот путь: отправить битое сообщение, увидеть его в DLQ, исправить ошибку и сделать replay.

Селькин Андрей
TGM / Go-разработчик из Fintech. Инженерные заметки: https://t.me/andrei_selkin_outbox
22