Как делать11 октября 2026 г.12 мин чтения
Архитектура ПОШаг 7 из 9

Событийная архитектура и очереди сообщений

Коротко

Событийная архитектура связывает части системы сообщениями: сервис фиксирует факт («заказ оформлен») и публикует событие, а заинтересованные сервисы реагируют сами, не блокируя исходный запрос. Брокеры дают доставку «хотя бы один раз», поэтому надёжная схема держится на двух приёмах: 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 г.

Содержание
  1. 01Синхронный вызов, команда и событие
  2. 02Очередь задач и лог событий: RabbitMQ и Kafka
  3. 03Гарантии доставки и честно об exactly-once
  4. 04Transactional outbox: событие не теряется и не опережает транзакцию
  5. 05Идемпотентный потребитель
  6. 06Порядок, повторы и dead letter
  7. 07Саги и когда события не нужны
  8. 08Упражнения

Синхронный вызов, команда и событие

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

Команда

Что выражает
Просьба выполнить действие; у неё один адресат, её можно отклонить
Пример
ReserveStock, «начисли бонусы»
Кто решает, что делать
Отправитель

Событие

Что выражает
Свершившийся факт в прошедшем времени; адресатов может быть сколько угодно или ни одного
Пример
order.placed, «заказ оформлен»
Кто решает, что делать
Каждый подписчик сам

Сообщение

Что выражает
Конверт для передачи через брокер: тело, заголовки, id
Пример
JSON с messageId и type
Кто решает, что делать
—

Мартин Фаулер в разборе «What do you mean by Event-Driven?» выделяет ловушку: если отправитель ждёт, что получатель обязательно что-то сделает, это не событие, а команда под видом события. Хороший признак события — источнику безразлично, кто и как на него отреагирует. Там же различаются уведомление о событии (в сообщении только факт и id, за деталями подписчик идёт к источнику) и перенос состояния (в событии все нужные данные, подписчик хранит свою копию и не зависит от источника). Event sourcing и CQRS — отдельные паттерны, которые не обязаны быть асинхронными; здесь мы их не рассматриваем.

  1. 01Сервис заказов
  2. 02outbox в PostgreSQL
  3. 03Публикатор
  4. 04RabbitMQ
  5. 05Сервис бонусов
Схема из практической части: событие проходит через таблицу outbox, а не отправляется из обработчика напрямую

Очередь задач и лог событий: 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.

bash
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
PostgreSQL 17 и RabbitMQ 4.3 в контейнерах (только 127.0.0.1, --rm удалит их после остановки), проект и зависимости
tsconfig.json
{
  "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 напрямую, удаляя аннотации типов
sql/schema.sql
-- 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)
);
Схема: заказы и outbox — у сервиса заказов, журнал бонусов и обработанные сообщения — у сервиса бонусов
bash
docker exec -i kb-pg-events psql -U postgres < sql/schema.sql
Применение схемы
src/db.ts
import { Pool } from 'pg';

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL ?? 'postgres://postgres:test@localhost:55432/postgres',
});
Пул подключений PostgreSQL; пароль test — только для локального контейнера
src/place-order.ts
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-очередь реплицируется и всегда долговечна; её параметры разберём в разделе о повторах.

src/rabbit.ts
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');
}
Объявление exchange и очередей идемпотентно: его вызывают и публикатор, и потребитель
src/relay.ts
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 отдельно, сбой между двумя записями снова откроет дорогу дублю или потере.

src/consumer.ts
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. Потребитель запущен в соседнем терминале.

terminal
$ 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
Реальный вывод: три события опубликованы дважды, бонусы начислены по одному разу, повторы пропущены по id

Второй источник — потребитель упал после COMMIT, но до ack. Брокер не получил подтверждения, поэтому после обрыва соединения вернул сообщение в очередь и доставил его заново — уже с флагом redelivered.

terminal
$ 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. Проектируйте без глобального порядка

    Большинству подписчиков он не нужен: начислить бонусы за заказы 1 и 2 можно в любом порядке.

  2. Порядок в пределах сущности

    Передавайте в событии номер версии сущности; подписчик хранит последнюю применённую версию и игнорирует старые. В Kafka тот же эффект даёт ключ сообщения: события одного заказа попадут в одну партицию.

  3. Строгий порядок очереди

    В 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 применяет её только к сообщениям с увеличенным счётчиком. Проверим: «сломаем» таблицу журнала переименованием, как это сделала бы неудачная миграция.

terminal
$ 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	3
Реальный вывод: паузы 1, 2, 3, 4, 5 секунд, после шестой неудачи сообщение ушло в DLQ
bash
curl -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"}'
Посмотреть сообщения DLQ через HTTP API плагина управления, не забирая их из очереди
terminal
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: 6
Сокращённый вывод: поля из JSON-ответа — id, тело, причина из заголовка x-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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.