← назад к разделу

В проблемах распределённых систем и согласованности и консенсусе мы разбирали механизмы: кворумы, fencing-токены, линеаризуемость, консенсус. Эта статья — про то, что стоит над механизмами: как вообще рассуждать о корректности приложения.

Главный вывод неочевиден и меняет то, как вы проектируете: надёжные нижние уровни (транзакции, TCP, контрольные суммы) сами по себе не гарантируют, что приложение работает правильно. Это общий зонтик над тактическими паттернами вроде идемпотентности и outbox: они отвечают «как», а здесь — «почему без них нельзя».

Проще всего это видно на переводе денег, который клиент повторил после обрыва связи: каждый шаг по отдельности отработал правильно, а результат неверный.

клиент потерял ответ и повторил перевод — каждый запрос сам по себе корректен что уходит от клиента что делает сервер итог на счёте 4321 перевод 11со счёта 4321 на 1234транзакция прошлаCOMMIT, всё честно4321: 100 → 89ответ не дошёл повтор без request_idтот же перевод 11новая транзакциядедуп TCP — в старой связи4321: 89 → 78списано 22 вместо 11 или — из той же точки 89, но с request_idтот же повторно с тем же request_idINSERT упал на UNIQUEтранзакция откатилась4321: 89 → 89списано 11, один раз кто ловит повторTCP — не ловитдедуп жил в старой связитранзакция — не ловитповтор — законная записьrequest_id — ловитот клиента до хранилища надёжность нижних уровней не отменяет проверку на концах

Повтор после потери ответа — не сбой сети, а вторая честная транзакция: и TCP, и база отработали как надо, а списалось 22 вместо 11. Ловит его только сквозной request_id с UNIQUE на приёмнике.

Обязательно

Сквозной аргумент: почему транзакции недостаточноспросят на собеседовании

Классическая ловушка: «мы используем сериализуемые транзакции — значит, данные корректны». Нет. Сериализуемая транзакция гарантирует лишь, что параллельные транзакции не мешают друг другу. Но если приложение по ошибке (баг в коде или повтор запроса) записало неверные данные — транзакция честно и надёжно их зафиксирует.

Разберём на примере. Клиент отправляет перевод денег, теряет соединение до ответа сервера и повторяет запрос. Каждый запрос сам по себе — корректная транзакция, но денег списалось вдвое:

BEGIN;
UPDATE accounts SET balance = balance - 11 WHERE id = 4321;  -- отправитель
UPDATE accounts SET balance = balance + 11 WHERE id = 1234;  -- получатель
COMMIT;

Ни база, ни TCP тут не спасут. TCP отбрасывает дубликаты внутри одного соединения — но клиент переподключился, и для базы это уже новый запрос. Дедупликация нужна сквозная — от клиента до самого хранилища.

клиент ставит request_id TCP дедуп в соединении приложение повтор как новый база UNIQUE по request_id

Защита каждого промежуточного уровня кончается на его границе, и повтор гасит только ключ, который клиент донёс до самой базы.

Это и есть сквозной аргумент (end-to-end argument, сформулирован Saltzer, Reed и Clark ещё в 1984-м): функцию корректности можно полностью обеспечить только с участием конечных точек системы. Промежуточные уровни (сеть, транзакция) полезны для скорости, но не заменяют проверку на концах. Решение — идентификатор операции, который клиент генерирует один раз и протаскивает через все звенья:

CREATE TABLE requests (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    request_id uuid        NOT NULL UNIQUE,   -- ключ приходит от клиента
    response   jsonb,                         -- ответ первой попытки
    created_at timestamptz NOT NULL DEFAULT now()
);

BEGIN;
INSERT INTO requests (request_id) VALUES ('0286fd6e-1b4c-4a71-9a3e-5c0f7d2e8a10');
UPDATE accounts SET balance = balance - 11 WHERE id = 4321;  -- отправитель
UPDATE accounts SET balance = balance + 11 WHERE id = 1234;  -- получатель
COMMIT;

Повторный запрос с тем же request_id не пройдёт вставку — и перевод выполнится ровно один раз. Дальше важно, что вы сделаете с этим падением. Отдать клиенту ошибку — плохой ответ: клиент ни в чём не виноват, он просто не дождался первого ответа и нажал ещё раз.

Правильно — поймать нарушение уникальности, прочитать по тому же request_id сохранённый response первой попытки и вернуть его. Для клиента повтор выглядит как обычный успех, а деньги ушли один раз. Причём это ограничение UNIQUE реляционная база держит даже на слабых уровнях изоляции. Это фундамент под всей идемпотентностью: «ровно один раз» (exactly-once) — это не магия брокера, а сквозной ключ операции плюс дедупликация на приёмнике.

Оговорка тут важная, иначе тезис легко опровергнуть. У Kafka действительно есть транзакции и режим exactly-once — и он честно работает, но только пока вся цепочка внутри самой Kafka: прочитал из топика, посчитал, записал в топик, и сдвиг прочитанного фиксируется той же транзакцией. Как только на конце цепочки оказывается кто-то посторонний — HTTP-запрос от клиента, запись в чужую базу, отправленное письмо, — гарантия кончается: брокер понятия не имеет, что человек нажал «оплатить» второй раз. Поэтому галочка exactly-once в настройках брокера повторного платежа не отменяет, а сквозной request_id — отменяет.

Сколько живут записи дедупликации

Таблица requests из примера выше растёт с каждой операцией и однажды становится самой большой в базе. Значит, у записей есть срок жизни — и выбор этого срока не технический, а смысловой: он равен времени, в течение которого возможен повтор.

Считают так. Повтор приходит от клиента (человек нажал кнопку дважды — секунды), от библиотеки с повторами (минуты), от брокера при повторной доставке (до нескольких часов, если потребитель стоял), от фоновой сверки с внешней системой (сутки). Берут самый долгий из тех, что реально возможны в вашей системе, и добавляют запас. Семь дней — типичный ответ для платежей, сутки — для обычных запросов, час — для внутренних вызовов между сервисами.

Чистят по времени создания, пачками, чтобы не держать долгую блокировку:

DELETE FROM requests
WHERE created_at < now() - interval '7 days'
  AND id IN (SELECT id FROM requests WHERE created_at < now() - interval '7 days' LIMIT 10000);

Ещё лучше — секционировать таблицу по дате и удалять целые секции: тогда уборка не создаёт мёртвых строк и не заставляет базу их вычищать.

И главное, что нужно понимать про удаление. После истечения срока хранения защиты больше нет: повтор, пришедший позже, пройдёт как новая операция и спишет деньги второй раз. Это не дефект — это цена, которую вы осознанно назначили, выбрав срок. Отсюда два правила.

Срок хранения должен быть строго больше максимального времени жизни повтора у всех источников (в том числе больше срока хранения сообщений в брокере — иначе «отравленное» сообщение, всплывшее через неделю, обработается дважды). И в тех местах, где двойная операция неприемлема совсем, дедупликацию не строят на сроке хранения: там ключом становится сама бизнес-сущность — уникальный индекс на (order_id, kind) вместо request_id, потому что «второй возврат по этому заказу» невозможен по определению и не зависит от того, сколько записей вы храните.

Ограничения без глобальной координацииспросят на собеседовании

Авиакомпания продала на рейс больше мест, чем в самолёте, — и это не сбой, а осознанное решение. Строгое ограничение уникальности (два человека не займут одно место или один ник) требует координации — все конкурирующие запросы должны пройти через одну точку, которая решает, кто первый. В распределённой системе это дорого: единый ведущий узел, синхронный кворум, падение пропускной способности.

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

Целостность против своевременностиспросят на собеседовании

Самая полезная рамка тут — разложить размытое слово «согласованность» на два разных требования. Формула короткая: нарушение своевременности — это «согласованность иногда», а нарушение целостности — «вечная несогласованность».

  • Своевременность (timeliness) — пользователь видит актуальное состояние. Нарушение своевременности временно: почитали устаревшую реплику, но потом всё сойдётся. Это линеаризуемость и «читаем свои же записи».
  • Целостность (integrity) — данные не повреждены: нет потерь, нет противоречий, нет «денег, которые списали, но никто не получил». Нарушение целостности постоянно: само оно не исправится, нужна явная проверка и восстановление.

Вывод: в большинстве приложений целостность важнее. Транзакция по карте, не появившаяся за 24 часа, — терпимо (банки сводят операции асинхронно). А вот если баланс не равен сумме операций или деньги исчезли — это уже катастрофа.

Практический смысл: системы на журналах событий (CDC, потоковая обработка, outbox) разводят эти два свойства. Своевременность они по умолчанию не дают (потребители асинхронны), но целостность держат железно — через идемпотентную доставку и дедупликацию по request_id. Часто это и есть правильный размен: отказаться от дорогой синхронной координации, сохранив целостность и приняв временную несвоевременность.

своевременность расхождение реплика догнала сошлось само целостность расхождение никто не заметил так и осталось

Нарушение своевременности рассасывается само, нарушение целостности остаётся навсегда, пока его не найдут и не починят руками.

«Позже пришло» не значит «позже случилось»спросят на собеседовании

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

Причин расхождения три, и все обычные: повторная доставка (событие пришло дважды, второй раз позже), несколько потребителей или партиций (события одного заказа разошлись по разным путям), задержка у одного из отправителей (сервис перезапускался и выгрузил накопленное разом).

Лечится это тем, что в модели различают два времени.

  • occurred_at — когда факт случился, по данным источника события. Это часть события, её ставит отправитель, и она едет вместе с телом.
  • received_at — когда мы это событие получили. Ставит получатель, по своим часам.

Дальше решение принимается по первому, а второе нужно для отладки и для сигнала «события приходят с задержкой в полчаса». Конкретных приёмов два, и они дополняют друг друга.

Версия или номер вместо времени. Если источник один, надёжнее часов растущий номер: версия агрегата, счётчик изменений, смещение в журнале. Событие с версией меньше текущей — устаревшее, применять не нужно. Это дешёвая проверка одним сравнением, и она же защищает от дублей.

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

Отдельно про ленту событий для пользователя. Там время события — единственное правильное: лента, отсортированная по времени получения, показывает вчерашнее сообщение сверху только потому, что оно задержалось в очереди. Сортировка по occurred_at, а received_at остаётся во внутренних журналах.

И финальный признак, что порядок вам действительно важен: если логика читается как «сначала должно прийти A, потом B», значит это не два независимых события, а одна последовательность шагов — и её надо выразить состоянием, а не надеждой на доставку.

Фоновая проверка целостности

Последняя мысль: даже корректный код работает на неидеальном железе. Биты на диске портятся, повреждение в сети иногда проскакивает мимо контрольных сумм TCP, у самих баз бывают баги в уровнях изоляции. Зрелые системы не верят в надёжность слепо: HDFS и S3 фоново перечитывают файлы и сверяют их с репликами. Продолжение той же мысли — аудитопригодность: системы на неизменяемых событиях позволяют проследить происхождение любого производного состояния и пересобрать его заново для проверки, а криптографические инструменты (деревья Меркла) — подтвердить, что данные не подменили. Проверяйте целостность периодически, а не узнавайте о повреждении тогда, когда бэкап уже испорчен.

Как это делает обычный сервис на PostgreSQL

Деревья Меркла и фоновое перечитывание файлов — это про хранилища, а не про ваш сервис. Но сама идея применима и в скромном виде, и обходится она дешевле, чем кажется. Вот четыре проверки, которые реально ставят.

Сверка суммы с журналом операций. Если у сущности есть накопленное значение (баланс, остаток на складе, счётчик бонусов) и журнал операций, из которого оно получилось, то раз в сутки их сравнивают:

живой пример

SELECT o.id, o.total_amount, coalesce(sum(p.amount), 0) AS captured
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id AND p.status = 'CAPTURED'
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED', 'COMPLETED')
GROUP BY o.id, o.total_amount
HAVING coalesce(sum(p.amount), 0) <> o.total_amount;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Здесь проверяется инвариант «у оплаченного заказа сумма проведённых платежей равна сумме заказа». Пустой результат — всё в порядке. Непустой — вы узнали о повреждении сегодня, а не через полгода от клиента. Это самая полезная проверка из всех, и она пишется за полчаса.

Инварианты как запрос. Любое утверждение «такого не бывает» превращается в запрос, который обязан вернуть ноль строк: заказ в статусе «оплачен» без записи платежа, платёж без заказа, отрицательный остаток, позиция заказа с ценой, которой нет ни в одной версии каталога. Набор таких запросов — это и есть проверка целостности; их держат в репозитории рядом с миграциями и запускают по расписанию.

Пересчёт производного состояния. Модель для чтения, кэш, поисковый индекс, агрегаты для отчётов — всё это выводится из основных данных, а значит может расходиться. Рабочая привычка: уметь пересобрать производное с нуля (из журнала событий или из исходных таблиц) и время от времени сравнивать пересобранное с текущим. Второе даже важнее первого: пересборка обычно есть, а сравнения почти никогда, и расхождения живут годами.

Ограничения в самой базе. Дешевле всего целостность держит не проверка, а невозможность: внешние ключи, CHECK, NOT NULL, уникальные индексы, исключающие ограничения для пересекающихся интервалов. То, что база отвергает, проверять не нужно. Это первое, куда стоит смотреть, когда захочется написать ночную сверку: возможно, достаточно ограничения.

Периодичность и что делать с расхождением. Дешёвые проверки — раз в час, тяжёлые агрегатные — ночью, на реплике, чтобы не мешать работе. Расхождение не исправляют автоматически: сигнал дежурному, запись в отдельную таблицу расхождений и разбор человеком. Автоматическое «выравнивание» опасно тем, что скрывает причину — и тем, что при ошибке в самой сверке испортит верные данные.

Где это применяется

Как только в системе появляются повторные запросы, несколько хранилищ или асинхронные потребители — вы обязаны рассуждать о корректности отдельно от «база же надёжная». Всё, о чём эта статья говорит принципами, в готовом виде разобрано в распределённых паттернах: там же лежат idempotent consumer и outbox, которыми эти принципы и реализуют.

Практическая рамка: генерируйте сквозной идентификатор операции на клиенте и дедуплицируйте на приёмнике (это и есть exactly-once); разделяйте своевременность и целостность и защищайте в первую очередь целостность; где строгая координация дорога — разрешайте слабое ограничение с компенсацией; периодически проверяйте целостность, не доверяя слепо.

Где спотыкаются начинающие:

  • «У нас сериализуемые транзакции, значит всё корректно». Транзакция не спасёт от повтора запроса или бага приложения. Нужен сквозной request_id.
  • Надеются на дедуп TCP или брокера. Он работает на одном звене; при переподключении клиента дубликат проходит. Дедупликация нужна сквозная, на приёмнике.
  • Путают своевременность и целостность. Гонятся за линеаризуемостью там, где хватило бы конечной согласованности с гарантией целостности, и платят за координацию зря.
  • Считают, что «база надёжная» = данные не испортятся. Без периодической проверки повреждение всплывает тогда, когда чинить уже поздно.
Дополнительно: при первом чтении можно пропустить

Глубже: сверка с внешней системой: ключ, несопоставленные строки и пять миллионов за часрасширенное

Фоновая проверка целостности выше это про своё хранилище. У неё есть старший брат, без которого не живёт ни один сервис с деньгами: сверка с внешней системой. Банк прислал реестр операций за день, у нас свой список платежей, и они обязаны сойтись; расхождение это либо потерянные деньги, либо двойное списание, и узнать о нём нужно из сверки, а не от клиента.

Ключ сводки. Сводят по идентификатору, который знают обе стороны, и он назначается в момент создания операции: наш идентификатор платежа передаётся провайдеру и возвращается в его реестре, или его идентификатор сохраняется у нас при ответе. Сводить по сумме и времени нельзя: две операции на 1 000 рублей в одну минуту это норма. Если общего ключа нет, это ошибка интеграции, и её чинят до первой сверки, а не подбирают эвристики.

Три исхода на строку. Есть у обоих и совпадает: сошлось. Есть у обоих и отличается (сумма, статус): расхождение. Есть только у одной стороны: несопоставленная строка, и у неё две причины, которые различают по времени: операция на границе периода (наша запись сегодня, их завтра) ждёт следующей сверки, а операция старше окна это потеря или дубль. Каждый исход считается и выносится на панель, и ноль расхождений подозрителен не меньше, чем сотня: обычно это значит, что сверка не работает.

реестр и наши платежи FULL OUTER JOIN общий ключ сошлось есть у обоих, равно расхождение есть у обоих, разное несопоставлено только у одной

Сверка сводит обе стороны одним запросом по общему ключу, и каждая строка попадает ровно в один из трёх исходов.

Почему расхождение отдают человеку. Автоматическое «исправление» по данным одной стороны это выбор, кому верить, и он неверен ровно в тех случаях, когда важен: провайдер ошибся или мы. Расхождение попадает в очередь оператора с обеими версиями и историей операции, а система лишь предлагает вероятную причину. Автоматом закрывают только классы расхождений с известной причиной и обратимым действием: «у провайдера статус проведён, у нас ожидает» решается повторным запросом статуса, а не правкой.

Пять миллионов строк за час. Сверка построчно через API не уложится: это делает база. Реестр провайдера загружают целиком в таблицу (COPY), сводят одним запросом с FULL OUTER JOIN по ключу с условием на период, и три исхода выше получаются как три ветки CASE. Пять миллионов строк с индексом по ключу это минуты. Помогает секционирование по дню (сверяют только вчерашнюю секцию) и итог по агрегатам перед деталями: если суммы и число операций за день сошлись, построчную сверку можно пропустить, а если нет, идти в детали. Сверка идёт на реплике, чтобы не мешать записи.

Расписание и хранение. Ежедневно за прошлые сутки плюс повтор за неделю назад, потому что провайдеры досылают операции; итоги сверки хранят как записи (что, когда, сколько расхождений, кто и как закрыл), и это тот же журнал, что для аудита в разделе про безопасность.

Коротко

  • Транзакции недостаточно: повтор запроса, баг приложения и потеря на любом звене требуют сквозного идентификатора и дедупликации на приёмнике.
  • Строгие ограничения требуют координации; многим хватает слабого ограничения с компенсацией постфактум.
  • Своевременность это «согласованность иногда», целостность это «никогда не потеряно»; целостность важнее, и журналы событий держат её без своевременности.
  • Железо и базы ошибаются: целостность проверяют фоном, а не верят в неё.
  • Сверка с внешней системой: общий ключ с момента создания, три исхода на строку с учётом границы периода, расхождения человеку, миллионы строк одним FULL OUTER JOIN в базе на реплике, итоги как журнал.
  • У записей дедупликации есть срок жизни, равный максимально возможному времени повтора (часы для внутренних вызовов, семь дней для платежей): после удаления защиты нет, а где двойная операция неприемлема — ключом берут саму сущность, а не request_id.
  • Порядок прихода не равен порядку событий: в модели два времени (occurred_at от источника и received_at у получателя), решение по версии или по разрешённым переходам состояний, лента сортируется по времени события.
  • Целостность в обычном сервисе проверяют запросами: сумма против журнала операций, инварианты «ноль строк», сравнение пересобранного производного с текущим, а дешевле всего — ограничения в базе. Расхождение отдают человеку, а не чинят автоматически.

Что почитать дальше