Мы убрали одну блокировку, чтобы апрувы перестали тормозить. Через несколько недель из-за этого клиент пробил квартальный бюджет – а наша система этого даже не заметила.
Полгода после 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 – репликация, лаг и границы согласованности на уровне, который стоит держать в голове, когда проектируешь проекции.