Мы убрали одну блокировку, чтобы апрувы перестали тормозить. Через несколько недель из‑за этого клиент пробил квартальный бюджет — а наша система этого даже не заметила.
Полгода после MVP, первые крупные клиенты. B2B travel SaaS, конец 2016-го. Компании начали подключать не по 15–20 человек, а по 80–100.
Один из новых клиентов оказался кратно крупнее остальных — финансовый департамент почти на сотню человек с фиксированным квартальным бюджетом на командировки порядка нескольких сотен тысяч рублей. К середине квартала большая часть бюджета уже потрачена, остаток — заметно меньше половины. Два руководителя — в разных городах, в разных браузерах — одновременно открывают форму апрува командировок. Оба видят один и тот же остаток. Один одобряет крупную поездку, другой почти в то же время — ещё одну, сопоставимую по сумме; каждая по отдельности в остаток вписывалась. Оба получают подтверждение. Вместе две поездки пробили лимит — перерасход, которого ни один из руководителей в одиночку не допускал.
Обнаружили через 3–4 часа — когда финансовый менеджер клиента открыл квартальную сводку и позвонил нам.
Архитектура к этому моменту выглядела так. Остаток бюджета в UI — проекция, агрегат, который пересчитывался батчем раз в несколько минут. На пути к этой версии система прошла через пессимистичную блокировку на операцию апрува: пока один руководитель подтверждал расходы, остальные запросы ждали в очереди. На клиентах в 15–20 сотрудников очередь проходила в пределах одной кнопки, никто её не видел. С компаниями по 80–100 сотрудников в конце квартала очередь стала тормозом — продакт приходил с жалобами на апрувы, уходившие с задержкой. Блокировку убрали, поставили батч‑пересчёт. Eventual consistency пришла вместе с этой заменой — в том числе на бюджетный лимит, где она не работает.

Мы не спроектировали плохо — мы не задали себе три вопроса.
Рамка: Memories, Guesses, Apologies
Пэт Хелланд в эссе «Memories, Guesses, and Apologies» (2007) предложил простую сортировку: все данные в распределённой системе делятся на три корзины в зависимости от того, что с ними можно сделать, когда что‑то пошло не так.
Memories — то, что узел уже зафиксировал у себя локально. Транзакция прошла, событие записано, деньги списаны. Для того узла, который это зафиксировал, это уже факт — остальной системе он может быть пока неизвестен. Memory всегда привязана к конкретному наблюдателю, не к «глобальному состоянию системы».
Guesses — любое действие или показание системы, опирающееся на локальные данные, которые могут оказаться неполными. Чаще всего guess представляют как проекцию или агрегат: остаток бюджета, который видели оба руководителя в нашей истории, — один и тот же остаток на двух экранах одновременно — это типичный guess. Но guess«ом оказывается и само решение об апруве, сделанное в тот момент: у каждого из руководителей была локальная картина без знания о втором, и оба действовали по своей версии правды.»
Третья корзина — Apologies: не «как не допустить перерасход», а что система делает после того, как выяснилось, что guess оказался неверным. Уведомление, компенсирующая транзакция, звонок клиенту.
Три вопроса дальше — по одному на каждый тип. Первый — про Memories: какие операции обязаны заканчиваться как зафиксированный факт, а не как guess. Второй — про Guesses: чьё это решение и как мы знаем, что guess ещё актуален. Третий — про Apologies: что система делает, когда guess уже не сошёлся.
Вопрос 1: Где у вас EC недопустима?
Eventual consistency приемлема не везде. Список исключений на удивление узкий, но его нужно составить явно — до архитектуры, не в момент разбора первого инцидента. Иначе граница между «можно» и «нельзя» складывается случайно.
Резервирование ограниченного ресурса. Последнее место в бизнес‑классе. Последний номер в отеле на нужные даты. Пока проекция доступности отстаёт на несколько секунд, два пользователя одновременно видят «доступно» — и оба получают подтверждение бронирования. Один из них вскоре получит отмену или ручной перезвон от менеджера отеля. Сильная согласованность нужна в момент самого резервирования — именно тогда, а не при отображении каталога.
Отчётные границы. Закрытие месяца, квартала, финансового года. Если 47 транзакций прошли в 23:13 31 декабря, они не могут «догнать» финансовый отчёт от 1 января: отчёт уже зафиксирован регулятором, а лаг репликации — нет. Финансовая отчётность регулируется, здесь нет места для «почти точно».
Проверки перед необратимым действием по требованию регулятора. Санкционный скрининг перед списанием (AML), KYC‑гейт перед активацией счёта. Действие необратимо: если блокировка контрагента ещё не доехала до узла, который проводит платёж, деньги уйдут — и «блокировка реплицируется через секунду» регулятору уже не объяснишь. Такую проверку держим синхронной, на сильно согласованном чтении, до того как деньги двинулись. Проекция здесь не годится: она по определению отстаёт.
Бюджетные лимиты. Как раз как в нашей истории: система управляет реальными деньгами с жёстким потолком. Остаток бюджета в UI — это guess, проекция, пересчитанная несколько минут назад. Когда два руководителя одновременно открывают форму апрува, оба видят один и тот же остаток и оба получают подтверждение — потому что guess не знал о втором решении. Там, где потолок жёсткий и нарушение его стоит реальных денег или отношений с клиентом, guess недостаточен. Важно где именно: сильная согласованность нужна не на дашборде с остатком, а в точке решения — когда бронь создаётся и деньги назначаются. Сам дашборд может спокойно отставать, если честно показывает, на какой момент он актуален.
Этот список не закладывается раз и навсегда — он меняется вместе с бизнесом. У нас в первой версии бюджетные лимиты в нём не значились: компании были маленькие, и никто всерьёз о такой грабли не думал. Пересмотр границы между «можно EC» и «нельзя» — регулярная часть архитектурной работы, а не разовая закладка при старте сервиса.
Составьте этот список для вашей системы, запишите и покажите продакту. Всё что не попало — кандидат на eventual consistency.
Вопрос 2: Кто владелец каждой проекции и какой максимальный лаг?
Проекция без владельца — бомба с таймером. Не потому что она обязательно сломается, а потому что никто в системе не знает, насколько она может отстать и что делать, когда это случится.
Что значит «владелец» в этом смысле:
Знает максимально допустимый лаг, согласованный с бизнесом.
Получает алерт, если проекция не обновлялась дольше этого лага.
Принимает решение, когда проекция отстаёт на 2 часа — останавливать ли операции, показывать ли предупреждение в UI, звонить ли клиенту.
Владелец — это конкретный человек или команда с именем, а не строчка в табличке «ответственных». У него есть телефон, который звонит, когда алерт сработал в 3 ночи. Если у проекции нет такого человека — её владелец де‑факто тот, кто последним правил её код, и он узнает об этом в момент инцидента. Тут же возникает вопрос, который любой архитектор слышал: «почему батч раз в несколько минут, а не event‑driven подписка с задержкой в секунды?». Ответ зависит не от моды, а от SLA. Если бизнес готов жить с минутной задержкой — батч проще, дешевле и не требует отдельной инфраструктуры доставки событий. Если нет — event‑driven. Этот выбор владелец и согласовывает с бизнесом, не архитектор в одиночку.
В нашей истории с перерасходом у проекции бюджета не было ни на SLA, ни на мониторинг лага. О том, что что‑то пошло не так, мы узнали из звонка финансового менеджера, а не из алерта. К тому моменту решения уже были подтверждены, деньги распределены, разговор с клиентом неизбежен.
Первое место для исправления — момент бронирования. Апрув мы сознательно оставляем мягким guess«ом: руководитель одобряет быстро, не упираясь в блокировки. Жёсткую проверку лимита переносим на необратимый шаг — фактическое создание брони, где система прежде слепо доверяла уже выданному апруву. Цену этого решения стоит назвать вслух: изредка бронь отлетит уже после одобрения, и это ровно та ситуация, под которую в Вопросе 3 мы заранее проектируем apology. В момент бронирования сознательно две разные стратегии. Превышение лимита — предсказуемый бизнес‑исход, его возвращаем как Result, без исключения: исключения для ожидаемых веток — антипаттерн, поток управления прячется в throw, система типов не помогает помнить про обработку. А вот concurrency‑конфликт ловим как DbUpdateConcurrencyException — это исключение бросает EF Core, мы его не выбираем, а реагируем на событие фреймворка и сигналим вызывающему повтор команды.»
// BEFORE: доверяем одобрению, не перепроверяем в момент бронирования public async Task Handle(BookTrip command) { // менеджер одобрил, создаём бронь await _bookings.Create(command.TripDetails); } // AFTER: проверяем бюджет на агрегате в момент фактического бронирования public async Task<Result> Handle(BookTrip command) { // budget грузится в трекинг текущего UoW: правка агрегата и новая бронь // уедут одним SaveChanges, в одной транзакции. var budget = await _budgets.GetById(command.DepartmentId); if (!budget.TryReserve(command.EstimatedCost)) return Result.BudgetExceeded(budget.Remaining); _bookings.Add(command.TripDetails); // событие в outbox в той же транзакции – read-model обновится надёжно, // без dual-write между БД и брокером сообщений _outbox.Add(new BookingConfirmed(command.DepartmentId, command.EstimatedCost)); try { // один SaveChanges – одна транзакция: бюджет (по RowVersion), бронь // и событие в outbox уходят атомарно. Упадёт списание – откатится всё. await _unitOfWork.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { // другой апрув записал бюджет первым, RowVersion разъехался. // Это событие фреймворка EF Core, не доменное – сигналим вызывающему повтор. return Result.RetryRequired(); } return Result.Ok(); }
Резонный вопрос: мы выкинули пессимистичную блокировку из‑за очередей — а оптимистичный конфликт с повтором не те же тормоза с чёрного хода? Нет. Пессимистичный лок сериализовал апрувы по всему клиенту: ждали все, даже те, кто трогал разные отделы. Конфликт по RowVersion возникает только при реальном пересечении на одной строке бюджета — а это апрувы одного отдела в одну и ту же секунду, и таких единицы. RetryRequired при этом обрабатывает шина команд ограниченным повтором (Polly и подобное), без «нажмите ещё раз» в лицо пользователю. А если на один бюджет реально летит высокая конкуренция — проблема уже не в способе блокировки, а в горячей точке домена, и чинить надо её.
Бронь, списание бюджета и событие в outbox уезжают одной транзакцией — это граница нашего сервиса. Всё, что за ней (charge в платёжном шлюзе, выписка билета у поставщика), с нашей записью уже не атомарно: там работает сага с компенсациями, и про идемпотентность её шагов продолжим в Вопросе 3.
На стороне агрегата — TryReserve вместо Reserve‑с-исключением, плюс явное поле версии:
public class DepartmentBudget { // Money – record-ValueObject с operator+, operator-, operator< // и проверкой одной валюты в конструкторе арифметики. public DepartmentId Id { get; } public Money Total { get; } public Money Spent { get; private set; } public Money Remaining => Total - Spent; [Timestamp] public byte[] RowVersion { get; private set; } = default!; public bool TryReserve(Money amount) { if (Remaining < amount) return false; Spent += amount; // Money.operator+ возвращает новый Money return true; } }
Result здесь — discriminated union вида Ok | BudgetExceeded(Money) | RetryRequired. Реализация на вкус команды: FluentResults, OneOf, собственные abstract record«ы. Сигнатура обработчика сразу показывает, чем команда может закончиться, без чтения тела метода.»
Намеренно упрощаю в двух местах. Резерв сразу увеличивает Spent, хотя по‑честному у него своя жизнь: reserve при бронировании, commit при выписке билета по фактической цене, release при отмене, с отдельным полем Reserved. И резервируем мы по EstimatedCost — реальная стоимость придёт при подтверждении, дальше сверка и доначисление разницы. Для тезиса поста существенно одно: жёсткая проверка лимита живёт на агрегате в момент решения. Полный lifecycle резерва — отдельная тема, сознательно оставим её за рамками.
Второе место — read‑model для UI. Это отдельная таблица: агрегат обеспечивает инварианты в момент записи, а read‑model даёт быстрые запросы для дашбордов и формы апрува. Здесь и фиксируем владельца и SLA прямо в коде, а не в Confluence‑документе, который никто не читает перед инцидентом:
internal sealed class DepartmentBudgetProjection : IEventHandler<BookingConfirmed>, IEventHandler<BookingCancelled> { // Владелец: команда travel-platform // Максимально допустимый лаг: 60 секунд // Алерт: если LastUpdatedAt отстаёт больше 2 минут // Spent/Total здесь – decimal-колонки: read-model денормализован, чтобы // обновляться одним SQL UPDATE; валюта фиксирована на уровне отдела private readonly AppDbContext _db; public DepartmentBudgetProjection(AppDbContext db) => _db = db; public async Task Handle(BookingConfirmed @event) { var now = DateTimeOffset.UtcNow; // фиксируем на клиенте, не в лямбде await _db.DepartmentBudgetReadModels .Where(b => b.DepartmentId == @event.DepartmentId) .ExecuteUpdateAsync(b => b .SetProperty(x => x.Spent, x => x.Spent + @event.Cost.Amount) .SetProperty(x => x.LastUpdatedAt, _ => now)); } // Handle(BookingCancelled) симметричен: Spent -= @event.Cost.Amount, LastUpdatedAt = now }
Этот комментарий‑блок — самая дешёвая форма документации SLA. Когда кто‑то сломает алерт и придёт разбираться в 2 ночи, grep находит владельца за секунду. Сам алерт — отдельная задача: агент мониторинга периодически читает LastUpdatedAt и сравнивает с порогом. Важно то, что порог зафиксирован прямо рядом с логикой обновления, а не в YAML‑файле Prometheus, который никто не открывал с 2022 года. (ExecuteUpdateAsync здесь — это EF Core 7+; на 6 тот же атомарный UPDATE ... SET Spent = Spent + ... пишется сырым SQL или batch‑расширением.)
Одно я сознательно вынес за скобки этого обработчика: при at‑least‑once доставке он обязан дедуплицировать события по их id, иначе повторная BookingConfirmed удвоит Spent. Это та же идемпотентность по намерению, которую разбирал в отдельном посте; здесь опускаю её, чтобы не растворять мысль про владельца и SLA.

Вопрос 3: Какие apologies вы проектируете заранее?
Apologies — это заранее спроектированные ответы на ситуации, когда guess оказался неверным. Ключевое слово — заранее: вы знали, что такое возможно, и решили до инцидента, что именно сделаете, вместо того чтобы разбираться постфактум.
Три UI‑паттерна, которые превращают apologies в нормальное состояние интерфейса:
Временная отметка у каждой агрегированной цифры. «Остаток бюджета: 248 500 ₽ на 14:32». Без отметки цифра — претензия на актуальность, которой у неё нет. Два руководителя в нашей истории видели один и тот же остаток, потому что UI ни словом не намекал, что это снимок пятиминутной давности. Временная метка не исправляет eventual consistency — она честно сообщает о ней пользователю.
«Обновляется…» вместо молчания. Асинхронная операция — полноценный элемент состояния UI, а не дырка между кликом и результатом. Когда пользователь видит, что система работает, он не начинает гадать, дошёл ли его запрос, и не жмёт кнопку повторно. Спиннер здесь несёт семантику: пока он крутится, транзакция действительно в процессе, и интерфейс гарантирует, что это видно. В нашей системе после фикса бюджет на дашборде обновлялся так: цифра сменяется не моментально, а через короткий «обновляется… 14:32 → 14:33» — пользователь видит, что данные только что приехали, и знает, насколько они свежие.
Оптимистичное обновление с откатом. Пользователь нажал «одобрить» — UI сразу показывает новое состояние, не дожидаясь ответа бэка. Если сервер вернул ошибку, интерфейс откатывается и объясняет причину. Отзывчивость отвязана от задержек бэкенда, и пользователь получает честный результат вместо просто медленного.
На стороне API явная временная метка живёт в контракте, а не только в UI‑компоненте:
public record BudgetSummaryResponse( DepartmentId DepartmentId, Money Total, Money Spent, Money Remaining, DateTimeOffset AsOf // когда эта цифра была актуальна );
Money здесь — Value Object, не decimal. Пара «сумма + валюта» с арифметическими проверками: нельзя сложить рубли с долларами без явного преобразования, нельзя уйти в минус там, где это запрещено доменом. Почему не decimal? Потому что decimal не знает валюту, а бюджетный лимит — это не просто число.
Когда saga компенсирует неверный guess, компенсирующее действие должно быть идемпотентным. Возврат средств, отмена брони, откат резерва бюджета — каждый из этих шагов может выполниться дважды: сеть оборвалась, воркер перезапустился, таймаут сработал в неудачный момент. Если компенсация не идемпотентна, вы получаете двойной возврат или двойную отмену. Идемпотентность по намерению разбирал отдельно, также рекомендую заглянуть в комменты — там обсудили несколько расширенных тем.
Последний нюанс: apology не всегда означает автоматическую компенсацию. Если возврат средств не прошёл три раза подряд — четвёртая попытка в цикле не поможет. Правильная apology здесь — тикет с контекстом и предложенным действием: что именно не сработало, какие данные задействованы, что ожидается от оператора. Иногда человек в контуре — сознательная часть спроектированного процесса: оператор видит контекст, принимает решение и закрывает кейс быстрее, чем автоматический повтор в десятый раз.
Тому клиенту, с которого началась эта история, apology достался ручной: мы признали перерасход, по согласованию с их финансовым менеджером скорректировали лимит и не стали отзывать уже одобренные поездки — разворачивать апрув, который человек считал окончательным, дороже разового перерасхода. А чтобы случай не повторился, на проекцию бюджета поставили владельца, SLA и алерт на лаг — ровно те, что в Вопросе 2. Так разовое извинение постфактум превратилось в спроектированный механизм.
Вывод
Эти три вопроса — повестка разговора с продактом. Архитектор не закрывает их в одиночку.
Если первый вопрос не согласован с бизнесом, вы не знаете, где ошибиться нельзя. Если второй — у вас проекции без владельцев, и об этом вы узнаете из инцидента. Если третий — пользователи познакомятся с eventual consistency раньше, чем вы успеете её объяснить. Все три следствия случаются не в момент архитектурного решения, а спустя месяцы — когда система вырастает, нагрузка меняется, а клиент звонит с вопросом о перерасходе.
Eventual consistency — природа распределённых систем, а не дефект архитектуры. «Принять» её — значит проектировать осознанно: где лаг допустим, какой у него потолок, что система делает, когда guess расходится с реальностью. В большинстве случаев лаг допустим, и тогда EC — нормальный, дешёвый и удобный режим работы. Спор всегда о том меньшинстве мест, где он недопустим. Контракт с продактом определяет именно эту границу.
Подпишите этот контракт с продактом до архитектуры. Иначе его подпишет за вас первый квартальный отчёт.
Что почитать
Pat Helland, «Memories, Guesses, and Apologies» (2007) — эссе, на котором держится вся рамка этого поста.
Martin Kleppmann, «Designing Data‑Intensive Applications», главы 5 и 9 — репликация, лаг и границы согласованности на уровне, который стоит держать в голове, когда проектируешь проекции.
