Как выбрать БД и как мигрировать: сравнительная сводка по всем классам
Восемнадцать статей трека разбирали хранилища поштучно: реляционные движки, документные, 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. Процедура выбора: от нагрузки к продукту, а не наоборот
Формализуем. Вход — описание нагрузки, выход — решение с обоснованием.
и объём через 24 месяца] --> B{Данные помещаются
в один узел
с запасом 3x?} B -- Нет --> S[Смотрите шардируемые классы:
Cassandra, ClickHouse,
NewSQL, объектное хранилище] B -- Да --> C{Нужны транзакции
между сущностями
и целостность?} C -- Да --> D{Профиль запросов
заранее известен?} C -- Нет --> E{Что доминирует
в чтении?} D -- Нет, аналитика ad hoc --> P[PostgreSQL] D -- Да, точечный OLTP --> P2[PostgreSQL или MySQL
оба подойдут] E -- Ключ, микросекунды --> R[Redis как кэш,
истина всё равно в СУБД] E -- Полнотекст и фасеты --> ES[Elasticsearch рядом с СУБД,
или tsvector в Postgres] E -- Агрегация по колонкам --> CH[ClickHouse рядом с СУБД] E -- Похожесть эмбеддингов --> V[pgvector,
при десятках миллионов - Qdrant] E -- Обход графа 4+ прыжков --> G[Neo4j,
иначе рекурсивный CTE] S --> T{Нужен ли SQL
и строгая изоляция
на распределённых данных?} T -- Да --> N[CockroachDB, YugabyteDB, TiDB] T -- Нет, запись потоком --> CS[Cassandra или ScyllaDB] T -- Нет, аналитика --> CH2[ClickHouse или lakehouse
поверх объектного хранилища]
Три замечания к этой схеме, без которых она вредна.
Первое: «рядом с СУБД», а не «вместо». Ветки 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.
Жизненный цикл гетерогенного переезда
маппинг типов зафиксирован ТеневаяСхема --> Backfill: слот репликации открыт,
CDC копит события Backfill --> ДвойнаяЗапись: снимок догнан,
лаг близок к нулю ДвойнаяЗапись --> Сверка: обе базы принимают запись Сверка --> ДвойнаяЗапись: найдены расхождения,
чиним и повторяем Сверка --> ТеневыеЧтения: расхождений нет
на полном цикле ТеневыеЧтения --> Канарейка: ответы совпадают,
латентность приемлема Канарейка --> ПолноеПереключение: 1% то 10% то 50%
без роста ошибок ПолноеПереключение --> Канарейка: откат по метрикам ПолноеПереключение --> ВыводСтарой: карантин выдержан ВыводСтарой --> [*]
Обратные переходы на диаграмме важнее прямых. Миграция, из которой нельзя вернуться на предыдущий шаг за минуты, — это не миграция, а прыжок с парашютом, который упаковали в первый раз.
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
Чек-лист переключения, который стоит держать в раннбуке:
- Заранее записаны критерии отката в числах: рост 5xx выше 0.1%, p99 хуже базового на 30%, любое расхождение сверки класса «отсутствует в приёмнике».
- Откат — одна операция (снять флаг), доступная дежурному без деплоя и без миграции данных.
- Старая база продолжает принимать записи весь период канарейки. Именно это делает откат дешёвым.
- Переключение не проводится в пятницу, перед праздниками и в пик сезона. Банально, но нарушается регулярно.
- Карантин перед выводом старой базы — не меньше, чем длина самого редкого цикла: месячные списания, годовые отчёты, ретраи платежей с задержкой в недели.
- Перед
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 из журнала, а не двойная запись: журнал уже упорядочен и атомарен с коммитом.
- Слот открывается до снимка. Дубли лечатся идемпотентностью, пропуски не лечатся ничем.
- Переезд корректен ровно настолько, насколько это доказала непрерывная сверка на полном цикле бизнес-процессов.
- Откат должен быть одной операцией без переноса данных, а старая база — оставаться полной и актуальной до конца карантина.
Источники
- Martin Kleppmann. Designing Data-Intensive Applications — главы 3, 5 и 11 про выбор модели, репликацию и производные данные: dataintensive.net
- Martin Fowler. PolyglotPersistence и ParallelChange (expand/contract): martinfowler.com/bliki/PolyglotPersistence.html, martinfowler.com/bliki/ParallelChange.html
- Debezium — документация коннекторов, инкрементальные снимки, режимы обработки типов: debezium.io/documentation
- Andreas Andreakis, Ioannis Papapanagiotou. DBLog: A Watermark Based Change-Data-Capture Framework — алгоритм, лежащий в основе инкрементальных снимков: arxiv.org/abs/2010.12597
- PostgreSQL — логическая репликация и обновление версий: postgresql.org/docs/current/logical-replication.html
gh-ost— онлайн-изменение схемы в MySQL без триггеров: github.com/github/gh-ost- Percona Toolkit —
pt-online-schema-changeиpt-table-checksumдля сверки: docs.percona.com/percona-toolkit - Stripe. Online migrations at scale — практика четырёхфазного переезда с двойной записью и сверкой: stripe.com/blog/online-migrations
- Michael Nygard. Documenting Architecture Decisions — формат ADR: cognitect.com/blog/2011/11/15/documenting-architecture-decisions
- Chris Richardson. Pattern: Transactional outbox: microservices.io/patterns/data/transactional-outbox.html
Что дальше
Трек «Базы данных» на этом закончен: от реляционной модели и планов выполнения через транзакции, репликацию и весь спектр NoSQL — до распределённых SQL-систем и, наконец, до процедуры выбора и переезда. Если начинали не с начала, вернитесь к карте трека — она расставляет прочитанное по осям: Базы данных: карта трека и как выбирать хранилище под задачу.
Естественные продолжения на портале:
- Инженерия данных — что происходит с данными после того, как хранилище выбрано: пайплайны, оркестрация, качество данных, хранилища и витрины.
- Архитектурные паттерны — как выбор хранилища встраивается в архитектуру системы целиком.
- Предметно-ориентированное проектирование — откуда берутся границы агрегатов, которые определяют границы транзакций.
- DevOps — эксплуатация, наблюдаемость и надёжность того, что вы выбрали и перенесли.
А чтобы понять, куда двигаться дальше по всей базе знаний, — общая карта: Дорожная карта.