Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья11 мин чтенияПросмотры0

Сообщение снова падает: проверяем dead-letter queue и безопасный возврат

На изолированной SQLite-модели проверяем лимит повторов, перенос постоянной ошибки в dead-letter queue, откат при сбое между вставкой и удалением и однократный возврат исправленного сообщения.

Комментарии 0

Повреждённая посылка отделена от основного конвейера в прозрачный карантинный отсек
В этой статье

Если одно сообщение снова и снова ломает обработчик, не оставляйте его в бесконечном цикле повторов. После постоянной ошибки или исчерпания лимита попыток перенесите сообщение в отдельную очередь ошибок — dead-letter queue, или DLQ. Возвращать его в рабочую очередь стоит только после исправления причины и с контролем повторной доставки.

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

Где заканчивается повтор и начинается очередь ошибок

Состояние

Действие

Критерий результата

Временная ошибка

Повторить с паузой и лимитом

Сообщение обработано либо достигло лимита

Постоянная ошибка

Сразу изолировать

Основная очередь продолжает работу

Лимит попыток исчерпан

Перенести в DLQ

Сообщение больше не крутится в рабочем потоке

Причина исправлена

Контролируемо вернуть

В DLQ записи нет, в рабочей очереди одна запись

Повторный возврат

Не создавать копию

Операция сообщает, что возвращать уже нечего

Временной считаем ошибку, которая может исчезнуть без изменения сообщения: например, HTTP 503 или короткий сетевой тайм-аут. Постоянной — ошибку данных или бизнес-правила, которую тот же запрос повторит с тем же результатом: неизвестный SKU, недопустимое количество, отсутствующий обязательный идентификатор. Если классификация сомнительна, нужен ограниченный повтор, а не вечность.

Что проверяем на маленьком примере

Учебная модель использует только стандартную библиотеку Python и временный файл SQLite. Она не подключается к сети и не работает с реальным брокером. После завершения удаляется временная директория файловой системы вместе с тестовой базой. Проверка выполнена 28 октября 2026 года на CPython 3.12.14 и SQLite 3.53.1.

  • временная ошибка дважды возвращает сообщение на повтор, после успеха оно исчезает из очереди и не попадает в DLQ;

  • постоянная ошибка переносит сообщение в DLQ после первой неудачи;

  • три временных сбоя исчерпывают лимит и изолируют сообщение;

  • искусственный сбой между записью в DLQ и удалением из рабочей очереди откатывает всю транзакцию;

  • исправленное сообщение возвращается один раз, а повторный redrive — контролируемый возврат — не создаёт копию.

Сценарий запускается обычным интерпретатором Python 3.12. Все идентификаторы, пути и данные заданы внутри примера; база создаётся в изолированном временном каталоге. Операций с рабочими файлами и внешними очередями нет.

#!/usr/bin/env python3 import sqlite3 import tempfile from pathlib import Path MAX_ATTEMPTS = 3 def create_schema(conn): conn.executescript(""" CREATE TABLE queue ( message_id TEXT PRIMARY KEY, payload TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT ); CREATE TABLE dead_letter ( message_id TEXT PRIMARY KEY, payload TEXT NOT NULL, attempts INTEGER NOT NULL, reason TEXT NOT NULL ); """) def enqueue(conn, message_id, payload): with conn: conn.execute( "INSERT INTO queue(message_id, payload) VALUES (?, ?)", (message_id, payload), ) def record_failure(conn, message_id, error, *, permanent, inject_crash=False): with conn: row = conn.execute( "SELECT payload, attempts FROM queue WHERE message_id = ?", (message_id,), ).fetchone() if row is None: raise LookupError(message_id) payload, attempts = row next_attempt = attempts + 1 if permanent or next_attempt >= MAX_ATTEMPTS: reason = "permanent" if permanent else "attempt_limit" conn.execute( """INSERT INTO dead_letter(message_id, payload, attempts, reason) VALUES (?, ?, ?, ?)""", (message_id, payload, next_attempt, reason), ) if inject_crash: raise RuntimeError("simulated crash before source delete") conn.execute("DELETE FROM queue WHERE message_id = ?", (message_id,)) return "dead_letter" conn.execute( """UPDATE queue SET attempts = ?, last_error = ? WHERE message_id = ?""", (next_attempt, error, message_id), ) return "retry" def acknowledge(conn, message_id): with conn: conn.execute("DELETE FROM queue WHERE message_id = ?", (message_id,)) def redrive(conn, message_id, corrected_payload): with conn: row = conn.execute( "SELECT 1 FROM dead_letter WHERE message_id = ?", (message_id,) ).fetchone() if row is None: return False conn.execute( """INSERT INTO queue(message_id, payload, attempts, last_error) VALUES (?, ?, 0, NULL)""", (message_id, corrected_payload), ) conn.execute( "DELETE FROM dead_letter WHERE message_id = ?", (message_id,) ) return True def count(conn, table, message_id): if table not in {"queue", "dead_letter"}: raise ValueError(table) return conn.execute( f"SELECT COUNT(*) FROM {table} WHERE message_id = ?", (message_id,) ).fetchone()[0] with tempfile.TemporaryDirectory(prefix="dlq-lab-") as tmpdir: db_path = Path(tmpdir) / "queue.sqlite3" conn = sqlite3.connect(db_path) create_schema(conn) enqueue(conn, "transient-ok", "sku=GOOD&qty=1") assert record_failure(conn, "transient-ok", "HTTP 503", permanent=False) == "retry" assert record_failure(conn, "transient-ok", "HTTP 503", permanent=False) == "retry" acknowledge(conn, "transient-ok") assert count(conn, "queue", "transient-ok") == 0 assert count(conn, "dead_letter", "transient-ok") == 0 print("transient_then_success: queue=0 dead_letter=0") enqueue(conn, "bad-sku", "sku=UNKNOWN&qty=1") assert record_failure(conn, "bad-sku", "unknown SKU", permanent=True) == "dead_letter" assert conn.execute( "SELECT attempts, reason FROM dead_letter WHERE message_id = ?", ("bad-sku",), ).fetchone() == (1, "permanent") print("permanent_error: queue=0 dead_letter=1 attempts=1 reason=permanent") enqueue(conn, "retry-limit", "sku=GOOD&qty=2") assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "retry" assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "retry" assert record_failure(conn, "retry-limit", "timeout", permanent=False) == "dead_letter" assert conn.execute( "SELECT attempts, reason FROM dead_letter WHERE message_id = ?", ("retry-limit",), ).fetchone() == (3, "attempt_limit") print("attempt_limit: queue=0 dead_letter=1 attempts=3 reason=attempt_limit") enqueue(conn, "rollback", "sku=BROKEN&qty=1") try: record_failure( conn, "rollback", "invalid payload", permanent=True, inject_crash=True, ) except RuntimeError as exc: assert str(exc) == "simulated crash before source delete" else: raise AssertionError("injected failure did not happen") assert count(conn, "queue", "rollback") == 1 assert count(conn, "dead_letter", "rollback") == 0 print("transaction_rollback: queue=1 dead_letter=0") assert redrive(conn, "bad-sku", "sku=FIXED&qty=1") is True assert redrive(conn, "bad-sku", "sku=FIXED&qty=1") is False assert conn.execute( "SELECT payload, attempts FROM queue WHERE message_id = ?", ("bad-sku",), ).fetchone() == ("sku=FIXED&qty=1", 0) acknowledge(conn, "bad-sku") assert count(conn, "queue", "bad-sku") == 0 print("corrected_redrive: first=true second=false final_queue=0 dead_letter=0") conn.close() assert not db_path.exists()

Наблюдаемый результат

transient_then_success: queue=0 dead_letter=0 permanent_error: queue=0 dead_letter=1 attempts=1 reason=permanent attempt_limit: queue=0 dead_letter=1 attempts=3 reason=attempt_limit transaction_rollback: queue=1 dead_letter=0 corrected_redrive: first=true second=false final_queue=0 dead_letter=0

Все проверки assert завершились успешно с кодом 0. После выхода из TemporaryDirectory файл базы отсутствовал. Это подтверждает поведение именно учебной модели; на RabbitMQ, Amazon SQS или собственном брокере нужно отдельно проверить их гарантии доставки, конкуренцию обработчиков, удержание сообщений и права доступа.

Почему перенос должен быть атомарным

Операция «записать в DLQ, затем удалить из основной очереди» состоит из двух изменений. Если между ними завершится процесс, без транзакции можно получить копию в обеих очередях или потерять сообщение. В модели оба изменения выполняются внутри одного контекста транзакции SQLite: исключение до удаления откатывает вставку, поэтому исходное сообщение остаётся на месте.

Такое поведение контекстного менеджера соединения — фиксация при успехе и откат при исключении — описано в документации sqlite3 для Python 3.12. Соединение он автоматически не закрывает, поэтому в примере вызов close указан явно.

Возврат после исправления: тут есть подвох

Redrive не должен означать «вернуть всё и посмотреть». Сначала исправляют причину: справочник SKU, схему сообщения, права или код обработчика. Затем выбирают ограниченную партию, сбрасывают счётчик попыток по явному правилу и наблюдают за скоростью ошибок, длиной очереди и временем обработки.

В модели метод redrive в одной транзакции вставляет исправленное сообщение в рабочую очередь и удаляет запись из DLQ. Первое выполнение возвращает true, второе — false. Это защищает только от повторного запуска именно этой функции в однопроцессной модели; распределённая система всё равно должна иметь идемпотентный обработчик.

В Amazon SQS очередь ошибок связывается с исходной очередью политикой redrive: после заданного числа получений сообщение попадает в DLQ. Руководство SQS по DLQ отдельно предупреждает о влиянии на порядок FIFO-очередей. При возврате AWS рекомендует начинать с небольшой скорости и наблюдать за системой; штатный redrive не изменяет и не фильтрует сообщения. Это описано в руководстве по redrive.

В RabbitMQ сообщения могут перейти в dead-letter exchange после reject или nack без requeue, истечения TTL, превышения длины очереди либо delivery limit для quorum queue. Условия и ограничения перечислены в официальной документации RabbitMQ по dead lettering. Механизм отличается от SQS, поэтому переносить значения лимитов и гарантии между продуктами нельзя.

Что заложить в промышленную реализацию

  1. стабильный message_id и идемпотентность потребителя: повторная доставка не должна создавать второй заказ или платёж;

  2. разделение временных и постоянных ошибок с явным кодом причины, а не только текстом исключения;

  3. лимит попыток, пауза с разбросом и общий бюджет повторов, чтобы несколько уровней не умножали нагрузку;

  4. атомарный перенос или документированная гарантия брокера; при невозможности — сверка по message_id и процедура устранения дублей;

  5. метрики длины рабочей очереди и DLQ, возраста старейшего сообщения, доли ошибок и числа возвратов;

  6. ограниченный redrive после исправления: небольшая партия, критерий остановки, журнал оператора и возможность вернуть сообщение в карантин;

  7. срок хранения, шифрование, доступ к полезной нагрузке и маскирование персональных данных;

  8. отдельная проверка последовательности обработки событий: DLQ и повторный ввод могут изменить хронологический порядок связанных сообщений.

Как DLQ сочетается с другими защитами

Transactional outbox отвечает за другое место отказа: событие должно появиться после сохранения заказа, даже если отправитель временно недоступен. Практический пример есть в разборе transactional outbox для webhook.

Лимит повторов не заменяет идемпотентность: одно сообщение может прийти повторно из-за тайм-аута подтверждения, перезапуска потребителя или redrive. Отдельно это проверено в материале о повторном webhook и идемпотентности.

Критерий приёмки простой: ядовитое сообщение перестаёт блокировать основной поток, его причина и число попыток видны, сбой во время переноса не теряет исходные данные. Возврат исправленных сообщений выполняется небольшими партиями, с заданной скоростью и критерием остановки; повторная доставка не создаёт второй бизнес-результат. Если хотя бы один пункт нельзя проверить, DLQ пока остаётся складом проблем, а не механизмом восстановления.

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.