23 февр. 2026 г.·6 мин чтения

Агрегатные инварианты, которые останавливают двойные записи в приложениях

Агрегатные инварианты помогают избежать двойных записей при изменениях денег, запасов или прав. Узнайте про границы, шаги, типичные ошибки и проверки.

Агрегатные инварианты, которые останавливают двойные записи в приложениях

Почему происходят двойные записи

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

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

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

Проблема усугубляется, когда люди моделируют вокруг таблиц, а не правил. Видят таблицу orders, таблицу inventory и таблицу payments и обновляют каждую там, где удобнее. Когда появляется баг, они патчат только тот путь. Другой путь всё ещё пропускает проверку, выполняется дважды или сохраняет одно изменение без другого.

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

Агрегатные инварианты помогают тем, что не дают разрозненным проверкам стать реальной логикой системы. Если одно действие пользователя меняет деньги, запас или разрешения, правило для этого изменения должно иметь один дом. Каждый контроллер, задача и скрипт должны вызывать ту же часть модели.

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

Что на самом деле защищает агрегат

Инвариант — это правило, которое должно оставаться верным при каждом изменении данных. Если правило нарушается даже на мгновение, пользователи получают плохие результаты. Деньги исчезают, запас уходит ниже нуля, или кто‑то сохраняет доступ после смены роли.

Агрегат защищает это правило, назначая ему одного владельца. Этот владелец — не таблица и не просто набор записей с одним ID. Это часть модели, которая решает: "это изменение разрешено" или "отклонить".

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

Счётчики запаса работают так же. "Доступный запас не может уйти ниже нуля" — это инвариант. Модель заказа, продукта или резерва может владеть этим правилом, но только одна должна. Если на этапе оформления уменьшают запас, складская синхронизация уменьшает его снова, а админ‑инструмент правит количество сам по себе, баг быстро вернётся.

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

Поэтому границы агрегатов должны следовать бизнес‑правилам, а не форме базы данных. Одно правило может затрагивать несколько таблиц, и одна таблица может поддерживать разные правила. Таблицы хранят факты. Агрегатные инварианты удерживают эти факты верными, когда два пользователя кликают одновременно, повтор запускается или фоновая задача опаздывает.

Когда правило имеет одного владельца, у двойных записей меньше мест, где они могут скрыться.

Начинайте с правила, а не со схемы

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

Напишите правило одним коротким предложением. Сделайте его простым, чтобы продакт‑менеджер, разработчик и тестировщик читали его одинаково. Хорошие правила звучат так: "Счёт может быть оплачен только один раз" или "Пользователь не может выдать разрешение, которого у него нет".

Это предложение даёт вам цель. Без него команды раскидывают проверки по обработчикам, задачам и триггерам базы, и никто не владеет окончательным «да» или «нет».

Простой способ закрепить агрегатные инварианты — задать четыре прямых вопроса:

  1. Какое одно правило никогда не должно нарушаться?
  2. Какие изменения состояния могут его нарушить?
  3. Какое действие должно одобрить или отклонить изменение?
  4. Какую наименьшую группу данных это действие должно контролировать?

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

Затем дайте одному действию полномочия решать. Не трём сервисам. Не одной проверке API плюс одной фоновой починке. Одно действие. В коде это часто становится методом вроде applyPayment() или grantRole(). Он проверяет правило относительно текущего состояния и либо меняет всё внутри своей границы, либо ничего не меняет.

Держите границу маленькой. Если для простого «да/нет» вам нужна половина базы, модель слишком широкая. Если действие может защитить правило одним инвойсом, одним заказом или одним набором разрешений — держите решение там. События можно публиковать после решения, но само решение должно жить в одном месте.

Такой подход «сначала правило» обычно менее впечатляющий, чем добавление инфраструктуры, но он работает. Команды работают быстрее, когда одно чёткое действие владеет правилом, и у двойных записей меньше мест, где они прячутся. Именно этим занимается Oleg Sotnikov в oleg.is в советах fractional CTO: сначала найти бизнес‑правило, затем построить безопасный путь записи вокруг него.

Как выбрать границу

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

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

Многие значения находятся рядом, но им не нужна одна и та же граница. Номер запаса продукта и счётчик на дашборде "продано сегодня" могут быть на одной админ‑странице, но риски у них разные. Запас должен быть точным. Дашборд может подтянуться с задержкой в несколько секунд.

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

Простой тест помогает. Спросите, что произойдёт, если одна часть обновит сейчас, а другая позже. Можно ли потерять деньги? Можно ли продать то, чего нет? Может ли кто‑то сохранить доступ, который должен был быть отозван немедленно? Если ответ «нет», возможно, вам и не нужен отдельный агрегат.

Противоположная ошибка тоже вредна. Команды строят огромные агрегаты, потому что "всё о клиенте" звучит красиво на бумаге. Тогда редактирование профиля, изменение биллинга и обновление ролей все борются за одну границу, хотя они не защищают одно и то же правило.

Разделяйте данные, которые могут изменяться позже без вреда. Адрес доставки часто можно обновить отдельно. Итого по счёту и платёж, который его погашает, обычно не должны разъезжаться.

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

Смоделируйте одно безопасное изменение

Untangle Large Aggregates
Split the boundary where it blocks unrelated changes and keep the rule where it belongs.

Выберите одно действие, которое может быстро всё испортить, если два запроса ударят одновременно. Резервирование запаса — чистый пример. Если у вас осталось 3 единицы, приложение никогда не должно обещать 4.

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

Поток должен быть простым. Приложение получает запрос зарезервировать 2 единицы для заказа 847. Загружаете агрегат инвентаря для этого SKU и склада. Вызываете метод вроде reserve(orderId, quantity). Этот метод либо меняет состояние и возвращает успех, либо отклоняет запрос с понятной причиной.

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

Если агрегат принимает изменение — сохраните новое состояние один раз. Не разделяйте правило между проверкой в контроллере, обновлением запаса и второй записью где‑то ещё. Именно так двойные записи возвращаются. Одно решение, одно изменение состояния, одно сохранение.

Если агрегат говорит нет — сохраните причину. Верните код вроде out_of_stock или duplicate_reservation и привяжите ID запроса или заказа. Служба поддержки увидит, почему действие провалилось. Разработчики найдут события в логах без догадок. Пользователи получат понятное сообщение вместо расплывчатой ошибки.

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

Что происходит, когда два покупателя гонятся

Представьте магазин с 2 единицами одного товара. Два покупателя нажимают "Купить" в одну секунду. Если приложение проверяет запас в одном месте и пишет заказ в другом, оба запроса могут увидеть "2 в наличии" и оба создать заказ. Теперь магазин продал 4 единицы, которых у него не было.

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

Теперь гонка проще для рассуждений. Запрос первый приходит и просит 2 единицы. Агрегат видит 2 доступных, резервирует все 2 и принимает заказ. Запрос второй приходит чуть позже и просит те же 2 единицы. Агрегат теперь видит 0 и отклоняет заказ с понятным out_of_stock.

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

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

Служба поддержки быстро почувствует разницу. Им не придётся объяснять, почему клиент заплатил за исчезнувший товар. Финансы тоже увидят — один источник правды о том, что было зарезервировано, что продано и что никогда не стало заказом.

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

Ошибки, которые возвращают баг

Clean Up Retry Logic
Add idempotency and one clear owner for writes that users or workers may repeat.

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

Обычная ошибка — разделение правила между кодом запроса и фоновыми задачами. API проверяет, может ли заказ зарезервировать запас, но воркер позже корректирует тот же запас с иной логикой. Теперь правило живёт в двух местах, и они расходятся. Малейшее несоответствие — и перепродажа, двойной возврат или сохранённый доступ после отзыва уже реальны.

Другой баг возникает, когда два агрегата могут менять одно и то же число. Если один агрегат обновляет баланс аккаунта, а другой тоже может редактировать этот баланс, то никто не владеет правилом. Та же проблема с запасами и наборами прав. Выберите одно место для решения и пропускайте через него все записи.

Прямые обновления SQL причиняют больше вреда, чем команды ожидают. Кому‑то нужен быстрый фикс, он запускает update в продакшене и пропускает проверки, которые обычно делает приложение. Данные выглядят правильно на время, но следующая запись сталкивается с состоянием, которого модель не ожидала. Одноразовый фикс часто создаёт долгую очистку.

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

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

Простой здравый вопрос помогает. Может ли два пути кода менять тот же баланс, счётчик или разрешение? Может ли повтор выполнить ту же запись снова? Могут ли обойти модель прямыми SQL‑обновлениями? Зависит ли запись от данных, считанных ранее из устаревшего представления? Если да — правило всё ещё легко сломать.

Проверки перед релизом

Make Payments Safer
If charges, refunds, or invoices drift apart, Oleg can help you design one write path.

Модель может выглядеть безопасной в тестах и всё же рушиться в продакшене, когда какой‑то путь проскользнёт мимо правила. Перед выпуском проверьте каждое изменение, которое касается денег, запаса или доступа, с одной конкретной операцией в уме, например "зарезервировать 3 единицы" или "отнять права админа".

Размытые ревью пропускают баги. Хорошее правило помещается в одно простое предложение, и в коде есть одно явное место, где это правило может отклонить изменение.

Начните с записи правила в одну строку. Например: "Заказ может резервировать товар только если достаточно единиц остаётся." Если двое разработчиков объясняют это по‑разному, модель всё ещё свободна.

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

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

Это ревью не требует долгой встречи. Во многих командах 15 минут с одним инженером и одним продуктовым человеком достаточно, чтобы заметить слабое место.

Простой тест работает хорошо: может ли кто‑то в команде объяснить правило и случай ошибки в одном предложении? Если нет — правило, вероятно, живёт в разрозненных проверках, комментариях или в памяти команды. Именно оттуда появляются двойные записи.

Агрегатные инварианты должны казаться немного строгими. Если изменение слишком легко просочится через боковую дверь, скорее всего так и есть. Поймайте это раньше пользователей.

Что делать дальше

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

Не начинайте с перетасовки классов. Запишите три вещи: правило, владелец и изменение. Например: "запасы не могут опуститься ниже нуля", "агрегат инвентаря владеет этим правилом" и "каждый резерв должен проходить через один путь записи". Это небольшое упражнение проясняет много путаницы команды до того, как кто‑то откроет редактор.

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

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

Если команда постоянно застревает на границах агрегатов, закажите дизайн‑ревью до того, как будете перекодировать ещё пути. Именно этим занимается Oleg Sotnikov через oleg.is как fractional CTO и советник стартапов. Пару часов чёткого моделирования может сэкономить дни патчей одних и тех же двойных записей.

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

Часто задаваемые вопросы

What is an aggregate invariant?

Aggregate invariant — это бизнес-правило, которое должно оставаться верным при любом изменении данных. Представьте правила вроде "счет оплачивается только один раз" или "запасы никогда не опускаются ниже нуля". Это правило помещают в один путь записи, чтобы каждый API-вызов, задача или скрипт следовали одному и тому же решению.

How do aggregate invariants stop double writes?

Двойные записи возникают, когда разные части приложения по-разному обновляют одно и то же правило. Агрегат решает эту проблему тем, что одна методика проверяет правило и применяет изменение вместе. Это сокращает количество мест, где два обновления могут разойтись.

How do I choose the right aggregate boundary?

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

Should reports and caches live inside the aggregate?

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

What should happen when two buyers try to buy the last item?

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

Do I still need idempotency if I use aggregates?

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

Can controller checks or database triggers solve this on their own?

Нет. Проверка в контроллере полезна, но другой путь может её обойти. Триггеры базы данных иногда ловят часть проблем, но прячут бизнес-правила в месте, которое мало кто читает. Поместите правило в модель, которая владеет записью, и заставьте все вызовы проходить через неё.

How can I tell that my aggregate is too big?

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

How should I test this before I ship?

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

Where should I start in an app that already has double-write bugs?

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