↑↓ / PgUp PgDn / колесо — навигация
Databases deep dive · Гвендолин-ночные-лекции

Базы данных: от страницы на диске до триграмм

Как СУБД превращает SQL в чтение байтов, почему B-tree живёт 50 лет, чем MVCC Postgres отличается от undo-лога InnoDB, как ClickHouse жмёт данные в 10 раз и читает миллиард строк в секунду, почему Elasticsearch - не база данных, и что за магия в трёх буквах pg_trgm. Ноль воды, максимум кишок.

Глава 1

Что такое база данных и зачем она вообще

Файл на диске - тоже хранилище. Разница в четырёх буквах ACID, в 55 годах теории Кодда и в том, что база переживает выдёргивание шнура из розетки на середине записи.

Что такое БД · мотивация

Почему не просто файл

  • Конкурентность. Два процесса пишут в один файл - получаешь кашу из байтов. СУБД даёт транзакции: 1000 клиентов одновременно, каждый видит консистентный мир.
  • Долговечность. Выключили питание на середине записи 4 КБ - в файле половина старого, половина нового. Базы пишут сначала в журнал (WAL), потом в данные: восстановление после краша - это проигрывание журнала.
  • Поиск. Найти одну строку в файле на 100 ГБ - прочитать 100 ГБ. B-tree индекс - 3-4 чтения по 8 КБ. Разница в 106 раз.
  • Декларативность. SQL говорит что хочешь, а не как. Планировщик сам выберет индекс, порядок соединений, алгоритм. Тот же запрос на 1000 строк и на 1 млрд строк исполняется по-разному - код не меняется.
  • Схема и целостность. NOT NULL, FOREIGN KEY, CHECK, UNIQUE - мусор не попадёт в данные, даже если приложение с багом.

ACID - четыре обещания

Atomicityтранзакция либо целиком, либо никак. Перевод денег: списали и зачислили, или ничего
Consistencyпосле транзакции все ограничения (constraints) выполнены. Баланс не отрицательный, FK указывает на существующую строку
Isolationпараллельные транзакции не видят промежуточных состояний друг друга (с оговорками - уровни изоляции, дальше)
DurabilityCOMMIT вернулся - данные на диске переживут краш. Цена: fsync, 0.1-10 мс на коммит
Durability - самое дорогое обещание. fsync на NVMe ~50-100 мкс, на HDD 5-10 мс. Поэтому базы группируют коммиты (group commit) и поэтому synchronous_commit=off ускоряет Postgres в 5-10 раз ценой потери последних ~200 мс при краше.
Что такое БД · история

55 лет: от статьи Кодда до векторных баз

1970Эдгар Кодд, IBM: «A Relational Model of Data». Таблицы, ключи, реляционная алгебра. Начальство IBM статью проигнорировало - у них был IMS
1974-79IBM System R (SQL - изначально SEQUEL), Ingres в Беркли (Стоунбрейкер). 1979 - первый коммерческий Oracle v2 (v1 не было - маркетинг)
1986POSTGRES (post-Ingres) Стоунбрейкера; SQL стандарт ANSI. 1989 - MS SQL Server (на коде Sybase)
1995-96MySQL (Монти Видениус, Финляндия), PostgreSQL 6.0 с SQL. 2000 - SQLite (Хипп, для эсминца ВМС США)
2004-09Google BigTable (2006), Amazon Dynamo (2007): CAP, eventual consistency. Cassandra (2008), MongoDB (2009), Redis (2009). Хайп «NoSQL»
2012Google Spanner - распределённый SQL с атомными часами. NewSQL: CockroachDB, TiDB, YDB
2016Yandex открывает ClickHouse - колоночная OLAP для Метрики, 20 трлн строк. 2019 - DuckDB
2023+pgvector, векторные БД (Pinecone, Qdrant, Milvus) под LLM. «Postgres for everything» как мейнстрим
  • Реляционная модель победила не потому что быстрее (иерархические IMS были быстрее), а потому что независима от физики: можно менять индексы и хранение, не трогая запросы. NoSQL 2009-го во многом откатил это назад - и к 2020 почти все NoSQL добавили SQL-подобные языки и транзакции.
  • Postgres - единственная из топ-5 СУБД, у которой нет владельца-корпорации: комьюнити, лицензия BSD-подобная, релиз каждый год в сентябре-октябре. MySQL принадлежит Oracle (через Sun, 2010) - отсюда MariaDB как форк Монти.

Рынок 2025 (DB-Engines / Stack Overflow)

Самая популярная у разработчиковPostgreSQL ~49% (SO survey), обошла MySQL в 2023
Самая распространённая в миреSQLite - в каждом телефоне, браузере, ТВ. >1 трлн инстансов
Самая дорогаяOracle: $47.5k за ядро Enterprise + 22% в год поддержка
Самая быстрая OLAP на строкуClickHouse: >1 млрд строк/с на ядро при сканировании
Рынок СУБД~$100 млрд в год, из них >50% облачные (RDS, Aurora, Cloud SQL)
Что такое БД · классификация

Зоопарк: восемь семейств и что у них внутри

СемействоМодельХранениеСильно вСлабо вПримеры
Реляционные OLTPтаблицы, SQL, ACIDстроки, B-tree, WALточечные чтения/записи, транзакции, целостностьаналитика по млрд строк, горизонтальный масштабPostgreSQL, MySQL, Oracle, MSSQL, SQLite
Колоночные OLAPтаблицы, SQL, без транзакцийколонки, сжатие, sparse indexагрегации, сканы, GROUP BY по млрд строкточечные UPDATE/DELETE, много мелких вставокClickHouse, BigQuery, Snowflake, Redshift, DuckDB
Key-Valueключ → байты/структурахэш в RAM / LSMлатентность 0.1 мс, кэш, счётчики, очередипоиск по значению, связиRedis, Valkey, Memcached, RocksDB, DynamoDB
ДокументныеJSON-документы, гибкая схемаB-tree/LSM, документ целикомбыстрый старт, вложенные данные, шардированиеджоины, целостность, аналитикаMongoDB, Couchbase, Firestore
Wide-columnpartition key + sorted columnsLSM-tree, SSTableзапись миллионы/с, линейный масштаб, гео-репликацияad-hoc запросы, джоины, транзакцииCassandra, ScyllaDB, HBase, Bigtable
Графовыеузлы, рёбра, свойстваadjacency listsобход связей глубины 5+, рекомендации, fraudагрегации, массовые сканыNeo4j, ArangoDB, Neptune, Memgraph
Time-seriesметка времени + метрики + тегиколонки по времени, downsamplingметрики, IoT, retention, окнавсё, что не по времениTimescaleDB, InfluxDB, VictoriaMetrics, Prometheus
Поисковыедокументы + инвертированный индексLucene-сегменты, immutableполнотекст, релевантность, фасеты, опечаткитранзакции, консистентность, источник правдыElasticsearch, OpenSearch, Solr, Manticore, Meilisearch
Границы размылись: Postgres умеет JSON (jsonb), полнотекст, графы (Apache AGE), время (Timescale), векторы (pgvector). MongoDB умеет транзакции и джоины ($lookup). ClickHouse умеет ключ-значение (Dictionary). Но физика хранения не размывается: строки против колонок, B-tree против LSM, и оттуда вытекает всё остальное.
Глава 2

Как СУБД устроена внутри

Парсер, планировщик, исполнитель, буферный пул, журнал. Что происходит за 200 микросекунд между SELECT и первой строкой ответа - и почему одна и та же строка может существовать в пяти версиях одновременно.

Внутри · путь запроса

Жизнь одного SELECT

1. СетьTCP, протокол (libpq / MySQL wire). Postgres: процесс на соединение (fork), MySQL: поток. Отсюда pgbouncer
2. ParserSQL → дерево разбора. Синтаксис, имена таблиц, права. ~10-50 мкс
3. Rewriterраскрытие VIEW, правил, RLS-политик. CTE inline (PG12+)
4. Plannerперебор планов: какой индекс, порядок джоинов, алгоритм. Оценка стоимости по статистике. Самая умная и капризная часть
5. Executorдерево операторов (Volcano-модель: каждый узел отдаёт строку по запросу next()). Seq Scan, Index Scan, Hash Join, Sort, Aggregate
6. Storageбуферный пул → страницы 8 КБ (PG) / 16 КБ (InnoDB) → диск. Промах в кэше = 100 мкс NVMe вместо 100 нс RAM
  • Prepared statements пропускают шаги 2-4: план кэшируется. На простых запросах даёт 20-40%. Но generic plan может быть хуже custom (PG после 5 выполнений решает сам - plan_cache_mode).
  • Планировщик оценивает, а не знает. Он считает стоимость в абстрактных единицах (seq_page_cost=1, random_page_cost=4 - настройка из эпохи HDD, на NVMe ставь 1.1) на основе статистики (ANALYZE: гистограммы, самые частые значения, доля NULL, корреляция). Устарела статистика - план мусор.
  • Volcano/iterator модель - каждый оператор pull-ит строки у ребёнка. Просто, гибко, но виртуальный вызов на каждую строку. ClickHouse и DuckDB делают векторное исполнение: передают блоки по 8-65 тыс. строк, в 10-100 раз меньше накладных расходов.

Сколько это стоит по времени (PG, тёплый кэш)

Round-trip по сети в ДЦ50-200 мкс
Parse + plan простого запроса30-100 мкс
Index Scan по PK, 1 строка10-30 мкс (3-4 страницы из RAM)
Промах буфера, чтение с NVMe~100 мкс на страницу
COMMIT с fsync0.1-1 мс NVMe, 5-10 мс HDD
Итого простой SELECT по PK~0.2-0.5 мс end-to-end; 5-20 тыс. QPS на соединение
Внутри · хранение

Страницы, буферный пул, WAL

Диск читается страницами по 8/16 КБ. Буферный пул держит горячие; грязные (изменённые) страницы сбрасываются фоновым writer'ом и на checkpoint. WAL пишется раньше данных - всегда.
ЭлементЧто этоГрабли
Страница (page/block)единица I/O: 8 КБ PG, 16 КБ InnoDB, 4 КБ SQLite. Заголовок + указатели на строки (line pointers) + сами строки (tuples) с конца. Строка не влезает в страницу → PG режет в TOAST (>2 КБ), InnoDB выносит в overflow pagesширокие строки = мало строк на странице = много I/O. Колонка TEXT в 1 МБ рядом с id убивает Seq Scan
Буферный пулкэш страниц в RAM. PG: shared_buffers (обычно 25% RAM) + кэш ОС сверху (двойное кэширование). InnoDB: buffer_pool_size 70-80% RAM, O_DIRECT, мимо ОС. Вытеснение: clock-sweep (PG), LRU с midpoint (InnoDB)рабочий набор не влезает в RAM → всё упирается в диск. Hit ratio <99% на OLTP - повод думать
WAL / redo logжурнал: перед изменением страницы записать «что меняю» в лог последовательно. Последовательная запись быстрее случайной в 100 раз даже на SSD. Краш → проиграть WAL с последнего checkpoint. Тот же WAL = репликацияWAL на том же диске, что данные → конкуренция. full_page_writes удваивает WAL после checkpoint. Реплика отвалилась и слот держит WAL → диск кончился
Checkpointсброс всех грязных страниц на диск, точка, с которой можно восстанавливаться. PG каждые checkpoint_timeout (5 мин) или по объёму WAL (max_wal_size)резкий checkpoint = I/O-шторм и латентность ×10. Лечится checkpoint_completion_target=0.9 и большим max_wal_size
Doublewrite / full pageзащита от torn page: ОС пишет по 4 КБ, страница 16 КБ, свет мигнул - половина записана. InnoDB пишет сначала в doublewrite buffer, PG - полную страницу в WAL при первом изменении после checkpointна ZFS/файловых системах с CoW можно отключить, на ext4 - нельзя
Внутри · MVCC

MVCC: почему читатели не ждут писателей

Multi-Version Concurrency Control: UPDATE не переписывает строку, а создаёт новую версию. Каждая транзакция видит «снимок» - версии, закоммиченные до её старта. Читатели никогда не блокируют писателей и наоборот. Цена - мусор из старых версий, который кто-то должен убирать.

PostgreSQLMySQL InnoDBOracle
Где лежат старые версиив самой таблице (heap): новая версия рядом, у каждой xmin/xmax - id транзакций создания/удаленияв undo log (отдельное пространство): в таблице всегда последняя версия, старые восстанавливаются по цепочке undoundo segments (rollback segments), как InnoDB - InnoDB списан с Oracle
UPDATE стоитINSERT новой + пометка старой + обновление всех индексов (если не HOT-update в той же странице)запись undo + правка на месте; вторичные индексы трогаются только если изменились их колонкикак InnoDB
Кто убирает мусорVACUUM (autovacuum): проходит по таблице, помечает мёртвые версии свободными. Не успевает → bloatpurge thread чистит undo, когда нет читателей старых версий. Долгая транзакция → undo растёт (history list length)автоматически; ORA-01555 snapshot too old, если undo переиспользован
Долгая транзакциядержит все версии → таблицы и индексы пухнут, xid wraparoundundo растёт, ibdata не сжимается (до 8.0 - навсегда)ORA-01555
Rollbackмгновенный: просто пометить транзакцию abortпропорционален объёму: откатить по undoкак InnoDB
Одна логическая строка, пять физических версий. Транзакция с id 105 видит ту, у которой xmin ≤ 105 закоммичен и xmax пуст или > 105. Остальные - мусор для vacuum.
Внутри · изоляция

Уровни изоляции и аномалии, которые они пропускают

УровеньDirty readNon-repeatable readPhantom readLost updateWrite skewПо умолчанию в
Read Uncommittedдададададаникто (PG его не реализует - работает как RC)
Read CommittedнетдадададаPostgreSQL, Oracle, MSSQL. Снимок на каждый statement
Repeatable ReadнетнетSQL-стандарт: да; PG/InnoDB: нетPG: ошибка serialization failure; InnoDB: да!даMySQL InnoDB. Снимок на всю транзакцию
SerializableнетнетнетнетнетPG: SSI, отслеживает зависимости, ошибка 40001 - повторяй транзакцию. InnoDB: все SELECT становятся LOCK IN SHARE MODE
  • Non-repeatable read: прочитал баланс = 100, коллега закоммитил 50, прочитал ещё раз = 50. В RC - норма.
  • Lost update: оба прочитали 100, оба написали +10 → 110 вместо 120. Лечение: UPDATE ... SET x = x + 10 (атомарно), SELECT ... FOR UPDATE, или optimistic lock по version.
  • Write skew: два врача одновременно проверяют «дежурных ≥ 2» и оба снимаются с дежурства. Каждый по отдельности прав, вместе - ноль дежурных. Ловится только Serializable.

Практика

Read Committed + явные FOR UPDATE в критических местах - стандарт для 95% приложений. Serializable в PG дёшев (5-15% оверхед), но приложение обязано ретраить 40001. В InnoDB Repeatable Read + gap locks на пишущих запросах = дедлоки на вставках в соседние диапазоны - классика, лечится READ COMMITTED и binlog_format=ROW.

Внутри · блокировки

Локи: что блокирует что и как они друг друга убивают

ЛокPostgreSQLInnoDB
Строчныйхранится в самой строке (xmax + флаги), не в памяти → миллионы локов бесплатно. FOR UPDATE / FOR SHARE / FOR NO KEY UPDATEв памяти (lock manager), по записи индекса. Record lock, gap lock (диапазон между ключами), next-key lock = оба
Табличный8 режимов от ACCESS SHARE до ACCESS EXCLUSIVE. ALTER TABLE берёт эксклюзив - и встаёт в очередь за долгим SELECT, а за ним встают всеmetadata lock (MDL): та же беда - DDL ждёт открытую транзакцию, остальные ждут DDL
Advisorypg_advisory_lock(key) - произвольный лок по числу, для распределённых мьютексовGET_LOCK('name', timeout)
Дедлокдетектор раз в deadlock_timeout (1 с), убивает одну транзакциюдетектор wait-for graph мгновенно, откатывает транзакцию с меньшим числом изменений

Классический дедлок

T1UPDATE acc SET .. WHERE id=1
T1UPDATE acc SET .. WHERE id=2 (ждёт T2)
T2UPDATE acc SET .. WHERE id=2
T2UPDATE acc SET .. WHERE id=1 (ждёт T1)

Лечение: всегда брать строки в одном порядке (ORDER BY id в FOR UPDATE), короткие транзакции, не делать сетевых вызовов внутри транзакции.

Грабли PG: FK без индекса на дочерней стороне → каждый DELETE родителя сканирует ребёнка и держит лок. Грабли InnoDB: UPDATE по неиндексированной колонке блокирует всю таблицу (все просканированные строки + gaps). Общие: lock_timeout на DDL и idle_in_transaction_session_timeout - ставить всегда.
Глава 3

Индексы: структуры, которые делают базу базой

B-tree с 1970 года не имеет конкурентов для точечного поиска и диапазонов. Но у Postgres ещё пять типов индексов, и каждый решает задачу, на которой B-tree бессилен: JSON, геометрия, полнотекст, «содержит подстроку», таблица на 10 ТБ по времени.

Индексы · B-tree

B-tree: три чтения на миллиард строк

Корень → внутренние узлы → листья. Каждый узел = одна страница 8 КБ с сотнями ключей. Листья связаны в двусвязный список - отсюда дешёвые диапазоны и ORDER BY по индексу.
СвойствоЧто означает на практике
Fan-out ~300-500в страницу 8 КБ влезает ~400 ключей int8 + указателей. Глубина: 1 млн строк - 3 уровня, 1 млрд - 4 уровня. Корень и второй уровень всегда в кэше → поиск = 1-2 реальных чтения с диска
Сбалансированностьвсе листья на одной глубине, всегда. При переполнении лист делится пополам (page split), при необходимости деление уходит вверх до корня. Дерево растёт «от корня», а не от листьев
Упорядоченностьподдерживает =, <, >, BETWEEN, LIKE 'abc%' (префикс!), ORDER BY, MIN/MAX, DISTINCT, merge join без сортировки
Не поддерживаетLIKE '%abc', LIKE '%abc%', функции над колонкой (WHERE lower(email) = .. без функционального индекса), !=, поиск внутри JSON/массива, регэкспы
Случайные ключиUUIDv4 как PK: вставки бьют в случайные листья → page split везде, кэш не помогает, индекс в 2 раза жирнее. Лечение: bigserial, UUIDv7 (время в старших битах), ULID
Deduplication (PG13+)одинаковые ключи хранятся один раз со списком TID → индекс по колонке с низкой кардинальностью (status) в 3-5 раз меньше
B+-tree (данные только в листьях) - то, что реально везде; «B-tree» - просторечие. В InnoDB сама таблица - это B+-tree по PK (clustered index), в Postgres таблица - куча (heap), а все индексы - отдельно и указывают на (page, offset).
Индексы · типы в PostgreSQL

Шесть типов индексов: когда B-tree не подходит

ТипСтруктураОператорыДля чегоРазмер / скорость записи
B-treeсбалансированное дерево= < > BETWEEN, LIKE 'x%', IS NULL99% случаев: PK, FK, диапазоны, сортировка~ размер колонки + 30%; запись дешёвая
Hashхэш-таблица на страницахтолько =длинные строки/URL, где нужно только равенство. С PG10 crash-safe. Редко оправданменьше B-tree на длинных ключах
GIN (Generalized Inverted)инвертированный индекс: элемент → список строк@> <@ ? ?| ?& @@ % (trgm)jsonb, массивы, полнотекст (tsvector), pg_trgm для LIKE '%x%'большой; вставка медленная (много ключей на строку). fastupdate буферизует
GiST (Generalized Search Tree)R-tree-подобное дерево с «ограничивающими» ключами&& @> <<->> (расстояние)геометрия (PostGIS), диапазоны (tsrange), EXCLUDE constraints (нет пересекающихся бронирований), KNN «ближайшие 10»средний; быстрее GIN на запись, медленнее на чтение
SP-GiSTпространственно-разбитые деревья (quad-tree, k-d, radix)как GiST для несбалансированных данныхточки, IP-адреса (inet), телефонные префиксы, текст с общими префиксамикомпактный на подходящих данных
BRIN (Block Range)min/max на каждые 128 страниц= < > на физически упорядоченных данныхогромные append-only таблицы по времени/serial: логи, события. 10 ТБ таблица - индекс 10 МБкрошечный; бесполезен, если данные перемешаны (UPDATE, случайные вставки)
MySQL/InnoDB: только B+-tree (clustered + secondary), FULLTEXT (инвертированный, слабый), SPATIAL (R-tree), адаптивный hash поверх B-tree в памяти. Нет GIN → индексировать JSON можно только через generated column + B-tree по ней или multi-valued index (8.0.17+) на массив. Нет partial и expression индексов напрямую - опять через generated columns.
Выбор: равенство/диапазон → B-tree. «Содержит элемент / подстроку / слово» → GIN. «Пересекается / рядом / внутри» → GiST. Таблица на терабайты, растёт по времени, не обновляется → BRIN. Векторы (pgvector) → HNSW или IVFFlat - отдельная история в главе про Postgres.
Индексы · составные и покрывающие

Порядок колонок решает всё

  • Составной индекс (a, b, c) - это B-tree по конкатенации. Работает для WHERE a=.., WHERE a=.. AND b=.., WHERE a=.. AND b=.. AND c=... Для WHERE b=.. - почти бесполезен (только полный скан индекса). Правило leftmost prefix.
  • Равенство слева, диапазон справа. WHERE user_id = 5 AND created_at > .. → индекс (user_id, created_at). Наоборот - после диапазона по created_at равенство по user_id уже не сузит поиск в дереве.
  • Селективные колонки не обязательно первые. Первой ставь ту, по которой чаще ищут равенством, и ту, что нужна для ORDER BY: (user_id, created_at DESC) закрывает «последние 20 заказов юзера» одним диапазонным сканом без Sort.
  • Covering / index-only scan. Если все нужные колонки есть в индексе, в таблицу можно не ходить. PG11+: INCLUDE (col) - колонка в листьях без участия в порядке. В InnoDB любой secondary индекс неявно включает PK. Но в PG index-only требует свежей visibility map - после массовых UPDATE без VACUUM он деградирует в обычный.

Один индекс вместо трёх

ЗапросИндекс (user_id, status, created_at)
WHERE user_id=?да, префикс
WHERE user_id=? AND status=?да
WHERE user_id=? AND status=? ORDER BY created_atда, без Sort
WHERE user_id=? ORDER BY created_atпоиск да, сортировка нет - status между ними ломает порядок
WHERE status=?нет (PG может сделать skip scan только с PG18)
WHERE created_at > ?нет
Каждый индекс - это +1 запись на каждый INSERT/UPDATE и +размер в кэше. Таблица с 12 индексами пишет в 13 мест. Неиспользуемые ищутся через pg_stat_user_indexes.idx_scan = 0 / sys.schema_unused_indexes. Дубликаты (a) при существующем (a, b) - удалять.
Индексы · планировщик

Почему индекс есть, а планировщик его не берёт

ПричинаЧто происходитЛечение
Низкая селективностьусловие возвращает >5-10% таблицы → Seq Scan дешевле: читать подряд быстрее, чем прыгать по индексу и потом в heap за каждой строкойэто правильно; partial index, если ищешь редкое значение
Устаревшая статистикапланировщик думает, что строк 100, а их 10 млн (или наоборот)ANALYZE; autovacuum_analyze_scale_factor меньше для больших таблиц; default_statistics_target 100 → 500
Несовпадение типаWHERE id = '5' при int - ок, но WHERE varchar_col = 5 → неявный cast колонки → индекс не применим. В MySQL WHERE phone = 89001234567 при VARCHAR - сканирует всю таблицу и тихо матчит '8900123456789'типы в приложении = типы в базе
Функция над колонкойWHERE date(created_at) = '2025-01-01', lower(email), col + 1 > 10переписать условие (created_at >= .. AND < ..) или expression index ON (lower(email))
Коррелированные колонкиcity='Москва' AND country='RU' - планировщик умножает вероятности и недооценивает в 100 разCREATE STATISTICS (dependencies) ON city, country (PG10+)
OR по разным колонкамчасто Seq Scan вместо двух индексовBitmapOr сработает сам, если индексы есть на обе; иначе UNION
LIMIT-ловушкаORDER BY created_at LIMIT 10 с фильтром по редкому user_id: планировщик идёт по индексу даты, ожидая быстро найти 10, а сканирует всёсоставной (user_id, created_at); в тяжёлых случаях - +0 к ORDER BY, чтобы отключить индекс
Индекс - это не «ускорить», а «прочитать меньше страниц». Если через индекс страниц выйдет больше (случайный доступ + heap на каждую строку), планировщик прав, что берёт Seq Scan. Bitmap Index Scan - компромисс на средней селективности (1-10%): собрать TID в битмап, прочитать heap последовательно. Спорить с планировщиком - через статистику, а не через enable_seqscan=off.
Индексы · EXPLAIN

Читаем EXPLAIN (ANALYZE, BUFFERS)

Типичный проблемный план

Nested Loop (cost=0.86..1250 rows=10 width=64)
  (actual time=0.05..2400 rows=180000 loops=1)
  Buffers: shared hit=1200 read=45000
  -> Index Scan using orders_user on orders
     (rows=10) (actual rows=180000)

Что читать в первую очередь

  • rows= vs actual rows - расхождение в 100+ раз = плохая статистика, корень всех бед
  • Buffers read - реальные чтения с диска; hit - из кэша
  • loops - вложенный узел выполнился N раз: время умножать
  • Sort Method: external merge Disk - не хватило work_mem
  • Rows Removed by Filter - индекс дал строки, которые потом выбросили: индекс не тот
  • Heap Fetches в Index Only Scan - visibility map устарела, нужен VACUUM
  • Hash Batches > 1 - хэш-таблица не влезла в work_mem, ушла на диск
  • Planning Time - если сравнимо с Execution, спасают prepared statements

Инструменты: explain.depesz.com, explain.dalibo.com, auto_explain для медленных запросов в лог. MySQL: EXPLAIN ANALYZE (8.0.18+), EXPLAIN FORMAT=TREE.

Индексы · специальные

Partial, expression, unique с нюансами

Partial (частичный)

CREATE INDEX ON orders (created_at) WHERE status = 'pending'

Индексирует только 0.1% строк, которые реально ищут. В 1000 раз меньше, в кэше целиком, запись почти бесплатна для остальных строк. Планировщик применит, только если условие запроса логически влечёт условие индекса.

Кейсы: очереди задач (WHERE processed = false), soft delete (WHERE deleted_at IS NULL), уникальность среди активных: UNIQUE (email) WHERE deleted_at IS NULL.

MySQL: нет. Обходной путь - generated column NULL для ненужных строк + индекс по ней.

Expression (функциональный)

CREATE INDEX ON users (lower(email))
CREATE INDEX ON events ((data->>'type'))
CREATE INDEX ON t (date_trunc('day', ts))

Индекс по результату выражения. Запрос обязан использовать то же выражение буква в букву. Функция должна быть IMMUTABLE - now() и to_char с локалью нельзя.

Для jsonb: индекс по (data->>'user_id') B-tree в 10 раз меньше GIN по всему документу, если ищешь одно поле.

MySQL 8.0.13+: functional index через ((lower(email))) - двойные скобки, под капотом hidden generated column.

Unique и его сюрпризы

NULL ≠ NULL: UNIQUE (a, b) пропустит сколько угодно (1, NULL). PG15: NULLS NOT DISTINCT.

Проверка в момент записи, не в конце транзакции (если не DEFERRABLE INITIALLY DEFERRED) → swap двух значений в UNIQUE колонке падает без deferrable.

Concurrent build: CREATE INDEX CONCURRENTLY не блокирует запись, но 2-3 прохода, дольше, и при ошибке оставляет INVALID индекс - проверять pg_index.indisvalid. MySQL: ALTER ... ALGORITHM=INPLACE, LOCK=NONE.

Upsert: INSERT .. ON CONFLICT (col) DO UPDATE требует именно unique-индекс по col. MySQL ON DUPLICATE KEY UPDATE - по любому unique, что сработает первым.

Индексы · подводные камни

Семь способов сделать индексы бесполезными

  • Write amplification. В PG любой UPDATE не-HOT создаёт запись во всех индексах таблицы, даже если менялась одна колонка без индекса. HOT (heap-only tuple) срабатывает, если новая версия влезла в ту же страницу и индексируемые колонки не менялись → fillfactor 70-90 на часто обновляемых таблицах (чем горячее, тем ниже; 90 - для умеренного апдейта).
  • Bloat. Удалённые ключи в B-tree освобождаются vacuum'ом, но полупустые страницы не сливаются. Индекс после года UPDATE в 3-5 раз больше нужного. REINDEX CONCURRENTLY (PG12+) или pg_repack. Мониторить через pgstattuple / pg_bloat_check.
  • Индекс на boolean / status с 3 значениями. При равномерном распределении (33%) бесполезен. Исключение - сильный перекос: 50 строк true из 10 млн планировщик по статистике (MCV) отловит и индекс возьмёт. Но partial index на редкое значение - в 1000 раз меньше и надёжнее.
  • Индекс на колонку с одинаковым префиксом. URL, начинающиеся с https://www. - первые 12 байт бесполезны для сравнения. Хэш или индекс по reverse(url).
  • ORDER BY random() / LIMIT с OFFSET 100000. OFFSET читает и выбрасывает 100 тыс. строк по индексу. Keyset pagination: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20.
  • Foreign key без индекса. PG не создаёт индекс на FK-колонку автоматически (InnoDB - создаёт). DELETE родителя = Seq Scan ребёнка под локом. Скрипт поиска FK без индекса - обязательная часть аудита.
  • Слишком много индексов. Insert-heavy таблица с 10 индексами: каждая вставка = 11 случайных записей + WAL. Bulk load: сначала данные, потом индексы (в 5-10 раз быстрее).
Универсальный рецепт: смотреть pg_stat_statements (top по total_time), для топ-10 запросов EXPLAIN ANALYZE, для каждого - ровно тот составной индекс, что закрывает WHERE + ORDER BY. Потом удалить всё с idx_scan=0 старше месяца. 80% проблем производительности решаются на этом слайде.
Глава 4

Стратегии соединений

Три алгоритма на всю индустрию: Nested Loop, Hash Join, Merge Join. Планировщик выбирает между ними по статистике, и ошибка в оценке в 100 раз превращает 2 мс в 2 минуты. MySQL 20 лет жил с одним из трёх.

Join · алгоритмы

Три алгоритма соединения

АлгоритмКак работаетСложностьКогда хорошКогда катастрофа
Nested Loopдля каждой строки внешней таблицы ищем совпадения во внутренней. Без индекса - полный скан внутренней на каждую внешнюю строку. С индексом (Index Nested Loop) - поиск по B-tree на каждую строкуO(N×M) без индекса; O(N log M) с индексомвнешняя маленькая (1-1000 строк), внутренняя с индексом по ключу. Единственный вариант для неравенств (<, LIKE, BETWEEN) и коррелированных подзапросовпланировщик думал, что внешних строк 10, а их 100 тыс. → 100 тыс. index lookups × 3 страницы = 300 тыс. случайных чтений
Hash Joinменьшую таблицу (build) целиком в хэш-таблицу в памяти по ключу; большую (probe) сканируем один раз, для каждой строки - lookup в хэш O(1)O(N+M); память O(меньшей)обе таблицы большие, условие - равенство, нет подходящих индексов. Стандарт для аналитики и отчётовbuild не влезла в work_mem → batches на диск (Hash Batches: 64 в EXPLAIN): на NVMe 1.5-3× медленнее, не катастрофа. Неравенство - не применим
Merge Joinобе таблицы отсортированы по ключу (индексом или Sort) → идём двумя указателями синхронно, как слияние в mergesortO(N+M) если уже отсортированы; O(N log N) с сортировкой. при массе дубликатов с обеих сторон - rescan, деградирует к O(N×M)обе стороны большие и уже упорядочены (B-tree по ключу с обеих сторон); результат нужен отсортированным; много дубликатов ключейодну сторону приходится сортировать целиком на диске
Nested Loop: цикл в цикле. Hash: построить таблицу, пройти второй раз. Merge: две отсортированные ленты застёгиваются как молния.
Join · выбор планировщика

Как планировщик выбирает и где ошибается

  • Порядок соединений важнее алгоритма. Для 5 таблиц - 120 порядков, для 12 - 479 млн. PG перебирает полностью до join_collapse_limit=8 таблиц, дальше - генетический оптимизатор GEQO (случайный поиск). MySQL - greedy search с отсечением.
  • Оценка кардинальности - корень всего. Планировщик оценивает, сколько строк выйдет из каждого узла: селективность WHERE × число строк, для join - N×M / max(distinct). Ошибка на первом уровне умножается на каждом следующем. После 3 джоинов оценка расходится с реальностью на порядки - это фундаментальная проблема, не баг.
  • Join elimination, subquery flattening, predicate pushdown - планировщик переписывает запрос: IN (SELECT) → semi join, EXISTS → semi join, NOT IN → anti join (но NOT IN с NULL - ловушка: возвращает пусто), протаскивает WHERE внутрь подзапросов и VIEW.
  • CTE (WITH x AS (...)) до PG12 был optimization fence - материализовался всегда. Теперь inline, если используется один раз; AS MATERIALIZED - принудительно старое поведение.

Ручки управления

work_memпамять на одну операцию (sort/hash) на один узел. 4 МБ по умолчанию - мало; 64-256 МБ для OLAP, но умножается на число узлов × соединений
enable_nestloop=offотладочный молоток: посмотреть, что будет без NL. В прод не ставить
random_page_cost4 → 1.1 на SSD: планировщик охотнее берёт индексы и NL
pg_hint_planрасширение: хинты /*+ HashJoin(a b) */ как в Oracle. Для случаев, когда статистика не спасает
MySQL хинтыSTRAIGHT_JOIN, /*+ NO_HASH_JOIN */, FORCE INDEX, optimizer_switch
Классика ошибки: Nested Loop с оценкой rows=1 внешней стороны, реально 50 тыс. Симптом: запрос 2 мс на тесте, 3 минуты на проде. Причина: статистика по колонке с перекошенным распределением (один клиент = 40% строк). Лечение: ANALYZE, statistics_target, extended statistics, а иногда - переписать запрос так, чтобы первым шёл более предсказуемый фильтр.
Join · MySQL vs PostgreSQL

MySQL 20 лет с одним алгоритмом

PostgreSQLMySQL
Nested Loopдада, единственный до 8.0.18 (2019)
Block Nested Loopне нуженбуферизует строки внешней таблицы в join_buffer, чтобы сканировать внутреннюю реже. Костыль вместо hash join. Убран в 8.0.20
Batched Key Accessне нуженсобирает ключи в батч и читает внутреннюю таблицу через MRR (сортировка по PK → последовательные чтения). Помогало на HDD
Hash Joinда, с батчами на диск8.0.18+; с 8.0.20 и для неравенств/outer/anti. До 8.0.18 - JOIN двух больших таблиц без индекса = смерть
Merge Joinданет
Parallel queryда (PG9.6+): Parallel Seq Scan, Hash Join, Aggregateнет (только parallel index build и count(*) в InnoDB 8.0.14+)
Semi/anti joinда, IN/EXISTS/NOT EXISTS → semi/anti5.6+ semi join; NOT IN/NOT EXISTS - anti join только с 8.0.17
Derived table / subquery в FROMinline или materialize по стоимостиmaterialize с автоиндексом (5.6+), merge с 5.7
  • Почему MySQL так долго жил без hash join: его ниша - OLTP-веб: 1-3 таблицы, всё по PK и индексам, NL с индексом идеален. Аналитику на MySQL никто в здравом уме не строил. Когда Oracle взялась за 8.0 - догнала за пару релизов.
  • Оптимизатор MySQL проще и предсказуемее: меньше «магии», меньше сюрпризов при смене плана, но и меньше потолок. Postgres умнее, но капризнее: один ANALYZE может перевернуть план в другую сторону.
  • Практическое следствие: запрос с 6 джоинами и агрегацией по 10 млн строк в PG сработает за секунды через Hash Join + Parallel. В MySQL 5.7 - минуты или таймаут; в 8.0 - уже сносно, но без параллелизма.
  • MySQL сильнее в одном: clustered index. Запрос по PK читает данные сразу из листа B-tree, без похода в heap. Range по PK - последовательное чтение диска. У PG - индекс + heap, две структуры.
Join · практика

Джоины, которые тормозят, и как их лечить

N+1 из ORM

Загрузить 100 заказов, потом в цикле order.user → 101 запрос. Каждый 0.3 мс, round-trip 0.2 мс → 50 мс вместо 1 мс. При 1000 - полсекунды.

Лечение: eager loading (JOIN FETCH, .Include(), select_related), или один запрос WHERE user_id IN (...). Детекция: логи запросов на тесте, pg_stat_statements.calls в топе.

JOIN + GROUP BY «пухнет»

Заказы × позиции × платежи → декартово раздутие: 1 заказ, 5 позиций, 3 платежа = 15 строк, SUM(amount) втрое больше реального.

Лечение: агрегировать каждую ветку в подзапросе / CTE до соединения, потом джоинить агрегаты 1:1. Или LATERAL подзапрос.

LATERAL - «for each»

SELECT u.*, o.* FROM users u, LATERAL (SELECT * FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 3) o

«Последние 3 заказа каждого юзера» одним запросом, без window functions. Исполняется как NL с индексом - идеально, когда юзеров мало. MySQL 8.0.14+ тоже умеет.

Anti-join правильно: NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id) или LEFT JOIN b ... WHERE b.id IS NULL. Никогда NOT IN (SELECT a_id FROM b) - если в b.a_id есть хоть один NULL, результат пустой по правилам трёхзначной логики SQL. И планировщик хуже его оптимизирует.
Джоин по разным типам (int = varchar, uuid = text) → неявный cast, индекс не работает, hash join невозможен. Джоин по выражению (ON lower(a.email) = lower(b.email)) - только hash/NL без индекса. Join на 8+ таблиц - думай о денормализации или materialized view; планировщик уже гадает.
Глава 5

PostgreSQL: слон, который умеет всё

Самая расширяемая СУБД в истории: типы, операторы, индексы и даже планировщик подменяются расширениями. Отсюда PostGIS, TimescaleDB, pgvector, Citus - и pg_trgm, три буквы, ради которых половина проектов не ставит Elasticsearch.

PostgreSQL · архитектура

Процессы, память, диск

postmasterглавный процесс, слушает порт, fork на каждое соединение
backend × Nпроцесс на клиента, ~5-10 МБ каждый. 500 соединений = 5 ГБ и context-switch ад
background writerсбрасывает грязные страницы понемногу
checkpointercheckpoint по таймеру/объёму WAL
WAL writerпишет WAL-буфер на диск
autovacuum launcher + workersчистка мёртвых версий, ANALYZE
WAL sender/receiverрепликация
archiverуносит закрытые WAL-сегменты в архив/S3 (archive_command) - основа PITR
shared_buffersобщий кэш страниц. Дефолтная методичка 25% RAM (двойное кэширование с ОС); на машинах с 0.5-1 ТБ RAM, где база влезает целиком, спокойно 50-60%
work_memна операцию сортировки/хэша в каждом backend
maintenance_work_memдля VACUUM, CREATE INDEX: 1-2 ГБ
wal_buffersбуфер WAL до записи на диск, ~16 МБ (1/32 shared_buffers), реальная память
effective_cache_sizeНЕ память: число-подсказка планировщику, сколько кэша ОС+PG ждать (50-75% RAM)
  • Процесс на соединение - главное архитектурное отличие от MySQL (поток) и главная боль. Выше ~300-500 активных соединений производительность падает. Решение - пулер: pgbouncer (transaction mode, 1000 клиентов → 50 реальных соединений), PgCat, Odyssey. Встроенного пулера в ядре нет ни в PG17, ни в PG18 - только внешний.
  • Файлы на диске: каждая таблица и индекс - файл(ы) по 1 ГБ в base/<dboid>/<relfilenode>, плюс _fsm (free space map), _vm (visibility map) и отдельная TOAST-таблица со своими файлами для длинных значений. Tablespace = просто другая директория.
  • Расширяемость на уровне ядра: hooks в планировщике и исполнителе (так живут pg_hint_plan, Citus, Timescale), пользовательские типы с операторами и классами операторов для индексов, фоновые воркеры (pg_cron), foreign data wrappers (postgres_fdw, mysql_fdw, файлы, S3).
  • Релизный цикл: major раз в год (PG17 - сен 2024, PG18 - сен 2025), поддержка 5 лет, minor раз в квартал. Major upgrade - pg_upgrade (минуты) или логическая репликация (без даунтайма).
PostgreSQL · VACUUM

VACUUM: цена MVCC, которую платишь постоянно

  • Что делает: находит мёртвые версии строк (xmax закоммичен и старше всех снимков), помечает место свободным в FSM, чистит индексные записи, обновляет visibility map (для index-only scan), замораживает старые xid.
  • Не делает: не отдаёт место ОС (файл не уменьшается, только VACUUM FULL с полным локом или pg_repack). Не сливает полупустые страницы индексов (полностью пустые - возвращает в free list, но не более).
  • Autovacuum запускается на таблице, когда мёртвых строк > threshold + scale_factor × rows = 50 + 20%. Для таблицы в 100 млн строк это 20 млн мёртвых - слишком поздно. Ставить autovacuum_vacuum_scale_factor = 0.01 для больших таблиц (per-table). PG18 добавил потолок autovacuum_vacuum_max_threshold (100 млн по умолчанию) - формула больше не улетает в бесконечность на гигантских таблицах.
  • Троттлинг: autovacuum_vacuum_cost_delay 2 мс (было 20 до PG12) и cost_limit 200 - vacuum спит бóльшую часть времени. На NVMe поднимать cost_limit до 2000-10000 или вовсе cost_delay = 0, если CPU позволяет.
  • Долгая транзакция = vacuum бессилен: он не может убрать версии, которые она теоретически видит. Одна забытая idle in transaction сессия на неделю = таблицы вдвое больше. idle_in_transaction_session_timeout, мониторинг pg_stat_activity.xact_start.

XID wraparound - тот самый апокалипсис

id транзакции - 32 бита, 4 млрд. Сравнение xid - по кругу: половина «в прошлом», половина «в будущем». Если за ~2 млрд транзакций старые строки не заморозить (frozen = «видна всем, навсегда»), они внезапно окажутся «в будущем» и исчезнут для всех запросов.

PG за 3 млн транзакций до предела (в старых версиях - 1 млн) останавливает запись целиком: «database is not accepting commands to avoid wraparound data loss». Sentry (2015), Mailchimp (2019), Notion - все проходили. Мониторить age(datfrozenxid), алерт на 500 млн. PG14+ vacuum замораживает агрессивнее, но 32 бита остались.

Bloat - таблица/индекс в N раз больше живых данных. Симптомы: Seq Scan медленнее, кэш забит воздухом, бэкап растёт. Оценить: pgstattuple, запрос с check_postgres. Лечить: агрессивный autovacuum, pg_repack (без лока), для индексов REINDEX CONCURRENTLY. Партиционирование по времени + DROP PARTITION вместо DELETE - и vacuum не нужен вовсе.
PostgreSQL · типы

Типы данных, которых нет у других

ТипЧто этоИндексЗачемГрабли
jsonbбинарный JSON, разобранный, с дедупликацией ключей. Операторы -> ->> @> ? #>, jsonpath (PG12), JSON_TABLE (PG17)GIN (jsonb_ops / jsonb_path_ops - второй компактнее, только @>), B-tree по выражениюгибкие атрибуты, настройки, события. «MongoDB внутри Postgres» - честно работаетUPDATE одного поля переписывает весь документ (+TOAST при >2 КБ). Не заменяет колонки для того, что фильтруешь всегда. json (без b) - просто текст, не использовать
Массивыint[], text[], многомерные. ANY, @>, &&, unnestGINтеги, списки id вместо таблицы-связки для мелких случаевнет FK на элементы; большие массивы в TOAST медленные
Диапазоныtstzrange, int4range, daterange; multirange PG14. Операторы && @> -|-GiSTбронирования, тарифы по периодам. EXCLUDE USING gist (room WITH =, period WITH &&) - база сама не даст пересечьсяграницы [ ) включ./исключ. - путаются
uuid16 байт нативно (не 36 символов). gen_random_uuid() встроен с PG13; uuidv7() в PG18B-treeраспределённые id, не палить count через serialUUIDv4 как PK → случайные вставки в B-tree (см. индексы). UUIDv7 решает
inet / cidr / macaddrIP-адреса и сети с операторами включения <<, >>=GiST, SP-GiSTгео-IP, ACL, «какой подсети принадлежит адрес»-
Геометрия / PostGISpoint, polygon встроенные; PostGIS: geometry/geography, 300+ функций, стандарт OGCGiST, SP-GiST, BRINвсе карты, доставки, «рядом со мной». PostGIS - причина №1 выбрать PG в 2010-хgeometry (плоскость) vs geography (сфера, медленнее, точнее)
hstore, ltree, citext, enum, композитные, domainkey-value (предок jsonb), иерархические пути ('top.science.astro'), case-insensitive text, перечисления, свои структуры, тип с CHECKGIN/GiST для ltreeltree - деревья категорий без рекурсии; domain - email тип с регэкспом на все таблицыenum: добавить значение можно, удалить/переставить - нет
КастомныеCREATE TYPE + функции ввода/вывода на C или SQL, операторы, opclass для индексовлюбойтак сделаны PostGIS, pgvector, pg_trgm, timescaledb-
PostgreSQL · расширения

Расширения: одна база вместо пяти сервисов

РасширениеЧто даётЗаменяетЗрелость / нюансы
pg_stat_statementsстатистика по всем запросам: calls, total/mean time, rows, buffers. Ставить всегда, первымAPM для запросовв contrib, 1-2% оверхед. pg_stat_kcache добавляет CPU/IO
pg_trgmтриграммы: LIKE '%abc%' и ILIKE по индексу, similarity(), fuzzy-поиск с опечаткамиElasticsearch для 80% «поиск по названию»contrib, стабильно с 2010. Детали на следующих слайдах
PostGISполноценная ГИС: геометрия, проекции, растры, маршрутизация (pgRouting)ArcGIS-сервер, Oracle Spatial ($$$)золотой стандарт индустрии с 2001; OSM, Uber, Foursquare
TimescaleDBhypertables: автопартиционирование по времени, сжатие 10-20×, continuous aggregates, retentionInfluxDB, частично ClickHouse для метриклицензия TSL для сжатия (не Apache). Уступает ClickHouse на аналитике >1 млрд строк, но это ещё SQL Postgres с джоинами
pgvectorтип vector(n), расстояния L2/cosine/inner, индексы IVFFlat и HNSWPinecone, Qdrant, Milvus для <10-50 млн векторовHNSW recall ~95-99%, build долгий и памятеёмкий; pgvectorscale (Timescale) добавляет StreamingDiskANN
Citusшардирование по distribution column, распределённые запросы, columnar storageVitess, CockroachDB для «PG на 10 нод»куплен Microsoft (2019), open source. Хорош для multi-tenant SaaS (tenant_id как ключ шарда)
pg_partman, pg_cronавтосоздание партиций по времени; cron внутри базыскрипты и crontab снаружистандарт; в RDS/Cloud SQL доступны
pg_repackубрать bloat таблицы/индекса без долгого лока (VACUUM FULL online)даунтаймнужно ×2 места на время; работает через триггеры
postgres_fdw, pg_hint_plan, pgaudit, pgcrypto, pg_bigm, hypopg, pglogical, pgroonga, plv8/plpythonвнешние таблицы; хинты; аудит; шифрование; биграммы для CJK; «а если бы индекс был»; логическая репликация до PG10; японский полнотекст; JS/Python в процедурах-hypopg - недооценённая штука: проверить пользу индекса без его создания
Идея «Postgres for everything»: очередь (SKIP LOCKED), кэш (UNLOGGED), поиск (trgm+FTS), время (Timescale), векторы, гео, cron - в одной базе с одной транзакцией и одним бэкапом. До ~1-5 ТБ и 10-50 тыс. TPS это дешевле пяти сервисов, каждый со своим failover и синхронизацией.
PostgreSQL · pg_trgm

pg_trgm: магия трёх букв

«postgres» → {" p"," po","pos","ost","stg","tgr","gre","res","es "}. Любая подстрока длиной ≥ 3 - это пересечение множеств триграмм. Опечатка меняет 3 триграммы из 9, остальные 6 совпали.
  • Триграмма - три подряд идущих символа. Слово дополняется двумя пробелами спереди и одним сзади, чтобы начало и конец слова весили больше. Регистр и не-буквы игнорируются (по умолчанию).
  • Индекс GIN по триграммам: для каждой триграммы - список строк, где она встречается. WHERE name LIKE '%грес%' → триграммы {"гре","рес"} → пересечение двух списков → recheck кандидатов по реальному LIKE. LIKE '%x%' по индексу - то, что не умеет ни один B-tree в мире.
  • Похожесть: similarity('postgres', 'postgress') = общие триграммы / все = 0.8. word_similarity - похожесть на подстроку. Оператор % с порогом pg_trgm.similarity_threshold (0.3) работает по GIN/GiST.
  • KNN по опечаткам: ORDER BY name <-> 'постргес' LIMIT 10 по GiST индексу - «10 самых похожих» без сканирования. Автодополнение с опечатками из коробки.
  • Регэкспы по индексу: WHERE name ~ 'pos.*gres' - trgm разбирает регэксп на обязательные триграммы и использует GIN. Единственная СУБД с индексируемыми регэкспами.
PostgreSQL · pg_trgm на практике

Триграммы: цифры, лимиты, рецепты

Рецепт

CREATE EXTENSION pg_trgm;
CREATE INDEX products_name_trgm
  ON products USING gin (name gin_trgm_ops);

-- подстрока, регистронезависимо, по индексу
SELECT * FROM products WHERE name ILIKE '%самсу%';

-- с опечатками, топ-10 похожих
SELECT name, similarity(name, 'самснуг') s
FROM products WHERE name % 'самснуг'
ORDER BY s DESC LIMIT 10;

-- по нескольким полям: индекс по выражению
CREATE INDEX ON products USING gin
  ((name || ' ' || brand || ' ' || sku) gin_trgm_ops);

ПараметрЗначение
Размер GIN trgm индекса~2-4× размера колонки (у B-tree ~1×). 10 млн названий по 40 байт = 400 МБ данных, 1-1.5 ГБ индекс
Скорость LIKE '%abc%'1-10 мс на 10 млн строк вместо 2-5 с Seq Scan. Чем длиннее паттерн, тем меньше кандидатов и быстрее
Паттерн короче 3 символов'%ab%' - ни одной полной триграммы → Seq Scan. Лимит по определению
Частые триграммы'%ста%' в русском - половина таблицы → recheck дорогой. Планировщик может предпочесть Seq Scan - и правильно
Записькаждая строка = ~N триграмм = N записей в GIN. INSERT в 3-5 раз медленнее, чем с B-tree. fastupdate буферизует в pending list, потом сливает
GIN vs GiST для trgmGIN: быстрее поиск, больше, медленнее запись, нет KNN. GiST: <-> ORDER BY (KNN), быстрее запись, медленнее поиск. Для автодополнения - GiST, для фильтра - GIN
Языкилюбой Unicode: русский, кириллица, но CJK (иероглифы) - слова по 1-2 символа, триграмм нет → pg_bigm или pgroonga
Где trgm проигрывает Elastic: релевантность (BM25), морфология («телефоны» → «телефон»), синонимы, фасеты по 10 полям с агрегациями, >100 млн документов. Где выигрывает: транзакционная консистентность (нашёл = существует), ноль синхронизации, одна инфраструктура, точный LIKE.
PostgreSQL · полнотекст

Встроенный Full Text Search: tsvector и tsquery

  • tsvector - документ, разобранный на лексемы с позициями: to_tsvector('russian', 'Быстрые коты бегали')'бегал':3 'быстр':1 'кот':2. Стемминг (snowball), стоп-слова, словари (ispell, hunspell, синонимы, тезаурус) - настраиваются через text search configuration.
  • tsquery - запрос с операторами: & | ! <-> (followed by), <N> (на расстоянии N). websearch_to_tsquery('кот -собака "быстрый бег"') - синтаксис как в гугле.
  • Ранжирование: ts_rank, ts_rank_cd (cover density) с весами A/B/C/D по полям (заголовок важнее тела). Не BM25 - нет нормализации по частоте в коллекции (IDF); есть расширения (pg_search / ParadeDB на Tantivy - там BM25 настоящий).
  • Хранение: generated column tsv tsvector GENERATED ALWAYS AS (to_tsvector('russian', coalesce(title,'') || ' ' || coalesce(body,''))) STORED + GIN индекс. Ничего не надо синхронизировать - колонка сама.
  • Подсветка: ts_headline - фрагменты с <b>.
PG FTSpg_trgmElasticsearch
Что ищетслова (лексемы)подстроки и похожие строкитокены с анализаторами
Морфологияда (snowball, hunspell)нет, но опечатки терпитда, лучше (много анализаторов)
Опечаткинет (только prefix :*)да, similarityfuzzy (Левенштейн ≤2)
Релевантностьts_rank, без IDFsimilarityBM25, function score, boosting
Фасеты/агрегацииGROUP BY, ок до млнGROUP BYaggregations - основная фича
Объёмдо 10-50 млн документов комфортнодо 10-50 млн строкмиллиарды, шардируется
Консистентностьтранзакционнаятранзакционнаяeventual, refresh 1 с
Комбо на практике: FTS для «найти документы со словами» + trgm для «название товара / имя клиента / артикул с опечаткой» + ORDER BY ts_rank .. + similarity ... Закрывает поиск в 90% B2B/админок/каталогов до миллионов записей.
PostgreSQL · pgvector

pgvector: векторный поиск там же, где данные

  • Зачем: эмбеддинги из LLM (768-3072 float) - «семантическая близость». RAG, рекомендации, поиск похожих картинок. Задача - k ближайших соседей по cosine/L2 среди миллионов векторов.
  • Точный поиск - ORDER BY embedding <=> $1 LIMIT 10 без индекса = Seq Scan с вычислением 1 млн расстояний по 1536 измерений = ~100-300 мс. До 100 тыс. векторов индекс не нужен.
  • IVFFlat: кластеризация k-means на N списков, при поиске смотрим только probes ближайших кластеров. Быстро строится, recall 80-95%, деградирует при вставках новых данных (центроиды не двигаются).
  • HNSW (Hierarchical Navigable Small World): многослойный граф соседей, поиск - жадный спуск по слоям. Recall 95-99%, поиск 1-5 мс на 1 млн векторов, но build - часы и много RAM (maintenance_work_mem), индекс ≈ размер данных. Параметры m=16, ef_construction=64 по умолчанию; hnsw.ef_search - точность vs скорость.
  • Гибрид: WHERE category = 'x' ORDER BY embedding <=> $1 - фильтр + вектор. Индекс HNSW не знает про фильтр → ищет 10 соседей, фильтр их выбрасывает → 0 результатов. PG17 + pgvector 0.8: iterative scan решает. Или partial HNSW по категории.
pgvector (HNSW)Qdrant / Milvus / Pinecone
Объём комфортныйдо 10-50 млн векторов на нодумиллиарды, шардирование из коробки
Латентность p995-20 мс на 1-10 млн2-10 мс, SIMD, квантизация
Фильтры + векторбыл слабым местом, PG17 лечитfilterable HNSW - основная фича
Квантизацияhalfvec (fp16), bit, sparsevec с 0.7scalar, product, binary
Транзакции с остальными даннымида, один JOINнет, синхронизировать
Стоимостьноль поверх PGотдельный кластер / SaaS $70+/мес за 1 млн
Правило: до 10 млн векторов - pgvector, если Postgres уже есть. Дальше - смотреть pgvectorscale (DiskANN, 28× дешевле Pinecone по их бенчмарку) или выделенную векторную. Главная экономия не в скорости, а в отсутствии второй базы, которую надо синхронизировать с первой.
PostgreSQL · репликация

Репликация и отказоустойчивость

Primary шлёт WAL репликам по мере записи. Синхронная реплика подтверждает до COMMIT, асинхронная - когда успеет. Отставшая реплика с активным слотом заставляет primary хранить WAL для неё - до переполнения диска.
МеханизмКакНюансы
Streaming (физическая)байт-в-байт копия WAL. Реплика - точный клон, read-only. Задержка мс. Основа всех HA-схемтолько вся база целиком, только та же major-версия и архитектура. Запросы на реплике конфликтуют с vacuum на мастере → hot_standby_feedback (и тогда bloat на мастере от долгих запросов реплики)
Synchronoussynchronous_standby_names: COMMIT ждёт подтверждения реплики. RPO = 0+1-2 мс латентности на коммит; реплика упала = мастер встал (если одна). Ставят ANY 1 (r1, r2)
Logicalдекодирование WAL в логические изменения (INSERT/UPDATE/DELETE по строкам). Publication/subscription по таблицам. Между версиями, в другую схему, в Kafka (Debezium через pgoutput/wal2json)не реплицирует DDL, sequence, большие объекты. Требует REPLICA IDENTITY (PK). Начальная синхронизация большой таблицы - долго. Слот забыли удалить - диск кончился
Failoverсвоего нет. Patroni (etcd/Consul для консенсуса, стандарт), repmgr, pg_auto_failover, Stolon. Облака: RDS Multi-AZ, Aurorasplit brain при сетевом разделении без консенсуса; после failover старый мастер надо pg_rewind, а не просто подключить. Клиенты переключать через VIP/HAProxy/pgbouncer или multi-host в libpq (target_session_attrs=read-write)
Бэкапpg_basebackup + архив WAL = PITR (восстановление на любую секунду). Инструменты: pgBackRest, WAL-G, Barman. pg_dump - логический, для миграций/маленьких базpg_dump на 1 ТБ = часы, restore = сутки (индексы). Бэкап, который не восстанавливали - не бэкап: тестовый restore раз в месяц
PostgreSQL · подводные камни

Грабли Postgres, на которые наступают все

  • count(*) медленный. MVCC: надо проверить видимость каждой строки, нет счётчика. 10 млн строк = 0.5-2 с. Лечение: reltuples из pg_class для приблизительного, счётчик в отдельной таблице, или EXPLAIN-оценка.
  • Соединения дорогие. 1000 клиентов напрямую = 1000 процессов = 10 ГБ и падение throughput. Пулер обязателен от ~100 клиентов.
  • Долгая транзакция. Держит vacuum, раздувает всё, блокирует DDL. Одна idle in transaction из ORM с открытым коннектом - и через неделю база в 3 раза больше.
  • ALTER TABLE в очереди за SELECT. ACCESS EXCLUSIVE ждёт долгий запрос, за ним встают все новые запросы → простой сервиса. Всегда SET lock_timeout = '3s' перед DDL и ретрай.
  • ADD COLUMN с DEFAULT до PG11 переписывал таблицу. Сейчас - мгновенно, кроме volatile default (now(), random()).
  • Смена типа колонки - полная перезапись таблицы под эксклюзивным локом. varchar(50) → varchar(100) - мгновенно; int → bigint - переписать. Поэтому id bigint с самого начала: 2.1 млрд int закончились у Basecamp, Notion, Sentry.
  • work_mem × параллелизм. 256 МБ × 8 узлов сортировки × 100 соединений = 200 ГБ теоретически. OOM killer убивает postmaster → рестарт всего. Ставить консервативно, поднимать per-session для отчётов.
  • TOAST и большие значения. jsonb на 100 КБ хранится сжатым кусками в отдельной таблице. Чтение одного ключа = разжать всё. Часто читаемое - в колонки.
  • Sequence не откатывается. Дыры в id после rollback - норма, не баг. Кто рассчитывает на непрерывность - страдает.
  • Case-sensitive идентификаторы. CREATE TABLE "Users" → потом только с кавычками. Никогда не кавычить, всё snake_case.
  • Кодировка и collation. Обновление glibc меняет порядок сортировки → индексы по text битые (PG15 предупреждает). ICU collation или C для служебных колонок.
  • Триггеры и FK на партициях, SELECT ... FOR UPDATE без ORDER BY - дедлоки, NOT IN с NULL, timestamp без tz - каждый второй проект.
Глава 6

MySQL: дельфин, на котором стоит веб

Facebook, YouTube, Wikipedia, Booking, Uber - все начинали и многие остались на MySQL. Простой, быстрый по PK, с репликацией, которая настраивалась за 5 минут в 2005-м. И с набором тихих сюрпризов, из-за которых половина индустрии перешла на Postgres.

MySQL · архитектура

Два слоя: сервер и storage engine

Сервер (SQL layer)соединения (поток на клиента, thread cache), парсер, оптимизатор, кэши, binlog, репликация
Handler API - плагинный интерфейс движков
InnoDBACID, MVCC, row locks, clustered PK, redo/undo, buffer pool, doublewrite. Единственный серьёзный с 2010. Куплен Oracle в 2005 (за 3 года до самой MySQL)
MyISAMбез транзакций, табличные локи, быстрый count(*). Мёртв, но живёт в легаси и в system tables до 8.0
MyRocksRocksDB (LSM) от Facebook: сжатие в 2× лучше InnoDB, для write-heavy
Memory, CSV, Archive, NDBтаблицы в RAM; текстовые; сжатые append-only; MySQL Cluster (телеком, in-memory, shared-nothing)
  • Поток на соединение - дешевле процессов PG: 5000 соединений держит спокойно (thread stack ~256 КБ). Пулер не обязателен, но thread pool в Percona/Enterprise помогает при тысячах активных.
  • Оптимизатор сравнительно простой: статистика - только кардинальность индексов (гистограммы с 8.0, но их надо создавать вручную ANALYZE TABLE ... UPDATE HISTOGRAM). Планы предсказуемее и тупее.

Ключевые параметры InnoDB

innodb_buffer_pool_size70-80% RAM на выделенном сервере. Главная ручка. O_DIRECT - мимо кэша ОС, двойного кэширования нет
innodb_log_file_size / redo_log_capacityredo log: 1-4 ГБ. Мало = частые checkpoints и стоп-запись при переполнении
innodb_flush_log_at_trx_commit1 = fsync на каждый commit (ACID). 2 = в ОС, fsync раз в секунду (потеря ≤1 с при крахе ОС, не MySQL). 0 - не надо
sync_binlog1 = binlog fsync на коммит (для корректной репликации). Вместе с trx_commit=1 - «двойная запись», group commit спасает
innodb_flush_methodO_DIRECT на Linux всегда
innodb_io_capacity200 по умолчанию (HDD!). На NVMe 2000-10000, иначе фоновая запись не поспевает
sql_modeSTRICT_TRANS_TABLES + ONLY_FULL_GROUP_BY - по умолчанию с 5.7. Без strict MySQL молча обрезает данные
MySQL · clustered index

Таблица = B+-tree по первичному ключу

  • В InnoDB нет heap. Строки лежат в листьях B+-tree, упорядоченного по PK. Поиск по PK = спуск по дереву прямо к данным, одна структура. Range по PK = последовательное чтение листьев. Это быстрее Postgres на точечных и диапазонных чтениях по PK.
  • Нет PK → InnoDB возьмёт первый UNIQUE NOT NULL, нет и его → скрытый 6-байтный row_id с глобальным мьютексом на инкремент. Таблица без PK в MySQL - ошибка проектирования и тормоз репликации (ROW-формат ищет строку полным сканом).
  • Вторичный индекс хранит не указатель на строку, а значение PK. Поиск по secondary = два спуска: по вторичному дереву найти PK, потом по clustered найти строку. Зато при page split и реорганизации таблицы вторичные индексы не трогаются (в PG - TID меняется, все индексы обновлять).
  • Следствие 1: длинный PK (UUID 36 символов, составной из трёх varchar) дублируется в каждом вторичном индексе. 10 индексов = PK хранится 11 раз. bigint 8 байт - правильный PK.
  • Следствие 2: случайный PK (UUIDv4) = вставки в случайные листья самой таблицы, не только индекса. Page split, фрагментация, кэш не работает. В InnoDB это больнее, чем в PG, в разы. UUIDv7 / UUID_TO_BIN(uuid, 1) с swap времени вперёд.
  • Следствие 3: covering index бесплатный для PK: любой secondary неявно «INCLUDE (pk)». SELECT id FROM t WHERE email = ? - не ходит в таблицу.

Хранение: PG heap vs InnoDB clustered

PostgreSQLInnoDB
Таблицакуча страниц, строки в порядке вставки/vacuumB+-tree по PK
Индекс указывает на(page, offset) - TIDзначение PK
Чтение по PKиндекс (3-4 стр.) + heap (1 стр.)только дерево (3-4 стр.)
Чтение по secondaryиндекс + heapsecondary + clustered (два дерева)
UPDATE неиндексной колонкиновая версия; HOT если влезла, иначе все индексына месте (undo сохраняет старую); индексы не трогаются
Старые версиив heap, vacuumв undo log, purge
Размер строки / null23 байта заголовок + null bitmap5-7 байт заголовок + 6 DB_TRX_ID + 7 DB_ROLL_PTR
Сжатие страництолько TOAST (pglz/lz4) для больших значенийpage compression (zlib/lz4), обычно 2×; MyRocks - 3-4×
MySQL · репликация

Репликация: binlog, GTID, Group Replication

МеханизмКак работаетНюансы
Binlogотдельный от redo лог логических изменений: STATEMENT (SQL-текст), ROW (образы строк - стандарт), MIXED. Реплика читает binlog (IO thread) и применяет (SQL thread)STATEMENT недетерминирован (NOW(), RAND(), LIMIT без ORDER BY) → расхождение реплик. ROW без PK на таблице = полный скан на каждую строку
Asyncпо умолчанию. COMMIT не ждёт репликупотеря последних транзакций при падении мастера. Лаг: Seconds_Behind_Master врёт, смотреть по GTID/heartbeat
Semi-syncCOMMIT ждёт, пока хотя бы одна реплика получила (не применила) событиетаймаут → откат в async молча. AFTER_SYNC (lossless) с 5.7
GTIDглобальный id транзакции server_uuid:N вместо file:position. Реплика знает, что уже применила; смена мастера - CHANGE MASTER TO ... MASTER_AUTO_POSITION=1включать с первого дня; конвертация online с 5.7.6. Дырки в GTID set после ручных правок - головная боль
Параллельное применениеLOGICAL_CLOCK (по group commit) или WRITESET (8.0): реплика применяет транзакции параллельно, если не пересекаютсябез него реплика однопоточная и отстаёт от мастера с 32 ядрами навсегда
Group Replication / InnoDB ClusterPaxos-подобный консенсус (XCom), single-primary или multi-primary, автоматический failover. MySQL Router для клиентов3-9 нод, чувствителен к сети; multi-primary с конфликтами - не надо. Альтернатива - Galera (Percona XtraDB Cluster, MariaDB): synchronous, certification-based
  • Почему MySQL захватил веб: в 2005 настроить master-slave репликацию можно было за 10 минут по инструкции с форума. У Postgres встроенная streaming-репликация появилась в 9.0 (2010), а вменяемый failover - ещё позже. Пять лет форы = LAMP-стек.
  • Failover-инструменты: Orchestrator (GitHub), ProxySQL для маршрутизации чтений/записей, Vitess (шардирование + HA, теперь PlanetScale).
  • Read replicas - главный способ масштабировать чтение в MySQL-мире. С ними приходит replication lag: пользователь создал запись, F5 - её нет (читали с реплики). Лечение: читать с мастера после записи (sticky) или ждать GTID (WAIT_FOR_EXECUTED_GTID_SET).
MySQL · подводные камни

Тихие сюрпризы MySQL

  • utf8 ≠ UTF-8. utf8 в MySQL - это utf8mb3, 3 байта, без эмодзи и редких иероглифов. Вставка 😀 → ошибка или «????». Нужен utf8mb4 (по умолчанию только с 8.0). Collation: utf8mb4_0900_ai_ci - accent/case insensitive: 'Резюме' = 'резюме' = 'резюмё'. Для точного сравнения - _bin или _as_cs.
  • Silent truncation и приведение типов без strict mode: 'abc' → 0, '12abc' → 12, varchar(10) обрезается, дата '2025-02-30''0000-00-00'. Strict включён по умолчанию с 5.7, но старые конфиги живут.
  • ONLY_FULL_GROUP_BY. До 5.7 SELECT name, COUNT(*) FROM t GROUP BY dept работал, возвращая случайное name из группы. Теперь ошибка - и правильно.
  • DDL блокирует. ALTER TABLE на 100 млн строк: INPLACE для многих операций с 5.6, INSTANT (только метаданные) для ADD COLUMN с 8.0.12. Но смена типа / порядок колонок / изменение PK = копия таблицы под MDL. gh-ost (GitHub) / pt-online-schema-change - копируют через триггеры/binlog без блокировки.
  • Metadata lock. Открытая транзакция с SELECT из таблицы держит MDL → ALTER ждёт → все запросы к таблице ждут ALTER. Тот же паттерн, что в PG, лечится lock_wait_timeout.
  • Gap locks в Repeatable Read. INSERT в диапазон, который другой транзакцией прочитан с FOR UPDATE или обновлён по не-unique индексу, ждёт. Дедлоки на параллельных вставках «из ниоткуда». Лечение: READ COMMITTED + binlog_format=ROW.
  • Транзакционный DDL отсутствует. ALTER внутри транзакции делает implicit commit. Миграция из 5 шагов упала на 3-м - откатывать руками. С 8.0 - атомарный DDL по одному statement, но не транзакция.
  • Auto-increment дырки и сброс. До 8.0 счётчик жил в памяти: после рестарта MAX(id)+1 - удалённые id переиспользуются. Bulk insert резервирует с запасом.
  • Query cache - выглядел как фича, работал как тормоз (глобальный мьютекс, инвалидация всей таблицы). Удалён в 8.0.
  • Оптимизатор и подзапросы: до 5.6 WHERE id IN (SELECT ...) выполнялся как dependent subquery на каждую строку. Легенды «в MySQL подзапросы нельзя» - оттуда.
  • TIMESTAMP до 2038, DATETIME без зоны, 0000-00-00 как валидная дата, FLOAT для денег, TEXT нельзя в PK без длины префикса, innodb_file_per_table и невозвращаемое место в ibdata1 - классика.
MySQL vs PostgreSQL

Честное сравнение

КритерийPostgreSQLMySQL 8Кто
Точечные чтения/записи по PKбыстробыстрее (clustered, поток на соединение)MySQL, на 10-30%
Сложные запросы, аналитика, джоины 5+merge/hash/parallel, CTE, window, LATERALhash с 8.0.18, без параллелизма, без mergePG, кратно
Соответствие SQL-стандартупочти полное; строгие типымного диалекта; неявные кастыPG
Типы данныхjsonb, массивы, диапазоны, гео, своиJSON (хуже индексируется), spatialPG
Индексы6 типов, partial, expression, INCLUDEB+-tree, FULLTEXT, spatial; functional через generatedPG
Расширяемостьрасширения меняют всёплагины движков и authPG, несравнимо
Репликация и failover «из коробки»streaming + Patroni/облакоbinlog + GTID + Group Replication / OrchestratorMySQL, проще
Соединенияпроцессы, нужен пулерпотоки, 5000 окMySQL
UPDATE-heavy нагрузкаbloat, vacuum, write amplification индексовundo log, индексы не трогаютсяMySQL
Транзакционный DDLда, полностьюнетPG
Online DDLбольшинство мгновенно; CONCURRENTLY для индексовINSTANT/INPLACE для части; gh-ostPG
Сжатиетолько TOASTpage compression, MyRocksMySQL
Лицензия и владелецPostgreSQL License (BSD), комьюнитиGPL + коммерческая, OraclePG
Экосистема облаковRDS, Aurora, Cloud SQL, Neon, Supabase, CrunchyRDS, Aurora, Cloud SQL, PlanetScaleпаритет
Найм DBAдефицит, дорогомного опытных, легаси-вебMySQL
Итог: новый проект в 2025 - PostgreSQL, если нет специфической причины. Причины для MySQL: команда его знает; нагрузка чисто OLTP по PK с тоннами UPDATE; шардирование через Vitess/PlanetScale; WordPress/legacy. Причина не выбирать: «MySQL быстрее» - это правда только на бенчмарке из одной таблицы.
MySQL · семейство

Форки и надстройки: MariaDB, Percona, Vitess, TiDB

MariaDB

Форк Монти Видениуса (2009) после покупки Sun Oracle'ом. Расходится с MySQL всё сильнее: свой оптимизатор, движки (Aria, ColumnStore, Spider), системные версионные таблицы, Galera встроена. Совместимость с драйверами MySQL сохранена, с 8.0 - уже не drop-in.

По умолчанию в Debian/RHEL вместо MySQL. Отставание по InnoDB-оптимизациям 8.0 (hash join есть с 10.x, но другой). Для нового проекта - нет причин, кроме Galera и лицензии.

Percona Server / XtraDB

MySQL с патчами Percona: thread pool, расширенная диагностика, audit, MyRocks, PAM. Плюс инструменты, которыми пользуются все: XtraBackup (горячий физический бэкап InnoDB), pt-online-schema-change, pt-query-digest, PMM (мониторинг).

Percona XtraDB Cluster = Galera. Percona - де-факто консалтинг №1 по MySQL и делает то же для Postgres и Mongo.

Vitess / TiDB / PlanetScale

Vitess (YouTube, 2011, CNCF): шардирование MySQL - VTGate роутит запросы, VTTablet у каждого шарда, автоматический resharding, connection pooling. Slack, GitHub, Shopify. PlanetScale - Vitess как сервис + бранчи схемы.

TiDB (PingCAP): MySQL-протокол поверх распределённого KV (TiKV на RocksDB + Raft) + TiFlash колоночный. HTAP: транзакции и аналитика в одном. Не MySQL внутри, только совместимость.

Отдельная ветка - Amazon Aurora (MySQL и PG): вычисления отделены от storage, 6 копий в 3 AZ, лаг реплик ~20 мс, до 128 ТБ. Плюс: HA без Patroni/Orchestrator. Минус: ×2 цена, специфичные лимиты, не запустить локально. И Google AlloyDB / Neon (serverless PG с copy-on-write бранчами) - тот же тренд разделения compute/storage.
Глава 7

ClickHouse: миллиард строк в секунду

Яндекс.Метрика в 2016 открыла движок, который делает GROUP BY по 20 триллионам строк. Секрет - колонки вместо строк, сжатие в 10 раз, векторное исполнение и полный отказ от всего, что мешает: транзакций, точечных UPDATE, честных JOIN.

ClickHouse · колонки

Почему колонки в 100 раз быстрее для аналитики

Строковое хранение: одна строка = один кусок, читаешь все колонки, даже если нужна одна. Колоночное: каждая колонка - отдельный файл, читаешь только нужные, соседние значения похожи и жмутся в 5-20×.
  • Читаешь только нужное. SELECT avg(price) FROM events на таблице из 50 колонок: строковая база читает 100% данных, колоночная - 2%. Сразу ×50.
  • Сжатие. Колонка country: тысячи одинаковых подряд значений → LZ4 сжимает в 10-50×. Колонка timestamp монотонная → Delta + DoubleDelta кодек, потом LZ4/ZSTD → 100×. Типичное общее сжатие 5-15× против 1-2× у InnoDB. Меньше байт с диска = быстрее.
  • Векторное исполнение. Операции над блоками по 65 536 значений одного типа, а не над строками по одной. CPU работает по SIMD, кэш процессора не промахивается, нет виртуальных вызовов на строку. 1-3 ГБ/с на ядро при агрегации.
  • Параллелизм тотальный. Каждый запрос использует все ядра, каждый шард - все ноды. Один GROUP BY на 64 ядрах.
  • Не подходит для: SELECT * WHERE id = 5 - надо собрать строку из 50 файлов (в 10-100 раз медленнее PG). UPDATE одной строки - переписать целый кусок колонки. Много мелких INSERT - каждый создаёт part на диске.
ClickHouse · MergeTree

MergeTree: parts, гранулы, разреженный индекс

  • INSERT создаёт part - директорию с файлами колонок, отсортированную по ORDER BY. Фоновые мерджи сливают мелкие parts в крупные (как LSM-tree compaction). Отсюда «Merge»Tree. Вставлять батчами по 10-100 тыс. строк раз в секунду, не по строке: иначе Too many parts и остановка вставок.
  • Primary key - разреженный. Не на каждую строку, а на каждую гранулу (8192 строк): в индексе хранится первое значение ключа гранулы. Индекс на 1 млрд строк = 122 тыс. записей, влезает в RAM целиком. Поиск: найти диапазон гранул по ключу, прочитать их целиком. Промах в 8192 строки - ничто для сканирования.
  • ORDER BY = физический порядок = индекс. Самое важное решение при создании таблицы: ORDER BY (site_id, toDate(ts), user_id) - от низкой кардинальности к высокой, по частым фильтрам. Изменить нельзя, только пересоздать.
  • PARTITION BY - обычно по месяцу. Партиции независимы: DROP PARTITION мгновенный, запросы по дате пропускают лишние. Не делать партиций по дням на годы (тысячи партиций = тысячи parts).
  • Data skipping indexes: minmax, set, bloom_filter, ngrambf/tokenbf - для колонок не из ORDER BY. Не «найти», а «пропустить гранулы, где точно нет».

Семейство движков MergeTree

MergeTreeбазовый: сортировка, merge, ничего не дедуплицирует
ReplacingMergeTreeпри merge оставляет последнюю строку по ORDER BY (версии). Дедупликация «eventually» - до merge дубли есть → FINAL в запросе (дорого) или argMax
SummingMergeTreeпри merge суммирует числовые колонки с одинаковым ключом. Предагрегация счётчиков
AggregatingMergeTreeхранит состояния агрегатов (uniqState, quantileState) и сливает их. Основа materialized views с агрегатами
CollapsingMergeTree / VersionedCollapsingстроки с sign +1/-1 взаимно уничтожаются: имитация UPDATE/DELETE через вставку
Replicated*MergeTreeто же + репликация через ZooKeeper/ClickHouse Keeper; multi-master, eventual
Distributedне хранит: роутит запросы по шардам и собирает. Поверх любого движка
Materialized Viewтриггер на INSERT: пишет преобразованные строки в другую таблицу. Не пересчитывает историю, не обновляется при merge источника
ПрочиеLog (мелкие таблицы), Memory, Dictionary (key-value справочники в RAM), Kafka/RabbitMQ/S3/PostgreSQL/MySQL (внешние источники), Buffer
ClickHouse · подводные камни

Чего ClickHouse не умеет и как с этим жить

  • UPDATE / DELETE - мутации. ALTER TABLE ... UPDATE/DELETE WHERE - асинхронно переписывает все затронутые parts целиком. Минуты-часы, нагружает диск, не транзакционно. Lightweight DELETE (22.8+) помечает строки маской - быстрее, но чтение медленнее до merge. Правило: данные в CH append-only; исправления - через ReplacingMergeTree + версия.
  • Нет транзакций. INSERT одного батча в одну партицию атомарен, между таблицами - нет. Ретрай вставки после сетевой ошибки = дубли (лечится: идемпотентные вставки одинаковых батчей дедуплицируются по хэшу в Replicated-таблицах; insert_deduplication_token).
  • JOIN держит правую таблицу в RAM. Hash join: правая сторона целиком в память. JOIN двух таблиц по миллиарду строк = OOM. Правило: справа - справочник, слева - факты. Или Dictionary для справочников, или grace hash / partial merge join (медленнее), или денормализовать при вставке.
  • Мелкие INSERT убивают. Каждый INSERT = part = файлы + merge. 1000 вставок в секунду по строке = «Too many parts (300)». Async inserts (21.11+) буферизуют на сервере, Buffer-движок, или Kafka → CH батчами.
  • Нет точечных чтений. WHERE user_id = 123 без user_id в ORDER BY = full scan. Он быстрый (ГБ/с), но не миллисекунды. Для key-value - Dictionary или другая база.
  • Nullable медленнее. Отдельный файл-маска на колонку. Использовать дефолты (0, '') вместо NULL, где семантика позволяет.
  • Строки - LowCardinality. LowCardinality(String) для колонок с <10 тыс. уникальных значений (страна, браузер, статус) = словарное кодирование, 3-10× быстрее и меньше. Не для user_id.
  • FINAL дорогой. SELECT ... FROM t FINAL сливает версии на лету - в 2-10 раз медленнее. Для отчётов лучше argMax/GROUP BY по ключу или дождаться merge (OPTIMIZE TABLE ... FINAL - руками, тяжело).
  • Репликация eventual, ZooKeeper/Keeper - точка отказа. Метаданные и очередь репликации в Keeper; упал Keeper → таблицы read-only. Keeper на 3 ноды отдельно от CH.
  • Память. GROUP BY по высококардинальному ключу (user_id на 100 млн) = хэш-таблица в RAM. max_memory_usage, max_bytes_before_external_group_by для спилла на диск. По умолчанию запрос падает, а не тормозит - это осознанно.
  • SQL-диалект свой: тысяча функций (uniqHM, quantileTDigest, arrayJoin, -If/-Array комбинаторы), но подзапросы и WITH иногда ведут себя не как в PG; JOIN ... USING vs ON нюансы, ANY/ALL join строгость. Читать доку, не полагаться на интуицию.
ClickHouse · где он и где Postgres

Когда ClickHouse, когда Postgres, когда оба

ЗадачаPostgreSQLClickHouse
Заказы, пользователи, платежи (OLTP)данет
Логи, события, клики, метрики: млрд строкнет (после ~100 млн больно)да, создан для этого
Дашборды: GROUP BY по 30 дням по 1 млрд событийминуты; или предагрегаты0.1-2 с
Точечный SELECT по id0.1 мс10-500 мс
UPDATE строкидамутация
Транзакция между таблицамиданет
Джоин фактов с фактамиhash/merge с дискомв RAM или денормализация
Time-series до 100 млн точекTimescale нормда
Хранение 10 ТБ сырых событий10 ТБ на диске~1 ТБ после сжатия
Стоимость железа на аналитику×5-20 против CHэталон

Типовая архитектура

PostgreSQLисточник правды: сущности, транзакции
CDC / Kafka / batchDebezium, или приложение пишет события напрямую батчами
ClickHouseсобытия, агрегаты, дашборды, отчёты за годы

Справочники из PG в CH - через PostgreSQL-движок или Dictionary с автообновлением: JOIN событий со справочником юзеров без копирования вручную. Обратно почти никогда не нужно.

  • Конкуренты CH: Snowflake/BigQuery/Redshift - облачные, дороже в 5-10 раз на тот же объём, зато ноль администрирования и разделение storage/compute (CH Cloud тоже так умеет). Druid/Pinot - real-time OLAP с суб-секундной задержкой, сложнее. DuckDB - «ClickHouse в процессе», один файл, для локальной аналитики и до сотен ГБ. StarRocks/Doris - лучше в JOIN.
  • ClickHouse Inc. (2021, $2 млрд+): CH Cloud, поддержка. Open-source остался Apache 2.0 полностью.
Глава 8

Oracle, MSSQL, SQLite, DuckDB

Oracle - $50 тысяч за ядро и лучший оптимизатор на планете. SQLite - бесплатно и в каждом телефоне. Между ними MSSQL для корпоративной Windows и DuckDB, который делает аналитику на ноутбуке.

Oracle

Oracle Database: за что платят $47 500 за ядро

  • Cost-based optimizer - эталон 30 лет: adaptive plans (меняет NL на hash во время исполнения, если оценка не сошлась), SQL plan baselines (план зафиксирован, регрессий после апгрейда нет), automatic SQL tuning, гистограммы по всему. PG приблизился, но не догнал по стабильности планов.
  • RAC (Real Application Clusters) - несколько инстансов на общем storage, active-active, Cache Fusion передаёт блоки между нодами по Infiniband. Единственная зрелая shared-disk кластеризация в реляционном мире. Отказ ноды - секунды, приложение не заметило.
  • Flashback: SELECT ... AS OF TIMESTAMP, FLASHBACK TABLE TO BEFORE DROP, flashback database - «отменить последний час» без бэкапа. Undo используется как машина времени.
  • PL/SQL - зрелый процедурный язык с пакетами, компиляцией в native, bulk collect. Половина банковской логики мира написана на нём. Exadata - железо+софт: smart scan фильтрует на уровне storage.
  • Партиционирование, компрессия, In-Memory column store, Data Guard, RMAN, ASM - каждое опция за деньги, каждое отточено.

Цена и кому это надо

Enterprise Edition$47 500 за процессорное ядро (×0.5 для Intel по core factor) + 22%/год поддержка
ОпцииRAC +$23k, Partitioning +$11.5k, Advanced Compression +$11.5k, Diagnostics Pack +$7.5k за ядро
Сервер 32 ядра, EE+RAC+Partitioning~$1.3 млн лицензии + $290k/год. Плюс аудит лицензий Oracle LMS как бизнес-модель
Standard Edition 2$17.5k за сокет, до 2 сокетов, 16 потоков - для маленьких
FreeOracle XE / 23ai Free: 2 ядра, 2 ГБ RAM, 12 ГБ данных. Для обучения
Кому оправдано: банки и телеком с 20-летним легаси на PL/SQL, SAP-ландшафты, те, кому нужен RAC и вендор с SLA и кого никто не уволит за выбор Oracle. Кто уходит: Amazon (7500 баз Oracle → Aurora/DynamoDB к 2019), большинство новых проектов. Миграция Oracle → PG: ora2pg, AWS SCT; PL/SQL → PL/pgSQL похож на 80%, остальные 20% - месяцы.

Чему PG у Oracle научился: MVCC-идея, hint bits, партиционирование, параллельные запросы. Чему не научился: shared-disk кластер, стабильность планов, инструменты диагностики уровня AWR/ASH.

MSSQL · SQLite · DuckDB

Ещё три, которые нельзя не знать

Microsoft SQL Server

Корни в Sybase (1989), с 2017 на Linux. Оптимизатор второй после Oracle; columnstore indexes (аналитика внутри OLTP), In-Memory OLTP (Hekaton), Always On Availability Groups (репликация + failover, удобнее PG), Query Store (история планов), T-SQL с процедурами.

Особенности: по умолчанию блокировочная изоляция (читатели ждут писателей!) - включать READ_COMMITTED_SNAPSHOT; clustered index как InnoDB; NOLOCK как народный костыль; identifier [brackets].

Цена: Enterprise ~$15k за 2 ядра; Standard $4k; Express бесплатно до 10 ГБ. Ниша: корпоративная Windows-разработка, .NET, BI-стек (SSIS/SSRS/SSAS), Azure SQL.

SQLite

Библиотека, не сервер: база = один файл, встраивается в процесс. >1 триллиона инстансов: каждый Android/iOS, Chrome, Firefox, macOS, Windows 10, самолёты Airbus. Public domain. Тесты: 100% branch coverage, 590 строк тестов на строку кода - авиационный стандарт DO-178B.

Как работает: B-tree страницы 4 КБ, WAL-режим (читатели не блокируют писателя), один писатель в момент времени, типизация «динамическая» (STRICT tables с 3.37). До ~100 ГБ и тысяч записей/с - честная замена серверной базе для одного приложения.

Тренд 2023+: SQLite на сервере (Litestream - стриминг WAL в S3, LiteFS, Turso/libSQL - реплики на edge, Cloudflare D1). Для read-heavy сайтов - нулевая латентность, ноль сети.

DuckDB

«SQLite для аналитики» (2019, CWI Амстердам, те же люди, что MonetDB). Встраиваемая колоночная векторная СУБД, один файл или in-memory. Читает Parquet/CSV/JSON напрямую с диска и S3: SELECT ... FROM 'data/*.parquet'.

Скорость: на ноутбуке агрегирует 100 млн-1 млрд строк за секунды, часто быстрее pandas в 10-100 раз и на уровне ClickHouse на одной ноде. Богатый SQL (PG-диалект + сахар: SELECT * EXCLUDE (col), GROUP BY ALL, лямбды, ASOF JOIN). Расширения: spatial, httpfs, postgres_scanner (запросы к PG-таблицам напрямую).

Ниша: data science локально, ETL без Spark, аналитика в приложении, замена pandas. Не сервер: один процесс-писатель. MotherDuck - облачная версия.

Глава 9

NoSQL: когда таблицы не подходят

Redis отвечает за 100 микросекунд, Cassandra принимает миллион записей в секунду на 100 нодах, MongoDB хранит документ как есть. Каждая из них отказалась от чего-то, что реляционные базы считают священным - и именно поэтому решает свою задачу.

NoSQL · теория

CAP, PACELC и что реально выбирают

  • CAP (Брюер, 2000): при сетевом разделении (P) распределённая система выбирает: Consistency (все видят одни данные, часть нод отказывает в обслуживании) или Availability (все отвечают, но данные могут расходиться). P не выбирается - сеть рвётся всегда. Значит, реальный выбор - CP или AP в момент разделения.
  • PACELC (Абади, 2010) - честнее: при разделении (P) - A или C; иначе (E, else) - Latency или Consistency. Cassandra/Dynamo: PA/EL. Spanner/CockroachDB: PC/EC. PG с sync-репликой: PC/EC; с async: PC/EL-ish.
  • Eventual consistency - реплики сойдутся «когда-нибудь» (мс-секунды). Читаешь после записи - можешь увидеть старое. Для лайков и просмотров - ок. Для баланса - нет.
  • Tunable consistency (Cassandra): на каждый запрос - сколько реплик должны ответить: ONE, QUORUM, ALL. W + R > N = strong consistency. QUORUM/QUORUM на RF=3 - стандарт.
  • Консенсус (Paxos, Raft): как ноды договариваются о лидере и порядке записей. Raft (2014) - в etcd, CockroachDB, TiKV, YDB, ClickHouse Keeper, Redis Raft. Без него - split brain.

Jepsen: кто врал про консистентность

Кайл Кингсбери с 2013 ломает базы сетевыми разделениями и проверяет гарантии. Результаты изменили индустрию:

MongoDB (2013-2020)терял подтверждённые записи при default write concern; read concern «majority» нарушал snapshot isolation. Дефолты исправлены в 5.0
Elasticsearch (2014-2015)потеря документов при разделении; «not a database» - вывод самого Jepsen. Улучшено в 7.x с новым кластерным алгоритмом
Redis Sentinel/Clusterпотеря записей при failover (async), WAIT не гарантирует
Cassandra (2013)lightweight transactions нарушали linearizability; починили
PostgreSQL 12 (2020)нашли нарушение Serializable (G2-item) - редкий баг, починен в 12.3. Единственный серьёзный за все годы
CockroachDB, YugabyteDB, TiDB, etcdпроходили, находили мелочи, чинили. Стандарт де-факто перед релизом
Мораль: «база гарантирует X» проверяй на jepsen.io, а не в маркетинге. И читай дефолты: half the battle - write concern / consistency level по умолчанию.
NoSQL · Redis

Redis: структуры данных по сети за 100 микросекунд

СтруктураОперацииКейс
StringGET/SET/INCR/SETEX, битовые операциикэш, счётчики, rate limit, флаги, distributed lock (SET NX PX)
HashHSET/HGET/HINCRBY по полямобъект с полями, сессии, профили
ListLPUSH/RPOP/BLPOP (блокирующий)простые очереди, последние N событий
Set / Sorted SetSADD/SINTER; ZADD/ZRANGE/ZRANK по scoreуникальные посетители; лидерборды, приоритетные очереди, отложенные задачи по времени
StreamXADD/XREADGROUP, consumer groups, ackлог событий, лёгкая Kafka
HyperLogLog / Bloom / BitmapPFADD/PFCOUNT (12 КБ на ~1% ошибки); BF.ADDуникальные за день без хранения id; «видел ли уже»
Geo, JSON, Search, TimeSeriesGEORADIUS; модули RedisJSON, RediSearch«рядом со мной»; вторичные индексы по JSON
Pub/Sub, Lua, FunctionsPUBLISH/SUBSCRIBE; EVAL атомарный скриптоповещения (без гарантии доставки); атомарная логика на сервере
  • Однопоточный (команды; I/O с 6.0 многопоточный). Все операции атомарны без локов. 100-200 тыс. оп/с на ядро, латентность 0.1-0.3 мс. Долгая команда (KEYS *, SMEMBERS на 10 млн) блокирует всех - SCAN вместо KEYS.
  • Память - потолок. Всё в RAM. maxmemory + политика вытеснения (allkeys-lru, volatile-ttl). Без maxmemory → своп → смерть.
  • Персистентность: RDB (снапшот раз в N минут, fork процесса - RAM ×2 на пике) и AOF (лог команд, fsync everysec - потеря ≤1 с). Redis - не источник правды: при рестарте потеря последних секунд нормальна.
  • Репликация async, Sentinel для failover (потери записей при переключении - Jepsen), Redis Cluster - шардирование по 16384 слотам, multi-key команды только в одном слоте ({hash_tag}).
  • Лицензия: в 2024 Redis ушёл с BSD на SSPL/RSAL → форк Valkey (Linux Foundation, AWS, Google); в 2025 Redis вернул AGPL. Valkey - безопасный выбор. Альтернативы: Dragonfly (многопоточный, ×25 throughput), KeyDB.
  • Не делай: Redis как основная база; ключи без TTL (утечка навсегда); большие значения (>100 КБ) - блокируют; один Redis на всё (кэш + очередь + сессии: eviction кэша выкинет очередь).
NoSQL · MongoDB

MongoDB: документы, WiredTiger и долгий путь к ACID

  • Модель: коллекции BSON-документов (JSON + типы: дата, ObjectId, Decimal128, бинарь) до 16 МБ. Схема необязательна (JSON Schema validation - опционально). Вложенные массивы и объекты - «всё, что нужно для экрана, в одном документе» - чтение без джоинов.
  • WiredTiger (движок с 3.2, 2015): B-tree, MVCC, сжатие snappy/zstd, document-level locking. До него MMAPv1 с блокировкой на всю базу, потом на коллекцию - отсюда репутация «медленно и теряет».
  • Транзакции: одиночный документ атомарен всегда; multi-document ACID с 4.0 (2018) на replica set, с 4.2 на шардах. Работают, но дороже и с лимитами (60 с, 1000 документов рекомендовано). Философия: моделируй так, чтобы транзакции не были нужны.
  • Индексы: B-tree, составные, multikey (по элементам массива), text, 2dsphere, TTL, partial, wildcard (по всем полям вложенного объекта). Тот же set проблем, что у PG: порядок в составном, селективность, лимит 64 на коллекцию.
  • Aggregation pipeline: $match → $group → $lookup → $project - SQL в виде цепочки стадий. Мощный, читаемый хуже SQL. $lookup = LEFT JOIN, но только NL без hash - на больших объёмах медленно.

Шардирование и репликация

Replica set: 3+ нод, Raft-подобные выборы primary, oplog как репликация. Write concern majority + read concern majority - иначе можно потерять подтверждённое (Jepsen). Дефолт majority только с 5.0.

Sharded cluster: mongos (роутер) + config servers + шарды (каждый - replica set). Shard key выбирается один раз: hashed (равномерно, диапазоны не работают) или ranged (hot spot на монотонном ключе). Плохой shard key = jumbo chunks и балансировщик, который не спит.

Грабли: документ растёт (массив комментариев в посте) → 16 МБ лимит и переписывание при каждом UPDATE; дубли данных между документами расходятся (нет FK); агрегация по 100 млн документов - не её жанр; лицензия SSPL с 2018 (не open source по OSI) - облака делают DocumentDB/Cosmos с совместимым API, но другим движком.

Когда честно хороша: каталоги с разнородными атрибутами, контент/CMS, события с вложенной структурой, быстрый MVP, гео. Когда jsonb в PG лучше: если рядом есть хоть одна сущность, требующая транзакции с документом.

NoSQL · LSM-tree

LSM-tree против B-tree: две философии записи

Запись падает в memtable (RAM) + WAL. Заполнилась - сбрасывается как immutable SSTable на диск. Фоновый compaction сливает уровни. Чтение - memtable, потом уровни сверху вниз, bloom-фильтры отсекают лишние файлы.
B-tree (PG, InnoDB, SQLite)LSM-tree (RocksDB, Cassandra, ScyllaDB, HBase, LevelDB, MyRocks, ClickHouse-подобный)
Записьнайти страницу, изменить на месте (случайный I/O), WALappend в memtable + WAL - последовательно. В 5-10 раз выше throughput записи
Чтение точечноеlog(N) страниц, предсказуемонесколько уровней; bloom-фильтры спасают; хуже на 20-50%
Диапазонотличный (листья связаны)merge из нескольких SSTable, медленнее
Местофрагментация, полупустые страницы (~70%)плотно + сжатие блоками; но до compaction - дубли версий
Write amplificationполная страница на изменение байта + WAL + full page writescompaction переписывает данные многократно (×10-30 leveled), но последовательно
Фоновая работаvacuum/purge, checkpointcompaction - жрёт I/O и CPU, стоп → чтения деградируют, диск растёт
Удалениепометить, vacuumtombstone - запись «удалено», живёт до compaction (gc_grace 10 дней в Cassandra). Массовые DELETE = чтение через тысячи tombstones = смерть
Идеален дляOLTP чтение+запись, диапазоны, транзакцииwrite-heavy: логи, метрики, события, очереди, time-series; SSD (случайная запись всё равно дорогая для контроллера)
NoSQL · Cassandra и ScyllaDB

Cassandra / ScyllaDB: миллион записей в секунду, ноль лидеров

  • Архитектура Dynamo: все ноды равны, consistent hashing по кольцу токенов, replication factor 3, gossip для членства. Нет мастера → нет failover: упала нода, остальные приняли. Линейный масштаб: 100 нод = 100× throughput. Netflix, Apple (160 тыс. инстансов), Discord (потом ушёл на Scylla).
  • Модель wide-column: таблица = partition key (какая нода) + clustering columns (порядок внутри партиции) + значения. Одна партиция - до 100 МБ / 100 тыс. строк разумно. Схема проектируется от запросов: одна таблица на один паттерн чтения, данные дублируются в 3-5 таблиц. Нет JOIN, нет GROUP BY (почти), нет ad-hoc WHERE не по ключу (ALLOW FILTERING = full scan).
  • CQL - похож на SQL, но это обман: WHERE только по partition key (+ диапазон по clustering), ORDER BY только по clustering columns. Secondary indexes - локальные на ноду, медленные, почти не используют.
  • Lightweight transactions (IF NOT EXISTS) - Paxos, в 4 раза медленнее. Counters - отдельный тип с приколами. Materialized views - были experimental годами.

ScyllaDB - Cassandra на C++

Переписана с Java на C++ с фреймворком Seastar: shard-per-core (поток на ядро, без локов, свой планировщик I/O), без GC-пауз, без JVM-тюнинга. Совместима по CQL и драйверам. В 5-10 раз выше throughput на ноду, p99 стабильный. Discord: с 177 нод Cassandra на 72 Scylla при трилионе сообщений. Есть DynamoDB-совместимый API (Alternator).

Грабли: tombstones от DELETE и от записи NULL (!) - чтение сканирует их все; горячие партиции (все события одного дня в одной партиции); repair надо гонять регулярно (anti-entropy), иначе реплики расходятся; compaction strategy выбирать под нагрузку (STCS/LCS/TWCS для time-series); JVM heap Cassandra - искусство; изменение схемы данных = переливка всех таблиц.

Родственники: HBase (поверх HDFS, для Hadoop-мира, есть мастер), Google Bigtable (оригинал, 2006), DynamoDB (Amazon, serverless, платишь за RCU/WCU, single-digit ms, нет опса вообще - но привязка к AWS и дорого на скане).

NoSQL · остальные

Графовые, time-series, NewSQL

Графовые

Neo4j (2007): узлы и рёбра со свойствами, язык Cypher (MATCH (a)-[:FRIEND*1..3]->(b)), index-free adjacency - переход по ребру O(1), а не JOIN по индексу. Обход 5 уровней друзей за мс, где SQL с 5 self-join умирает.

Кейсы: рекомендации, fraud (кольца транзакций), knowledge graph, права доступа, зависимости. Слабое: агрегации по всему графу, шардирование (граф плохо режется).

Ещё: ArangoDB (мульти-модель), Neptune (AWS), Memgraph (in-memory), Apache AGE (Cypher внутри Postgres), стандарт GQL (2024). Для 90% «графовых» задач хватает рекурсивного CTE в PG.

Time-series

Метки времени + метрики + теги; запись append-only миллионы точек/с, запросы - окна и downsampling, retention автоматом. Prometheus (pull-модель, PromQL, локальный TSDB, для мониторинга), VictoriaMetrics (совместим, в 10× экономнее по RAM/диску, кластер), InfluxDB (1.x популярен, 2.x Flux провалился, 3.x на Rust/Arrow), TimescaleDB (Postgres), QuestDB, ClickHouse как универсал.

Правило: метрики инфраструктуры - Prometheus/VM; бизнес-события - ClickHouse; IoT с джоинами на метаданные - Timescale.

NewSQL / Distributed SQL

SQL + ACID + горизонтальный масштаб + автоfailover: то, чего не было ни у RDBMS, ни у NoSQL. Google Spanner (2012, TrueTime - атомные часы + GPS для глобального порядка), CockroachDB (PG-протокол, Raft на диапазонах ключей, гео-партиции), YugabyteDB (PG-код запросов поверх DocDB/RocksDB), TiDB (MySQL-протокол), YDB (Яндекс, open source 2022, Paxos-подобный, serverless).

Цена: латентность записи 5-20 мс (консенсус через сеть), сложные запросы медленнее PG, свои грабли (hot ranges, распределённые транзакции с retry). Оправдано от нескольких ТБ и/или multi-region. До этого - один жирный Postgres с репликами дешевле и проще.

Глава 10

Почему Elasticsearch - не база данных

Инвертированный индекс ищет слово среди миллиарда документов за миллисекунды - и не умеет ничего из того, ради чего существуют базы: транзакций, консистентности, целостности. Elastic, Sphinx, Solr, Manticore - это индексы поверх базы, а не вместо неё.

Поиск · инвертированный индекс

Инвертированный индекс: от слова к документам

Прямой индекс: документ → слова. Инвертированный: слово → отсортированный список документов (posting list) с позициями и частотой. Запрос из двух слов = пересечение двух отсортированных списков за O(n+m).
  • Анализатор: текст → токены. Tokenizer (по пробелам/юникоду/n-граммам) → фильтры: lowercase, стоп-слова, стемминг (бегали → бега) или лемматизация (→ бегать), синонимы, транслит, ASCII-folding. Один и тот же анализатор на индексации и на запросе - иначе ничего не найдётся.
  • Posting list хранит doc_id + term frequency + позиции (для фраз и подсветки). Сжат (delta + variable byte / PForDelta / Roaring bitmap). Пропуск через skip lists. Терм-словарь - FST (finite state transducer) в Lucene: префиксы, fuzzy, регэкспы по нему.
  • Релевантность BM25: score = Σ по словам IDF(слово) × tf×(k+1) / (tf + k×(1 - b + b×len/avglen)). Редкие слова важнее (IDF), частота в документе с насыщением (k=1.2), короткие документы выигрывают (b=0.75). Плюс boosting по полям, function score (свежесть, популярность), rescoring.
  • Сегменты immutable: Lucene пишет индекс сегментами, которые никогда не меняются. Обновление = удалить (пометить) + вставить новый. Фоновые merge сегментов. Новый документ виден только после refresh (1 с по умолчанию) - отсюда «near real-time» и eventual по определению.
  • Doc values - колоночное хранение полей рядом с индексом: для сортировки, агрегаций, фасетов. По сути, маленький ClickHouse внутри.
Поиск · Elasticsearch

Elasticsearch / OpenSearch: как устроен кластер

  • Индекс = N primary шардов × (1 + реплики). Каждый шард - отдельный Lucene-индекс. Число primary шардов фиксируется при создании (reindex, чтобы поменять). Документ роутится по hash(_id) % shards. Запрос - scatter к всем шардам, gather топ-K (query-then-fetch).
  • Ноды по ролям: master (метаданные кластера, 3 dedicated), data (hot/warm/cold tiers), ingest (пайплайны), coordinating. Кворум мастеров - раньше minimum_master_nodes руками (забыли → split brain и потеря данных), с 7.0 - автоматически (Zen2 на основе Raft-идей).
  • Mapping - схема полей: text (анализируется) vs keyword (как есть, для фильтров и агрегаций), числа, даты, geo_point, nested (массив объектов как отдельные документы), dense_vector (kNN с 8.0). Dynamic mapping угадывает тип по первому документу - и ошибается навсегда.
  • Aggregations - главное после поиска: terms (фасеты), histogram, date_histogram, percentiles, cardinality (HyperLogLog), nested/composite. На doc values. Это то, что делает Kibana дашбордами.
  • ELK/Elastic Stack: Logstash/Beats/Fluentd → Elastic → Kibana. 60% инсталляций Elastic - логи, не поиск. Тут он конкурирует уже с ClickHouse и Loki, и проигрывает по стоимости хранения в 5-10×.

Цифры и лимиты

Размер шарда10-50 ГБ оптимум; >100 ГБ - долгие recovery и merge
Шардов на ноду≤20 на ГБ heap; 1000 шардов на 30 ГБ heap - потолок. Oversharding - болезнь №1
Heap≤ 31 ГБ (compressed oops), 50% RAM; остальное - page cache для Lucene
Refresh interval1 с; для bulk-загрузки ставить 30 с или -1
Fielddata на textагрегация по text-полю грузит все термы в heap → OOM. Только keyword
Deep paginationfrom+size ≤ 10 000; дальше search_after / PIT / scroll
Mapping explosionлимит 1000 полей; динамические ключи (user_123: ...) как поля - классический взрыв
Стоимостьданные ×1.5-3 после индексации против исходника (сжатие + индекс + doc values + реплика); в 5-10× дороже ClickHouse на ТБ логов

OpenSearch - форк AWS (2021) после смены лицензии Elastic на SSPL; в 2024 Elastic вернул AGPL. Функционально близки, расходятся. Managed: Elastic Cloud, AWS OpenSearch Service. Оба - Java, оба любят RAM.

Поиск · почему не база

Семь причин, почему Elastic - не источник правды

  • Нет транзакций. Один документ - атомарно. Два документа, документ + счётчик, «перевести и списать» - нет. Bulk API частично успешен: половина записалась, половина нет, разбирайся по ответу.
  • Eventual consistency по конструкции. Записал - прочитал через 1 с (refresh). Реплика отстаёт от primary; чтение с реплики может вернуть старое. ?refresh=wait_for лечит одиночный кейс ценой производительности.
  • Терял данные. Jepsen 2014-2015: при сетевом разделении и смене мастера - потеря подтверждённых записей, dirty reads, split brain. С 7.x (2019) кластерная координация переписана, потери при разделении убраны, но «мы не заявляем ACID» - официальная позиция Elastic.
  • Нет схемы в смысле целостности. Нет FK, UNIQUE (кроме _id), CHECK, NOT NULL. Mapping говорит, как индексировать, не что валидно. Мусор влетает молча.
  • Обновление = переиндексация документа. Изменить одно поле - Lucene удаляет и пишет весь документ заново; частичные update внутри - те же операции. 1000 update/с на документ - убивает. Счётчики, статусы, «last_seen» - не сюда.
  • Восстановление долгое. Перебалансировка шардов при падении ноды - часы на ТБ; restore из снапшота тоже часы.
  • Mapping нельзя менять. Поле было integer, стало string - reindex всего индекса через alias.

А что он умеет лучше всех

  • Полнотекст с релевантностью, морфологией, синонимами, опечатками, подсветкой на 100+ языках
  • Фасеты и агрегации по десяткам полей одним запросом за мс
  • Гео + текст + фильтры в одном запросе с score
  • Горизонтальный масштаб до сотен нод и десятков ТБ индекса
  • Autocomplete (completion suggester, FST), «did you mean»
  • Kibana для логов и дашбордов без кода
  • Гибридный поиск: BM25 + kNN по векторам (RRF)
Правило архитектуры: данные живут в БД. Elastic - производный, восстановимый индекс: если он сгорел, его переливают из базы за N часов, и никто ничего не потерял. Если после потери Elastic данные не восстановить - архитектура сломана.
Поиск · Sphinx, Solr, новые

Sphinx / Manticore, Solr, Meilisearch, Typesense, Tantivy

ДвижокЧто этоСильноеСлабоеКогда брать
Sphinx (2001, Андрей Аксёнов)C++, индексирует прямо из MySQL/PG по SQL-запросу (indexer), SphinxQL - MySQL-протокол. Стоял за половиной рунета 2005-2015 (Avito, Habr, Craigslist)скорость и экономность: ГБ индексов на 1 ядре; простотаразвитие остановилось (3.x закрытый), пересборка индекса целиком (RT-индексы позже), нет кластералегаси; для нового - Manticore
Manticore Search (2017)открытый форк Sphinx 2.3, активно развивается: RT-индексы, репликация (Galera), JSON, вектора, columnar storage, SQL и HTTP JSON APIв 5-20× меньше RAM чем Elastic на тех же данных, быстрые фасеты, MySQL-совместимый протоколэкосистема мала, нет Kibana-аналога, меньше анализаторов языковпоиск по каталогу/сайту с ограниченным железом; замена Sphinx
Apache Solr (2004)старший брат Elastic на том же Lucene (Solr появился раньше). SolrCloud с ZooKeeper, XML/JSON конфиги, schema.xmlзрелость, гибкость конфигурации, сильный фасетный поиск, e-commerce (Lucidworks)неуклюжий API, меньше сообщество, ZooKeeper, менее удобная аналитикаenterprise-поиск, где уже есть; Hadoop-мир
Meilisearch (2018, Rust)поиск «как у Algolia»: typo-tolerance из коробки, instant search по мере набора, простой RESTзапуск за 5 минут, отличный UX-поиск, ранжирование правиламиодин процесс (без шардов), до ~10-100 млн документов, слабая аналитикапоиск на сайте/в приложении, замена Algolia
Typesense (2015, C++)то же семейство: in-memory индекс, typo-tolerant, фасеты, вектора, кластер через Raftлатентность <50 мс, простота, HAвсё в RAM (дорого на объёме)то же, что Meili, если нужен кластер
Tantivy / Quickwit / ParadeDBTantivy - Lucene на Rust; Quickwit - поиск по логам на S3; ParadeDB (pg_search) - BM25-поиск как расширение Postgres на Tantivyскорость Rust, S3 как хранилище, BM25 внутри PGмолодыелоги в объектном хранилище; «Elastic внутри Postgres» для средних объёмов
Общее для всех: индексы, а не базы. Ни один не заявляет ACID между документами. Все - производное хранилище с переливкой из источника. Разница - в весе (Elastic самый тяжёлый), удобстве (Meili/Typesense), и объёме (Elastic/Solr/Quickwit до петабайт).
Поиск · где что

Где база, а где поисковый движок: матрица решения

ЗадачаPG (B-tree/FTS/trgm)ClickHouseElastic / ManticoreВердикт
Найти клиента по фамилии/телефону/email с опечаткой, 1 млн записейpg_trgm, 5 мснетможно, но лишний сервисPG
Поиск товара по названию в каталоге на 500 тыс. SKU, фасеты по 5 атрибутамtrgm + GROUP BY, 20-100 мс, релевантность слабаянет10 мс, фасеты, релевантность, синонимыElastic/Manticore/Meili, если UX важен; PG, если «и так сойдёт»
Полнотекст по статьям/тикетам, 5 млн документов, ранжированиеFTS ок, ранжирование без IDFнетBM25, подсветка, морфологияElastic или ParadeDB внутри PG
Поиск по логам: 1 ТБ/день, «найти строки с error и request_id»нетtokenbf-индекс + full scan ГБ/с, ×5-10 дешевлеклассика ELK, дорого по диску и RAMClickHouse (или Loki/Quickwit); Elastic - если нужна Kibana и деньги есть
Аналитика: воронка по 1 млрд событий за кварталнетсекундыагрегации есть, но медленнее и дороже в 10×ClickHouse
Автодополнение в строке поиска, опечатки, 100 тыс. терминовtrgm GiST KNNнетcompletion suggesterлюбой; PG, если он уже есть
Семантический поиск по 1 млн текстов (эмбеддинги)pgvector HNSWесть ANN-индексы, экспериментальноdense_vector kNN + гибрид с BM25PG до 10 млн; Elastic, если нужен гибрид BM25+вектор; Qdrant при 100 млн+
Транзакции: заказ + склад + платёжданетнетPG/MySQL, без вариантов
Гео: «рестораны в 2 км с рейтингом и словом пицца»PostGIS + FTSгео-функции есть, текст слабыйgeo_distance + match + scorePG до млн точек; Elastic на масштабе
Эвристика: если запрос можно выразить как WHERE + ORDER BY по индексу - это база. Если он про «похожесть», «релевантность», «фасеты по всему сразу» на объёмах, где PG задыхается - это поисковый движок. Если про «сколько/сумма/среднее по миллиардам» - колоночная. И почти всегда порог, где PG перестаёт справляться, выше, чем кажется.
Поиск · синхронизация

Как держать индекс в согласии с базой

СпособКакПлюсы / минусы
Dual writeприложение пишет в БД и сразу в Elasticпросто. Но: БД закоммитила, Elastic упал → расхождение навсегда. Порядок событий из двух инстансов приложения перемешан. Не делать
Периодическая переливкаcron: SELECT ... WHERE updated_at > last_run → bulk indexнадёжно, просто, идемпотентно. Лаг минуты. Удаления не видны (нужен soft delete или полная пересборка). Для 80% проектов достаточно
Transactional outboxв той же транзакции с данными - строка в таблицу outbox; отдельный воркер читает outbox и пишет в Elastic/Kafka, потом удаляетатомарно с данными, порядок сохранён, at-least-once (индексация идемпотентна по _id - ок). Стандарт для сервисов
CDC (Change Data Capture)Debezium читает WAL (PG logical replication / MySQL binlog) → Kafka → consumer в Elastic/ClickHouse. Ноль кода в приложениивсе изменения, включая ручные UPDATE в базе; порядок; ~сотни мс лаг. Минусы: Kafka + Connect + слоты репликации, которые надо мониторить; схема меняется - ломается пайплайн
Триггеры + очередь в БДтриггер на таблице пишет id в очередь (таблица / LISTEN-NOTIFY / pgq)без внешних систем; нагрузка на БД; NOTIFY теряется при отключении слушателя
Полная пересборкановый индекс с версией → залить всё → переключить alias → удалить старыйобязательна в любом случае: смена mapping, восстановление после потери. Blue-green через alias без даунтайма

Схема, которая работает

ПриложениеINSERT данные + INSERT outbox в одной транзакции
PostgreSQLисточник правды
Debezium / воркерчитает WAL или outbox
Kafkaпорядок по ключу, ретраи, replay
Elastic + ClickHouseидемпотентный upsert по id

Плюс еженощная сверка: count и checksum по updated_at между базой и индексом; расхождение → пересобрать диапазон.

  • Денормализация при индексации: в Elastic летит не строка orders, а документ с именем клиента, названиями товаров, категорией. Изменилось имя клиента - переиндексировать все его заказы. Это цена поиска.
  • Удаления: hard DELETE в базе CDC поймает, cron - нет. Soft delete (deleted_at) решает оба случая и даёт «восстановить».
  • Zero-downtime reindex: алиас productsproducts_v7; строим v8 параллельно, переключаем алиас атомарно.
Глава 11

Практика: схема, масштаб, бэкапы, мониторинг

Вещи, которые не пишут в документации, но которые определяют, спишь ты ночью или нет: нормализация без фанатизма, пагинация без OFFSET, партиции вместо DELETE, бэкап, который реально восстанавливается, и пять метрик, на которые надо смотреть.

Практика · схема

Нормализация и денормализация без религии

  • 3НФ по умолчанию: каждый факт хранится один раз. Имя клиента - в customers, не в каждом заказе. Обновил в одном месте - везде верно. Нарушение = аномалии обновления и расхождения, которые находят через год.
  • Денормализация - осознанная и задокументированная: счётчик posts.comments_count вместо COUNT на каждый рендер; orders.total вместо SUM позиций; snapshot цены и названия товара в момент заказа (это не денормализация, это история - товар потом переименуют). Каждая - с механизмом поддержки (триггер, транзакция, пересчёт).
  • Materialized view - денормализация с кнопкой пересчёта: REFRESH MATERIALIZED VIEW CONCURRENTLY. Для отчётов и агрегатов, которые можно отставать на минуты. Инкрементальные MV: pg_ivm, Timescale continuous aggregates, ClickHouse MV.
  • EAV (entity-attribute-value) - таблица (entity_id, attr, value): «гибкая схема» 2005 года. Запрос с 10 атрибутами = 10 self-join, типы потеряны, индексы бесполезны. В 2025 вместо EAV - jsonb с GIN или нормальные колонки.
  • Типы: деньги - numeric(19,4) или integer в копейках, никогда float; время - timestamptz; id - bigint; статусы - enum или text с CHECK, не int с магией; булевы - boolean, не char(1).

Ключи и связи

Суррогатный PKbigint identity / bigserial - всегда. Естественный (email, ИНН) - UNIQUE, но не PK: он меняется
UUIDкогда id генерируется клиентом или нельзя палить порядок. v7, не v4. 16 байт против 8 - ощутимо в индексах
FKставить. «FK тормозит» - на 5-10%, а ловит баги на 100%. Индекс на FK-колонку в PG - руками. ON DELETE: RESTRICT по умолчанию, CASCADE осознанно
Many-to-manyтаблица связи с составным PK (a_id, b_id) + индекс (b_id, a_id) для обратного направления
Иерархииparent_id + recursive CTE (до 5-6 уровней), ltree / materialized path (глубокие, часто читаемые), closure table (много запросов «все потомки»)
Soft deletedeleted_at timestamptz + partial unique index WHERE deleted_at IS NULL. Или таблица-архив, если удалённых много
Аудитcreated_at / updated_at на всём (триггер); history-таблица через триггер или temporal tables (MariaDB, MSSQL; PG - расширение)
Практика · запросы

Пагинация, батчи, блокировки очередей

Пагинация

OFFSET 100000 LIMIT 20 читает и выбрасывает 100 000 строк. Страница 5000 = 5000× медленнее первой. Плюс дубли/пропуски при вставках между страницами.

Keyset (seek): WHERE (created_at, id) < ($last_ts, $last_id) ORDER BY created_at DESC, id DESC LIMIT 20 + индекс (created_at, id). Любая страница за 1 мс. Минус: нет «перейти на страницу 57» - и это нормально, никто туда не ходит.

Общее число страниц: не COUNT(*) на каждый запрос, а оценка или кэш; или «показать ещё» без числа.

Массовые операции

Bulk insert: COPY (PG) / LOAD DATA (MySQL) в 10-50× быстрее INSERT по строке; multi-row INSERT VALUES (...),(...) по 1000 - в 10×. Индексы и FK - после загрузки. UNLOGGED таблица для staging.

Массовый UPDATE/DELETE: не одним statement на 50 млн строк (лок, WAL, реплика отстаёт на час, vacuum). Батчами по 5-50 тыс. с паузой: DELETE WHERE id IN (SELECT id ... LIMIT 10000) в цикле. Ещё лучше - партиция и DROP.

Upsert: INSERT ... ON CONFLICT DO UPDATE / ON DUPLICATE KEY вместо SELECT-then-INSERT (race condition).

Очередь в базе

SELECT id FROM jobs WHERE status='new' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED - N воркеров берут разные задачи без конфликтов и без Redis/RabbitMQ. Транзакционно с данными: задача создана вместе с заказом или никак.

До ~1-5 тыс. задач/с - честно работает (PG: pgq, Que, Oban, Graphile Worker, River; MySQL - то же). Дальше - Kafka/NATS. Грабли: таблица jobs пухнет (bloat от UPDATE status) - partial index WHERE status='new' и агрессивный autovacuum, или удалять выполненные.

SELECT * запрещён в коде. Тянет TOAST-колонки, ломается при ADD COLUMN, мешает index-only scan. Транзакция короткая: открыл - сделал - закрыл, без HTTP-вызовов внутри. Таймауты везде: statement_timeout 30 с для веба, lock_timeout 3 с, idle_in_transaction_session_timeout 60 с. Prepared statements через драйвер - защита от инъекций и +20% скорости.
Практика · масштаб

Партиционирование и шардирование

ПартиционированиеШардирование
Чтоодна логическая таблица = много физических внутри одного сервера. По диапазону (дата), списку (регион), хэшуданные разложены по разным серверам. Ключ шарда (tenant_id, user_id) определяет, куда идти
ЗачемDROP PARTITION вместо DELETE миллионов строк; запросы по дате читают одну партицию (partition pruning); vacuum и индексы меньше; старое - на медленный дискодна машина кончилась: 10 ТБ, 100 тыс. TPS, RAM не вмещает рабочий набор. Единственный способ масштабировать запись
ЦенаPK должен включать ключ партиции; FK на партиционированную - с PG12; тысячи партиций замедляют планирование; global index нет (PG)кросс-шардовые JOIN и транзакции - нет или дорого; уникальность глобальная - нет; resharding - боль; отчёты «по всем» - через ClickHouse
Когдатаблица > 50-100 млн строк с временной природой (события, логи, заказы). Почти всегда полезнокогда вертикаль исчерпана: 96 ядер, 1 ТБ RAM, NVMe RAID - это очень много. 95% проектов до этого не доходят
ИнструментыPG declarative partitioning (10+), pg_partman; MySQL PARTITION BY; Timescale автоматомCitus (PG), Vitess (MySQL), приложение сам роутит (Instagram: 4096 логических шардов), CockroachDB/YDB/Spanner (автоматически)
Перед шардированием: read replicas для чтения; кэш; вынести события в ClickHouse (обычно это 80% объёма); партиционировать; апгрейд железа. Шардирование - последнее, потому что необратимое.
Роутер знает: user_id 12345 → шард 3. Запрос по одному юзеру - одна нода, быстро. Запрос «все юзеры с балансом > 1000» - все ноды, медленно, и агрегировать снаружи.
Практика · бэкапы

Бэкапы: три вида и одно правило

ВидPGMySQLClickHouseСвойства
Логическийpg_dump / pg_dumpall (custom format -Fc, параллельный -j)mysqldump, mysqlpump, mydumper (параллельный)SELECT ... INTO OUTFILE / Native formatпереносим между версиями и ОС, можно одну таблицу. Медленный: 1 ТБ дамп - часы, restore - сутки (индексы). Не PITR
Физическийpg_basebackup; pgBackRest, WAL-G, Barman (инкрементальные, в S3, параллельные)XtraBackup (горячий, инкрементальный), снапшоты LVM/EBS; MySQL Enterprise BackupBACKUP TABLE ... TO S3 (22.x+), clickhouse-backup; снапшоты parts (файлы immutable - легко)быстро (скорость диска), restore = копия файлов. Только та же major-версия и архитектура
PITRbasebackup + непрерывный архив WAL (archive_command / pgBackRest) → восстановить на любую секундуXtraBackup + binlog → до нужной позиции/временинет в классическом виде; реплика с задержкойединственная защита от «DROP TABLE в проде в 14:03»: восстановиться на 14:02
Репликане бэкап! DROP TABLE реплицируется за 10 мсdelayed replica (MASTER_DELAY=3600) - час на «ой»то жезащита от отказа железа, не от людей и багов
  • 3-2-1: три копии, два носителя, одна вне площадки (другой регион / провайдер). Бэкап на том же сервере - не бэкап. Бэкап в том же облачном аккаунте - половина бэкапа (удалённый аккаунт = всё).
  • Restore test - единственная проверка. Автоматически: раз в неделю поднять из бэкапа на отдельной машине, прогнать проверки (count по ключевым таблицам, целостность), записать время. RTO измеряется, а не предполагается. GitLab 2017: 5 механизмов бэкапа, ни один не работал, потеряли 6 часов данных.
  • Retention: ежедневные 2 недели, недельные 2 месяца, месячные год - и юридические требования. Инкрементальные (pgBackRest, XtraBackup) экономят место, но цепочка зависит от полного.
  • Шифрование и доступ: бэкап = вся база в открытом виде. Шифровать, immutable bucket от ransomware. И алерт, если последний успешный старше 26 часов.
Практика · мониторинг

На что смотреть, пока не упало

МетрикаГдеПорог тревоги
Топ запросов по total_timepg_stat_statements / performance_schema, sys.statement_analysis / system.query_logодин запрос > 20% всего времени; mean_time вырос ×2 после деплоя
Активные соединения / ожиданияpg_stat_activity (state, wait_event) / SHOW PROCESSLIST, sys.innodb_lock_waitsactive > число ядер ×2; idle in transaction > 1 мин; lock waits > 5 с
Replication lagpg_stat_replication (replay_lsn diff) / SHOW REPLICA STATUS + heartbeat / system.replication_queue> 10 с; слот с inactive и растущим WAL
Cache hit ratiopg_stat_database blks_hit/(hit+read) / innodb_buffer_pool_read_requests vs reads< 99% на OLTP
Dead tuples, bloat, vacuum agepg_stat_user_tables n_dead_tup, last_autovacuum; age(datfrozenxid) / history list lengthdead > 10% живых; xid age > 500 млн; HLL > 1 млн
Дискразмер данных, WAL/binlog, темп роста, IOPS/latency< 20% свободно; WAL растёт без реплики-потребителя; fsync latency > 10 мс
Ошибки и медленныеlog_min_duration_statement=500ms; slow_query_log; deadlocks; connection refusedрост дедлоков; любые serialization failures без retry
Checkpoint / mergepg_stat_bgwriter checkpoints_req vs timed / system.merges, parts countcheckpoints по requested чаще timed; parts > 300 на партицию

Инструменты

  • Метрики: postgres_exporter / mysqld_exporter / CH встроенный Prometheus endpoint → Grafana (готовые дашборды 9628, 7362); PMM от Percona (всё в одном для MySQL/PG/Mongo)
  • Запросы: pg_stat_statements + pgBadger (анализ логов), pganalyze, Datadog DBM; MySQL: pt-query-digest, sys schema
  • Здоровье: check_postgres / pgwatch2; pghero (простой веб); pg_stat_kcache; для MySQL - pt-summary, mysqltuner (с осторожностью)
  • Explain: auto_explain с log_analyze для запросов > 1 с; explain.dalibo.com
  • Алерты: Alertmanager правила на пороги слева; не алертить на всё - 5 метрик, которые реально предвещают инцидент: connections, lag, disk, lock waits, xid age
Ритуал раз в неделю: топ-10 pg_stat_statements → есть ли новые → EXPLAIN → индекс или переписать. Раз в месяц: неиспользуемые индексы, bloat, размер таблиц, restore-тест. Раз в квартал: минорный апгрейд. Это 2 часа в месяц и ноль ночных звонков.
Итоги · карта

Все базы на одном слайде

СистемаХранениеСильнейшая сторонаГлавная слабостьБрать для
PostgreSQLheap + B-tree/GIN/GiST/BRIN, MVCC в таблице, WALрасширяемость: trgm, PostGIS, pgvector, Timescale; умный планировщик; строгий SQLvacuum/bloat, процесс на соединение, count(*), UPDATE-heavyвсего по умолчанию до нескольких ТБ
MySQL / InnoDBclustered B+-tree по PK, undo log, redo + binlogточечные чтения по PK, UPDATE без bloat, репликация и failover проще, потокиоптимизатор слабее, диалект, DDL, тихие касты, Oracleклассический веб-OLTP, легаси, Vitess-шардирование
ClickHouseколонки, MergeTree, sparse index, LZ4/ZSTDагрегации по миллиардам за секунды, сжатие 10×, дёшевонет транзакций, UPDATE/DELETE мутации, JOIN в RAM, мелкие INSERTсобытия, логи, метрики, аналитика, дашборды
Oracleкак InnoDB + всё остальноеоптимизатор, RAC, flashback, PL/SQL, вендорцена ×100, лицензионный аудит, vendor lockбанки, легаси, где деньги не вопрос
SQLite / DuckDBфайл; B-tree / колонкиноль инфраструктуры; DuckDB - аналитика на ноутбукеодин писатель, не серверembedded, мобильные, локальная аналитика, edge
Redis / ValkeyRAM, структуры данных100 мкс, атомарные структуры, TTLRAM = потолок, персистентность с потерямикэш, сессии, счётчики, лидерборды, локи
MongoDBBSON-документы, WiredTiger B-treeгибкая схема, вложенность, шардирование из коробкиджоины, целостность, история потерь, SSPLкаталоги, контент, MVP, гетерогенные данные
Cassandra / ScyllaLSM, wide-column, кольцо без мастеразапись миллионы/с, линейный масштаб, нет SPOFсхема от запросов, нет ad-hoc, tombstones, repairсобытия/сообщения на сотнях нод, multi-DC
Elastic / Manticore / Solrинвертированный индекс, immutable сегментыполнотекст, релевантность, фасеты, масштабне ACID, eventual, дорого, mappingпоиск и логи как производный индекс поверх базы
NewSQL (Cockroach, YDB, Spanner)Raft/Paxos на диапазонах, RocksDBSQL + ACID + масштаб + auto-failover + геолатентность записи, сложные запросы медленнее, молодостьmulti-region, десятки ТБ с транзакциями
Три идеи под всей лекцией: физика хранения определяет всё (строки/колонки, B-tree/LSM, heap/clustered); планировщик оценивает, а не знает - статистика важнее индексов; поисковые движки и аналитика - производные от базы, источник правды один.
Финал

Одна страница 8 килобайт

Всё, что было выше, сводится к одному вопросу: сколько страниц по 8 КБ нужно прочитать, чтобы ответить на запрос. B-tree - чтобы их было три. Колонки - чтобы они были плотные. MVCC - чтобы читать их, не ожидая никого. WAL - чтобы после выключения света они остались. Планировщик - чтобы угадать, каких страниц меньше. Триграммы - чтобы страницы нашлись даже по обрывку слова.

Postgres, MySQL, ClickHouse, Cassandra, Elastic - это пять разных ответов на вопрос, какие страницы читать первыми и какими жертвовать. Знаешь физику - выберешь правильно. Не знаешь - выберет за тебя очередь на диске в три часа ночи.