Данные и консистентность: SQL/NoSQL, транзакции и миграции
Данные — это место, где архитектура backend перестаёт быть «красивой схемой» и становится системой с реальными гарантиями: что произойдёт при одновременных запросах, сбое сети, повторе команды, изменении схемы и росте нагрузки.
В прошлых статьях мы построили базу:
требования → сценарии (use cases) → доменная модель → инварианты
слои и границы (Clean Architecture, модули)
API как контракт и тонкий адаптерТеперь фиксируем следующий ключевой слой реальности:
как хранить и менять данные так, чтобы инварианты не ломались
как выбирать между SQL и NoSQL осознанно
как проектировать транзакции и конкурентные изменения
как делать миграции схемы безопасно!Где живёт транзакция в Clean Architecture и как она проходит через use case и репозитории
Консистентность: что именно мы хотим гарантировать
Слово консистентность используют в разных смыслах, и это источник постоянных ошибок.
Консистентность как целостность данных
Это про то, что данные не могут перейти в запрещённое состояние.
Примеры:
оплаченный заказ не может снова стать неоплаченным
сумма позиций заказа должна совпадать с итоговой суммой
нельзя создать две оплаты с одним и тем же idempotencyKeyЭти правила живут в домене и сценариях, но часто требуют поддержки БД:
UNIQUE индекс (для уникальности)
внешние ключи (для ссылочной целостности)
CHECK ограничения (для простых инвариантов)Важно:
БД не заменяет доменную модель, но может быть второй линией защиты
если инвариант критичный, лучше иметь и доменную проверку, и ограничение в БДКонсистентность как согласованность при распределённости
Когда система распределена (несколько сервисов, очереди, репликация), появляется выбор: делать всё строго синхронно или допускать задержку.
Классический ориентир — CAP-теорема: в распределённой системе при сетевых разделениях невозможно одновременно гарантировать и доступность, и строгую согласованность.
Источник: CAP theorem
Практический вывод для backend:
внутри одной БД вы часто можете позволить себе строгие транзакции
между сервисами чаще используют асинхронность и eventual consistencyДальше в курсе это продолжится темами очередей, outbox и саг, но сейчас нам важна база: транзакции и миграции.
SQL и NoSQL: как выбирать без религии
Короткая карта выбора
| Вопрос | SQL (PostgreSQL/MySQL) | NoSQL (MongoDB/Cassandra и другие) |
|---|---|---|
| Нужны сложные связи и джоины | Обычно сильная сторона | Часто придётся денормализовать |
| Нужны транзакции и строгие инварианты | Обычно проще и естественнее | Зависит от конкретной БД, часто сложнее |
| Схема данных стабильно описуема | Да (DDL, миграции) | Может быть schema-less, но это не значит «без правил» |
| Очень высокая запись по ключу, горизонтальное масштабирование | Возможно, но требует проектирования | Часто одна из сильных сторон |
| Отчёты, агрегации, аналитические запросы | Обычно удобнее | Зависит от продукта и модели данных |
Практическое правило для большинства продуктовых backend:
если вы строите систему со сценариями, инвариантами, заказами, оплатами, балансами, правами доступа, то SQL (часто PostgreSQL) — лучший дефолт
NoSQL выбирают, когда есть конкретная причина: модель данных, масштаб записи, требования к латентности, распределённостьACID и почему это важно для архитектуры
ACID — набор свойств транзакций, который помогает думать о гарантиях.
Источник: ACID
Разберём на человеческом языке:
Atomicity: либо выполнилось всё, либо ничего
Consistency: после транзакции данные не нарушают правил целостности БД
Isolation: параллельные транзакции не ломают друг друга
Durability: после коммита данные не пропадут при падении процессаКлючевой для backend-архитектуры пункт — Isolation: именно он определяет, что происходит при одновременных запросах.
Транзакция: единица целостного изменения
В терминах прошлых статей:
use case описывает сценарий и оркестрацию шагов
транзакция часто должна покрывать критическую часть сценария, где инварианты могут быть нарушены конкурентноГде открывать транзакцию в архитектуре
Обычно транзакцию открывают в application layer, потому что:
транзакция — техническая деталь (инфраструктурная гарантия)
она координирует несколько репозиториев
домен не должен импортировать БД-клиентПаттерн, который удобно держать в голове:
use case: withTransaction(async (tx) => { ... })
репозитории: принимают tx как контекст выполненияМинимальный пример транзакции в Node.js (PostgreSQL)
Ниже пример без ORM, чтобы было видно, где границы.
Use case держит транзакционную границу:
Что здесь важно архитектурно:
доменное правило order.markPaid() не знает о транзакции
транзакция покрывает проверку идемпотентности и запись результатов
репозитории получают tx и выполняют запросы на одном соединенииИзоляция: почему параллельные запросы ломают инварианты
Даже если ваш код «всё проверил», два запроса могут прийти одновременно.
Пример гонки:
два запроса оплатить один и тот же заказ
оба увидели статус PENDING_PAYMENT
оба провели списание
оба обновили заказБез дополнительных мер вы получите двойную оплату.
База даёт два основных инструмента:
уровень изоляции транзакции
блокировки и атомарные операцииДокументация PostgreSQL по изоляции: PostgreSQL: Transaction Isolation
Уровни изоляции в SQL на практике
| Уровень | Что обычно означает | Типичный риск |
|---|---|---|
| READ COMMITTED | каждый запрос внутри транзакции видит уже закоммиченные изменения | гонки возможны, нужно явно блокировать строки или использовать уникальные ограничения |
| REPEATABLE READ | транзакция видит «снимок» данных на момент старта | может быть проще рассуждать, но конфликты проявляются иначе |
| SERIALIZABLE | БД старается обеспечить эффект как будто транзакции шли строго по очереди | возможны ошибки сериализации, которые надо корректно ретраить |
Практический вывод:
чаще всего начинают с READ COMMITTED и решают гонки через UNIQUE + блокировки/атомарные запросы
SERIALIZABLE полезен, когда критична корректность, но требует дисциплины обработки конфликтовПессимистическая блокировка: SELECT ... FOR UPDATE
Когда вы меняете сущность и хотите гарантировать, что её не меняют параллельно, вы можете заблокировать строку.
Плюсы:
нет долгих блокировок
хорошо подходит для высоконагруженных системМинусы:
сценарий должен уметь обрабатывать конфликты (409 Conflict на API-уровне или ретрай внутри)SQL-схема как часть архитектуры: ограничения и индексы
Если в прошлых статьях инварианты жили в домене, то здесь важное усиление:
некоторые инварианты выгодно и правильно дублировать в БДТиповые примеры:
уникальность: UNIQUE (idempotency_key)
ссылочная целостность: FOREIGN KEY (order_id) REFERENCES orders(id)
недопустимые значения: CHECK (total_amount > 0)Индексы — это не «оптимизация потом», а часть проектирования данных, потому что они влияют на доступность системы под нагрузкой.
Практические ориентиры:
индексируйте поля, по которым ищете и сортируете в горячих запросах
помните, что каждый индекс замедляет запись
уникальные индексы одновременно дают и производительность, и гарантию инвариантаNoSQL и консистентность: что важно понимать заранее
NoSQL — это не «просто быстрее».
Частые причины выбирать NoSQL:
модель данных естественно документная (например, сложные вложенные структуры, которые читаются целиком)
запись по ключу и горизонтальное масштабирование важнее сложных запросов
вы готовы к денормализации и переносите часть согласованности в приложениеВажно заранее выяснить:
есть ли транзакции и какие у них ограничения
как устроена репликация и какие гарантии чтения
какие операции атомарныПример: MongoDB поддерживает транзакции, но это влияет на архитектуру и производительность, и важно понимать ограничения.
Документация: MongoDB Transactions
Миграции: как менять схему без боли и простоев
Миграция — это управляемое изменение структуры данных и схемы.
Опасная ошибка начинающих:
«поменяю схему, задеплою, всё заработает»В реальности у вас почти всегда есть:
несколько версий приложения одновременно (rolling deploy)
фоновые задачи, которые читают старые поля
кэш и реплики
большие таблицы, где ALTER TABLE может быть дорогимБазовые принципы миграций
Источник с хорошими практиками: Evolutionary Database Design
Практические правила:
миграции должны быть воспроизводимыми и версионированными
изменения делайте маленькими шагами
по возможности делайте миграции backward compatible на период раскаткиСтратегия expand/contract для изменений без даунтайма
Один из самых надёжных подходов для продакшена.
Expand
1. Добавляем новое поле/таблицу/индекс
2. Код начинает писать в новое место (иногда параллельно со старым)
Backfill
1. Переносим исторические данные фоном
2. Проверяем метриками и выборками корректность
Switch
1. Код начинает читать из нового места
Contract
1. Удаляем старые поля/таблицы после периода стабильности
!Безопасная миграция схемы без простоя
Пример: переименование поля без простоя
Хотим заменить total на totalAmount.
Expand
1. добавить колонку
total_amount
Switch write
1. приложение пишет и в
total, и в
total_amount
Backfill
1. фоновая задача заполняет
total_amount для старых строк
Switch read
1. приложение читает
total_amount
Contract
1. удалить
totalМиграции данных и миграции схемы
Два разных типа изменений:
схема: ALTER TABLE, индексы, ограничения
данные: пересчёт, заполнение новых колонок, исправление форматовАрхитектурный совет:
тяжёлые миграции данных делайте отдельными фоновых джобами, а не внутри DDL-миграцииМиграции и модульность
Если у вас модульный монолит, удобно, когда каждый модуль владеет своими таблицами и миграциями.
Практическая дисциплина:
migrations лежат рядом с модулем или строго разложены по контекстам
таблицы именуются с префиксом контекста, чтобы границы были видны (например, orders_orders, payments_payments или отдельные схемы)Это продолжает идеи из статьи про границы: данные тоже должны уважать bounded context.
Как это связано с API и доменом
Ошибки консистентности должны отражаться в контракте
Если транзакция не прошла из-за конфликта (например, UNIQUE нарушен или optimistic locking), это не «500».
Чаще всего это:
409 Conflict
иногда 422 для доменных правилЭто стыкуется с тем, что мы обсуждали в статье про стабильный формат ошибок и идемпотентность.
Домен и БД: кто за что отвечает
Рабочая модель ответственности:
домен формулирует инварианты и запрещённые состояния
use case определяет транзакционную границу и стратегию конкурентного доступа
БД гарантирует целостность на уровне хранения (ограничения, транзакции)Если вы делаете только один слой защиты, система будет ломаться либо от багов, либо от гонок.
Что дальше по курсу
Дальше из темы данных логично вырастают «производственные» темы архитектуры:
идемпотентность и повторяемость операций под нагрузкой
outbox и гарантированная публикация событий из транзакции
очереди, саги и согласованность между модулями/сервисами
кэширование и чтение под нагрузкойЕсли резюмировать эту статью в одном принципе:
сценарий должен быть корректен при повторах, сбоях и конкуренции, а не только в happy path