Базы данных Как выбрать БД и как мигрировать: сравнительная сводка по всем классам
0%

Как выбрать БД и как мигрировать: сравнительная сводка по всем классам

Как выбрать БД и как мигрировать: сравнительная сводка по всем классам

Восемнадцать статей трека разбирали хранилища поштучно: реляционные движки, документные, wide-column, колоночные, временные ряды, поиск, векторы, объектные, графовые, распределённые SQL. Эта статья делает две вещи, которых не сделает ни одна из предыдущих.

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

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

1. Одна система координат для всех классов

Повторим оси из вводной статьи (https://courses.digitable.life/post/databases/00-overview/), но теперь с прицелом на выбор.

Модель данных — что база считает единицей и что она умеет проверять сама. Реляционная модель уникальна тем, что хранит факты, не зная будущих вопросов (https://courses.digitable.life/post/databases/01-relational-model/). Все остальные модели торгуют эту гибкость на скорость конкретного паттерна: документ — на чтение агрегата целиком, wide-column — на запись и диапазонный скан внутри партиции, колоночная — на агрегацию по немногим полям поперёк миллиардов строк.

Паттерн доступа — точечное чтение по ключу, диапазон, полнотекст, агрегация, обход графа, ANN-поиск. Плюс соотношение чтений и записей, размер горячего множества относительно RAM и допустимая задержка на 99-й перцентили.

Гарантии — что происходит при конкурентном доступе (https://courses.digitable.life/post/databases/07-transactions-and-isolation/) и при сбое узла или разделении сети (https://courses.digitable.life/post/databases/09-nosql-landscape/). Здесь чаще всего врут маркетинговые материалы: «ACID» без указания уровня изоляции и границ транзакции не означает ничего.

Масштабирование — до какого предела растёт вертикально и что происходит при горизонтальном росте: ручной решардинг, автоматический ребаланс, потеря части возможностей (джойны, уникальные индексы, транзакции между шардами) (https://courses.digitable.life/post/databases/08-replication-and-sharding/).

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

Сводная таблица: модель, паттерн, гарантии

Класс Модель Профильный паттерн Транзакции Консистентность по умолчанию
PostgreSQL (https://courses.digitable.life/post/databases/02-postgresql/) Отношения + JSONB, массивы, расширения Смешанный OLTP, любые запросы Полные ACID, до Serializable (SSI) Строгая на primary, реплики отстают
MySQL / MariaDB (https://courses.digitable.life/post/databases/03-mysql-and-mariadb/) Отношения, кластерный первичный ключ Точечный OLTP, простые джойны ACID в InnoDB, REPEATABLE READ Строгая на primary, асинхронные реплики
MS SQL / Oracle (https://courses.digitable.life/post/databases/04-ms-sql-and-oracle/) Отношения + процедурный слой Корпоративный OLTP + отчётность ACID, богатые уровни изоляции Строгая, синхронные группы доступности
SQLite (https://courses.digitable.life/post/databases/05-sqlite-and-embedded/) Отношения в одном файле Локальные чтения, встраивание ACID, один писатель Строгая (процесс один)
MongoDB (https://courses.digitable.life/post/databases/10-mongodb/) Документы, схема в приложении Чтение агрегата целиком по ключу ACID в документе, мультидокументные — дорого Настраиваемая через readConcern/writeConcern
Redis (https://courses.digitable.life/post/databases/11-redis/) Структуры данных в памяти Точечный доступ за десятки микросекунд Атомарность команды и скрипта, не ACID Асинхронная репликация, окно потери
Cassandra / Scylla (https://courses.digitable.life/post/databases/12-cassandra-and-wide-column/) Партиции и кластерные ключи Запись потоком, скан внутри партиции Только легковесные транзакции (Paxos) Настраиваемый кворум, обычно eventual
ClickHouse (https://courses.digitable.life/post/databases/13-clickhouse-and-olap/) Колонки, MergeTree Агрегация по миллиардам строк Нет в привычном смысле Eventual между репликами
TimescaleDB / Influx (https://courses.digitable.life/post/databases/14-timeseries-and-search/) Ряды с тегами и временем Запись потоком, окна и агрегаты В Timescale — как в Postgres Как у базового движка
Elasticsearch / OpenSearch Инвертированный индекс Полнотекст, фасеты, аналитика логов Нет Near-real-time, сегменты видны через refresh
Векторные (https://courses.digitable.life/post/databases/15-vector-databases/) Векторы + метаданные ANN-поиск по косинусу или L2 Обычно нет Eventual, индекс строится асинхронно
Объектные (https://courses.digitable.life/post/databases/16-object-storage/) Плоское пространство ключей Потоковая запись и чтение больших блобов Атомарность на уровне объекта Read-after-write для новых ключей
Neo4j / графовые (https://courses.digitable.life/post/databases/17-graph-and-keyvalue/) Вершины, рёбра, свойства Обход на много прыжков ACID (Neo4j) Строгая на лидере
etcd / RocksDB / LMDB Ключ-значение Конфигурация, встраиваемый KV etcd — линеаризуемость через Raft Строгая (etcd), локальная (embedded)
CockroachDB / Yugabyte / TiDB (https://courses.digitable.life/post/databases/18-newsql-and-distributed/) Отношения поверх распределённого KV Гео-распределённый OLTP Serializable глобально Линеаризуемость через Raft и часы

Сводная таблица: масштаб, эксплуатация, стоимость, когда НЕ брать

Класс Практический потолок одного узла Горизонтальный рост Стоимость эксплуатации Когда НЕ брать
PostgreSQL 5–20 ТБ, десятки тысяч TPS на приличном железе Реплики для чтения; запись — только внешним шардингом или Citus Средняя: вакуум, bloat, планировщик, обновление мажорной версии Агрегация десятков миллиардов строк; запись выше возможностей одного узла; глобальная гео-распределённость
MySQL / MariaDB Похоже на Postgres, чуть лучше на точечных чтениях Реплики; Vitess или ProxySQL для шардинга Средняя, но проще вакуума нет — есть undo и purge Сложная аналитика, богатые типы, оконные функции старых версий
MS SQL / Oracle Очень высокий, вплоть до сотен ядер Дорогие штатные средства (RAC, AlwaysOn) Высокая деньгами, низкая усилиями — вендор помогает Стартап без бюджета; облачно-нативная архитектура с сотнями инстансов
SQLite Гигабайты, один писатель Отсутствует по замыслу Почти нулевая Много параллельных писателей по сети
MongoDB Единицы ТБ на шард Автоматический шардинг из коробки Средняя: балансировщик, выбор ключа шарда необратим Жёсткая реляционная целостность, отчётность произвольной формы
Redis Ограничен RAM: 100–500 ГБ Cluster с хеш-слотами Низкая, пока помещается в память Единственный источник истины для критичных данных
Cassandra / Scylla Десятки ТБ на узел Линейный, добавлением узлов Высокая: компакции, repair, тюнинг JVM (Scylla проще) Запросы, не предусмотренные при проектировании таблиц; джойны; сильная консистентность
ClickHouse Десятки ТБ, миллиарды строк в секунду на скан Шарды + реплики, вручную или через Keeper Средняя: мерджи, партиции, мутации дороги Точечные апдейты, OLTP, много мелких запросов
Timescale / Influx Как Postgres / десятки ТБ Ограниченно Средняя Данные без временной оси
Elasticsearch 20–50 ГБ на шард, heap до 31 ГБ Шарды, но решардинг болезненный Высокая: сайзинг, ILM, мапинги неизменяемы Источник истины; частые обновления документов
Векторные Десятки миллионов векторов на узел Зависит от продукта Средняя, растёт с размерностью и объёмом Меньше миллиона векторов — хватит pgvector внутри Postgres
Объектные Практически безграничны Встроенный Низкая в облаке, заметная в self-hosted MinIO Низкая задержка, частичные обновления, транзакции
Графовые Единицы ТБ Слабый; распределённый обход дорог Средняя, экспертиза дефицитна Обходы на 1–2 прыжка — рекурсивный CTE в SQL быстрее и дешевле
NewSQL Десятки узлов и выше Прозрачный, автоматический Высокая: распределённая отладка, часы, латентность коммита Один регион и нагрузка, которую тянет Postgres — вы платите латентностью за ненужную распределённость

Читать таблицы стоит по столбцу «когда НЕ брать»: он отсекает быстрее, чем перечисление достоинств. Достоинства у всех примерно одинаково красиво описаны в маркетинге.

2. Процедура выбора: от нагрузки к продукту, а не наоборот

Формализуем. Вход — описание нагрузки, выход — решение с обоснованием.

Три замечания к этой схеме, без которых она вредна.

Первое: «рядом с СУБД», а не «вместо». Ветки Redis, Elasticsearch, ClickHouse и векторных баз почти никогда не заменяют основное хранилище — они дополняют его как производные представления, наполняемые через CDC. Источник истины остаётся один, и это почти всегда реляционная база. Это принципиально: производное хранилище можно потерять и пересобрать, источник истины — нет.

Второе: запас 3x по объёму. Не «поместится ли сейчас», а «поместится ли, когда данных станет втрое больше, и останется ли место под индексы, bloat, временные файлы сортировки и резервные копии». Полный диск в проде — одна из самых частых причин полной недоступности.

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

Позиционирование классов на плоскости «гибкость запросов — масштаб записи»

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

3. Правило по умолчанию: начинайте с PostgreSQL и знайте, где он кончается

Совет «берите Postgres, пока он справляется» стал общим местом, но редко подкрепляется цифрами. Дадим их — это порядки величин с типичного облачного узла масштаба 16 vCPU / 64 ГБ RAM / NVMe, а не рекорды.

Задача Postgres справляется до Чем закрывать дальше
Точечные чтения по индексу 20–50 тыс. QPS с пулером, десятки миллиардов строк при партиционировании Реплики для чтения, затем кэш, затем шардинг
Запись OLTP 5–15 тыс. TPS на коммит с synchronous_commit = on Групповой коммит, батчинг, затем Citus или NewSQL
Полнотекстовый поиск Миллионы документов, tsvector + GIN, простое ранжирование Elasticsearch, когда нужны языковые анализаторы, фасеты, релевантность
Аналитика Сотни миллионов строк на сканы в секунды при партиционировании и BRIN ClickHouse от миллиардов строк или при требовании субсекундного отклика
Векторный поиск 1–5 млн векторов размерности 768 с HNSW в pgvector Qdrant или Milvus от десятков миллионов, при фильтрации и шардинге
Временные ряды Десятки миллиардов точек с TimescaleDB и сжатием Специализированные движки при миллионах точек в секунду
Очередь задач 1–5 тыс. задач в секунду через SELECT ... FOR UPDATE SKIP LOCKED Kafka или SQS при десятках тысяч и требовании ретеншена
JSON-документы Полноценно через JSONB с GIN-индексами MongoDB, если документов десятки миллионов и нужен автошардинг

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

Цена полиглотной персистентности

Посчитаем на конкретном примере — сервис с 2 ТБ данных, 8 тыс. запросов в секунду, командой из шести человек.

Вариант Компоненты Железо и сервисы в месяц Операционная нагрузка Число режимов отказа
Моно-Postgres 1 primary + 2 реплики + PgBouncer ~1200–1800 USD 1 технология, знакомый рантайм Отказ узла, лаг реплики, bloat
Postgres + Redis + кэш-кластер +250–400 USD 2 технологии, инвалидация кэша + рассинхрон кэша, вытеснение, потеря при рестарте
Полиглот + Elasticsearch + ClickHouse + Kafka +1500–3000 USD 5 технологий, CDC-пайплайн + лаг индексации, дубли CDC, ребаланс шардов, перекос партиций

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

Отдельная статья расходов, которую забывают, — CDC-пайплайн. Debezium с Kafka Connect — это ещё один кластер, свои слоты репликации (которые при остановке потребителя раздувают WAL и кладут primary), свои схемы, свой мониторинг лага. Он редко стоит дешевле самого производного хранилища.

4. Обоснование выбора: ADR, который не стыдно показать через два года

Выбор без записанного обоснования — не выбор, а привычка. Формат ADR (https://courses.digitable.life/post/architecture-patterns/00-overview/ — про архитектурные решения в целом) сжат до одной страницы и содержит проверяемые числа.

# ADR-014. Хранилище для событий биллинга

## Контекст
- Доминирующий запрос: агрегация сумм по клиенту и периоду; p99 < 300 мс.
- Второй по частоте: выборка последних 50 событий клиента по идентификатору.
- Объём: 400 млн строк сейчас, +30 млн в месяц; через 24 месяца ~1.1 млрд.
- Требования: события неизменяемы, потеря недопустима, нужна сверка с банком.
- Команда: 4 бэкендера, дежурство 24/7 отсутствует, есть управляемый Postgres.

## Варианты
1. PostgreSQL с партиционированием по месяцу + BRIN по времени.
2. ClickHouse отдельным кластером.
3. Cassandra.

## Решение
Вариант 1. Замер на реальном срезе: агрегация за месяц по клиенту  120 мс
на партиции 30 млн строк, выборка последних 50  2 мс по индексу.
ClickHouse даёт 15 мс на агрегации, но не даёт транзакционной сверки
и добавляет пятую технологию в стек без дежурства.

## Последствия
- Ретеншен реализуется через DETACH PARTITION, выгрузку в S3 и DROP.
- Ежемесячный отчёт по всей истории будет медленным  выносим в ночной джоб.

## Условия пересмотра (проверять ежеквартально)
- p99 агрегации превысил 250 мс на протяжении двух недель, ИЛИ
- объём активных партиций превысил 1.5 ТБ, ИЛИ
- появился второй потребитель с ad hoc аналитикой по всей истории.

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

5. Таксономия миграций: четыре разных зверя под одним словом

Слово «миграция» обозначает как минимум четыре разные операции, различающиеся риском на порядки.

Тип Пример Основной риск Типичная длительность
Эволюция схемы Добавить колонку, переименовать поле Долгая блокировка, несовместимость с работающим кодом Минуты
Смена версии движка Postgres 14 → 17 Изменение планов, несовместимость расширений Часы, одно окно
Гомогенный переезд Свой Postgres → управляемый Postgres Простой, потеря данных в окне Дни подготовки, минуты переключения
Гетерогенный переезд MongoDB → PostgreSQL, MySQL → ClickHouse Расхождение семантики типов и гарантий Недели или месяцы

Дальше разбираем первый и четвёртый — они дают почти все инциденты.

Эволюция схемы: expand / contract

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

Задача: разбить users.full_name на first_name и last_name. Наивно — одна миграция, которая переименует и переложит данные, и деплой кода. На проде это гарантированный простой: между применением миграции и выкаткой всех подов будут инстансы, ожидающие старую колонку.

-- Волна 1 (expand). Совместимо со старым кодом: он просто не знает про новые колонки.
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name  text;

-- Триггер: любая запись старого кода наполняет новые колонки.
CREATE OR REPLACE FUNCTION sync_name() RETURNS trigger AS $$
BEGIN
  IF NEW.full_name IS DISTINCT FROM OLD.full_name OR TG_OP = 'INSERT' THEN
    NEW.first_name := split_part(NEW.full_name, ' ', 1);
    NEW.last_name  := nullif(substr(NEW.full_name, length(split_part(NEW.full_name,' ',1)) + 2), '');
  END IF;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER users_sync_name BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_name();
-- Волна 2 (backfill). Батчами, чтобы не держать длинную транзакцию и не раздувать WAL.
DO $$
DECLARE
  last_id bigint := 0;
  affected int;
BEGIN
  LOOP
    WITH batch AS (
      SELECT id FROM users
      WHERE id > last_id AND first_name IS NULL
      ORDER BY id LIMIT 5000
    )
    UPDATE users u
       SET first_name = split_part(u.full_name, ' ', 1),
           last_name  = nullif(substr(u.full_name, length(split_part(u.full_name,' ',1)) + 2), '')
      FROM batch b WHERE u.id = b.id;

    GET DIAGNOSTICS affected = ROW_COUNT;
    EXIT WHEN affected = 0;
    SELECT max(id) INTO last_id FROM users WHERE first_name IS NOT NULL;
    COMMIT;                 -- в процедуре: даём вакууму и репликам догнать
    PERFORM pg_sleep(0.05); -- дросселируем нагрузку
  END LOOP;
END $$;
-- Волна 3: выкатываем код, который пишет и читает новые колонки. Триггер ещё жив.
-- Волна 4 (contract), только после того как все инстансы старого кода погашены:
DROP TRIGGER users_sync_name ON users;
ALTER TABLE users DROP COLUMN full_name;
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;

Ловушки, которые стоят простоя и знать их надо наизусть:

  • ALTER TABLE ... ADD COLUMN ... DEFAULT в PostgreSQL с версии 11 не переписывает таблицу — но добавление NOT NULL без валидного значения по-прежнему требует полного сканирования под ACCESS EXCLUSIVE.
  • Любой DDL ждёт блокировку в очереди, и пока он ждёт, за ним встают все обычные запросы. Долгий SELECT плюс безобидный ALTER дают полную остановку таблицы. Лечится SET lock_timeout = '2s' перед DDL и ретраями.
  • CREATE INDEX блокирует запись; нужен CREATE INDEX CONCURRENTLY — он медленнее, не работает в транзакции и может оставить невалидный индекс, который придётся дропать и строить заново.
  • Внешние ключи и check-ограничения добавляйте как NOT VALID, затем отдельным шагом VALIDATE CONSTRAINT — это берёт более слабую блокировку.
  • В MySQL смотрите на ALGORITHM=INPLACE, LOCK=NONE; там, где это невозможно, применяйте gh-ost или pt-online-schema-change, которые строят теневую таблицу и догоняют её по binlog.

Жизненный цикл гетерогенного переезда

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

Фазы переезда: кто принимает записи и где ещё можно откатиться

6. Backfill и CDC: почему порядок операций критичен

Перенос состоит из двух потоков: снимок существующих данных и поток изменений, происходящих во время переноса. Их корректная стыковка — сердце всей миграции.

Порядок «слот - снимок» решает, будет ли перенос полным

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

# Debezium: коннектор к PostgreSQL. Ключевые параметры, а не полный конфиг.
name: billing-outbound
config:
  connector.class: io.debezium.connector.postgresql.PostgresConnector
  plugin.name: pgoutput
  slot.name: billing_cdc
  publication.autocreate.mode: filtered
  table.include.list: "public.invoices,public.payments"
  # Снимок делается ПОСЛЕ создания слота — это гарантия самого Debezium.
  snapshot.mode: initial
  # Инкрементальный снимок: чанками, без блокировок, можно догрузить таблицу на лету.
  incremental.snapshot.chunk.size: 4096
  # Ключ сообщения = первичный ключ, чтобы Kafka гарантировала порядок по строке.
  message.key.columns: "public.invoices:id;public.payments:id"
  heartbeat.interval.ms: 10000    # иначе слот не двигается на «тихих» таблицах и WAL растёт
  decimal.handling.mode: string   # numeric не влезает в double — потеря копеек гарантирована
  time.precision.mode: adaptive_time_microseconds

Четыре параметра из этого конфига — прямые уроки чужих инцидентов.

heartbeat.interval.ms. Если отслеживаемые таблицы редко меняются, а база в целом активна, подтверждённая позиция слота не двигается, и WAL копится, пока не кончится диск на primary. Heartbeat заставляет коннектор периодически подтверждать позицию. Ставьте алерт на pg_replication_slots.confirmed_flush_lsn и на размер pg_wal до запуска CDC, а не после первого инцидента.

decimal.handling.mode: string. Значение по умолчанию кодирует numeric в байты со шкалой, а многие приёмники молча приводят к double. Для денег это гарантированная потеря точности, которую заметит бухгалтерия через квартал.

message.key.columns. Без ключа сообщения Kafka распределит события одной строки по разным партициям, и порядок «создан → оплачен → отменён» перестанет соблюдаться. Приёмник увидит «отменён» перед «оплачен».

snapshot.mode и инкрементальный снимок. Классический снимок читает таблицу одной длинной транзакцией: на терабайтной таблице это часы удержания снапшота, распухание версий строк и риск отмены по max_standby_streaming_delay на репликах. Инкрементальный снимок (алгоритм DBLog, применяемый Debezium) читает чанками и переплетает их с потоком изменений, снимая обе проблемы.

Двойная запись — и почему ей нельзя доверять как единственному механизму

Соблазнительная идея: пусть приложение пишет в обе базы. Разберём, почему она ломается.

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

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

Строгий вариант двойной записи без CDC — паттерн transactional outbox: приложение в одной транзакции с бизнес-данными пишет строку в таблицу outbox, а отдельный процесс читает её и доставляет в приёмник. Атомарность обеспечивает сама СУБД, а доставка становится задачей ретраев с идемпотентностью.

BEGIN;
INSERT INTO invoices (id, customer_id, amount_cents) VALUES ($1, $2, $3);
INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
VALUES ($1, 'invoice.created', jsonb_build_object('id',$1,'amount',$3), now());
COMMIT;
-- Публикатор: SELECT ... ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 500, отправка, DELETE.

7. Сверка: единственное доказательство, что переезд корректен

Ни один переезд не считается успешным, пока сверка не показала расхождение ноль на полном цикле бизнес-процессов. Наивное «сравним count(*)» ловит только грубые потери. Нужны три уровня.

Уровень 1: количества по окнам. Дёшево, ловит пропуски целых диапазонов. Считайте по окнам времени, а не по всей таблице — иначе одна лишняя и одна потерянная строка компенсируют друг друга.

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

Уровень 3: построчное сравнение внутри расходящихся чанков. Дорого, но применяется к малой доле данных.

"""Трёхуровневая сверка двух хранилищ. Идея: сначала сравниваем хеши чанков,
построчно спускаемся только туда, где хеши разошлись.
Сложность: O(N) чтения на полный проход, O(k) построчных сравнений,
где k — число строк в расходящихся чанках. Память: O(размер чанка)."""

import hashlib
from dataclasses import dataclass

CHUNK = 50_000  # строк в чанке: компромисс между числом запросов и точностью локализации


def canon(row: dict) -> bytes:
    """Каноническое представление строки. Здесь живут все различия семантики типов:
    numeric против float, timestamptz против naive, NULL против пустой строки,
    порядок ключей в JSON. Ошибка в этой функции = тысячи ложных расхождений."""
    parts = []
    for key in sorted(row):
        v = row[key]
        if v is None:
            parts.append(f"{key}=\\N")
        elif isinstance(v, float):
            raise TypeError(f"{key}: float в сверке денег недопустим, приводите к Decimal")
        else:
            parts.append(f"{key}={v!s}")
    return "\x1f".join(parts).encode()


def chunk_digest(rows) -> str:
    """Хеш чанка. XOR-свёртка делает результат независимым от порядка выдачи —
    важно, потому что новая база может отдавать строки в другом физическом порядке."""
    acc = bytearray(32)
    for r in rows:
        h = hashlib.blake2b(canon(r), digest_size=32).digest()
        for i in range(32):
            acc[i] ^= h[i]
    return acc.hex()


@dataclass
class Divergence:
    pk: object
    reason: str


def reconcile(src, dst, table: str, pk: str, lo, hi) -> list[Divergence]:
    """src/dst — адаптеры с методом fetch(table, pk, lo, hi) -> iterable[dict]."""
    out: list[Divergence] = []
    cursor = lo
    while cursor < hi:
        upper = min(cursor + CHUNK, hi)
        a = list(src.fetch(table, pk, cursor, upper))
        b = list(dst.fetch(table, pk, cursor, upper))

        if chunk_digest(a) != chunk_digest(b):
            # Спускаемся построчно только по расходящемуся чанку.
            ma = {r[pk]: canon(r) for r in a}
            mb = {r[pk]: canon(r) for r in b}
            for k in ma.keys() - mb.keys():
                out.append(Divergence(k, "missing_in_target"))
            for k in mb.keys() - ma.keys():
                out.append(Divergence(k, "extra_in_target"))
            for k in ma.keys() & mb.keys():
                if ma[k] != mb[k]:
                    out.append(Divergence(k, "value_mismatch"))
        cursor = upper
    return out

Три правила, без которых сверка врёт.

Сравнивайте согласованный срез, а не «сейчас». Пока вы читаете источник, он меняется. Либо сравнивайте только данные старше лага репликации плюс запас (например, updated_at < now() - interval '5 minutes'), либо читайте обе стороны на зафиксированной позиции журнала.

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

Считайте расхождения метрикой, а не событием. Гоните сверку непрерывно всю фазу двойной записи и рисуйте график. Ноль в один момент времени ничего не доказывает; ноль на протяжении полного цикла самого редкого бизнес-процесса — доказывает.

8. Переключение и откат

Переключение чтений — самая заметная, но не самая рискованная часть; риск уже израсходован на предыдущих фазах.

# Флаг на уровне сущности, а не глобальный тумблер. Позволяет катить по 1%,
# держать конкретных клиентов на старой базе и мгновенно откатывать.
def read_invoice(invoice_id: str, customer_id: str):
    if flags.enabled("invoices.read_from_new", subject=customer_id):
        try:
            return new_db.get(invoice_id)
        except Exception:
            metrics.inc("migration.new_read_failed")
            return old_db.get(invoice_id)   # деградация, а не отказ
    return old_db.get(invoice_id)


def shadow_compare(invoice_id: str):
    """Теневое чтение: результат новой базы не отдаётся клиенту, только сравнивается.
    Гоняем на проценте трафика — это ловит ошибки маппинга на реальных данных,
    которых нет в тестовых наборах."""
    old, new = old_db.get(invoice_id), new_db.get(invoice_id)
    if canon(old) != canon(new):
        metrics.inc("migration.shadow_mismatch", tags={"table": "invoices"})
        log.warning("shadow mismatch", extra={"id": invoice_id})
    return old

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

  1. Заранее записаны критерии отката в числах: рост 5xx выше 0.1%, p99 хуже базового на 30%, любое расхождение сверки класса «отсутствует в приёмнике».
  2. Откат — одна операция (снять флаг), доступная дежурному без деплоя и без миграции данных.
  3. Старая база продолжает принимать записи весь период канарейки. Именно это делает откат дешёвым.
  4. Переключение не проводится в пятницу, перед праздниками и в пик сезона. Банально, но нарушается регулярно.
  5. Карантин перед выводом старой базы — не меньше, чем длина самого редкого цикла: месячные списания, годовые отчёты, ретраи платежей с задержкой в недели.
  6. Перед DROP старой базы — финальный снимок в объектное хранилище с проверенным восстановлением. Проверенным — значит вы его действительно развернули и посчитали контрольные суммы.

Особый случай: смена версии движка

Мажорное обновление PostgreSQL или MySQL — это гомогенная миграция, но с собственным набором граблей.

  • Планы запросов меняются. Новая версия планировщика может выбрать другой план для критичного запроса. Соберите pg_stat_statements до обновления, повторите после, сравните по total_exec_time. Статистику после pg_upgrade нужно пересобрать ANALYZE — без неё планы будут случайными.
  • Логическая репликация даёт обновление почти без простоя. Поднимаете новую версию как подписчика, дожидаетесь нулевого лага, переключаете трафик. Простой — секунды вместо часов pg_upgrade. Ограничения помнить обязательно: не реплицируются DDL, последовательности переносятся вручную, таблицы без первичного ключа требуют REPLICA IDENTITY FULL.
  • Расширения обновляются отдельно и не всегда доступны для новой версии в тот же день. Проверьте pgvector, postgis, timescaledb заранее — это частая причина отложить обновление на квартал.

9. Гетерогенный переезд: что ломается на стыке моделей

Самая недооценённая часть — семантика типов и гарантий, которая не переносится автоматически.

Из В Что молча ломается
MongoDB PostgreSQL Отсутствующее поле против null; Decimal128 против numeric; массивы разной длины в «одинаковых» документах; дубли, невозможные при уникальном индексе
MySQL PostgreSQL 0000-00-00 как валидная дата; регистронезависимое сравнение по умолчанию; tinyint(1) как boolean; молчаливое усечение строк в нестрогом режиме
PostgreSQL ClickHouse NULL требует Nullable, что стоит производительности; апдейты становятся мутациями; порядок в ORDER BY определяет всю физику хранения
Реляционная Cassandra Джойны исчезают; уникальность не обеспечивается; каждый новый запрос требует новой денормализованной таблицы
Любая Elasticsearch Числа в строках, динамический маппинг, взрыв полей; неизменяемость маппинга требует переиндексации

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

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

10. Типичные ошибки

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

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

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

Отсутствие абстракции над хранилищем — и избыточная абстракция. Полный доступ к SQL из бизнес-логики делает переезд переписыванием. Универсальный ORM-слой, отрицающий особенности движка, лишает вас того, за что вы движок и выбрали. Рабочая середина: доступ к данным собран в слое репозиториев, где нативные запросы допустимы, но локализованы.

Миграция без плана отката. Если откат требует обратного переноса данных, отката нет.

Сверка «после», а не «во время». Расхождение, найденное через месяц после переключения, неотличимо от бага приложения и почти нечинимо.

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

Забытые потребители. Аналитик с прямым доступом, ночной отчёт, скрипт бухгалтерии, дашборд в BI — все они ходят в старую базу. Инвентаризация по логам подключений (pg_stat_activity, аудит) делается до переключения, а не после жалоб.

Слот репликации без потребителя. Остановленный на выходные Debezium — и WAL забивает диск primary. Алерт на возраст слота обязателен.

11. Мини-итог

  • Выбор хранилища определяется паттерном доступа, объёмом и требуемыми гарантиями — в таком порядке. Название продукта — следствие, а не отправная точка.
  • Столбец «когда НЕ брать» полезнее столбца достоинств: он отсекает варианты быстрее.
  • PostgreSQL по умолчанию — не догма, а экономия: одна база закрывает роли поиска, очереди, аналитики средних объёмов, JSON-документов и векторов, пока не измерена конкретная нехватка.
  • Каждое дополнительное хранилище стоит не только денег, но и нового набора режимов отказа. Вводите его по записанному триггеру из ADR, а не по интуиции.
  • Решение оформляется как ADR с условиями пересмотра — это превращает архитектуру в проверяемую гипотезу.
  • Основа миграции — CDC из журнала, а не двойная запись: журнал уже упорядочен и атомарен с коммитом.
  • Слот открывается до снимка. Дубли лечатся идемпотентностью, пропуски не лечатся ничем.
  • Переезд корректен ровно настолько, насколько это доказала непрерывная сверка на полном цикле бизнес-процессов.
  • Откат должен быть одной операцией без переноса данных, а старая база — оставаться полной и актуальной до конца карантина.

Источники

Что дальше

Трек «Базы данных» на этом закончен: от реляционной модели и планов выполнения через транзакции, репликацию и весь спектр NoSQL — до распределённых SQL-систем и, наконец, до процедуры выбора и переезда. Если начинали не с начала, вернитесь к карте трека — она расставляет прочитанное по осям: Базы данных: карта трека и как выбирать хранилище под задачу.

Естественные продолжения на портале:

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

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов