Событийная архитектура связывает части системы сообщениями: сервис фиксирует факт («заказ оформлен») и публикует событие, а заинтересованные сервисы реагируют сами, не блокируя исходный запрос. Брокеры дают доставку «хотя бы один раз», поэтому надёжная схема держится на двух приёмах: transactional outbox — событие пишется в базу в той же транзакции, что и данные, — и идемпотентный потребитель, который распознаёт повтор по id сообщения. «Ровно один раз» для внешних эффектов получается только так: повторы допускаются, но не меняют результат.
После материала вы сможете
- отличить команду от события и выбрать между очередью RabbitMQ и логом Kafka под конкретную задачу
- реализовать transactional outbox на PostgreSQL 17 с публикатором, который ждёт подтверждений брокера
- сделать потребителя идемпотентным: id сообщения и результат фиксируются в одной транзакции, повторная доставка не создаёт дубль
- настроить повтор с паузой, лимит доставок и dead letter queue для quorum-очереди RabbitMQ 4.3
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)RabbitMQ 4.3.6 (rabbitmq:4.3-management-alpine)PostgreSQL 17 (17.11, postgres:17-alpine)amqplib 2.2.0node-postgres (pg) 8.23TypeScript 7.0
Сверено с документацией: 11 октября 2026 г.
Содержание
Синхронный вызов, команда и событие
Сервис заказов после оформления должен начислить бонусы, отправить уведомление и передать заказ на склад. Проще всего вызвать три API подряд прямо в обработчике. Но тогда заказ зависит от доступности всех трёх сервисов, время ответа складывается, а при сбое на третьем вызове первые два уже выполнены. Событийный подход меняет направление зависимости: сервис заказов сохраняет заказ и сообщает «заказ оформлен», а бонусы, уведомления и склад подписываются на это сообщение и обрабатывают его в своём темпе.
Команда
- Что выражает
- Просьба выполнить действие; у неё один адресат, её можно отклонить
- Пример
ReserveStock, «начисли бонусы»- Кто решает, что делать
- Отправитель
Событие
- Что выражает
- Свершившийся факт в прошедшем времени; адресатов может быть сколько угодно или ни одного
- Пример
order.placed, «заказ оформлен»- Кто решает, что делать
- Каждый подписчик сам
Сообщение
- Что выражает
- Конверт для передачи через брокер: тело, заголовки, id
- Пример
- JSON с
messageIdиtype - Кто решает, что делать
- —
Мартин Фаулер в разборе «What do you mean by Event-Driven?» выделяет ловушку: если отправитель ждёт, что получатель обязательно что-то сделает, это не событие, а команда под видом события. Хороший признак события — источнику безразлично, кто и как на него отреагирует. Там же различаются уведомление о событии (в сообщении только факт и id, за деталями подписчик идёт к источнику) и перенос состояния (в событии все нужные данные, подписчик хранит свою копию и не зависит от источника). Event sourcing и CQRS — отдельные паттерны, которые не обязаны быть асинхронными; здесь мы их не рассматриваем.
- 01Сервис заказов
- 02outbox в PostgreSQL
- 03Публикатор
- 04RabbitMQ
- 05Сервис бонусов
Очередь задач и лог событий: RabbitMQ и Kafka
Брокеры сообщений устроены по двум моделям. В очереди сообщение удаляется после подтверждения, а несколько потребителей одной очереди делят поток между собой (competing consumers). Рассылка всем заинтересованным (pub/sub) в RabbitMQ строится через exchange — точку маршрутизации: у каждого подписчика своя очередь, привязанная к exchange, и каждая получает копию. В логе (Kafka) событие дописывается в конец раздела-партиции и не удаляется после чтения: каждая группа потребителей хранит свою позицию (offset) и может перемотать её назад.
RabbitMQ: очереди
- Сообщение живёт до подтверждения потребителем, потом удаляется
- Гибкая маршрутизация: exchange типов direct, topic, fanout, headers
- Повторы, лимит доставок, dead letter — на стороне брокера
- Порядок FIFO в пределах очереди, но повторная доставка может его нарушить
- Подходит для распределения работы и интеграции сервисов
Kafka: лог событий
- События не удаляются после чтения; срок хранения задаётся для топика
- Позиция чтения — offset, его можно вернуть назад и перечитать историю
- Порядок гарантирован внутри партиции; одинаковый ключ — одна партиция
- Параллелизм ограничен числом партиций
- Подходит для потоков событий, аналитики и восстановления состояния повтором
Очередь фоновых задач внутри одного приложения — отдельный случай: BullMQ на Redis из урока о фоновых задачах ставит задачу и выполняет её воркером того же проекта. Брокер вроде RabbitMQ нужен, когда сообщения пересекают границы сервисов, команд или языков, а у сообщения несколько независимых получателей. Kafka оправдана, когда события нужно хранить и перечитывать: подключить новый сервис к истории, пересчитать витрину, разобрать инцидент.
Гарантии доставки и честно об exactly-once
Документация Kafka формулирует три семантики доставки, и они применимы к любому брокеру. Выбор между ними — это выбор, когда подтверждать обработку: до выполнения работы или после.
At-most-once (не более одного раза)
- Что гарантирует
- Сообщение может потеряться, но не повторится
- Как получается
- Подтверждение до обработки, автоподтверждение
- Чем платим
- Потери при любом сбое потребителя
At-least-once (хотя бы один раз)
- Что гарантирует
- Сообщение не потеряется, но может прийти повторно
- Как получается
- Подтверждение брокера при публикации и ручное подтверждение после обработки
- Чем платим
- Дубли: потребитель обязан их распознавать
Exactly-once (ровно один раз)
- Что гарантирует
- Каждое сообщение обрабатывается один раз
- Как получается
- Внутри Kafka — идемпотентный продюсер и транзакции; во внешнем мире — только кооперация с получателем
- Чем платим
- Сложность и ограничения области действия
RabbitMQ в руководстве по надёжности прямо пишет: подтверждения дают доставку «хотя бы один раз», без них возможны потери. Подтверждение издателя (publisher confirm) означает, что брокер взял ответственность за сообщение: для постоянного сообщения в долговечной очереди — записал на диск, для quorum-очереди — получил согласие большинства реплик. Подтверждение потребителя (ack) — что получатель закончил работу. Если соединение оборвалось до ack, брокер вернёт сообщение в очередь и доставит снова с флагом redelivered.
Флаг redelivered — только подсказка. RabbitMQ гарантирует, что сообщение без флага раньше не доставлялось, но обратное неверно: дубль может прийти и без флага — например, если издатель повторил отправку после обрыва связи. Поэтому защищаться нужно по id сообщения, а не по флагу.
Transactional outbox: событие не теряется и не опережает транзакцию
Проблема двойной записи: обработчику нужно сохранить заказ в базе и опубликовать событие в брокер. Если сначала сохранить, а затем опубликовать, процесс может упасть между шагами — заказ есть, события нет. Если сначала опубликовать, подписчики получат событие о заказе, транзакция которого потом откатится. Распределённая транзакция (2PC) между базой и брокером обычно недоступна или нежелательна.
Паттерн transactional outbox (microservices.io) решает это так: событие записывается в таблицу outbox той же базы и в той же транзакции, что и бизнес-данные. Отдельный процесс-публикатор (message relay) читает неопубликованные строки и отправляет их в брокер. Событие появится тогда и только тогда, когда транзакция зафиксирована. Публикатор можно сделать опросом таблицы (polling publisher, как ниже) или чтением журнала транзакций БД (transaction log tailing, CDC) — второй способ не нагружает базу запросами, но требует отдельной инфраструктуры.
Пример ниже — сервис заказов и сервис бонусов. Для наглядности их таблицы лежат в одной базе в разных схемах orders и loyalty; в реальной системе у каждого сервиса своя база. Порты 55432 и 55672 выбраны нестандартные, чтобы не конфликтовать с локальными PostgreSQL и RabbitMQ.
docker run -d --rm --name kb-pg-events -e POSTGRES_PASSWORD=test \
-p 127.0.0.1:55432:5432 postgres:17-alpine
docker run -d --rm --name kb-rabbit-events \
-p 127.0.0.1:55672:5672 -p 127.0.0.1:55673:15672 rabbitmq:4.3-management-alpine
mkdir outbox-demo && cd outbox-demo
npm init -y && npm pkg set type=module
npm install pg@8.23.1 amqplib@2.2.0
npm install -D typescript@7.0.2 @types/pg @types/node@24{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"]
}npx tsc -p .): Node.js 24 исполняет .ts напрямую, удаляя аннотации типов-- Orders service: business table + outbox in the same database
CREATE SCHEMA orders;
CREATE TABLE orders.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
total_kzt integer NOT NULL CHECK (total_kzt > 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders.outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
-- the relay only looks for unpublished rows
CREATE INDEX outbox_unpublished ON orders.outbox (id) WHERE published_at IS NULL;
-- Loyalty service: its own tables (in production — its own database)
CREATE SCHEMA loyalty;
-- a ledger: every row is a points accrual, nothing stops a duplicate row
CREATE TABLE loyalty.ledger (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
order_id bigint NOT NULL,
points integer NOT NULL
);
CREATE TABLE loyalty.processed_messages (
consumer text NOT NULL,
message_id uuid NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id)
);docker exec -i kb-pg-events psql -U postgres < sql/schema.sqlimport { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL ?? 'postgres://postgres:test@localhost:55432/postgres',
});test — только для локального контейнераimport { pool } from './db.ts';
export type OrderPlaced = { orderId: number; customerId: number; totalKzt: number };
// The order and its event are written in ONE transaction:
// either both exist or neither does.
export async function placeOrder(customerId: number, totalKzt: number): Promise<number> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: string }>(
'INSERT INTO orders.orders (customer_id, total_kzt) VALUES ($1, $2) RETURNING id',
[customerId, totalKzt],
);
const orderId = Number(rows[0].id);
const event: OrderPlaced = { orderId, customerId, totalKzt };
await client.query(
`INSERT INTO orders.outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', $1, 'order.placed', $2)`,
[String(orderId), event],
);
await client.query('COMMIT');
return orderId;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// node src/place-order.ts <customerId> <totalKzt>
if (import.meta.main) {
const [customerId, totalKzt] = process.argv.slice(2).map(Number);
const orderId = await placeOrder(customerId, totalKzt);
console.log(`[orders] order ${orderId} saved, event written to outbox`);
await pool.end();
}Топология RabbitMQ: exchange orders типа topic, очередь сервиса бонусов loyalty.order-placed и очередь «мёртвых» сообщений. Quorum-очередь реплицируется и всегда долговечна; её параметры разберём в разделе о повторах.
import { connect, type Channel } from 'amqplib';
export const EXCHANGE = 'orders'; // events of the orders service
export const DLX = 'orders.dlx'; // where rejected messages go
export const QUEUE = 'loyalty.order-placed'; // the loyalty service's queue
export const DLQ = 'loyalty.order-placed.dlq';
export function connectRabbit() {
return connect(process.env.AMQP_URL ?? 'amqp://guest:guest@localhost:55672');
}
// Declarations are idempotent: every process may call this on start
export async function assertTopology(ch: Channel) {
await ch.assertExchange(EXCHANGE, 'topic', { durable: true });
await ch.assertExchange(DLX, 'fanout', { durable: true });
await ch.assertQueue(DLQ, { durable: true, arguments: { 'x-queue-type': 'quorum' } });
await ch.bindQueue(DLQ, DLX, '');
await ch.assertQueue(QUEUE, {
durable: true,
arguments: {
'x-queue-type': 'quorum', // replicated durable queue
'x-dead-letter-exchange': DLX,
'x-delivery-limit': 5, // more than 5 failed deliveries -> DLQ
'x-delayed-retry-type': 'failed', // RabbitMQ 4.3+: pause before a retry
'x-delayed-retry-min': 1000, // 1 s, 2 s, 3 s... (linear back-off)
'x-delayed-retry-max': 10000,
},
});
await ch.bindQueue(QUEUE, EXCHANGE, 'order.placed');
}import { setTimeout as sleep } from 'node:timers/promises';
import { pool } from './db.ts';
import { assertTopology, connectRabbit, EXCHANGE } from './rabbit.ts';
type OutboxRow = { id: string; event_id: string; event_type: string; payload: unknown; created_at: Date };
const BATCH = 100;
const once = process.argv.includes('--once');
const conn = await connectRabbit();
const ch = await conn.createConfirmChannel(); // the broker confirms every publish
await assertTopology(ch);
async function publishBatch(): Promise<number> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// SKIP LOCKED: several relay instances take different rows instead of waiting
const { rows } = await client.query<OutboxRow>(
`SELECT id, event_id, event_type, payload, created_at
FROM orders.outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT $1
FOR UPDATE SKIP LOCKED`,
[BATCH],
);
if (rows.length === 0) {
await client.query('COMMIT');
return 0;
}
for (const row of rows) {
ch.publish(EXCHANGE, row.event_type, Buffer.from(JSON.stringify(row.payload)), {
messageId: row.event_id, // consumers deduplicate by this id
type: row.event_type,
contentType: 'application/json',
persistent: true,
timestamp: Math.floor(row.created_at.getTime() / 1000),
});
}
await ch.waitForConfirms(); // rejects if the broker did not take a message
console.log(`[relay] published ${rows.length}: ${rows.map((r) => r.event_id.slice(0, 8)).join(', ')}`);
if (process.env.CRASH_AFTER_PUBLISH) {
console.log('[relay] simulated crash before marking rows as published');
process.exit(1); // the transaction is rolled back, the rows stay unpublished
}
await client.query('UPDATE orders.outbox SET published_at = now() WHERE id = ANY($1::bigint[])', [
rows.map((r) => r.id),
]);
await client.query('COMMIT');
return rows.length;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
do {
const sent = await publishBatch();
if (!once && sent === 0) await sleep(1000); // polling interval when idle
} while (!once);
await ch.close();
await conn.close();
await pool.end();FOR UPDATE SKIP LOCKED— документация PostgreSQL называет этот режим подходящим для таблиц-очередей с несколькими обработчиками: заблокированные строки пропускаются, и два экземпляра публикатора не возьмут одно событие одновременно.createConfirmChannelиwaitForConfirms— промис отклоняется, если брокер не принял хотя бы одно сообщение; тогда транзакция откатывается, и строки будут отправлены снова.messageId— этоevent_idиз outbox. Повторная отправка той же строки несёт тот же id, и по нему потребитель распознаёт дубль.- Транзакция открыта, пока публикатор ждёт подтверждений. Это осознанный компромисс: строки заблокированы, но при медленном брокере транзакция длинная. Альтернатива — «арендовать» строки отметкой с временем истечения и публиковать вне транзакции.
Идемпотентный потребитель
Паттерн idempotent consumer (microservices.io): потребитель записывает id обработанного сообщения в таблицу в той же транзакции, что и свои изменения. Первичный ключ (consumer, message_id) не даст записать id дважды, а INSERT … ON CONFLICT DO NOTHING вернёт 0 вставленных строк — это и есть сигнал «уже обработано». Ключевое слово — «в той же транзакции»: если записать id отдельно, сбой между двумя записями снова откроет дорогу дублю или потере.
import type { ConsumeMessage } from 'amqplib';
import { pool } from './db.ts';
import type { OrderPlaced } from './place-order.ts';
import { assertTopology, connectRabbit, QUEUE } from './rabbit.ts';
const CONSUMER = 'loyalty.points';
const conn = await connectRabbit();
const ch = await conn.createChannel();
await assertTopology(ch);
await ch.prefetch(10); // at most 10 unacknowledged messages per consumer
function parse(msg: ConsumeMessage): OrderPlaced | null {
try {
const e = JSON.parse(msg.content.toString());
return Number.isInteger(e.orderId) && Number.isInteger(e.customerId) && e.totalKzt > 0 ? e : null;
} catch {
return null;
}
}
async function handle(msg: ConsumeMessage) {
const id: string | undefined = msg.properties.messageId;
const tag = `${id?.slice(0, 8)}${msg.fields.redelivered ? ' (redelivered)' : ''}`;
const event = parse(msg);
if (!id || !event) {
console.log(`[loyalty] ${tag} malformed -> DLQ`);
ch.reject(msg, false); // requeue = false: a retry cannot fix bad data -> DLQ
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// Idempotency: the message id and the effect are committed together
const seen = await client.query(
`INSERT INTO loyalty.processed_messages (consumer, message_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[CONSUMER, id],
);
if (seen.rowCount === 0) {
await client.query('ROLLBACK');
console.log(`[loyalty] ${tag} duplicate, skipped`);
ch.ack(msg);
return;
}
const points = Math.floor(event.totalKzt / 100); // 1 point per 100 KZT
await client.query('INSERT INTO loyalty.ledger (customer_id, order_id, points) VALUES ($1, $2, $3)', [
event.customerId,
event.orderId,
points,
]);
await client.query('COMMIT');
console.log(`[loyalty] ${tag} order ${event.orderId}: +${points} points`);
if (process.env.CRASH_BEFORE_ACK) {
console.log('[loyalty] simulated crash after commit, before ack');
process.exit(1); // the broker will redeliver the unacknowledged message
}
ch.ack(msg); // ack only after the effect is committed
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error(`[loyalty] ${new Date().toISOString().slice(11, 19)} ${tag} failed: ${(err as Error).message}`);
ch.reject(msg, true); // counts as a failed delivery: delayed retry, then DLQ
} finally {
client.release();
}
}
console.log(`[loyalty] waiting for messages in ${QUEUE}`);
await ch.consume(QUEUE, (msg) => {
if (!msg) return; // null means the broker cancelled the consumer
handle(msg).catch((err) => {
// e.g. the database is unreachable: return the message to the queue
console.error(`[loyalty] ${(err as Error).message}`);
ch.reject(msg, true);
});
});messageId, ack только после COMMITПроверим два источника повторов. Первый — публикатор упал после публикации, но до отметки в outbox. Добавим к двум заказам событие с ошибкой в данных, чтобы заодно увидеть путь в dead letter. Потребитель запущен в соседнем терминале.
$ node src/place-order.ts 101 12000
[orders] order 1 saved, event written to outbox
$ node src/place-order.ts 102 4500
[orders] order 2 saved, event written to outbox
$ docker exec kb-pg-events psql -U postgres -c "INSERT INTO orders.outbox (aggregate_type, aggregate_id, event_type, payload) VALUES ('order', '999', 'order.placed', '{\"orderId\": \"oops\"}')"
INSERT 0 1
$ CRASH_AFTER_PUBLISH=1 node src/relay.ts --once
[relay] published 3: a17a9f69, f883ea62, e1c7b7f3
[relay] simulated crash before marking rows as published
$ node src/relay.ts --once
[relay] published 3: a17a9f69, f883ea62, e1c7b7f3
# node src/consumer.ts (another terminal)
[loyalty] waiting for messages in loyalty.order-placed
[loyalty] e1c7b7f3 malformed -> DLQ
[loyalty] f883ea62 order 2: +45 points
[loyalty] a17a9f69 order 1: +120 points
[loyalty] e1c7b7f3 malformed -> DLQ
[loyalty] a17a9f69 duplicate, skipped
[loyalty] f883ea62 duplicate, skippedВторой источник — потребитель упал после COMMIT, но до ack. Брокер не получил подтверждения, поэтому после обрыва соединения вернул сообщение в очередь и доставил его заново — уже с флагом redelivered.
$ node src/place-order.ts 103 8000
[orders] order 3 saved, event written to outbox
$ node src/relay.ts --once
[relay] published 1: 09d38984
# CRASH_BEFORE_ACK=1 node src/consumer.ts
[loyalty] waiting for messages in loyalty.order-placed
[loyalty] 09d38984 order 3: +80 points
[loyalty] simulated crash after commit, before ack
# node src/consumer.ts (restarted)
[loyalty] waiting for messages in loyalty.order-placed
[loyalty] 09d38984 (redelivered) duplicate, skipped
$ docker exec kb-pg-events psql -U postgres -c "SELECT customer_id, order_id, points FROM loyalty.ledger ORDER BY order_id"
customer_id | order_id | points
-------------+----------+--------
101 | 1 | 120
102 | 2 | 45
103 | 3 | 80
(3 rows)Без таблицы processed_messages в журнале loyalty.ledger оказалось бы шесть строк вместо трёх: в нём нет уникального ограничения на заказ — как в настоящем журнале начислений, где у клиента может быть несколько записей. Если же у эффекта есть естественный ключ (одно начисление на заказ), его уникальное ограничение — ещё один рубеж защиты. Для внешних вызовов (платёж, письмо) id сообщения передают дальше как ключ идемпотентности, если внешний API его поддерживает.
Порядок, повторы и dead letter
Посмотрите на первый вывод ещё раз: событие заказа 1 опубликовано раньше, а бонусы за заказ 2 начислены первыми. Очередь отдала сообщения по порядку, но потребитель с prefetch(10) обрабатывает их параллельно, и запросы к базе завершаются в произвольном порядке. Документация RabbitMQ уточняет и другие источники перестановок: сообщения из разных каналов перемешиваются, а повторная доставка при нескольких потребителях меняет порядок. В Kafka порядок гарантирован только внутри партиции.
Проектируйте без глобального порядка
Большинству подписчиков он не нужен: начислить бонусы за заказы 1 и 2 можно в любом порядке.
Порядок в пределах сущности
Передавайте в событии номер версии сущности; подписчик хранит последнюю применённую версию и игнорирует старые. В Kafka тот же эффект даёт ключ сообщения: события одного заказа попадут в одну партицию.
Строгий порядок очереди
В RabbitMQ — один активный потребитель (single active consumer) с
prefetch(1)или stream. Это снижает параллелизм — применяйте точечно.
Теперь ошибки. Битые данные повтором не исправить, поэтому событие с "orderId": "oops" потребитель отклонил через reject(msg, false), и брокер переложил его в exchange orders.dlx — в очередь «мёртвых» сообщений (dead letter queue, DLQ). Временную ошибку (база недоступна, таблица заблокирована миграцией) имеет смысл повторить, но не бесконечно.
Вторая новинка 4.3 — отложенный повтор (delayed retry): вернувшееся сообщение выдерживается перед следующей доставкой. Пауза линейная: min(delayed-retry-min × delivery_count, delayed-retry-max). Тип failed применяет её только к сообщениям с увеличенным счётчиком. Проверим: «сломаем» таблицу журнала переименованием, как это сделала бы неудачная миграция.
$ docker exec kb-pg-events psql -U postgres -c "ALTER TABLE loyalty.ledger RENAME TO ledger_broken"
ALTER TABLE
$ node src/place-order.ts 104 3000
[orders] order 4 saved, event written to outbox
$ node src/relay.ts --once
[relay] published 1: 670fd021
# node src/consumer.ts
[loyalty] waiting for messages in loyalty.order-placed
[loyalty] 02:28:57 670fd021 failed: relation "loyalty.ledger" does not exist
[loyalty] 02:28:58 670fd021 (redelivered) failed: relation "loyalty.ledger" does not exist
[loyalty] 02:29:00 670fd021 (redelivered) failed: relation "loyalty.ledger" does not exist
[loyalty] 02:29:03 670fd021 (redelivered) failed: relation "loyalty.ledger" does not exist
[loyalty] 02:29:07 670fd021 (redelivered) failed: relation "loyalty.ledger" does not exist
[loyalty] 02:29:12 670fd021 (redelivered) failed: relation "loyalty.ledger" does not exist
$ docker exec kb-rabbit-events rabbitmqctl -q list_queues name messages
name messages
loyalty.order-placed 0
loyalty.order-placed.dlq 3curl -s -u guest:guest -H 'content-type: application/json' \
-X POST http://localhost:55673/api/queues/%2F/loyalty.order-placed.dlq/get \
-d '{"count":5,"ackmode":"ack_requeue_true","encoding":"auto"}'e1c7b7f3 {"orderId":"oops"} | reason: rejected | x-delivery-count: 1
e1c7b7f3 {"orderId":"oops"} | reason: rejected | x-delivery-count: 1
670fd021 {"orderId":4,"totalKzt":3000,"customerId":104} | reason: delivery_limit | x-delivery-count: 6x-death и счётчик доставокВ DLQ три сообщения: битое событие дважды (его дважды опубликовал упавший публикатор — у обоих копий один id) с причиной rejected и событие заказа 4 с причиной delivery_limit. После исправления причины сообщения из DLQ переотправляют в рабочую очередь — потребитель идемпотентен, поэтому повтор безопасен.
Саги и когда события не нужны
Если бизнес-операция затрагивает несколько сервисов — оформить заказ, зарезервировать товар, списать оплату, — одной транзакцией её не покрыть. Сага (microservices.io) — последовательность локальных транзакций: каждый шаг меняет данные своего сервиса и публикует событие или команду для следующего. При отказе на шаге выполняются компенсирующие транзакции, которые отменяют предыдущие шаги: снять резерв, вернуть деньги. Координация бывает хореографией (сервисы реагируют на события друг друга) и оркестрацией (центральный оркестратор отдаёт команды участникам).
- Компенсации пишет разработчик: автоматического отката между сервисами нет.
- Изоляции нет: другие операции видят промежуточное состояние (товар зарезервирован, оплаты ещё нет). Это закрывают контрмерами — статусами вроде «ожидает оплаты», семантическими блокировками.
- Каждый шаг саги сам является потребителем сообщений, поэтому outbox и идемпотентность нужны на каждом шаге.
Событийная архитектура — инструмент для слабой связности и независимого масштабирования, а не обязательный уровень зрелости. Она лишняя, если:
- Всё работает в одном приложении и одной базе: обычная транзакция проще и надёжнее любой саги
- Пользователю нужен результат сразу: «есть ли товар на складе» — это синхронный запрос, а не событие
- Подписчик ровно один и всегда известен — возможно, это команда, и для неё хватит фоновой задачи
- Команда не готова поддерживать брокер, мониторинг очередей, DLQ и трассировку
- Нет требований к доставке при сбоях, а задачу закрывает вызов внешнего API с повтором
Outbox превращает потерю в повтор, идемпотентный потребитель превращает повтор в ничто. Вместе они дают надёжность, которую не обещает ни один брокер.
Упражнения
Дальше — материал о вебхуках: это тот же at-least-once, только через HTTP между компаниями, и тот же идемпотентный приёмник. О границах сервисов, между которыми вообще стоит пускать события, — в сравнении модульного монолита и микросервисов.
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
sql/schema.sql, src/db.ts, src/place-order.ts, src/rabbit.ts, src/relay.ts, src/consumer.ts, src/ex3-notifications.ts, tsconfig.json
временный проект в scratchpad/kb/verify/sobytiya-i-ocheredi: pg@8.23.1, amqplib@2.2.0 (встроенные типы), typescript@7.0.2, @types/node@24; `tsc -p .` без ошибок; Node 24.18 исполняет .ts напрямую; PostgreSQL 17.11 (postgres:17-alpine, kb-pg-events, --rm) и RabbitMQ 4.3.6 / Erlang 27.3 (rabbitmq:4.3-management-alpine, kb-rabbit-events, --rm), порты только на 127.0.0.1
- запущено, работает
сценарии из статьи (выводы shellsession)
run-article.sh, выводы скопированы как есть: (1) CRASH_AFTER_PUBLISH — 3 события опубликованы дважды, потребитель начислил по разу, повторы 'duplicate, skipped', битое событие → DLQ; (2) CRASH_BEFORE_ACK — повторная доставка с redelivered=true пропущена, в ledger 3 строки; (3) таблица переименована → reject(requeue) → паузы 1/2/3/4/5 с (delayed retry 4.3) → после 6-й неудачи в DLQ (x-death reason delivery_limit, x-delivery-count 6). Сценарии прогонялись 4 раза, результат стабилен; порядок обработки заказов 1 и 2 в двух последних прогонах — сначала 2 (параллельная обработка при prefetch 10)
- запущено, работает
режим опроса relay без --once, второй подписчик (упражнение 3), SQL очистки (упражнение 2)
relay в цикле опубликовал 2 заказа, созданные во время работы; ex3-notifications и consumer получили одно событие оба; DELETE-запросы выполнены (DELETE 0)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.