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

Заказ сохранён, а webhook не отправлен: проверяем transactional outbox

Разбираем transactional outbox на безопасной модели: заказ и исходящее событие фиксируются одной транзакцией, сбои до и после отправки не теряют событие, а повтор использует стабильный event_id.

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

Посылка и жетон события вместе проходят через промышленный транспортный шлюз
В этой статье

Если заказ и запись об исходящем webhook сохраняются раздельно, между двумя операциями остаётся окно сбоя: заказ уже принят, а событие для внешней системы потеряно. Transactional outbox закрывает это окно: бизнес-запись и событие попадают в одну транзакцию базы, а отдельный обработчик отправляет накопленные события позже.

Но тут есть подвох: outbox не даёт «ровно одну доставку». Сбой после отправки, но до отметки события как отправленного, приводит к повтору. Поэтому отправитель хранит стабильный event_id, а получатель обрабатывает его идемпотентно.

Ниже — безопасная учебная модель, фактически проверенная 25 октября 2026 года на CPython 3.12.14 и SQLite 3.53.1. Она работает только во временном каталоге, не обращается к сети и автоматически удаляет базу после завершения. Это не схема конкретного проекта на 1С-Битрикс и не результат внедрения у клиента.

Какая проблема решается

Обычная последовательность «сохранить заказ → вызвать внешний API» связывает две разные системы без общей транзакции. Если процесс завершится между шагами, база и получатель расходятся. Обратный порядок не лучше: внешний сервис может принять событие, а локальная запись заказа — откатиться.

В outbox заказ и событие сохраняются в одной локальной транзакции. Документация SQLite по транзакциям указывает, что явная транзакция продолжается до COMMIT или ROLLBACK, а описание atomic commit раскрывает принцип: изменения одной транзакции применяются вместе либо не применяются.

Точка сбоя

Состояние локальной базы

Что делать

Между INSERT заказа и INSERT события

Оба изменения откатываются

Повторить бизнес-операцию по её правилам

До сетевой отправки

Событие остаётся pending

Обработчик повторяет позже

После отправки, до UPDATE sent_at

Событие остаётся pending, получатель мог принять его

Повторить с тем же event_id

После sent_at

Событие помечено отправленным

Не выбирать его снова

Минимальная модель

Таблица orders хранит заказ, outbox — стабильный event_id, JSON-полезную нагрузку и отметку sent_at. Ограничение UNIQUE на event_id не позволяет создать две локальные записи одного события. Оно не защищает внешний сервис: дедупликация там остаётся отдельной обязанностью.

В Python объект Connection используется как контекстный менеджер: при нормальном выходе транзакция фиксируется, при исключении откатывается. Документация модуля sqlite3 отдельно описывает commit(), rollback() и управление транзакциями.

python3 - <<'PY' import json import sqlite3 import tempfile from pathlib import Path class FakeEndpoint: def __init__(self): self.calls = 0 self.received_ids = set() def post(self, event_id: str, payload: str) -> str: self.calls += 1 json.loads(payload) if event_id in self.received_ids: return "duplicate" self.received_ids.add(event_id) return "accepted" def counts(con: sqlite3.Connection) -> tuple[int, int, int]: orders = con.execute("SELECT count(*) FROM orders").fetchone()[0] pending = con.execute( "SELECT count(*) FROM outbox WHERE sent_at IS NULL" ).fetchone()[0] sent = con.execute( "SELECT count(*) FROM outbox WHERE sent_at IS NOT NULL" ).fetchone()[0] return orders, pending, sent def create_order( con: sqlite3.Connection, order_id: str, event_id: str, *, fail_between_writes: bool = False, ) -> None: payload = json.dumps( {"event": "order.created", "order_id": order_id}, separators=(",", ":"), ) with con: con.execute( "INSERT INTO orders(order_id, status) VALUES (?, ?)", (order_id, "new"), ) if fail_between_writes: raise RuntimeError("simulated_failure_between_writes") con.execute( "INSERT INTO outbox(event_id, payload) VALUES (?, ?)", (event_id, payload), ) def deliver_one( con: sqlite3.Connection, endpoint: FakeEndpoint, *, fail_before_send: bool = False, fail_after_send: bool = False, ) -> str: row = con.execute( """ SELECT event_id, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT 1 """ ).fetchone() if row is None: return "nothing_to_send" event_id, payload = row if fail_before_send: raise RuntimeError("simulated_network_failure_before_send") result = endpoint.post(event_id, payload) if fail_after_send: raise RuntimeError("simulated_crash_after_send") with con: con.execute( "UPDATE outbox SET sent_at = CURRENT_TIMESTAMP WHERE event_id = ?", (event_id,), ) return result def main() -> None: with tempfile.TemporaryDirectory(prefix="outbox-lab-") as temp_dir: db_path = Path(temp_dir) / "lab.sqlite3" con = sqlite3.connect(db_path) con.executescript( """ CREATE TABLE orders ( order_id TEXT PRIMARY KEY, status TEXT NOT NULL ); CREATE TABLE outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, sent_at TEXT ); """ ) con.commit() try: create_order( con, "order-broken", "evt-broken", fail_between_writes=True, ) except RuntimeError as error: assert str(error) == "simulated_failure_between_writes" assert counts(con) == (0, 0, 0) print("rollback: orders=0 pending=0 sent=0") create_order(con, "order-1001", "evt-1001") assert counts(con) == (1, 1, 0) print("commit: orders=1 pending=1 sent=0") endpoint = FakeEndpoint() try: deliver_one(con, endpoint, fail_before_send=True) except RuntimeError as error: assert str(error) == "simulated_network_failure_before_send" assert counts(con) == (1, 1, 0) assert endpoint.calls == 0 print("before_send_failure: pending=1 endpoint_calls=0") try: deliver_one(con, endpoint, fail_after_send=True) except RuntimeError as error: assert str(error) == "simulated_crash_after_send" assert counts(con) == (1, 1, 0) assert endpoint.calls == 1 assert endpoint.received_ids == {"evt-1001"} print("after_send_crash: pending=1 unique_received=1 endpoint_calls=1") result = deliver_one(con, endpoint) assert result == "duplicate" assert counts(con) == (1, 0, 1) assert endpoint.calls == 2 assert endpoint.received_ids == {"evt-1001"} print("retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2") con.close() if __name__ == "__main__": main() PY

Что получилось

rollback: orders=0 pending=0 sent=0 commit: orders=1 pending=1 sent=0 before_send_failure: pending=1 endpoint_calls=0 after_send_crash: pending=1 unique_received=1 endpoint_calls=1 retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2

Проверка

Ожидаемый результат

Наблюдение

Исключение между двумя INSERT

Нет ни заказа, ни outbox-события

orders=0, pending=0, sent=0

Успешный commit

Один заказ и одно pending-событие

orders=1, pending=1

Сбой до отправки

Событие остаётся в очереди, вызова нет

pending=1, endpoint_calls=0

Сбой после отправки

Событие остаётся pending, получатель уже видел ID

pending=1, unique_received=1

Повтор

Тот же ID распознан как дубль и затем помечен отправленным

pending=0, sent=1, два вызова, одно уникальное событие

Сценарий завершился с кодом 0. Проверка подтверждает атомарность двух локальных записей в этой модели, сохранение pending-события при двух видах сбоя и повтор с тем же event_id. Реальная сеть, СУБД проекта, параллельные обработчики, блокировки и API конкретного получателя здесь не испытывались.

Механизм повтора

  1. Создать event_id один раз вместе с бизнес-операцией; не генерировать новый ID при каждой попытке отправки.

  2. Выбирать только события без sent_at и передавать исходный event_id получателю.

  3. Считать сетевой тайм-аут неоднозначным исходом: запрос мог дойти, даже если ответ потерян.

  4. Повторять отправку по ограниченной политике с паузой, не удаляя pending-событие.

  5. Отмечать sent_at только после допустимого ответа получателя.

  6. Хранить у получателя обработанные event_id либо использовать его штатный ключ идемпотентности.

На маленьком примере видно границу гарантии. До вызова внешнего API outbox предотвращает потерю события. После вызова он обеспечивает повторяемость, а не уникальность доставки. Если получатель не умеет распознавать event_id, повтор после неоднозначного тайм-аута может создать второе действие.

Что добавить в рабочую реализацию

  • Индекс по состоянию и времени следующей попытки, чтобы обработчик не сканировал всю таблицу.

  • Счётчик попыток, last_error и next_attempt_at без токенов, персональных данных и полного тела ответа в журнале.

  • Безопасное распределение событий между несколькими обработчиками средствами выбранной СУБД.

  • Отдельное состояние для окончательно отклонённых сообщений и процедура разбора, а не бесконечный цикл.

  • Срок хранения отправленных событий и дедупликационных ключей, согласованный с максимальным окном повторов.

  • Метрики возраста самого старого pending-события и числа окончательных ошибок.

Конкретный SQL для конкурентного захвата зависит от СУБД и режима изоляции. Переносить учебный SELECT из SQLite в PostgreSQL, MySQL или рабочую базу 1С-Битрикс без проектирования блокировок нельзя.

Граница с 1С-Битрикс

В интеграции магазина бизнес-операция может создавать заказ, менять остаток или формировать задание обмена. Outbox имеет смысл только тогда, когда бизнес-запись и событие действительно фиксируются одной транзакцией в одном хранилище. Если модуль, агент или внешний коннектор открывает отдельное соединение либо выполняет скрытый commit, это условие нужно проверить по версии и коду конкретного решения.

Не стоит объявлять outbox заменой штатного механизма событий 1С-Битрикс. Это шаблон согласования локальной записи и внешней доставки. Точка подключения, границы транзакции, права и влияние повторной обработки требуют отдельной приёмки на изолированном стенде проекта.

Критерий готовности

  • При исключении между записью заказа и события обе записи отсутствуют либо обе зафиксированы.

  • Сбой до отправки не удаляет pending-событие.

  • Сбой после фактической отправки приводит к повтору с тем же event_id.

  • Получатель подтверждённо не выполняет второе бизнес-действие для уже обработанного event_id.

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

  • Есть ограничение повторов, наблюдаемая очередь ошибок и безопасная процедура ручного разбора.

Готовность подтверждает не один успешный webhook, а управляемое поведение в трёх точках: до commit, до сетевой отправки и после отправки до локальной отметки. Именно третья точка показывает, почему transactional outbox и идемпотентный получатель нужны вместе.

Обсуждение 0

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

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