Выбор хранилища под паттерн доступа

Предпосылки: Профили нагрузки (B-tree vs LSM-tree, read-heavy vs write-heavy), Кэширование (Redis как внешний кэш, key→value lookup), Инвертированный индекс.

Message Queues: асинхронная коммуникация между сервисами | API Design: проектирование границы между системами

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

Интернет-магазин хранит в PostgreSQL всё: пользователей, заказы, каталог товаров, историю поисковых запросов, аналитические события, сессии. Архитектура уже зрелая — primary с репликами, шардинг по user_id для заказов, Redis для кэша, message queue для фоновых задач. Система справляется. Но появляются боли, которые не решаются добавлением реплик или шардов, потому что их причина — не в объёме нагрузки, а в форме запросов.

Когда PostgreSQL работает не в своей зоне

Покупатель вводит в поисковую строку «красный кожанный рюкзак для ноутбука» — с опечаткой. PostgreSQL tsvector работает с лексемами: слово нормализуется (убираются окончания), и ищется точное совпадение нормализованной формы. «Кожанный» даёт лексему, не совпадающую с «кожаный» из индекса — ноль результатов. Расширение pg_trgm позволяет нечёткий поиск через триграммное расстояние, но не даёт ранжирования по релевантности и не поддерживает синонимы (кроссовки → кеды → sneakers). PostgreSQL может закрыть каждую из этих возможностей по отдельности через комбинацию расширений, CTE и оконных функций, но их совмещение в одном запросе — с fuzzy matching, синонимами, ранжированием по релевантности и фасетными фильтрами (сужение результатов по атрибутам: бренд, цвет, размер) одновременно — превращается в самописный поисковый движок поверх реляционной базы.

Менеджер просит отчёт: конверсия из просмотра в покупку по каждой из 500 категорий за месяц. Таблица аналитических событий: user_id (8 байт), event_type (4 байта), timestamp (8 байт), category_id (4 байта), payload (JSON, ~500 байт) — итого 524 байта на строку. При 50M событий в день за месяц накапливается 1.5 миллиарда строк. Запросу нужны только event_type и category_id — 8 байт из 524. Но PostgreSQL хранит строку целиком в одной странице: чтобы прочитать два поля, приходится поднять с диска все 524 байта каждой строки. 1.5B × 524 = ~750 GB чтения ради ~12 GB полезных данных. На SSD с последовательным чтением 500 MB/s это 25 минут чистого I/O на один отчёт.

Индекс по (event_type, category_id) теоретически мог бы обойтись без чтения полных строк (index-only scan), но на практике PostgreSQL часто обращается к основной таблице для проверки видимости строки — особенно при 50M вставок в день, когда VACUUM (команда очистки устаревших версий строк; autovacuum запускает её автоматически) не успевает за потоком изменений. К тому же каждый индекс нужно поддерживать: 50M записей в день — это вставка не только в таблицу, но и в каждый индекс. Для append-only аналитической нагрузки, где записей на порядки больше чем запросов, цена индекса на запись становится существенной.

Сессии пользователей: 10M активных, каждая живёт 24 часа. Паттерн доступа простейший — токен → данные сессии. Сам по себе это не проблема для PostgreSQL. Проблема — в жизненном цикле: каждый день 10M сессий создаётся и 10M истекает. Истёкшие нужно удалять — DELETE FROM sessions WHERE expires_at < now() порождает 10M dead tuples ежедневно, и VACUUM должен их обработать. Redis с командой EXPIRE убирает ключ по TTL автоматически, без аналога dead tuples — потому что key-value store не имеет концепции «мёртвой версии строки», которую нужно вычищать.

Три боли — три разных причины. Поиск упирается в capabilities: совмещение fuzzy matching, синонимов и ранжирования на уровне продуктового качества поверх PostgreSQL обходится дороже, чем эксплуатация специализированного поискового движка. Аналитика упирается в физический layout: построчное хранение заставляет читать ненужные данные. Сессии упираются в жизненный цикл: PostgreSQL не удаляет строки физически — он помечает их как мёртвые, и VACUUM должен их вычистить позже, что создаёт overhead при массовом удалении короткоживущих записей. Во всех трёх случаях проблема не в масштабе — добавление реплик и шардов не меняет форму запроса и не меняет физическую организацию данных на диске.

Построчное и поколоночное хранение

Причина аналитической боли — не в объёме данных, а в физическом layout на диске.

PostgreSQL хранит данные построчно: вся строка — все колонки — лежит в одной странице. Страница на 8 KB содержит несколько полных строк:

Страница 1:  [user_1, view, 10:00, cat_5, {payload...}]
             [user_2, purchase, 10:01, cat_3, {payload...}]

Страница 2:  [user_4, view, 10:02, cat_1, {payload...}]
             ...

Для запроса SELECT * FROM events WHERE id = 42 это идеально: одно чтение страницы — все поля на месте. Для аналитического запроса, которому нужны только event_type и category_id, приходится поднимать всю строку целиком — 98% прочитанного с диска не используется.

Поколоночное хранение кладёт значения одной колонки рядом друг с другом:

Файл event_type:   [view, purchase, view, view, purchase, ...]
Файл category_id:  [5, 3, 5, 1, 3, ...]
Файл user_id:      [1, 2, 3, 4, 5, ...]
Файл timestamp:    [10:00, 10:01, 10:01, 10:02, 10:03, ...]
Файл payload:      [{...}, {...}, {...}, {...}, {...}, ...]

Запрос «конверсия по категориям» читает два файла: event_type и category_id. Вместо 524 байт на строку — 8 байт. Вместо 750 GB — 12 GB. На том же SSD: 24 секунды вместо 25 минут. Сверх того, значения одной колонки однотипны и часто повторяются: event_type принимает 5–10 различных значений на миллиарды строк. Такие данные сжимаются в десятки раз — 12 GB после сжатия превращаются в 1–2 GB, несколько секунд чтения.

Обратная сторона проявляется на OLTP-запросах. Чтобы собрать одну строку с 15 колонками, поколоночное хранилище читает 15 разных файлов — 15 O вместо одного. При 10 000 таких запросов в секунду разница между 10K и 150K random reads существенна. Запись одной строки — это запись в 15 разных мест вместо одной страницы. Поколоночные хранилища буферизуют данные и пишут пачками, что добавляет задержку перед тем, как новые записи станут видимы.

Из этого вырастают два фундаментальных паттерна работы с данными. OLTP (Online Transaction Processing) — обработка транзакций: много коротких запросов, каждый трогает одну или несколько строк, критичен latency, смесь чтения и записи. Создать заказ, обновить корзину, прочитать профиль. OLAP (Online Analytical Processing) — аналитическая обработка: мало запросов, но каждый сканирует миллионы или миллиарды строк, агрегирует и группирует. Конверсия по категориям, средний чек по регионам. Latency менее критичен, но throughput на строку определяет, будет ли отчёт готов за секунды или за часы.

Одна физическая организация данных не может быть оптимальной для обоих паттернов. Построчное хранение платит за аналитику чтением ненужных данных. Поколоночное — за point lookups множественными random reads и задержкой записи.

Паттерн доступа определяет хранилище

Аналитика и OLTP — две точки на карте, но карта шире. Поиск товаров — это не OLTP (не по ключу) и не OLAP (не агрегация). Это отдельный паттерн: по набору слов с опечатками и синонимами найти наиболее релевантные документы и отранжировать их. Оптимальная структура для этого — инвертированный индекс, где для каждого токена хранится список документов, в которых он встречается.

Кэш в Redis — ещё один паттерн. Когда приложение обращается к кэшу, оно делает одну операцию: «вот ключ, дай значение». Без фильтрации, без агрегации, без JOIN — point lookup по ключу. Именно потому что паттерн настолько прост, Redis отбрасывает всё лишнее: нет SQL-парсера (нечего парсить), нет планировщика запросов (путь всегда один — хеш-таблица → bucket → значение), нет механизма версионирования строк. Не «Redis быстрый, поэтому используем для кэша», а наоборот: паттерн доступа кэша — key→value, хеш-таблица оптимальна для этого паттерна, Redis построен вокруг хеш-таблицы — поэтому он быстрый для этой задачи.

Из этого следует принцип: хранилище выбирается не под «тип данных» (логи, пользователи, метрики), а под паттерн доступа — форму запросов, которые к данным обращаются. Одни и те же данные о заказах могут жить в двух местах: PostgreSQL для SELECT * FROM orders WHERE id = 123 (показать заказ покупателю) и ClickHouse для SELECT category, SUM(amount) FROM orders GROUP BY category (отчёт менеджеру). Это не дублирование ради дублирования — это признание того, что один физический layout не может быть оптимальным для разных паттернов доступа.

Категории хранилищ

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

Key-Value Store — хеш-таблица в основе. Одна операция: ключ → значение. Redis, Memcached. Оптимален для кэша, сессий, конфигураций — любых данных, к которым обращаются по единственному ключу. Redis поддерживает нативный TTL (EXPIRE), что решает проблему сессий без dead tuples. Ценой за простоту является отсутствие сложных запросов: нельзя найти «все сессии пользователей из Берлина» без полного перебора.

Реляционная БД — B-tree индексы, построчное хранение, SQL. Строки по ключу или индексу, JOIN между таблицами, ACID-транзакции. PostgreSQL, MySQL. Оптимальна для OLTP: заказы, пользователи, корзины — данные с чёткой структурой, связями между сущностями и требованием консистентности. По умолчанию — первый выбор для основных данных приложения.

Column-oriented OLAP — поколоночное хранение, сжатие, пакетная обработка. Агрегации по колонкам на миллиардах строк. ClickHouse, Apache Druid, BigQuery. Оптимальна для аналитики и отчётов. Данные поступают из OLTP-базы через CDC (Change Data Capture — поток изменений из WAL) или периодический ETL (Extract-Transform-Load — выгрузка, преобразование, загрузка). Этот pipeline — отдельный компонент, который нужно строить и поддерживать.

Search Engineинвертированный индекс с анализаторами текста. При индексации текст разбивается на токены, строятся N-граммы (подстроки длины N, позволяющие находить похожие слова несмотря на опечатки) для fuzzy matching, хранятся частоты для ранжирования по релевантности (BM25 — алгоритм оценки, учитывающий частоту слова в документе и корпусе). Elasticsearch, OpenSearch. Оптимален для полнотекстового поиска продуктового качества: нечёткое совпадение, синонимы, autocomplete, фасетные фильтры. PostgreSQL с tsvector и pg_trgm закрывает базовые сценарии поиска; Elasticsearch оправдан, когда стоимость реализации сопоставимого качества поверх PostgreSQL превышает стоимость эксплуатации отдельного кластера.

Document Store — JSON/BSON-документы с произвольной вложенной структурой. MongoDB, CouchDB. Каждый документ — самодостаточный объект; разные документы в одной коллекции могут иметь разные поля. Подходит для данных с часто меняющейся или непредсказуемой структурой: CMS с вложенными блоками контента, конфигурации IoT-устройств, каталоги с произвольными атрибутами. На практике PostgreSQL с JSONB покрывает большинство таких сценариев: произвольная структура в JSON-поле, индексирование через GIN (обобщённый инвертированный индекс), запросы внутрь документа. MongoDB оправдана в двух специфичных случаях: вся модель данных — деревья произвольной глубины без JOIN между документами (удобнее навигация, проще разработка), или нужно встроенное горизонтальное шардирование с автоматической балансировкой (MongoDB проектировалась для этого, PostgreSQL требует Citus (расширение для горизонтального шардирования) или ручную маршрутизацию). При наличии PostgreSQL добавлять MongoDB ради JSONB-подобных документов почти никогда не стоит.

Graph Database — узлы и рёбра как физически хранимые структуры. Neo4j, Amazon Neptune. Связь между сущностями — не вычисляемый JOIN, а хранимый указатель. Обход графа «от узла A по рёбрам на глубину 3» — это O(количество соседей на каждом шаге), не O(размер таблицы). В PostgreSQL запрос «друзья друзей, которые покупали товар X» — это три вложенных JOIN таблицы связей с самой собой; на миллионах пользователей и десятках миллионов связей каждый JOIN тяжёлый. На практике потребность в графовой БД возникает редко: социальные графы, fraud detection, рекомендации через цепочки связей. Для задачи «с этим товаром покупают» обычно достаточно batch precomputation — materialized view или предвычисленная таблица, обновляемая раз в несколько часов.

Time-Series DB — оптимизация под данные, привязанные ко времени с предсказуемым паттерном: непрерывный append, запросы по временны́м окнам, агрегация с заданным разрешением. TimescaleDB, InfluxDB, Prometheus. Встроенное автоматическое партиционирование по времени, функции агрегации по окнам (средний CPU за последний час с минутным разрешением), автоматический downsampling: вместо 10-секундных точек за прошлый месяц хранить 5-минутные средние. PostgreSQL с BRIN-индексом (Block Range Index — индекс по диапазонам блоков, эффективен для упорядоченных по времени данных) и партиционированием по дням справляется с временны́ми рядами, но downsampling, retention policies и временну́ю агрегацию приходится реализовывать вручную. TimescaleDB — расширение PostgreSQL, которое добавляет именно эти возможности, не требуя отдельного хранилища.

Object Storage — хранилище неструктурированных бинарных данных: изображения, видео, PDF. S3, MinIO. Оптимизировано под паттерн «записал один раз — читаю много, отдаю через HTTP/CDN». Хранить изображения товаров в PostgreSQL (bytea) технически возможно, но это раздувает таблицу, мешает VACUUM и не интегрируется с CDN. На практике в базе хранят только URL: image_url: "https://cdn.example.com/products/123/photo1.jpg".

Паттерн доступаФизическая структураКатегорияПример
Key → ValueХеш-таблицаKey-Value StoreRedis, Memcached
Строки по ключу, JOIN, транзакцииB-tree, строкиРеляционная БДPostgreSQL, MySQL
Агрегации по колонкамПоколоночное хранениеColumn-oriented OLAPClickHouse, BigQuery
Полнотекстовый поискИнвертированный индексSearch EngineElasticsearch
Вложенные документыДокументы (JSON/BSON)Document StoreMongoDB
Обход связей на глубинуУзлы + рёбра (указатели)Graph DatabaseNeo4j
Метрики по временны́м окнамPartition по времени, downsamplingTime-Series DBTimescaleDB, Prometheus
Бинарные файлыBlob + HTTP/CDNObject StorageS3, MinIO

Четыре оси вместо одной дихотомии

Категории выше разделяют хранилища по модели данных и паттерну доступа. Но на практике при выборе конкретного хранилища учитываются и другие оси — и распространённая дихотомия «SQL vs NoSQL» мешает их видеть.

Термин «NoSQL» появился в 2009 году как хештег для meetup’а в Сан-Франциско, где обсуждали альтернативы реляционным базам. Он прижился не потому что точен, а потому что был запоминающимся. Позже его стали расшифровывать как «Not Only SQL» — чуть ближе к реальности, но всё ещё неудачно. Redis, ClickHouse, Elasticsearch, MongoDB, Neo4j — что у них общего? Почти ничего. Они решают разные задачи, имеют разные модели данных, разные гарантии. Объединять их в одну категорию «не-SQL» — примерно как объединять велосипед, самолёт и подводную лодку в категорию «не-автомобиль».

«SQL vs NoSQL» — не бинарный выбор. Хранилища различаются по четырём независимым осям, и позиция на одной не определяет позицию на другой.

Модель данных

Модель данных — главная ось при выборе, потому что она определяет физическую структуру и, как следствие, для какого паттерна доступа хранилище оптимально. Реляционная (таблицы со строками и связями через foreign keys), документная (JSON-деревья), key-value (ключ → blob), колоночная, графовая (узлы + рёбра), инвертированный индекс — каждая оптимальна для своего паттерна и субоптимальна для остальных. Порядок выбора: сначала паттерн доступа определяет модель, затем модель сужает список конкретных хранилищ.

Язык запросов

SQL — декларативный язык, изначально созданный для реляционной модели. Но ClickHouse — колоночная OLAP-система, а поддерживает SQL. MongoDB использует MQL, Elasticsearch — Query DSL на JSON, Redis — команды (GET, SET, HGETALL), Neo4j — Cypher. Наличие или отсутствие SQL не определяет категорию хранилища и не коррелирует с моделью данных.

ACID и BASE

ACID (Atomicity, Consistency, Isolation, Durability) — формальный набор гарантий транзакции: выполняется целиком или не выполняется; база переходит из одного консистентного состояния в другое; параллельные транзакции изолированы; результат фиксации переживает сбой.

BASE (Basically Available, Soft state, Eventual consistency) — не формальная спецификация, а описание философии: система старается быть доступной, допускает временные расхождения между узлами, данные в итоге приходят к согласованному состоянию. По сути BASE — это eventual consistency из контекста CAP, переупакованная в акроним как противовес ACID.

Граница между ними размыта. MongoDB даёт ACID-транзакции на нескольких документах с версии 4.0. Redis с AOF в режиме always даёт durability. PostgreSQL с асинхронной репликацией на реплике даёт eventual consistency. «ACID = SQL, BASE = NoSQL» — упрощение, которое ломается при ближайшем рассмотрении. На практике гарантии — это фильтр, сужающий выбор: если бизнес требует многозаписных транзакций (списать с одного счёта и зачислить на другой в одной операции), хранилище без ACID не подходит, какая бы у него ни была модель данных.

Schema-on-write и schema-on-read

PostgreSQL требует CREATE TABLE с определёнными колонками и типами до первой записи. Структура проверяется при каждом INSERT: попытка записать строку с неверным типом — ошибка. Это schema-on-write. Ошибки обнаруживаются рано — на уровне БД, но эволюция схемы требует миграций: ALTER TABLE под нагрузкой.

MongoDB и Elasticsearch позволяют записать документ произвольной структуры. Разные документы в одной коллекции могут иметь разные поля. Структура не проверяется при записи — приложение интерпретирует её при чтении. Это schema-on-read. Эволюция быстрая: новое поле — просто начни его записывать. Но ошибки обнаруживаются поздно — когда код читает поле и получает nil вместо значения. Схема при таком подходе не исчезает — она переезжает из базы в код приложения.

Когда добавлять новое хранилище

Каждое новое хранилище — это деплой, мониторинг, экспертиза в команде, точка отказа и pipeline для перемещения данных. Решение добавить его определяется тремя критериями.

Первый — capabilities: PostgreSQL не закрывает задачу на нужном уровне. Полнотекстовый поиск продуктового качества — синонимы, fuzzy matching, ранжирование, autocomplete, фасеты — в совокупности реализовать поверх PostgreSQL обходится дороже, чем эксплуатировать Elasticsearch. Это вопрос не масштаба, а стоимости реализации.

Второй — нагрузка: PostgreSQL может решить задачу, но текущий или прогнозируемый объём данных делает это непрактичным. 750 GB аналитического скана вместо 12 GB в поколоночном хранилище — разница в десятки раз по времени. Если аналитические отчёты нужны ежедневно и их объём растёт — ClickHouse оправдан. Если в год накапливаются десятки гигабайт, а не терабайты — PostgreSQL с партиционированием справляется.

Третий — PostgreSQL справляется: нагрузка выдерживается, функциональность достаточна. Лента уведомлений — SELECT ... WHERE user_id = 123 ORDER BY created_at DESC LIMIT 50 — один index scan, микросекунды. Аудит-лог финансовых операций — append-only INSERT с партиционированием по месяцам, поиск по order_id, ACID-гарантии для регулятора. Корзина покупок — OLTP-операции с транзакциями. Нет причины добавлять новое хранилище.

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

Sources

  • Kleppmann, 2017, Designing Data-Intensive Applications, Chapters 2–3 — модели данных, storage engines, OLTP vs OLAP
  • Stonebraker, Çetintemel, 2005, “One Size Fits All”: An Idea Whose Time Has Come and Gone — аргумент за специализированные хранилища

Message Queues: асинхронная коммуникация между сервисами | API Design: проектирование границы между системами