Урок курса
Статус и outbox в одной транзакции
Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и DockerПочему Трасса A из 8.1 оставляет дыру и как outbox её закрывает
В 8.1 мы зафиксировали конкретный провал: tx.Commit() вернул nil, процесс упал до вызова publisher.Publish(...). Ticket DB содержит ticket-42 со статусом resolved, Kafka не получила event-ticket-900, History пуста. При рестарте процесс видит resolved, но не находит устойчивой записи о необходимости отправить event-ticket-900; в этой конкретной трассе Publish вообще не вызывался.
Корень проблемы не в порядке вызовов. В момент Commit в Ticket DB нет никакой записи, связывающей изменение статуса с намерением отправить событие. Ticket DB и Kafka — две независимые системы, у каждой свой момент подтверждения. Любой код, который делает сначала одно, а потом другое, имеет окно между двумя подтверждениями.
Outbox закрывает именно это окно — но только в пределах одной PostgreSQL базы данных. Идея проста: в той же транзакции, в которой Ticket меняет статус, мы записываем строку в таблицу outbox. Эта строка — устойчивое намерение: «событие event-ticket-900 должно быть опубликовано». После Commit обе записи — в tickets и в outbox — либо есть вместе, либо их нет вместе. Третьего варианта PostgreSQL-транзакция не допускает.
Ticket service владеет обеими таблицами в одной локальной БД. Kafka и History projection находятся за пределами этой транзакции — они не участники, и Commit ничего не говорит об их состоянии. Outbox-строка — это не доставка, это только надёжно сохранённое намерение. Отдельный publisher (тема 8.3) прочитает эту строку позже и займётся фактической отправкой.
Для нашей учебной трассы начальное состояние такое: tickets('ticket-42', 'open') зафиксирована, строки outbox с event_id='event-ticket-900' нет, переход open → resolved ранее не инициировался.
-- Состояние до транзакции
SELECT id, status FROM tickets WHERE id = 'ticket-42';
-- id | status
-- -----------+-------
-- ticket-42 | open
SELECT event_id FROM outbox WHERE event_id = 'event-ticket-900';
-- (0 rows)
Именно от этой точки мы начинаем.
Схема таблиц и смысл каждого поля outbox
Минимальная схема, достаточная для цели урока:
CREATE TABLE tickets (
id text PRIMARY KEY,
status text NOT NULL
);
CREATE TABLE outbox (
event_id text PRIMARY KEY,
aggregate_id text NOT NULL REFERENCES tickets(id),
payload jsonb NOT NULL,
published_at timestamptz NULL
);
Разберём, зачем каждый столбец именно такой.
event_id — PRIMARY KEY. Это стабильный идентификатор одного логического события: перехода ticket-42 из open в resolved. Значение event-ticket-900 определяется до начала транзакции и не меняется при повторных попытках. PRIMARY KEY гарантирует, что одно и то же событие нельзя вставить дважды — повторный INSERT с тем же event_id вызовет ошибку конфликта. Это не полная политика идемпотентности запроса, но защита от случайного дублирования при повторе транзакции с тем же идентификатором.
aggregate_id — ссылка на конкретный Ticket. Здесь это ticket-42. Внешний ключ на tickets(id) проверяет существование строки-владельца, но не проверяет корректность содержимого payload или правильность перехода — это ответственность кода приложения.
payload — сериализованное JSON-представление события, тип jsonb NOT NULL. Payload готовится до начала транзакции, передаётся параметром и не конкатенируется в SQL-строку. Для нашего перехода:
{
"event_id": "event-ticket-900",
"ticket_id": "ticket-42",
"from_status": "open",
"to_status": "resolved",
"changed_at": "2026-10-03T08:00:00Z"
}
Именно этот документ будущий publisher отправит в topic tickets.status.v1 с ключом ticket-42. В таблице outbox нет отдельного столбца topic — publisher знает маршрутизацию самостоятельно, либо topic добавляется как поле внутри payload или отдельным столбцом при необходимости. Для этого урока четырёх столбцов достаточно.
published_at — nullable timestamptz, изначально NULL. NULL в этом поле означает ровно одно: в этой БД ещё не зафиксирован факт публикации. Это не доказательство отсутствия события в Kafka — publisher может отправить событие и упасть до того, как отметит строку. Пока мы в 8.2, этот столбец просто фиксирует исходное состояние новой outbox-строки. Семантику маркировки и её ограничения разбирает 8.3.
BeginTx → guarded UPDATE → INSERT → Commit через один tx
Оба SQL-вызова — UPDATE и INSERT — должны идти через одну и ту же переменную tx. Если хотя бы один из них вызван через db.ExecContext, он выполнится в отдельном соединении и не будет частью транзакции: тогда атомарность теряется. Это ключевое техническое требование database/sql.
Ниже — учебный фрагмент, который показывает полный путь с проверкой ошибок. Это не самостоятельная программа, а фрагмент с обозначенными переменными:

// Фрагмент: предполагается, что db *sql.DB и ctx context.Context уже определены.
// payload сериализован заранее, до начала транзакции.
const payloadJSON = `{"event_id":"event-ticket-900","ticket_id":"ticket-42",` +
`"from_status":"open","to_status":"resolved","changed_at":"2026-10-03T08:00:00Z"}`
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Отложенный Rollback — cleanup на случай незавершённой транзакции.
// После успешного Commit вернёт sql.ErrTxDone; эту ошибку игнорируем.
defer func() {
if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone {
// В этом фрагменте не логируем и не подменяем основную ошибку.
_ = rbErr
}
}()
// Шаг 1: guarded UPDATE — переход только из состояния 'open'.
res, err := tx.ExecContext(ctx,
`UPDATE tickets SET status = 'resolved' WHERE id = $1 AND status = 'open'`,
"ticket-42",
)
if err != nil {
return err // tx.Rollback вызовет defer
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
// Ticket не в состоянии 'open' или не существует; событие не нужно.
return fmt.Errorf("unexpected rows affected: %d", affected)
}
// Шаг 2: INSERT outbox — тот же tx, параметризованный запрос, без конкатенации JSON.
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox (event_id, aggregate_id, payload, published_at)
VALUES ($1, $2, $3::jsonb, NULL)`,
"event-ticket-900",
"ticket-42",
payloadJSON,
)
if err != nil {
return err // включает конфликт PRIMARY KEY; defer вызовет Rollback
}
// Шаг 3: Commit — только если оба предыдущих шага прошли без ошибок.
if err = tx.Commit(); err != nil {
return err
}
// После этой строки обе записи устойчивы и видны другим транзакциям.
return nil
Несколько деталей, на которые стоит обратить внимание.
WHERE id = $1 AND status = 'open' — это охранное условие. Если ticket-42 уже resolved или строки нет вовсе, PostgreSQL вернёт ноль затронутых строк. RowsAffected() == 0 — не ошибка драйвера, а бизнес-условие: переход не состоялся, записывать событие о нём не нужно. В этом случае транзакцию нужно завершить (defer вызовет Rollback), а вызывающий код получит конкретную причину.
$3::jsonb — явное приведение и проверка JSON на стороне PostgreSQL. Здесь оно делает намерение читаемым; в контексте INSERT PostgreSQL может вывести тип параметра из целевого столбца и без этого cast, поэтому его отсутствие само по себе не означает ошибку.
Конфликт PRIMARY KEY при повторном event-ticket-900 — не тихий успех. Если в outbox уже есть строка с этим event_id (например, из предыдущей успешно зафиксированной транзакции), INSERT вернёт ошибку unique_violation. Использовать ON CONFLICT DO NOTHING здесь нельзя: тогда мы потеряем информацию о том, что строка уже существует, и не сможем отличить «повтор транзакции, которая ранее прошла» от «первая попытка». Вся текущая транзакция откатывается — это правильное поведение для базового случая.
tx.Commit() завершает транзакцию. После возврата nil обе строки — tickets('ticket-42', 'resolved') и outbox('event-ticket-900', 'ticket-42', ..., NULL) — зафиксированы вместе и видны любому последующему читателю.
Что происходит в каждой точке сбоя и где граница гарантий
PostgreSQL-транзакция детерминирована в том смысле, что незавершённая транзакция всегда откатывается — либо явным Rollback, либо при потере соединения. Поэтому для большинства точек сбоя состояние предсказуемо.
| Точка сбоя | `tickets` (`ticket-42`) | `outbox` (`event-ticket-900`) | Kafka / History |
|---|---|---|---|
| Процесс падает после `UPDATE`, до `INSERT` или `Commit`; незавершённая tx откатилась | `open` | строки нет | событие не отправлялось |
| `INSERT` вернул ошибку, вызван `Rollback` | `open` | новой строки нет | событие не отправлялось |
| `UPDATE` и `INSERT` успешны, процесс падает до `Commit`; незавершённая tx откатилась | `open` | строки нет | событие не отправлялось |
| `Commit` вернул `nil`, затем процесс падает до любого вызова `Publish` | `resolved` | строка есть, `published_at IS NULL` | Kafka не вызывалась этой попыткой; History без события |
В первых трёх строках таблицы при заданном начальном отсутствии event-ticket-900 обе таблицы остаются в исходном состоянии, Kafka не затронута: незавершённые изменения откатились вместе. Конфликт PK — отдельная трасса с уже существующей строкой outbox; Rollback отменит новый UPDATE, но не удалит ранее зафиксированную строку.
Четвёртая строка — единственная, в которой outbox-строка появляется. В этой строго заданной трассе процесс падает до любого вызова Publish: Kafka не получила событие от этой попытки, а published_at IS NULL показывает, что публикация в БД не отмечена. Отдельный будущий случай из 8.3 иной: publisher может отправить событие и упасть до отметки, так что published_at останется NULL даже при наличии события в Kafka. Поэтому в общем случае NULL не доказывает отсутствие публикации; до запуска publisher эта строка просто ждёт обработки.
Неопределённый случай — потерянный ответ Commit. Если tx.Commit() вызван, но Go-процесс не получил ответа (обрыв соединения, таймаут), у нас нет информации о том, зафиксировала ли PostgreSQL транзакцию или нет. Это не то же самое, что подтверждённый nil. В такой ситуации нельзя считать транзакцию откатившейся и генерировать новый event_id для повтора — это создаст второе событие, если первая транзакция прошла. Правильный путь: прочитать состояние по стабильному ticket-42 и event-ticket-900 и сверить с ожидаемым результатом. Именно поэтому event_id стабилен и задаётся до транзакции.
Что локальная транзакция гарантирует и что нет:
Гарантирует — атомарность между tickets и outbox в этой PostgreSQL БД. После подтверждённого Commit при любом последующем падении и рестарте обе записи сохранены вместе. Publisher при рестарте найдёт outbox-строку и будет знать, что событие нужно отправить.
Не гарантирует — что событие уже в Kafka, что History обновилась, что доставка когда-либо произойдёт автоматически без работающего publisher, или что доставка будет exactly-once. Это обязанности publisher (8.3) и consumer-inbox (8.4).
Сравнение с Трассой A из 8.1 конкретное: тогда после подтверждённого Commit в Ticket DB не было никакой записи о намерении публикации. При рестарте процесс видел resolved и не мог знать, нужно ли публиковать событие. Теперь при том же Commit фиксируется и resolved, и outbox-строка. При рестарте publisher найдёт строку с published_at IS NULL — это и есть устойчивое намерение, которого в 8.1 не хватало.
Задача 1
Ticket service выполняет переход ticket-42 из open в resolved. Исходное состояние: tickets('ticket-42', 'open') зафиксирована, строки outbox с event_id='event-ticket-900' нет. Какая последовательность операций корректно помещает оба изменения в одну атомарную единицу?
Задача 2
Дано: tickets('ticket-42', 'open') зафиксирована. В таблице outbox уже существует ранее зафиксированная строка с event_id='event-ticket-900' (PRIMARY KEY). Ticket service открывает новую транзакцию: BeginTx → tx.ExecContext UPDATE вернул RowsAffected=1 → tx.ExecContext INSERT в outbox с тем же event_id='event-ticket-900' вернул ошибку конфликта PRIMARY KEY → вызван tx.Rollback. Каково состояние таблиц после завершения Rollback?
Задача 3
Исходное состояние: tickets('ticket-42', 'open') зафиксирована, строки outbox с event_id='event-ticket-900' нет, предыдущих попыток публикации этого перехода не было. Ticket service выполнил BeginTx → tx.ExecContext UPDATE → tx.ExecContext INSERT → tx.Commit, и Commit вернул подтверждённый успех (nil). После этого процесс упал прежде, чем был вызван Publish. Каково состояние системы непосредственно после падения?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
