Poison pill: как одно сообщение кладёт весь прод
Самое неприятное в Kafka — сообщение, которое нельзя обработать, но и пропустить тоже нельзя. Оно приходит в consumer. Consumer падает. Сообщение остаётся в очереди. Consumer пытается прочитать его снова. Снова падает. Ещё retry. И ещё. А сообщения после него просто стоят и ждут своей очереди.
Это и есть тот самый poison pill. Причём выглядит он обычно совсем безобидно. Обычный JSON, который из-за бага в producer, несовместимой версии схемы или просто кривых данных внезапно становится необрабатываемым.
Допустим, consumer читает сообщения последовательно:
Если третье сообщение падает каждый раз, до четвёртого можно вообще не добраться.
Можно сделать бесконечный retry. Тогда consumer будет долбиться в одну и ту же ошибку, жрать CPU и засыпать логи одинаковыми stack trace. Можно сделать ack и сказать: «Ну, и ОК». Тогда сообщение потеряется. Оба варианта так себе.
Поэтому проблемные сообщения обычно отправляют в DLQ — Dead Letter Queue. Идея простая: если сообщение после определённого количества попыток или из-за фатальной ошибки обработать не получается, перестаём мучить им основной consumer и складываем его отдельно.
А дальше уже можно спокойно разобраться:
- что именно сломалось;
- почему сообщение стало невалидным;
- нужно ли чинить код или сами данные;
- можно ли после исправления сделать replay.
И здесь возникает следующий вопрос: а где вообще хранить эту DLQ?Первое, что приходит в голову, — Kafka.
У нас уже есть Kafka, значит, создаём отдельный топик:
Работает. Но не всегда удобно.
Представьте: через неделю в DLQ накопилось 50 тысяч сообщений. Нужно понять, сколько из них упало на валидации, какие события ломаются чаще всего и что вообще происходило последние семь дней.
Если DLQ лежит в Kafka, внезапно появляется ещё одна задача: написать consumer, который всё это прочитает, отфильтрует и каким-то образом покажет.
Если DLQ лежит в PostgreSQL, запрос может выглядеть примерно так:
И всё.
При этом рядом можно хранить 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.