Базы данных: карта трека и как выбирать хранилище под задачу
Это вход в трек из двадцати статей. Она не пересказывает остальные, а даёт то, без чего они превращаются в каталог брендов: систему координат. После неё вы сможете про любое хранилище ответить на пять вопросов — какую модель данных оно навязывает, под какой паттерн доступа заточен его движок, какие гарантии даёт при сбое, чего стоит его эксплуатация и в какой момент оно перестанет справляться.
Главный тезис трека звучит неудобно: выбор базы данных — это на 80% выбор паттерна доступа и только на 20% выбор продукта. Инженеры спорят «Postgres или Mongo», а спорить надо о том, читаете ли вы данные по первичному ключу поштучно, диапазонами, полнотекстово или агрегируете миллиарды строк по трём колонкам. Ответ на этот вопрос детерминирует движок; название на логотипе — уже почти косметика.
1. Цена ошибки, или почему это не «просто выберем позже»
Выбор хранилища — одно из немногих архитектурных решений, которое сложно откатить. Фреймворк меняют за спринт, язык — за квартал, базу — за год, и то не всегда. Причин три.
Данные тяжёлые. Перенести 20 ТБ — это не pg_dump | psql. Это двойная запись, сверка, обратная совместимость
читателей, окно переключения и план отката, который тоже надо чем-то тестировать. Разбору этого посвящена
https://courses.digitable.life/post/databases/19-choosing-and-migrating/.
Модель протекает в код. Если вы выбрали документную БД и денормализовали заказ вместе с адресом доставки, то доменная логика, API-контракты и кэши построены вокруг этой формы. Смена хранилища тянет за собой смену модели, а смена модели — переписывание бизнес-логики.
Гарантии протекают в продукт. Система, построенная поверх eventual consistency, содержит компенсирующие механизмы: идемпотентные обработчики, версии, разрешение конфликтов, «данные обновятся в течение минуты» в UI. Переезд на строгую консистентность эти механизмы не удалит — они уже вросли.
Отсюда практический вывод: решение надо обосновывать письменно и заранее, в формате ADR, где явно записаны паттерн доступа, ожидаемый объём, требуемые гарантии и условия, при которых решение подлежит пересмотру. Шаблон — в разделе 12.
2. Пять осей, на которых живёт любое хранилище
Прежде чем смотреть на продукты, зафиксируем оси. Их ровно пять, и любая честная сравнительная таблица — это проекция на эти оси.
Ось 1. Модель данных. Что база считает «единицей»: кортеж в отношении, документ, пара ключ-значение, строка в широкой колоночной таблице, вершина и ребро, вектор. Модель определяет, какие вопросы задавать легко, а какие — через боль. Реляционная модель (https://courses.digitable.life/post/databases/01-relational-model/) уникальна тем, что она не оптимизирована под конкретный вопрос: вы храните факты нормализованно, а вопрос формулируете в момент чтения. Все остальные модели торгуют эту гибкость на скорость конкретного паттерна.
Ось 2. Паттерн доступа. Точечное чтение по ключу, диапазонный скан, полнотекстовый поиск, агрегация по колонкам, обход графа, поиск ближайших соседей. И отдельно — соотношение чтений и записей, и размер горячего множества относительно RAM.
Ось 3. Гарантии. Атомарность и изоляция (https://courses.digitable.life/post/databases/07-transactions-and-isolation/), консистентность при репликации, durability при потере узла, поведение при сетевом разделении (https://courses.digitable.life/post/databases/09-nosql-landscape/).
Ось 4. Масштабирование. Вертикально до какого предела, горизонтально — по чтению, по записи, автоматически или руками, что происходит при решардинге (https://courses.digitable.life/post/databases/08-replication-and-sharding/).
Ось 5. Эксплуатация и стоимость. Кто дежурит ночью, сколько стоит лицензия и железо, насколько предсказуемы апгрейды, есть ли на рынке люди с этим опытом. Эта ось убивает больше проектов, чем первые четыре вместе.
3. Физика под капотом: почему пределы именно такие
Маркетинг обещает «масштабируется бесконечно». Физика говорит иначе, и понимание физики — это то, что отличает осознанный выбор от выбора по популярности.
3.1 Лестница задержек
Все решения в устройстве БД вырастают из одного факта: разница в скорости между уровнями памяти — порядки величины. Порядки (по мотивам Latency Numbers Every Programmer Should Know Джеффа Дина, актуализированные под современное железо):
| Операция | Порядок задержки | Во сколько раз медленнее L1 |
|---|---|---|
| Обращение к L1-кэшу | ~1 нс | 1x |
| Обращение к RAM | ~100 нс | ~100x |
| Случайное чтение с NVMe SSD | ~20–100 мкс | ~10⁴–10⁵x |
| Случайное чтение с HDD | ~5–10 мс | ~10⁷x |
| Round-trip внутри дата-центра | ~0.5 мс | ~5·10⁵x |
| Round-trip между регионами | ~50–150 мс | ~10⁸x |
Отсюда три следствия, объясняющие почти всё в этом треке.
Следствие A: индекс — это способ не читать диск. B-tree высотой 4 при страницах 8 КБ адресует сотни миллионов строк за 4 обращения, и первые 2–3 уровня почти всегда в кэше. Без индекса — full scan, то есть линейное чтение всего объёма (https://courses.digitable.life/post/databases/06-indexes-and-query-plans/).
Следствие B: синхронная репликация между регионами стоит минимум один RTT на коммит. 100 мс между Европой и США — физика скорости света в оптоволокне, её не «оптимизируют». Именно поэтому геораспределённые системы со строгой консистентностью (Spanner, CockroachDB) имеют принципиально другой профиль латентности записи, чем одно-региональные — https://courses.digitable.life/post/databases/18-newsql-and-distributed/.
Следствие C: если рабочее множество влезает в RAM, почти любая база быстрая. Различия между движками начинают проявляться ровно там, где данные перестают помещаться в память. Бенчмарк на 10 ГБ данных при 64 ГБ RAM не измеряет ничего, кроме качества сетевого стека.
3.2 Строки против колонок
Это первая большая развилка. Строчное хранение кладёт кортеж целиком в одну страницу — идеально, когда вы работаете
с сущностью целиком (SELECT * FROM orders WHERE id = 1042, затем UPDATE). Колоночное раскладывает каждый атрибут
в отдельный файл — идеально, когда вы читаете 3 колонки из 40 по 200 миллионам строк.
Разница не в процентах, а в разах. Аналитический запрос на строчном хранилище поднимает с диска весь кортеж, включая
39 ненужных колонок; на колоночном — один файл, где однородные значения жмутся в 5–20 раз (Delta + LZ4 для чисел,
словарное кодирование для низкокардинальных строк). Отсюда типичный разрыв в 10–100x на агрегациях —
https://courses.digitable.life/post/databases/13-clickhouse-and-olap/.
Обратная сторона: точечный UPDATE в колоночном хранилище означает переписывание блоков во всех колонках, поэтому
ClickHouse и подобные либо не поддерживают полноценный UPDATE, либо реализуют его как асинхронную мутацию.
Колоночная база — не «быстрый Postgres», это база для другого класса вопросов.
3.3 B-tree против LSM
Вторая развилка — как движок кладёт данные на диск. B-tree обновляет страницу на месте: чтение предсказуемо, запись случайная и усиленная (одна изменённая строка = записанная страница целиком плюс WAL). LSM-tree только дозаписывает: запись уходит в memtable в памяти и сбрасывается пачками, но чтение может потребовать проверки нескольких уровней SSTable, а фоновый compaction создаёт всплески I/O и latency.
Практическое правило: write-heavy нагрузка с точечными чтениями по ключу → LSM; read-heavy с диапазонами и честными апдейтами → B-tree. Каноническое изложение — глава 3 «Designing Data-Intensive Applications» Мартина Клеппмана (книга), лучший однотомник по теме на сегодня.
4. Честное сравнение классов
Теперь таблицы. Читайте колонку «Когда НЕ брать» первой — она информативнее остальных.
4.1 Модель, гарантии, масштабирование
| Класс | Модель данных | Транзакции | Консистентность по умолчанию | Горизонтальное масштабирование |
|---|---|---|---|---|
| Реляционные (Postgres, MySQL) | отношения, схема на записи | полноценные ACID, multi-statement | строгая на лидере; реплики — асинхронные | чтение — реплики; запись — только шардинг руками |
| NewSQL (CockroachDB, Spanner, TiDB) | отношения, SQL-совместимость | распределённый ACID, serializable | строгая (linearizable) | автоматический решардинг, запись масштабируется |
| Документные (MongoDB) | JSON-документы, схема на чтении | ACID внутри документа; multi-doc — с 4.0, дороже | настраиваемая (readConcern/writeConcern) |
встроенный шардинг по ключу |
| Key-value (Redis) | ключ → структура данных | атомарность команды, MULTI, Lua-скрипт |
одно-узловая; кластер — без кросс-слот транзакций | слоты в кластере, ребалансировка |
| Wide-column (Cassandra, Scylla) | таблица с partition key + clustering | лёгкие транзакции только через Paxos, дорого | tunable (ONE/QUORUM/ALL) |
линейное, безмастерное, лучшее в классе |
| Аналитические (ClickHouse) | колонки, широкие таблицы | практически нет; вставки атомарны по блоку | eventual между репликами | шардирование по ключу, distributed-таблицы |
| Поисковые (Elasticsearch) | инвертированный индекс + документы | нет | near-real-time (refresh ~1 с) | шардирование индексов |
| Графовые (Neo4j) | вершины и рёбра как first-class | ACID (в Neo4j — да) | строгая в кластере | слабое место класса |
| Векторные (Qdrant, Milvus) | вектор + метаданные | обычно нет | eventual | шардирование коллекций |
| Объектные (S3, MinIO) | объект целиком по ключу | атомарная замена объекта; read-after-write | strong read-after-write (S3 с 2020) | практически неограниченное |
4.2 Эксплуатация и стоимость
| Класс | Порог входа | Типичная боль в проде | Модель стоимости | Когда НЕ брать |
|---|---|---|---|---|
| PostgreSQL | низкий | vacuum и bloat, wraparound, долгие транзакции, connection storm без пулера | железо + люди; лицензия 0 $ | запись выше возможностей одного узла; чистая аналитика на терабайтах |
| MySQL / MariaDB | низкий | репликационный лаг, DDL на больших таблицах, различия форков | железо + люди; лицензия 0 $ (GPL) | нужны сложные типы, оконные функции старых версий, богатые расширения |
| MS SQL / Oracle | средний | лицензионный аудит, привязка к вендору, стоимость ядра | лицензия по ядрам — десятки тысяч $ на сервер | стартап без бюджета; нагрузка, где ядер надо много, а фич — мало |
| SQLite | минимальный | один писатель, отсутствие сетевого доступа | 0 $ | многопользовательская запись через сеть |
| MongoDB | низкий | неудачный shard key, распухшие документы, рост индексов | Atlas дорого масштабируется; on-prem SSPL | нужны сложные джойны и строгая схема; финансовые инварианты |
| Redis | низкий | OOM и вытеснение, потеря данных при падении, big keys, hot slot | RAM — самая дорогая память на гигабайт | primary storage для данных, которые нельзя потерять |
| Cassandra / Scylla | высокий | tombstones, ремонт (repair), перекос партиций, JVM-паузы | много узлов, дорогая эксплуатация | меньше нескольких ТБ; нужны ad-hoc запросы и джойны |
| ClickHouse | средний | мутации, слияния, кардинальность ключа партиционирования | дёшево на терабайт, дорого на «много мелких запросов» | OLTP, точечные апдейты, высокая конкуренция коротких запросов |
| Elasticsearch | средний | сплит-брейн в старых версиях, mapping explosion, heap | лицензия ELv2/SSPL; RAM-ёмкий | как источник истины; как обычная БД |
| Neo4j | средний | масштаб записи, стоимость Enterprise | Enterprise-лицензия | графы, которые прекрасно живут в рекурсивном CTE Postgres |
| Векторные | низкий | пересборка индекса, память под HNSW, качество recall | RAM-ёмкие | < 1 млн векторов — хватит pgvector |
4.3 Ориентиры производительности
Числа ниже — порядки величины на типичном узле (современный x86, NVMe, данные не влезают в RAM целиком), а не обещания. Всегда меряйте на своих данных; но если ваши ожидания расходятся с этой таблицей на порядок — вы что-то недопоняли о своей нагрузке.
| Операция | PostgreSQL | Redis | Cassandra | ClickHouse |
|---|---|---|---|---|
| Точечное чтение по PK, p50 | 0.1–1 мс | 0.05–0.2 мс | 0.5–2 мс | плохо приспособлен |
| Точечная запись, p50 | 0.5–2 мс (fsync) | 0.05–0.2 мс | 0.5–2 мс | вставка только пачками |
| Пропускная способность записи, узел | 5–50 тыс/с | 100+ тыс/с | 20–100 тыс/с | 100 тыс – 1 млн строк/с (батчами) |
| Агрегация по 100 млн строк | минуты | нет | нет | 0.1–2 с |
| Полнотекстовый поиск | приемлемо (GIN) | нет | нет | ограниченно |
Отдельно — про пропускную способность записи в Postgres. Ограничитель почти всегда fsync при коммите WAL. Отсюда
классический приём: батчить записи в одну транзакцию. Разница между 1000 отдельных INSERT и одним INSERT на 1000
строк — обычно 20–50x, и это лечит больше «медленных баз», чем любой тюнинг конфигов.
5. CAP, PACELC и что на самом деле выбирают
CAP-теорема — самая цитируемая и самая неправильно понятая вещь в области. Формулировка Гилберта и Линча (оригинал, 2002): при сетевом разделении система не может быть одновременно консистентной и доступной. Ключевое слово — при разделении. Это не меню «выберите два из трёх», которое действует всегда: разделение — редкое событие, и CAP говорит только о поведении в этот момент.
Куда полезнее PACELC Дэниела Абади (статья, 2012): если Partition, выбирай между Availability и Consistency; Else — между Latency и Consistency. Второй половиной вы платите каждый день, а не раз в год. Синхронная репликация на кворум — это плюс один сетевой round-trip на каждый коммит, всегда, а не только при аварии.
Практический перевод для архитектора:
| Что вы объявили | Что это значит в коде | Пример |
|---|---|---|
| CP при разделении | часть узлов отвечает ошибкой, клиент повторяет | etcd, Spanner, CockroachDB, Mongo с majority |
| AP при разделении | все узлы отвечают, потом разрешают конфликты | Cassandra с ONE, Redis-кластер, DynamoDB (настраиваемо) |
| EL — низкая латентность | читаете с ближайшей реплики и видите устаревшее | любая асинхронная read-replica, включая Postgres |
| EC — консистентность | читаете с лидера или с кворума, платите RTT | SELECT ... FOR UPDATE, readConcern: linearizable |
Самая частая ошибка на этой оси — не выбрать AP или CP, а не заметить, что выбор уже сделан за вас. Асинхронная реплика Postgres, с которой читает ваше приложение, — это eventual consistency, со всеми последствиями: пользователь сохранил профиль, перезагрузил страницу, увидел старые данные, написал в поддержку. Лечится либо read-your-writes через чтение с лидера для «своих» запросов, либо ожиданием LSN.
6. Процедура выбора
Теперь соберём всё в воспроизводимую процедуру. Она сознательно начинается не с продукта, а с вопроса, который вы задаёте данным.
и есть инварианты
между сущностями?} B -- нет, поток событий --> C{Нужны агрегации
по колонкам?} B -- да --> D{Объём и запись
помещаются в один узел?
ориентир: < 10 ТБ, < 50k w/s} C -- да --> C1[Колоночная OLAP
ClickHouse, DuckDB, MPP] C -- нет, поиск по тексту --> C2[Поисковая
Elasticsearch, OpenSearch] C -- нет, метрики по времени --> C3[Time-series
TimescaleDB, InfluxDB] C -- нет, поиск по смыслу --> C4[Векторная
pgvector, Qdrant, Milvus] D -- да --> E[PostgreSQL
это дефолт, и он редко неправ] D -- нет --> F{Нужен ли распределённый
ACID и SQL?} F -- да --> F1[NewSQL
CockroachDB, YugabyteDB, TiDB] F -- нет, важнее запись и доступность --> F2{Запросы известны заранее
и всегда по ключу?} F2 -- да --> G1[Wide-column
Cassandra, ScyllaDB] F2 -- нет, документы разной формы --> G2[Документная
MongoDB] E --> H{Есть отдельный горячий
подпаттерн?} H -- кэш, счётчики, очередь --> H1[+ Redis рядом] H -- полнотекст сверх GIN --> H2[+ Elasticsearch рядом] H -- аналитика мешает OLTP --> H3[+ ClickHouse рядом] H -- нет --> H4[Не усложняйте.
Одна база — это фича]
Три комментария к схеме, без которых она вредна.
PostgreSQL как дефолт — это не вкусовщина, а математика рисков. Он покрывает реляционную модель, JSONB-документы,
полнотекстовый поиск, гео (PostGIS), временные ряды (TimescaleDB), векторы (pgvector) и очереди (SKIP LOCKED) —
достаточно хорошо для подавляющего большинства нагрузок. «Достаточно хорошо в одной системе» почти всегда дешевле,
чем «отлично в пяти системах», потому что пять систем — это пять моделей отказа, пять графиков апгрейдов и пять
наборов знаний в дежурной смене. Детали — https://courses.digitable.life/post/databases/02-postgresql/.
Пороги в ромбе — ориентиры, а не законы. Одиночный Postgres на хорошем железе спокойно живёт с десятками терабайт и десятками тысяч записей в секунду; известны инсталляции много больше. Уходить в распределённые системы надо не когда «стало много данных», а когда упёрлись в конкретный измеренный предел и исчерпали дешёвые способы его отодвинуть: партиционирование, архивирование холодных данных, вынос blob-ов в объектное хранилище (https://courses.digitable.life/post/databases/16-object-storage/), пулер соединений, реплики для чтения.
Специализированное хранилище добавляется рядом, а не вместо. Elasticsearch, ClickHouse, векторная база — это обычно производные представления, а источник истины остаётся в реляционной БД. Такая асимметрия резко упрощает жизнь: производное можно перестроить из источника, значит его бэкапы, миграции и аварии дешевле.
7. Один вопрос — три модели
Абстракции становятся понятны на конкретике. Задача: «показать последние 20 заказов пользователя с позициями».
Реляционная модель. Факты хранятся один раз, форма ответа собирается в момент запроса.
-- Схема: нормализованная, инварианты выражены ограничениями.
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
status text NOT NULL CHECK (status IN ('new','paid','shipped','cancelled')),
total_cents bigint NOT NULL CHECK (total_cents >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
sku text NOT NULL,
qty int NOT NULL CHECK (qty > 0),
price_cents bigint NOT NULL,
PRIMARY KEY (order_id, sku)
);
-- Ключевой индекс: составной, порядок колонок = порядок фильтрации и сортировки.
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
-- Запрос: сначала отбираем 20 заказов, потом подтягиваем позиции.
-- Джойн ДО лимита читал бы все позиции всех заказов пользователя.
WITH recent AS (
SELECT id, status, total_cents, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20
)
SELECT r.id, r.status, r.total_cents, r.created_at,
i.sku, i.qty, i.price_cents
FROM recent r
JOIN order_items i ON i.order_id = r.id
ORDER BY r.created_at DESC, i.sku;
План выполнения — то, что надо уметь читать (подробно в https://courses.digitable.life/post/databases/06-indexes-and-query-plans/):
Sort (cost=214.8..215.3 rows=180 width=64) (actual time=0.412..0.418 rows=173 loops=1)
-> Nested Loop (cost=0.85..208.1 rows=180 width=64) (actual time=0.031..0.352 rows=173 loops=1)
-> Limit (cost=0.43..21.9 rows=20 width=32) (actual time=0.019..0.041 rows=20 loops=1)
-> Index Scan using idx_orders_user_created on orders
Index Cond: (user_id = 42) -- индекс отработал: без Filter
-> Index Scan using order_items_pkey on order_items i
Index Cond: (order_id = r.id) -- 20 быстрых точечных обращений
Planning Time: 0.21 ms
Execution Time: 0.44 ms
Что здесь важно: Index Scan вместо Seq Scan, Index Cond вместо Filter (значит условие ушло в индекс, а не
проверяется после чтения), и расхождение между rows=180 в оценке и rows=173 фактически — небольшое, то есть
статистика адекватна. Расхождение в 100+ раз — главный признак того, что планировщик выберет неверный алгоритм
соединения.
Документная модель. Форма ответа фиксируется в момент записи.
// Один документ = один заказ вместе с позициями: чтение — одно обращение.
{
_id: ObjectId("..."),
userId: 42,
status: "paid",
totalCents: 129900,
createdAt: ISODate("2026-07-10T12:00:00Z"),
items: [
{ sku: "KB-01", qty: 1, priceCents: 99900 },
{ sku: "MS-07", qty: 2, priceCents: 15000 }
]
}
// Запрос тривиален и очень быстр — ровно под этот паттерн.
db.orders.find({ userId: 42 })
.sort({ createdAt: -1 })
.limit(20);
db.orders.createIndex({ userId: 1, createdAt: -1 });
Цена: вопрос «сколько всего продано SKU KB-01 за месяц» требует агрегации с $unwind по всем документам; смена цены
SKU не имеет единого места для правки; а инвариант «сумма позиций равна total» теперь обязанность приложения, а не
базы. Подробный разбор компромиссов — https://courses.digitable.life/post/databases/10-mongodb/.
Wide-column. Модель диктуется запросом буквально: сначала пишете запрос, потом таблицу под него.
-- Cassandra: partition key = userId (все заказы пользователя на одном узле),
-- clustering key = createdAt DESC (данные физически лежат уже отсортированными).
CREATE TABLE orders_by_user (
user_id bigint,
created_at timestamp,
order_id uuid,
status text,
total_cents bigint,
items frozen<list<tuple<text,int,bigint>>>,
PRIMARY KEY ((user_id), created_at, order_id)
) WITH CLUSTERING ORDER BY (created_at DESC, order_id ASC);
-- Единственный дешёвый запрос: точно тот, под который спроектирована таблица.
SELECT * FROM orders_by_user WHERE user_id = 42 LIMIT 20;
Нужен доступ по order_id? Заводите вторую таблицу orders_by_id и пишете в обе. Это не костыль, а
дизайн-принцип класса: денормализация и дублирование записи в обмен на линейное масштабирование и отсутствие
координации — https://courses.digitable.life/post/databases/12-cassandra-and-wide-column/.
8. Цена полиглотной персистентности
Как только хранилищ становится больше одного, появляется проблема, которую недооценивают все и всегда: атомарно записать в две системы нельзя. Ни ретраями, ни «мы же в транзакции».
Ретрай не спасёт: процесс уже умер end rect rgb(225, 238, 228) Note over App,ES: Transactional outbox: одна атомарная запись App->>PG: BEGIN; INSERT order; INSERT outbox; COMMIT PG-->>App: ok (обе строки или ни одной) loop релей читает outbox / WAL через CDC Q->>PG: SELECT ... FROM outbox FOR UPDATE SKIP LOCKED PG-->>Q: события Q->>ES: index(order) — идемпотентно, по версии документа Q->>PG: DELETE отправленные end Note right of Q: at-least-once + идемпотентность потребителя
= согласованность за конечное время end
Три правила, которые следуют из этой картинки и стоят дороже любых бенчмарков.
- Один источник истины на факт. У каждого факта ровно одно место, где он «настоящий». Всё остальное — производные проекции, которые можно перестроить с нуля. Если перестроить нельзя, у вас не проекция, а второй источник истины и гарантированный будущий инцидент.
- Никогда не пишите в две системы в одном обработчике. Пишите в одну атомарно (вместе с outbox-записью), остальное — асинхронно через релей или CDC (Debezium читает WAL). Паттерн описан у Криса Ричардсона.
- Потребители обязаны быть идемпотентными. Доставка at-least-once означает дубликаты. Идемпотентность — через версию документа, natural key или дедупликацию по event id.
9. Позиционирование: чем платите за что получаете
Диаграмма читается по диагонали: чем правее продукт (богаче язык запросов), тем труднее ему быть высоко (масштабировать запись), потому что произвольный запрос требует координации между узлами. NewSQL сидит выше и правее остальных именно потому, что платит за это латентностью записи — распределённый консенсус на каждый коммит.
10. Типичные ошибки: симптом → диагноз → что делать
Собрано из реальных инцидентов; каждая строка встречалась не по одному разу.
| Симптом в проде | Настоящая причина | Что делать |
|---|---|---|
| «База тормозит», CPU в полке, запросы простые | нет индекса под самый частый предикат, Seq Scan на большой таблице |
pg_stat_statements → топ по total_exec_time → EXPLAIN (ANALYZE, BUFFERS) |
| Таблица растёт, диск кончается, строк не прибавилось | bloat: dead tuples не вычищены, autovacuum не успевает | настроить autovacuum агрессивнее, найти долгие транзакции, pg_repack |
| «Всё встало» при 500 подключениях | каждое соединение Postgres — процесс; нет пулера | PgBouncer в transaction mode, лимит на пул в приложении |
| Redis внезапно потерял ключи | достигнут maxmemory, сработала политика вытеснения |
явно задать maxmemory-policy, разделить кэш и данные по инстансам |
| Cassandra: чтения деградировали за месяцы | tombstones от массовых удалений, перекос партиции | пересмотреть модель удалений, TTL, следить за размером партиции |
| Mongo: один шард горячий, остальные скучают | shard key с низкой кардинальностью или монотонный | hashed shard key или составной; смена ключа — дорого, думать заранее |
| Elasticsearch: OOM после релиза | mapping explosion — динамические поля из пользовательских данных | dynamic: strict, отдельное поле-объект flattened |
| ClickHouse: вставки тормозят, «too many parts» | вставляют по одной строке вместо батчей | батчи от тысяч строк, async_insert, буферные таблицы |
| Реплика отстала на часы, отчёты врут | долгий запрос на реплике конфликтует с применением WAL | hot_standby_feedback, отдельная реплика под аналитику |
| Пользователь не видит только что сохранённое | чтение с асинхронной реплики (read-your-writes нарушен) | «свои» чтения — с лидера, либо ожидание LSN |
| Миграция схемы заблокировала прод на 40 минут | ALTER TABLE с перезаписью и полной блокировкой |
безопасные DDL, lock_timeout, pt-online-schema-change/gh-ost |
Отдельная категория — ошибки выбора, а не эксплуатации:
- «Взяли NoSQL ради масштаба» при 50 ГБ данных. Заплатили отсутствием джойнов и транзакций за масштаб, который не понадобился. Это самая дорогая и самая частая ошибка в отрасли.
- «Взяли Kafka как базу данных». Лог — не хранилище с произвольным доступом; запрос «дай состояние объекта X» требует свёртки всего лога или отдельного стейт-стора.
- «Elasticsearch как источник истины». Он не транзакционный и допускает потерю при определённых сценариях отказа; это поисковый индекс, а не БД.
- «Redis как основное хранилище без анализа durability».
appendfsync everysecозначает окно потери до секунды, а RDB-снапшот — до минут. Для сессий приемлемо, для платежей нет (https://courses.digitable.life/post/databases/11-redis/). - «Микросервис = своя база» доведённое до абсурда. Двенадцать сервисов с двенадцатью Postgres — это ещё нормально; двенадцать сервисов с шестью разными СУБД — это дежурство, которое никто не выдержит.
11. Стоимость владения: считаем честно
Лицензия — обычно не главная статья. Порядок статей в TCO на горизонте трёх лет:
| Статья | Доля | Комментарий |
|---|---|---|
| Люди (эксплуатация, дежурства, экспертиза) | 40–60% | доминирует почти всегда; растёт линейно с числом разных СУБД |
| Инфраструктура (RAM, диски, сеть, реплики) | 20–40% | RAM дороже всего на гигабайт; отсюда цена Redis и Elasticsearch |
| Лицензии | 0–30% | 0 $ для Postgres/MySQL; десятки тысяч за ядро для Oracle |
| Миграции и техдолг | 10–20% | скрытая статья; проявляется при апгрейдах мажорных версий |
Пара практических ориентиров.
Managed или self-hosted. Управляемый сервис (RDS, Cloud SQL, Atlas) обычно дороже железа в 1.5–3 раза, и почти всегда дешевле, чем инженер, который умеет чинить репликацию в три часа ночи. Self-hosted оправдан при большом масштабе, специфических требованиях (расширения, версии, размещение) или регуляторных ограничениях.
Стоимость на терабайт различается на порядки. Данные в объектном хранилище — единицы долларов за ТБ в месяц; на NVMe в облаке — сотни; в оперативной памяти — тысячи. Отсюда очевидный, но редко применяемый приём: разложить данные по температуре. Горячее — в RAM/OLTP, тёплое — в колоночную БД, холодное — в S3/Parquet (https://courses.digitable.life/post/databases/16-object-storage/). Одно только вынесение архивных данных из основной БД часто даёт больше, чем любой тюнинг.
12. ADR: как зафиксировать выбор
Решение, не записанное в трёх абзацах, через год превратится в «так исторически сложилось». Минимальный шаблон:
# ADR-014: Хранилище для сервиса заказов
## Контекст
- Главный паттерн доступа: чтение заказов по user_id, последние N, с позициями (~95% запросов).
- Второй паттерн: поиск по номеру заказа в админке (~4%).
- Объём: 300 ГБ сейчас, +150 ГБ/год. Пик записи: 1200 заказов/с.
- Требования: строгая консистентность (деньги), инвариант «сумма позиций = total».
- Ограничение: в команде нет опыта эксплуатации распределённых БД.
## Решение
PostgreSQL 17, партиционирование orders по created_at помесячно,
составной индекс (user_id, created_at DESC). Реплика для админки и отчётов.
## Альтернативы и почему нет
- MongoDB: удобная форма документа, но инвариант суммы уходит в приложение,
а денег в тестах на кросс-документные транзакции нам не хватит.
- Cassandra: не нужен её масштаб (1200 w/s — это ~2% от возможностей одного
узла Postgres), а цена эксплуатации несопоставима с выигрышем.
## Условия пересмотра
- Пик записи превысил 15k/с, ИЛИ объём превысил 5 ТБ,
ИЛИ p99 записи стабильно > 50 мс при исчерпанном вертикальном масштабировании.
## Последствия
- Аналитика в основной БД запрещена; для неё — CDC в ClickHouse (ADR-021).
- Требуется PgBouncer с первого дня.
Обратите внимание на секцию условий пересмотра с числами. Она превращает архитектурное решение из религии в инженерную гипотезу с критерием фальсификации.
13. Карта трека
Рекомендованные маршруты:
- Полный, по порядку — если строите фундамент. Статьи 01, 06, 07, 08 обязательны для всех, даже если вы «нереляционщик»: изоляция и репликация универсальны.
- Прикладной бэкенд — 01 → 02 → 06 → 07 → 11 → 19.
- Аналитика и данные — 01 → 06 → 13 → 14 → 16, дальше в трек https://courses.digitable.life/post/data-engineering/00-overview/.
- Распределённые системы — 07 → 08 → 09 → 12 → 18.
- AI-приложения — 02 → 15, дальше https://courses.digitable.life/post/ai-engineering/00-overview/.
Все статьи трека:
| # | Статья | О чём |
|---|---|---|
| 01 | https://courses.digitable.life/post/databases/01-relational-model/ | отношения, нормальные формы, SQL как декларативный язык |
| 02 | https://courses.digitable.life/post/databases/02-postgresql/ | MVCC, vacuum, индексы, расширения, эксплуатация |
| 03 | https://courses.digitable.life/post/databases/03-mysql-and-mariadb/ | InnoDB, репликация, где форки разошлись |
| 04 | https://courses.digitable.life/post/databases/04-ms-sql-and-oracle/ | корпоративный контур, лицензии, специфика |
| 05 | https://courses.digitable.life/post/databases/05-sqlite-and-embedded/ | где встраиваемая БД сильнее сервера |
| 06 | https://courses.digitable.life/post/databases/06-indexes-and-query-plans/ | B-tree, hash, GIN, GiST, покрывающие индексы, EXPLAIN |
| 07 | https://courses.digitable.life/post/databases/07-transactions-and-isolation/ | ACID, уровни изоляции, аномалии, блокировки |
| 08 | https://courses.digitable.life/post/databases/08-replication-and-sharding/ | физическая и логическая репликация, failover, шардинг |
| 09 | https://courses.digitable.life/post/databases/09-nosql-landscape/ | таксономия, CAP, BASE, границы применимости |
| 10 | https://courses.digitable.life/post/databases/10-mongodb/ | документная модель, агрегации, транзакции, грабли |
| 11 | https://courses.digitable.life/post/databases/11-redis/ | структуры данных, персистентность, кластер, кэш-паттерны |
| 12 | https://courses.digitable.life/post/databases/12-cassandra-and-wide-column/ | query-first моделирование, кворумы, compaction |
| 13 | https://courses.digitable.life/post/databases/13-clickhouse-and-olap/ | MergeTree, партиционирование, проекции |
| 14 | https://courses.digitable.life/post/databases/14-timeseries-and-search/ | TimescaleDB, InfluxDB, Elasticsearch, OpenSearch |
| 15 | https://courses.digitable.life/post/databases/15-vector-databases/ | pgvector, Qdrant, Milvus, HNSW, метрики близости |
| 16 | https://courses.digitable.life/post/databases/16-object-storage/ | S3, MinIO, слои хранения, lakehouse |
| 17 | https://courses.digitable.life/post/databases/17-graph-and-keyvalue/ | Neo4j, Dgraph, etcd, RocksDB, LMDB |
| 18 | https://courses.digitable.life/post/databases/18-newsql-and-distributed/ | CockroachDB, YugabyteDB, TiDB, Spanner |
| 19 | https://courses.digitable.life/post/databases/19-choosing-and-migrating/ | сводное сравнение и техника миграции |
14. Чек-лист перед выбором
Пройдите его до того, как открывать сравнение продуктов. Если на большинство пунктов ответ «не знаю» — вы выбираете не базу, а лотерейный билет.
- Записан главный паттерн доступа одной фразой и его доля в трафике.
- Известен объём сейчас и оценка роста на 2–3 года.
- Известны пиковые RPS на чтение и на запись отдельно.
- Определено, какое горячее множество и влезает ли оно в разумный объём RAM.
- Явно решено, где нужна строгая консистентность, а где допустима eventual — и что видит пользователь.
- Записаны инварианты данных и решено, кто их обеспечивает: база или приложение.
- Проверено, что бюджет ошибки/потери данных совместим с durability-настройками кандидата.
- Оценена эксплуатация: кто дежурит, есть ли опыт, как выглядит восстановление из бэкапа (и оно проверено).
- Посчитан TCO на 3 года, включая людей, а не только лицензию.
- Записано условие пересмотра решения — с числами.
Если после чек-листа кандидатом остаётся PostgreSQL — это, скорее всего, правильный ответ, а не отсутствие воображения.
15. Источники
Книги:
- Martin Kleppmann, Designing Data-Intensive Applications — основной ориентир по моделям данных, репликации, транзакциям и распределённым системам. Если читать одну книгу — эту.
- Abraham Silberschatz, Henry Korth, S. Sudarshan, Database System Concepts — учебник, полный текст доступен свободно; фундамент реляционной теории и внутренностей СУБД.
- Alex Petrov, Database Internals — B-tree, LSM, страничная организация, консенсус.
- Markus Winand, SQL Performance Explained — индексы и планы, лучшее введение в тему; онлайн-версия бесплатна.
Статьи и документация:
- Gilbert, Lynch, Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services (2002) — формальная CAP.
- Daniel Abadi, Consistency Tradeoffs in Modern Distributed Database System Design (2012) — PACELC.
- Corbett et al., Spanner: Google’s Globally-Distributed Database (OSDI 2012).
- DeCandia et al., Dynamo: Amazon’s Highly Available Key-value Store (SOSP 2007).
- Документация PostgreSQL — образцовая по качеству; читается как учебник.
- Jepsen, аналитические отчёты о консистентности — независимая проверка того, что базы обещают в маркетинге, против того, что делают при разделении сети. Обязательное чтение перед выбором распределённой БД.
Что дальше
Дальше — фундамент, на котором стоит вся отрасль: как из наблюдений о мире получаются отношения, зачем нужна нормализация и почему SQL, несмотря на возраст и странности, оказался самым живучим интерфейсом к данным.