Согласованность: транзакции, идемпотентность, outbox и саги
Как сохранить данные корректными, когда работа охватывает базы данных, очереди и сторонние API.
За 30 секунд
Внутри одной базы данные согласованными держат транзакции. Как только работа пересекает сетевую границу (очередь, платёжный провайдер, другой сервис), единой транзакции больше нет. Инструменты, которые сохраняют данные корректными, — ключи идемпотентности, transactional outbox, саги с компенсирующими шагами и аккуратная сверка. Цель не в том, чтобы избежать сбоев, а в том, чтобы любой сбой можно было безопасно повторить.
Почему я это использую
- Я использую эти паттерны, потому что платежи, заказы и остатки — именно то место, где дублированные или потерянные сообщения стоят реальных денег.
- Сети ломаются самым неприятным образом: запрос может выполниться, а ответ — потеряться. Идемпотентность делает повтор безвредным.
- Паттерн outbox убирает целый класс багов, когда база говорит одно, а очередь — другое.
- Честное объяснение eventual consistency помогает клиентам принимать надёжные решения, а не те, что лишь выглядят простыми.
Как это работает
01ACID и уровни изоляции
Транзакция в базе группирует изменения так, чтобы они вместе либо выполнились, либо нет. ACID описывает это обещание: Atomicity (всё или ничего), Consistency (ограничения соблюдаются), Isolation (параллельные транзакции не видят недоделанную работу друг друга) и Durability (закоммиченные данные переживают сбой). Изоляция бывает разных уровней. Read committed, уровень по умолчанию в PostgreSQL, показывает только закоммиченные данные, но позволяет значению измениться между двумя чтениями в одной транзакции. Repeatable read даёт стабильный снимок — это уровень по умолчанию в MySQL InnoDB. Serializable заставляет параллельные транзакции вести себя так, будто они выполнялись одна за другой, ценой повторов при конфликтах. Классические аномалии — потерянные обновления (два пользователя одновременно уменьшают остаток) и write skew. Предотвращайте их блокировками строк (SELECT ... FOR UPDATE), атомарными обновлениями (SET stock = stock - 1 WHERE stock > 0), уникальными ограничениями или уровнем serializable с повтором.
02Почему распределённые транзакции — это сложно
Когда заказ затрагивает вашу базу, платёжного провайдера и очередь сообщений, никакая одна транзакция не охватывает все три. Двухфазный коммит (2PC) пытается: координатор просит каждого участника подготовиться, а затем закоммитить. В некоторых закрытых системах это работает, но блокируется при отказе координатора, держит блокировки на время сетевых round-trip'ов и не поддерживается большинством очередей и ни одним сторонним HTTP API. Фундаментальная проблема — двойная запись: вы сохраняете заказ, а затем публикуете «заказ создан». Если процесс упадёт между этими шагами, то либо событие потеряется, либо — если вы публикуете первым — появится событие для заказа, который так и не сохранился. Повторы добавляют ещё одну проблему: таймаут не говорит, выполнила ли другая сторона работу. Эти сбои нельзя устранить, поэтому вы проектируете с расчётом на них.
- Таймаут означает «неизвестно», а не «не удалось».
- Каждый шаг через границу должен быть повторяемым без побочных эффектов.
- Держите путь сверки для состояний, которые нельзя определить автоматически.
03Ключи идемпотентности
Операция идемпотентна, если её двойное выполнение даёт тот же эффект, что и однократное. GET, PUT и DELETE идемпотентны по определению; POST, который создаёт сущности или списывает деньги с карт, — нет. Решение — ключ идемпотентности: клиент генерирует уникальный ID (UUID) для каждой логической операции и отправляет его, обычно в заголовке Idempotency-Key. Сервер сохраняет ключ вместе с результатом в той же транзакции, что и саму работу, под защитой уникального ограничения в рамках tenant'а или пользователя. Повтор с тем же ключом возвращает сохранённый результат, а не повторяет действие; параллельный дубликат упирается в уникальное ограничение и ждёт или аккуратно падает. Ключи должны истекать через разумный срок, а ключ, повторно использованный с другим телом запроса, должен отклоняться. Ту же идею применяйте к consumer'ам: записывайте ID обработанных сообщений.
CREATE TABLE idempotency_keys (
tenant_id uuid NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, key)
);
-- Inside the same transaction as the business write:
INSERT INTO idempotency_keys (tenant_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING key;
-- No row returned? It is a retry: load and return the stored response.
04Transactional outbox
Паттерн outbox решает проблему двойной записи. Вместо прямой публикации в брокер вы вставляете событие в таблицу outbox в той же транзакции, что и бизнес-изменение. Либо закоммитится и то и другое, либо ничего. Затем отдельный процесс-ретранслятор читает неопубликованные строки, публикует их в очередь или брокер и помечает как отправленные. Если ретранслятор упадёт после публикации, но до пометки, событие опубликуется ещё раз, так что доставка — at-least-once, и consumer'ы должны быть идемпотентными. Используйте FOR UPDATE SKIP LOCKED, чтобы несколько воркеров-ретрансляторов делили работу без коллизий, и подчищайте старые опубликованные строки. Альтернативный ретранслятор — change data capture (например, Debezium, читающий лог базы), который избавляет от поллинга. Обратный паттерн, inbox, хранит ID входящих сообщений для дедупликации на стороне consumer'а.
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'order.paid', $2);
COMMIT;
-- Relay worker, in a loop:
SELECT id, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each row, then:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
05Саги: оркестрация и хореография
Сага — длинная бизнес-транзакция, разбитая на локальные шаги, у каждого из которых есть компенсирующее действие, семантически отменяющее его. Оформление заказа может выглядеть так: зарезервировать товар, списать деньги с карты, создать отправку. Если списание не удалось, сага снимает резерв. Компенсация — это не откат: отправленное письмо не «разотправишь», поэтому вы шлёте исправление; захваченный платёж возвращают, а не стирают. При хореографии каждый сервис слушает события и порождает следующее («StockReserved» запускает оплату). Это слабо связано, но трудно отслеживать, когда шагов много. При оркестрации один компонент — явная машина состояний — говорит каждому участнику, что делать, и записывает прогресс. Это проще мониторить, тестировать и менять, и для процессов больше чем из двух-трёх шагов это мой вариант по умолчанию. В любом случае сохраняйте состояние саги, делайте каждый шаг идемпотентным и показывайте операторам зависшие саги.
06Eventual consistency, мифы об exactly-once и CAP
Eventual consistency означает, что если новых изменений не поступает, все копии данных в итоге сойдутся, но какое-то короткое время они могут расходиться. На практике это нормально для баллов лояльности, появившихся на секунду позже, и не нормально для баланса, который пользователь может перерасходовать. Проектируйте пользовательский опыт с учётом этого: показывайте состояния «в обработке», читайте свои записи из primary и регулярно сверяйтесь с источником правды, например с отчётом платёжного провайдера. «Exactly-once доставка» по сети — миф; построить можно exactly-once эффект — из at-least-once доставки плюс идемпотентной обработки. CAP простыми словами: когда сеть разрезает вашу систему надвое, каждая половина должна либо отказывать в части запросов (выбор согласованности), либо продолжать отвечать, возможно, устаревшими данными (выбор доступности). Без разделения сети реальный компромисс — задержка против согласованности.
Когда применять
- Любой процесс, который пишет в базу и публикует сообщение или вызывает внешний API.
- Платежи, возвраты, заказы и движение товаров, где дубликаты или потери стоят денег.
- Публичные или партнёрские API, которые клиенты могут повторять после таймаутов.
- Многошаговые бизнес-процессы через сервисы или внешних партнёров.
Когда не стоит
- Работа, которая помещается в одну транзакцию базы: используйте транзакцию и не усложняйте.
- Полноценный фреймворк саг для двухшагового процесса, который уже покрывают outbox и идемпотентный обработчик.
- Распределённый двухфазный коммит между HTTP-сервисами: он туда не подходит и вызывает блокировки.
Альтернативы
- Одна транзакция в базе
Самый простой и самый надёжный вариант, когда все данные живут в одной базе.
- Change data capture
Стримить лог базы (например, через Debezium) вместо поллинга таблицы outbox.
- Workflow-движки
Temporal и похожие инструменты сохраняют состояние саг и повторы для длинных процессов.
- Двухфазный коммит
Возможен между поддерживающими его базами в закрытых системах; для веб-приложений редко того стоит.
Лучшие практики
- Считайте таймаут неизвестным исходом и выясняйте его проверкой, а не догадкой.
- Требуйте ключи идемпотентности на каждом неидемпотентном публичном эндпоинте.
- Публикуйте события через outbox, записанный в той же транзакции, что и изменение.
- Делайте каждый consumer идемпотентным с помощью таблиц обработанных ID, уникальных ограничений или upsert'ов.
- Для многошаговых саг предпочитайте оркестрацию с сохранённым состоянием и видимыми зависшими процессами.
- Регулярно сверяйте деньги и остатки с источником правды по расписанию.
Частые ошибки
- Публиковать в очередь сразу после коммита и терять события при падениях.
- Повторять платёжные запросы без ключей идемпотентности и списывать с клиентов дважды.
- Помечать операцию как «не удалась» после таймаута, хотя на самом деле она выполнилась.
- Считать, что настройка «exactly-once» у брокера покрывает записи в базу и письма.
- Писать компенсации, которые сами не идемпотентны, так что повторы возвращают деньги дважды.
Глубже
07MVCC, serializable-изоляция и циклы ретраев
PostgreSQL и MySQL InnoDB используют multi-version concurrency control: писатели создают новые версии строк, а читатели видят снапшот, поэтому чтения не блокируют записи. Отсюда и аномалии: две транзакции могут каждая прочитать снапшот, принять решение и записать, не видя друг друга. Уровень serializable в PostgreSQL использует Serializable Snapshot Isolation: он отслеживает read-write зависимости и прерывает одну из транзакций с SQLSTATE 40001 (serialization_failure), когда складывается опасный паттерн. Это даёт самую сильную гарантию без блокировки каждой строки, но только если приложение повторяет всю транзакцию при 40001 — в идеале с небольшим backoff и лимитом попыток. Держите serializable-транзакции короткими, избегайте внешних вызовов внутри них и используйте их для инвариантов, охватывающих несколько строк, например «хотя бы один дежурный врач», где простой блокировки строки недостаточно.
async function withSerializableRetry<T>(fn: (tx: Tx) => Promise<T>, attempts = 5): Promise<T> {
for (let i = 1; ; i++) {
try {
return await db.transaction(fn, { isolationLevel: "serializable" });
} catch (err: any) {
const retryable = err?.code === "40001" || err?.code === "40P01"; // serialization / deadlock
if (!retryable || i >= attempts) throw err;
await new Promise((r) => setTimeout(r, Math.random() * 50 * i));
}
}
}
08Идемпотентность в деталях: запросы в процессе и отпечатки
У наивной таблицы идемпотентности есть дыра: первый запрос ещё выполняется, когда приходит ретрай. Храните вместе с ключом состояние: in_progress, затем completed с ответом или failed с флагом retryable. Ретрай, который видит in_progress, получает 409 Conflict (или ненадолго ждёт), но никогда не второе выполнение. Снимайте отпечаток запроса (метод, путь, канонический хэш тела) и отклоняйте повторное использование ключа с другим отпечатком. Решите, что хранить: финальный успех и детерминированные ошибки валидации проигрываются повторно, а временные ошибки вроде таймаута нижестоящего сервиса не кэшируются, чтобы клиент мог повторить запрос. Ограничивайте область ключей аккаунтом, чтобы избежать коллизий, удаляйте их после окна ретраев клиента, а когда операция вызывает третью сторону, передавайте ей производный idempotency key, чтобы вся цепочка была безопасной от начала до конца.
- Состояния: in_progress, completed (с ответом), failed.
- Тот же ключ + другое тело = 422, а не повтор.
- Передавайте производный ключ платёжным провайдерам и другим API.
09Outbox в масштабе: порядок, CDC и слоты репликации
Polling relay с SKIP LOCKED прост, но может переставлять события местами, потому что несколько воркеров публикуют параллельно. Если потребителям нужен порядок по сущности, разделите работу по aggregate ID или используйте один relay на партицию и добавляйте в каждое событие версию агрегата, чтобы потребители могли отбрасывать устаревшие. Инструменты change data capture, например Debezium, читают write-ahead log через logical replication slot вместо опроса — это даёт низкую latency и порядок коммитов. Эксплуатационная ловушка: слот репликации удерживает WAL на primary, пока потребитель его не подтвердит. Если CDC-коннектор остановится, WAL копится и может заполнить диск. Мониторьте lag слотов, задайте max_slot_wal_keep_size (PostgreSQL 13+) как предохранитель и удаляйте брошенные слоты. Регулярно чистите таблицу outbox, в идеале по партициям, чтобы она оставалась маленькой и быстрой.
-- How much WAL each replication slot is holding back
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
-- postgresql.conf: never let a stuck slot fill the disk
-- max_slot_wal_keep_size = '20GB'
10Дизайн саги: pivot-шаги и контрмеры для изоляции
Саги жертвуют изоляцией: другие транзакции могут видеть промежуточные состояния, например зарезервированный товар для заказа, который будет отменён. Упорядочивайте шаги осознанно. Сначала идут компенсируемые шаги (резерв товара, холд средств), затем pivot-шаг — точка невозврата, например capture платежа, а затем повторяемые шаги, которые обязаны в итоге выполниться (создание отправки, отправка чека) и ретраятся, а не компенсируются. Чтобы ограничить аномалии, используйте контрмеры. Semantic lock помечает записи как pending, чтобы другие процессы обращались с ними осторожно. Коммутативные обновления (прибавление и вычитание балансов) делают порядок неважным. Повторное чтение значения перед обновлением показывает, что оно изменилось. Поля версий отклоняют устаревшие записи. Сохраняйте состояние саги с таймаутами на каждый шаг, чтобы сага, вечно ждущая потерянного ответа, становилась видимой задачей, с которой можно работать, а не тихой утечкой зарезервированного товара.
Реальные ситуации
Платёжный шлюз отвечает таймаутом во время checkout
Проблема
Приложение вызывает шлюз, чтобы провести capture платежа, и через 30 секунд запрос падает по таймауту. Клиент видит ошибку, пробует снова, и с него списывают деньги дважды.
Как решить
Считайте таймаут неизвестным исходом. Перед вызовом шлюза сохраните попытку платежа со статусом pending и сгенерированным idempotency key и отправьте этот ключ вместе с запросом. При таймауте оставьте статус outcome_unknown и покажите клиенту «платёж подтверждается». Фоновая задача опрашивает шлюз по этому ключу или reference и определяет состояние; ретрай с тем же ключом не может списать дважды. Блокируйте кнопку оплаты, пока платёж в pending, и ежедневно запускайте сверку с settlement-отчётом шлюза для всего, что осталось неразрешённым.
События теряются во время деплоев
Проблема
Склады иногда так и не узнают об оплаченных заказах. Код коммитит заказ, затем публикует в очередь, и потери совпадают с рестартами при деплое.
Как решить
Это dual write. Вставляйте строку outbox в той же транзакции, что и смену статуса, а relay пусть публикует и помечает строки. На принимающей стороне добавьте таблицу inbox с уникальным ID сообщения, чтобы повторные доставки игнорировались. Восстановите пропущенные события, сравнив оплаченные заказы с подтверждениями склада за затронутый период. Настройте graceful shutdown, чтобы воркеры дорабатывали текущий батч перед выходом, и поставьте алерт на строки outbox старше нескольких минут — это сигнал зависшего relay.
Перепродажа во время flash sale
Проблема
Лимитированный товар продали в количестве 130 штук, хотя было только 100. Код читает остаток, проверяет его в коде приложения, затем записывает новое значение.
Как решить
Замените read-check-write одним атомарным запросом: UPDATE products SET stock = stock - $2 WHERE id = $1 AND stock >= $2, и считайте ноль затронутых строк признаком «распродано». Для checkout, который длится минуты, создавайте резервы со сроком действия в той же транзакции и освобождайте их плановой задачей, если оплата не завершилась. При очень высокой конкуренции за одну строку подумайте о разделении остатка на несколько счётчиков или об очереди, которая выстраивает заказы на этот товар последовательно. Добавьте тест, который запускает параллельные покупки и проверяет, что остаток никогда не становится отрицательным.
Шпаргалка
- Атомарное списание остатка
UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0- Заблокировать строку на время транзакции
SELECT ... FROM accounts WHERE id = $1 FOR UPDATE- Параллельные воркеры relay
SELECT ... FROM outbox WHERE published_at IS NULL LIMIT 100 FOR UPDATE SKIP LOCKED- Дедупликация через однократную вставку
INSERT INTO inbox (message_id) VALUES ($1) ON CONFLICT DO NOTHING- Ретрай serializable-конфликтов
Повторять всю транзакцию при SQLSTATE 40001 / 40P01- Заголовок идемпотентности
Idempotency-Key: 7f1c2d9e-3b4a-4c55-9a77-1e2f3a4b5c6d- Найти зависшие CDC-слоты
SELECT slot_name, active FROM pg_replication_slots;- Ограничить WAL, удерживаемый слотами
max_slot_wal_keep_size = '20GB'- Порядок шагов саги
Компенсируемые шаги -> pivot -> повторяемые шаги
В моих реальных проектах
- В POS SaaS доменные события идут через transactional outbox в BullMQ, а затем в обработчики, а исходящие вебхуки подписываются HMAC и отправляются через очередь.
- Партнёрские заказы там используют ключи идемпотентности, а офлайн-команды синхронизации от касс филиалов дедуплицируются по терминалу.
- У возвратов там есть явное состояние «исход неизвестен» и задача сверки, вместо того чтобы гадать после таймаута платёжного шлюза.
- В CRM для спортзала отправка SMS повторяется только при ошибке соединения, чтобы ни одно сообщение не ушло дважды, а приложение зоомагазина повторяет только идемпотентные HTTP-методы.
Вопросы и ответы
Q1Что такое паттерн transactional outbox и зачем он нужен?
Он решает проблему двойной записи: сохранение данных и публикация сообщения — это две системы, и падение между ними оставляет их несогласованными. С outbox вы вставляете событие в таблицу в той же транзакции, что и бизнес-изменение, так что оба либо коммитятся вместе, либо не коммитятся вовсе. Затем процесс-ретранслятор читает неопубликованные строки, отправляет их в брокер и помечает опубликованными. Если он упадёт на полпути, то опубликует повторно, так что доставка — at-least-once, и consumer'ы должны быть идемпотентными. В итоге ни одно событие не теряется и ни одно не публикуется для данных, транзакция которых откатилась.
Q2Как работают ключи идемпотентности в платёжном API?
Клиент генерирует уникальный ключ для каждой логической операции, например для одной попытки оформления заказа, и отправляет его с запросом. Сервер сохраняет ключ, хэш запроса и результат в той же транзакции, что и саму работу, с уникальным ограничением в рамках аккаунта. Если у клиента случился таймаут и он повторяет запрос с тем же ключом, сервер возвращает сохранённый результат, а не списывает деньги снова. Параллельный дубликат сталкивается на ограничении. Повторное использование ключа с другим телом отклоняется. Ключи истекают через определённый срок — часто около суток, в зависимости от политики повторов.
Q3Оркестрация или хореография для саг?
Хореография подходит для коротких процессов: каждый сервис реагирует на событие и порождает следующее, без центрального компонента и со слабой связанностью. По мере роста числа шагов процесс становится неявным и размазанным по сервисам, так что никто не видит, где завис заказ, и не может рассуждать о компенсациях. Оркестрация помещает процесс в одну явную машину состояний, которая командует каждым шагом, сохраняет прогресс и запускает компенсации. Это проще тестировать, мониторить и менять ценой ещё одного компонента, знающего весь процесс. Для всего, что длиннее двух-трёх шагов, особенно когда замешаны деньги, я предпочитаю оркестрацию.
Q4Возможна ли exactly-once доставка?
Не как общая сетевая гарантия. Отправитель не может знать, означает ли потерянное подтверждение, что сообщение не было обработано или что потерялся только ответ, поэтому он вынужден либо рискнуть потерять его, либо рискнуть отправить повторно. Системы выбирают at-least-once и затем делают обработку идемпотентной, что даёт exactly-once эффект. Семантика exactly-once в Kafka реальна, но ограничена транзакционным чтением и записью topic'ов Kafka. Как только обработчик касается базы, почтового провайдера или платёжного API, на этой границе нужна дедупликация: уникальные ключи, upsert'ы или таблица обработанных сообщений.
Q5Объясните теорему CAP на практике.
CAP говорит, что во время разделения сети, когда части распределённой системы не могут общаться друг с другом, нужно выбирать между согласованностью и доступностью. Выбор согласованности означает, что часть запросов отклоняется или ждёт, пока ноды договорятся, — зато никто не читает устаревшие данные. Выбор доступности означает, что каждая нода продолжает отвечать, возможно, устаревшими данными, а конфликты разрешаются позже. Разделения в реальных сетях неизбежны, так что выбор на самом деле — что делать, когда они происходят. Многим командам полезнее PACELC: даже без разделений вы выбираете между задержкой и согласованностью, например между синхронной и асинхронной репликацией.
Q6Как не дать двум пользователям купить последний товар на складе?
Сделать решение атомарным в базе. Простейший вариант — условное обновление: уменьшить остаток там, где количество ещё положительное, и проверить число затронутых строк; ноль означает, что товар распродан. Другой вариант — заблокировать строку через SELECT FOR UPDATE внутри транзакции или вести таблицу резервов с ограничениями. Для товаров с высокой конкуренцией хорошо работают короткоживущие резервы с истечением: товар удерживается на время оформления и освобождается, если оплата не прошла. Никогда не проверяйте остаток в коде приложения, а затем не обновляйте его отдельным шагом без блокировки — это классическое состояние гонки.
Вопросы уровня senior
S1Когда бы вы внедрили workflow engine вместо саг, написанных вручную?
Когда процессы становятся долгими, многочисленными или плохо наблюдаемыми. Самописный оркестратор подходит для нескольких процессов с небольшим числом шагов: таблица состояний, outbox и идемпотентные обработчики. Как только понадобятся таймеры на несколько дней, ручные согласования, версионированные определения процессов, ретраи с политиками для каждого шага и UI для разбора зависших экземпляров, вы уже заново строите workflow engine. Инструменты вроде Temporal сохраняют историю событий процесса и детерминированно проигрывают код, так что после сбоя работа продолжается ровно с места остановки. Компромиссы: новый кусок критичной инфраструктуры, который нужно эксплуатировать или оплачивать, модель программирования с правилами детерминизма и кривая обучения. Я внедряю его, когда бизнес-логика, а не обвязка, становится большей частью кода.
S2Как доказать, что система, двигающая деньги, остаётся корректной со временем?
Проектируйте под проверяемость, а не только под корректность. Используйте double-entry ledger: каждое движение — неизменяемая пара проводок с суммой ноль, поэтому балансы вычисляются, а не редактируются на месте. Пусть каждая внешняя операция несёт reference, который можно сопоставить с провайдером. Проводите сверку на нескольких уровнях: внутренние инварианты (сумма по ledger равна нулю, нет отрицательных балансов) и внешние сопоставления с отчётами шлюза и банка, получая список расхождений с ответственными. Храните суммы целыми числами в минимальных единицах с явной валютой. Настройте алерты на несверенные позиции по возрасту и сумме. Компромисс — больше таблиц и задач, чем простая колонка баланса, но это превращает «мы думаем, что всё верно» в доказательства, которые принимают аудиторы и финансовые команды.
S3Strong или eventual consistency для фичи: как вы решаете вместе с бизнесом?
Я формулирую это как цену кратковременной ошибки против цены медленности или меньшей доступности. Для каждого вида данных спрашиваю, что будет, если пользователь увидит значение многосекундной давности. Баллы лояльности, аналитика и результаты поиска — безвредно, поэтому eventual consistency с асинхронными обновлениями подходит и дёшево масштабируется. Балансы счетов, бронирование мест, остаток на checkout — устаревшее чтение может привести к перерасходу или двойному бронированию, поэтому точка принятия решения должна читать из primary под блокировкой или через условную запись. Часто ответ смешанный: просмотр на eventually consistent данных, а затем строгая проверка в момент фиксации. Записанное решение по каждой фиче избавляет от случайных гарантий, о которых никто не договаривался.
Глоссарий
- Dual write
- Запись в две системы без общей транзакции, из-за чего сбой может оставить их несогласованными.
- Idempotency key
- ID, сгенерированный клиентом, который позволяет серверу распознать повторный запрос и безопасно его проиграть.
- Outbox
- Таблица событий, записываемых в бизнес-транзакции и публикуемых позже через relay.
- Inbox
- Таблица ID обработанных сообщений, с помощью которой потребитель игнорирует дубликаты.
- Компенсирующее действие
- Бизнес-шаг, который по смыслу отменяет предыдущий, например возврат средств.
- Pivot transaction
- Шаг саги, после которого процесс уже нельзя компенсировать, только довести до конца.
- MVCC
- Хранение нескольких версий строк, чтобы читатели видели согласованный снапшот, не блокируя писателей.
- Write skew
- Две транзакции читают пересекающиеся данные и пишут разные строки, вместе нарушая правило.
- Сверка
- Периодическое сравнение своих записей с источником истины и исправление расхождений.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.