Базы данных NoSQL: таксономия, CAP, BASE и когда реляционка не подходит
0%

NoSQL: таксономия, CAP, BASE и когда реляционка не подходит

NoSQL: таксономия, CAP, BASE и когда реляционка не подходит

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

Эта статья — про то, где и почему эту идею начали ломать, что получилось взамен и как теперь выбирать. Главный тезис сразу: NoSQL — это не «база данных получше», это отказ от нескольких конкретных гарантий в обмен на несколько конкретных выгод. Если вы не можете назвать, от какой гарантии отказались и что за это получили, — выбор был не сделан, а угадан.

Откуда это взялось: короткая честная история

Термин «NoSQL» был придуман дважды. В 1998 году Карло Строцци назвал так свою реляционную БД без SQL-интерфейса — к нынешнему движению отношения не имеет. В 2009-м Йохан Оскарссон искал хэштег для митапа в Сан-Франциско про «open source distributed non-relational databases» и выбрал #nosql. Хэштег прижился, смысл — нет: под ним объединились системы, у которых общего было ровно одно свойство — «не то, что мы делали раньше».

За хэштегом стояли две конкретные инженерные работы, которые стоит прочитать в оригинале — они до сих пор объясняют 80% дизайна современных NoSQL:

  • Bigtable (Google, 2006) — разреженная, распределённая, персистентная многомерная сортированная map-структура. Отсюда растут HBase, Cassandra (частично), все wide-column (research.google/pubs/pub27898).
  • Dynamo (Amazon, 2007) — key-value с консистентным хэшированием, кворумами, векторными часами и полным отсутствием мастера. Отсюда растут Riak, Voldemort, ядро Cassandra и DynamoDB (allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf).

Обе работы решали не «проблему SQL». Они решали проблему горизонтального масштабирования при отказах узлов как норме жизни. Google и Amazon имели тысячи машин, где отказ — рутинное событие, а не инцидент. Реляционные СУБД того времени такой режим не поддерживали в принципе: они масштабировались вертикально и предполагали, что узел либо работает, либо его чинят.

Важный нюанс, о котором забывают: к 2026 году контекст изменился. PostgreSQL умеет JSONB с GIN-индексами, логическую репликацию и партиционирование; появились CockroachDB и YugabyteDB, дающие SQL и ACID поверх распределённого консенсуса (см. NewSQL и распределённые БД); а один сервер сегодня — это 128 ядер и 4 ТБ RAM. Многие аргументы 2010 года просто протухли. Поэтому современный выбор NoSQL требует более сильного обоснования, чем десять лет назад.

Чем NoSQL на самом деле отличается — и чем нет

Разберём популярные объяснения и отделим правду от мифологии.

Расхожее утверждение Реальность
«NoSQL — это schemaless» Схема есть всегда, вопрос лишь в том, где она живёт. В document-БД схема переезжает из DDL в код приложения — это schema-on-read вместо schema-on-write. Схема из кода не проверяется движком, и старые документы с полем price: "12.50" живут рядом с новыми price: 12.5 до первой продакшн-аварии
«NoSQL быстрее» Быстрее на тех запросах, под которые спроектирована модель, и катастрофически медленнее на остальных. Cassandra отдаёт строку по partition key за микросекунды и не умеет WHERE age > 30 без full scan всего кластера
«NoSQL масштабируется, SQL нет» Реляционные БД шардируются (см. репликацию и шардирование), просто вручную. NoSQL встраивает шардирование в движок — это удобство, а не магия. Физика та же
«NoSQL не поддерживает транзакции» Устарело. MongoDB имеет мультидокументные транзакции с 4.0, DynamoDB — TransactWriteItems, Redis — MULTI/EXEC и Lua-скрипты. Но они дороже, ограниченнее и часто аннулируют выигрыш, ради которого систему брали
«NoSQL — про большие данные» Про много запросов и много узлов, что не то же самое, что много байт. Терабайтная база прекрасно живёт на одном PostgreSQL

Настоящее отличие — глубже, и сформулировали его Прамодкумар Садаладж и Мартин Фаулер в книге NoSQL Distilled: большинство NoSQL-систем агрегатно-ориентированы.

Ключевая идея: агрегат

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

Агрегатная модель поступает наоборот: она заранее склеивает связанные данные в один объект под одним ключом. Этот объект и есть единица атомарности, единица репликации и единица шардирования одновременно.

Одна сущность в реляционной и агрегатной модели

Из этой одной картинки следует почти вся практика NoSQL:

  1. Атомарность бесплатна внутри агрегата и дорога между агрегатами. Обновить заказ вместе со всеми позициями — одна операция без координации. Списать деньги у одного пользователя и начислить другому — распределённая транзакция со всеми её проблемами.
  2. Шардирование становится тривиальным. Агрегат целиком лежит на одном узле, значит, любой запрос по ключу — это один сетевой хоп к одному узлу. Никаких распределённых JOIN.
  3. Модель проектируется от запросов, а не от предметной области. В реляционке вы нормализуете, а потом строите индексы под нагрузку. В агрегатной — вы сначала выписываете список запросов приложения, а потом рисуете структуры так, чтобы каждый запрос был чтением по ключу. Это принципиально другой порядок мышления, и он ломается, когда список запросов меняется.
  4. Незапланированный запрос стоит очень дорого. «Покажи все заказы, содержащие товар X» без заранее построенного индекса — это полный обход всех агрегатов кластера.

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

Отдельная категория — графовые БД, которые к агрегатам отношения не имеют вовсе: они наоборот делают связи максимально мелкозернистыми и дешёвыми. Их попадание в «NoSQL» — историческая случайность (подробности в статье про графовые и key-value БД).

Таксономия: восемь классов, а не четыре

Классическое деление «key-value / document / column-family / graph» устарело: с 2010 года выделились поисковые, временные, аналитические и векторные хранилища, у которых своя физика.

Каждому классу в треке посвящена отдельная статья: MongoDB, Redis, Cassandra и wide-column, ClickHouse, временные ряды и поиск, векторные базы, объектные хранилища, графовые и key-value. Здесь наша задача — научиться выбирать между ними.

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

Правый верхний угол — не «лучше». Universality стоит денег: чем гибче запросы, тем труднее движку гарантировать предсказуемую задержку.

CAP: что теорема на самом деле говорит

Это самая цитируемая и самая неправильно понятая теорема в индустрии. Разберём строго.

Эрик Брюер сформулировал гипотезу в 2000 году на PODC, Сет Гилберт и Нэнси Линч доказали её формально в 2002-м (Brewer’s conjecture and the feasibility of consistent, available, partition-tolerant web services). Точная формулировка:

  • Consistency здесь означает линеаризуемость (atomic consistency): существует единый порядок операций, и каждое чтение видит результат последней завершившейся записи. Это НЕ буква «C» из ACID, которая про соблюдение инвариантов схемы. Совпадение букв — историческая неудача.
  • Availability означает, что каждый запрос к неотказавшему узлу завершается ответом. Не «система в основном работает», а «ни один живой узел никогда не отвечает ошибкой и не висит бесконечно».
  • Partition tolerance означает, что система продолжает работать при произвольной потере сообщений между группами узлов.

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

Отсюда вытекает главное следствие, которое переворачивает популярное изложение: выбора «два из трёх» не существует. Партиции — это свойство физического мира, а не опция в конфиге. Кабель рвут, коммутатор перезагружается, GC-пауза на 30 секунд делает узел неотличимым от упавшего. Вы не можете «выбрать CA». Вы можете лишь заранее решить, что система сделает, когда партиция уже случилась: ответить возможно устаревшими данными (AP) или отказать в обслуживании (CP).

Мартин Клеппман написал разбор, почему формулировку стоит перестать использовать вовсе: Please stop calling databases CP or AP. Его аргументы весомы: определения CAP настолько узкие, что почти любая реальная система не является ни CP, ни AP в строгом смысле. Cassandra с QUORUM не линеаризуема (нет compare-and-set без Paxos), а MongoDB даже с w: majority не «CP» полностью — до версии 3.4 она теряла подтверждённые записи при некоторых сценариях, что зафиксировали тесты Jepsen (jepsen.io/analyses).

PACELC: формулировка, которой стоит пользоваться

Дэниел Абади в 2012 году предложил расширение, устраняющее главный дефект CAP — то, что CAP описывает только редкое состояние партиции и ничего не говорит про 99.99% времени, когда сеть исправна (Consistency Tradeoffs in Modern Distributed Database System Design).

PACELC читается так: если случилась партиция (P), система выбирает между A и C; иначе (E) — между задержкой (L) и консистентностью (C).

Карта PACELC: четыре квадранта с примерами баз

Второй выбор важнее первого, потому что он работает всегда. Если вы хотите, чтобы чтение видело последнюю запись, нужно либо читать с лидера, либо собирать кворум — и то и другое стоит сетевого обхода. В мультирегиональной инсталляции обход между Франкфуртом и Вирджинией — это ~90 мс физики, которую не обойти. Отсюда практический вывод: географически распределённая строго согласованная запись стоит около 100 мс, и это не проблема реализации. Spanner платит эту цену честно, используя TrueTime и ожидание на границе неопределённости часов (Spanner: Google’s Globally-Distributed Database).

Консистентность — это спектр, а не переключатель

«Строгая» и «eventual» — крайние точки. Между ними лежат модели, которых обычно достаточно и которые стоят намного дешевле.

Модель Гарантия Цена Где встречается
Linearizable Все операции выглядят как выполненные мгновенно в едином порядке; чтение видит последнюю запись Кворум или лидер на каждой операции; при партиции меньшинство недоступно etcd, ZooKeeper, Spanner, CockroachDB, Cassandra LWT
Sequential Единый порядок есть, но он может отставать от реального времени Дешевле линеаризуемости на чтениях Многие реплицированные state machine
Causal Если A причинно предшествует B, все видят их в этом порядке; параллельные события — в любом Нужны векторные часы или зависимости; доступна при партиции MongoDB causal consistency sessions, COPS, AntidoteDB
Read-your-writes Клиент всегда видит собственные записи Липкая сессия или чтение с лидера для своего ключа MongoDB readConcern, DynamoDB ConsistentRead
Monotonic reads Клиент не видит «откат назад во времени» Закрепление клиента за репликой Практически любая система с sticky routing
Eventual Если записи прекратятся, реплики когда-нибудь сойдутся Почти бесплатно Cassandra ONE, DynamoDB по умолчанию, DNS

Практическое правило: пользователи почти никогда не требуют линеаризуемости; они требуют read-your-writes и monotonic reads. Человек, оставивший комментарий и не увидевший его после перезагрузки страницы, считает продукт сломанным. Тот же человек не заметит, что счётчик лайков в соседнем регионе отстаёт на две секунды. Целиться в правильную модель дешевле, чем целиться в максимальную.

Формальная иерархия и определения — в базовой работе Виктора Виотти и Марко Вукольича Consistency in Non-Transactional Distributed Storage Systems, где перечислено более пятидесяти моделей.

BASE против ACID

Аббревиатуру BASE предложил тот же Брюер как намеренно кислотно-щелочной каламбур: Basically Available, Soft state, Eventually consistent.

Свойство ACID BASE
Доступность Ниже: при сомнении система откатывает и отказывает Выше: система отвечает всегда, разбирается потом
Состояние Всегда валидно относительно инвариантов Может быть временно противоречивым, «мягким»
Согласованность На границе каждой транзакции Отложенная, гарантируется только при затишье записей
Разрешение конфликтов Блокировки, MVCC, откат — конфликта не бывает видно Конфликты нормальны, их разрешают LWW, версиями или CRDT
Где живёт логика целостности В движке БД: FOREIGN KEY, CHECK, UNIQUE В коде приложения и в компенсирующих операциях
Стоимость ошибки разработчика Транзакция откатится Данные тихо разъедутся; узнаете через месяц из жалобы

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

Практическая замена транзакциям в BASE-мире — саги и компенсирующие операции: последовательность локальных транзакций, каждая с заранее написанным «откатом назад». Саги не дают изоляции: промежуточное состояние видно всем, и его нужно проектировать так, чтобы оно было бизнес-осмысленным (статус pending, а не «денег нет ни там, ни там»).

Кворумы: как AP-системы получают полезные гарантии

Механика Dynamo-подобных систем: данные хранятся на N узлах, запись подтверждается после W ответов, чтение опрашивает R узлов. Если R + W > N, множества читающих и писавших узлов обязаны пересечься, и чтение увидит хотя бы одну свежую копию.

Что важно понимать про кворумы честно:

  • Кворум не даёт линеаризуемости. Классический контрпример из Designing Data-Intensive Applications (глава 9): два клиента пишут конкурентно, оба получают подтверждение от кворума, но их операции не упорядочены между собой. Для настоящего compare-and-set нужен консенсус — в Cassandra это LWT (lightweight transactions) на основе Paxos, и он в разы дороже обычной записи.
  • Sloppy quorum ломает даже пересечение. Когда «правильные» узлы недоступны, координатор пишет на любые живые (hinted handoff). Формально W ответов есть, но пересечение с будущим чтением не гарантировано.
  • Настройка R и W — это ручка задержки. W=1, R=1 — максимально быстро, никаких гарантий. W=N, R=1 — быстрое чтение, запись падает при любом отказе. W=quorum, R=quorum — сбалансированный дефолт, который и стоит брать, пока не доказано обратное.

Практическая конфигурация Cassandra:

-- Топология: 3 реплики в каждом из двух ЦОД
CREATE KEYSPACE shop
  WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'dc_eu': 3,
    'dc_us': 3
  };

-- Модель проектируется ОТ ЗАПРОСА, а не от сущности.
-- Запрос: "последние заказы пользователя, от новых к старым".
CREATE TABLE orders_by_user (
    user_id    uuid,
    created_at timeuuid,
    order_id   uuid,
    status     text,
    total      decimal,
    PRIMARY KEY ((user_id), created_at)   -- partition key + clustering key
) WITH CLUSTERING ORDER BY (created_at DESC);

-- Это читается за один сетевой хоп, диапазоном по одной партиции:
SELECT order_id, status, total
FROM orders_by_user
WHERE user_id = 7f3c... AND created_at > maxTimeuuid('2026-07-01')
LIMIT 20;

-- А ЭТОТ запрос в той же таблице невозможен без обхода всего кластера:
--   SELECT * FROM orders_by_user WHERE status = 'pending';
-- Нужна ВТОРАЯ таблица с другим partition key и двойная запись.

Вот этот последний комментарий — суть компромисса. В PostgreSQL вы бы добавили CREATE INDEX ON orders (status) и пошли дальше.

# Тот же выбор уровня консистентности в коде драйвера Cassandra.
from cassandra.cluster import Cluster
from cassandra import ConsistencyLevel
from cassandra.query import SimpleStatement

session = Cluster(["10.0.1.11", "10.0.1.12"]).connect("shop")

# Баланс на счёте — читаем и пишем кворумом: R+W > N
balance_write = SimpleStatement(
    "UPDATE accounts SET amount = ? WHERE user_id = ?",
    consistency_level=ConsistencyLevel.LOCAL_QUORUM,   # 2 из 3 в своём ЦОД
)

# Лента активности — устаревание на секунду никого не убьёт,
# зато задержка падает примерно вдвое и переживает отказ двух реплик.
feed_read = SimpleStatement(
    "SELECT * FROM feed_by_user WHERE user_id = ? LIMIT 50",
    consistency_level=ConsistencyLevel.ONE,
)

# Резервирование уникального ника требует НАСТОЯЩЕЙ атомарности —
# только здесь платим за Paxos (примерно 4 сетевых обхода вместо одного).
session.execute(
    "INSERT INTO usernames (name, user_id) VALUES (%s, %s) IF NOT EXISTS",
    ("alice", user_id),
)

Конфликты: три способа и один антипаттерн

Если система принимает записи на нескольких узлах, конкурентные изменения одного ключа неизбежны. Варианты:

  1. Last Write Wins (LWW). Побеждает запись с большей меткой времени. Просто, дёшево и молча теряет данные: две конкурентные записи — одна исчезает без следа. Хуже того, метки времени берутся с разных машин, а часы расходятся. Aphyr в разборе Cassandra показал потерю подтверждённых записей именно из-за расхождения часов. LWW допустим только там, где перезапись семантически корректна (кэш, последнее известное состояние датчика).
  2. Версионирование и разрешение на клиенте. Dynamo хранит все конкурентные версии (siblings) с векторными часами и отдаёт их приложению. Классический пример из статьи — корзина покупок: конфликт разрешается объединением, и «удалённый товар может вернуться», зато купленный не потеряется. Amazon сознательно выбрал вернуть товар, а не потерять заказ.
  3. CRDT — структуры, сходящиеся автоматически. G-Counter, PN-Counter, OR-Set, LWW-Register: операции коммутативны, ассоциативны и идемпотентны, поэтому порядок доставки не важен. Реализованы в Riak, Redis Enterprise (Active-Active), Automerge и Yjs. Формальная основа — работа Шапиро и соавторов A comprehensive study of Convergent and Commutative Replicated Data Types.

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

Жизненный цикл записи в AP-системе

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

Сводная таблица. Числа — порядки величин для типовой продакшн-инсталляции, а не рекорды бенчмарков.

Класс Модель данных Гарантии Масштабирование Задержка чтения (p99) Эксплуатация Когда НЕ брать
Реляционная (PostgreSQL) Отношения, произвольные JOIN ACID, снапшотная изоляция Вертикально + read-реплики; шардирование вручную 0.2–2 мс Зрелая: инструменты, специалисты, книги Когда нужен write-throughput выше возможностей одного узла и данные естественно шардируются
Key-value in-memory (Redis) Ключ и структура: строка, hash, set, zset Атомарность отдельной команды, MULTI/EXEC Кластер по 16384 слотам 0.1–0.5 мс Простая, но нужен план на потерю данных и eviction Как основное хранилище истины без реплики на диске; при данных больше RAM
Key-value распределённое (DynamoDB) Партиционный и сортировочный ключ Настраиваемо; транзакции до 100 элементов Автоматическое, прозрачное 3–10 мс Managed: почти нулевая; on-prem — тяжёлая Когда нужны произвольные фильтры и агрегации; при непредсказуемых скачках стоимости
Document (MongoDB) JSON-документы, вторичные индексы ACID внутри документа; мультидокументные транзакции с 4.0 Шардирование по ключу 1–5 мс Средняя: нужен контроль за shard key и балансировкой Когда данные сильно связаны и вы всё равно пишете $lookup вместо JOIN
Wide-column (Cassandra, ScyllaDB) Партиции с сортированными строками Настраиваемый кворум; LWT дорогие Линейное, без мастера, до сотен узлов 1–10 мс на партиционный ключ Тяжёлая: compaction, repair, tombstones, hot partitions При запросах, не выводимых из partition key; при частых удалениях; при малых объёмах
Columnar OLAP (ClickHouse) Колонки, сжатие, MergeTree Обычно eventual, ограниченный UPDATE Шардирование и репликация 50 мс – 10 с на скан Средняя, но своя философия слияний Для точечных UPDATE и OLTP-нагрузки
Search (Elasticsearch) Инвертированный индекс, документы Near-real-time, refresh раз в ~1 с Шардирование по индексам 5–50 мс Тяжёлая: heap, shard sizing, мапинги Как единственный источник истины: реиндексация неизбежна
Time-series (TimescaleDB, VictoriaMetrics) Метрика, теги, время Обычно eventual; TimescaleDB — ACID Партиции по времени, ретеншн 1–100 мс Средняя; главная забота — кардинальность Для данных, где время не является главной осью
Graph (Neo4j) Узлы, рёбра, свойства ACID в Neo4j Плохо шардируется: связи мешают разрезанию 1–50 мс на траверс Средняя; нишевая экспертиза Для нагрузки, где обходы редки: 2 JOIN не оправдывают отдельную БД
Vector (Qdrant, pgvector) Эмбеддинги, ANN-индекс Приближённый ответ по построению Шардирование по коллекциям 5–50 мс Средняя; главное — recall против latency Когда векторов меньше миллиона: pgvector в существующем Postgres дешевле

Стоимость: то, что почти никогда не считают

Ценник хранилища состоит из четырёх частей, и лицензия — самая маленькая.

Статья затрат Что это на практике
Железо и облако Cassandra-кластер минимально осмысленного размера — 6 узлов (3 на ЦОД). При 16 vCPU / 64 ГБ это порядка 3–5 тыс. долларов в месяц против одного PostgreSQL за 500–800
Managed-надбавка DynamoDB on-demand считает по запросам: 100 млн записей в месяц по 1 КБ — около 125 долларов; те же 100 млн при provisioned-режиме и ровной нагрузке — в разы дешевле. Скачок трафика или горячая партиция ломают расчёт
Экспертиза Инженера, умеющего чинить PostgreSQL, найти легко. Инженера, понимающего compaction strategy и tombstone GC в Cassandra, — сложно и дорого. Это самая недооценённая статья
Сложность интеграций Каждое дополнительное хранилище требует бэкапов, мониторинга, алертов, схемы миграций, плана восстановления и синхронизации с остальными. Стоимость растёт нелинейно от числа систем

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

Замеры: как проверять, а не верить

Единственный отраслевой стандарт — YCSB (Yahoo! Cloud Serving Benchmark), с фиксированными профилями нагрузки (github.com/brianfrankcooper/YCSB): workload A — 50/50 чтение/запись, B — 95/5, C — только чтение, D — чтение свежих записей, E — короткие сканы, F — read-modify-write.

# Загрузка 10 млн записей и прогон workload B на Cassandra
./bin/ycsb load cassandra-cql -P workloads/workloadb \
  -p hosts=10.0.1.11,10.0.1.12,10.0.1.13 \
  -p recordcount=10000000 -threads 64 -s

./bin/ycsb run cassandra-cql -P workloads/workloadb \
  -p hosts=10.0.1.11,10.0.1.12,10.0.1.13 \
  -p recordcount=10000000 -p operationcount=20000000 \
  -p cassandra.readconsistencylevel=LOCAL_QUORUM \
  -p cassandra.writeconsistencylevel=LOCAL_QUORUM \
  -threads 128 -target 40000 -s

Правила, без которых замер бесполезен:

  • Смотрите p99 и p999, а не среднее. Среднее прячет compaction-паузы и GC. Разница между p50 = 2 мс и p999 = 900 мс — это ваш SLA.
  • Датасет должен быть заметно больше RAM. Иначе вы измеряете скорость page cache, а не БД. Множитель 5–10 к объёму памяти — разумный минимум.
  • Прогоняйте с отказом узла. Убейте одну ноду в середине прогона и посмотрите на график задержек. Именно это поведение вы купите.
  • Не верьте чужим бенчмаркам вендоров. Разбор методологической ловушки «coordinated omission» у Гила Тене обязателен к прочтению для всех, кто измеряет задержки (How NOT to Measure Latency).

Когда реляционка действительно не подходит

Честный список. Признак должен быть измеренным, а не предполагаемым.

  1. Write throughput выше того, что даёт один узел, при естественно шардируемых данных. Телеметрия устройств, лента событий, метрики. Признак: пишете больше ~50–100 тыс. строк в секунду устойчиво, данные разделяются по ключу без кросс-шардовых транзакций.
  2. Данные по природе — иерархический документ с переменной структурой. Товарный каталог, где у каждой категории свои атрибуты; конфигурации; ответы внешних API. EAV-схема в реляционке — известный антипаттерн; JSONB в PostgreSQL закрывает большинство таких случаев, но при десятках миллионов документов с активным поиском по вложенным полям документная БД честнее.
  3. Требование доступности на запись при разрыве между регионами. Если бизнес говорит «принимай заказ даже когда Европа не видит Америку», это прямое требование AP, и реляционная БД с синхронной репликацией его не выполнит по теореме.
  4. Обходы графа глубины 3+ по данным с высокой связностью. Рекомендации, антифрод, права доступа в иерархии. Рекурсивный CTE в PostgreSQL работает, пока граф небольшой; на десятках миллионов рёбер и глубине 5 разница с Neo4j — порядки.
  5. Полнотекстовый поиск с релевантностью, синонимами, фасетами и морфологией. tsvector в PostgreSQL — прекрасная вещь до момента, когда продукт запрашивает ранжирование, подсветку и агрегации по фасетам. Тогда нужен Elasticsearch.
  6. Аналитические сканы по сотням миллионов строк. Row-store физически читает всю строку ради двух колонок. ClickHouse даёт выигрыш в 10–100 раз на тех же данных за счёт колоночного хранения и сжатия.
  7. Кэш и эфемерное состояние. Сессии, rate limiting, очереди задач, leaderboard. Держать это в основной БД — значит тратить самый дорогой ресурс на самые дешёвые данные.
  8. ANN-поиск по эмбеддингам при сотнях миллионов векторов. pgvector с HNSW закрывает до нескольких десятков миллионов; дальше специализированные системы.

И обратный список: когда кажется, что не подходит, а на самом деле подходит

  • «У нас будет очень много данных». Сколько именно? PostgreSQL спокойно живёт на 5–20 ТБ с грамотным партиционированием. Ваш прогноз на три года вперёд, скорее всего, завышен на порядок.
  • «Схема будет часто меняться». ALTER TABLE ADD COLUMN в PostgreSQL с версии 11 не переписывает таблицу и выполняется мгновенно. А в schemaless-БД схема всё равно меняется — только миграцию вы пишете сами и в рантайме.
  • «Нам нужна гибкая структура для части полей». Колонка JSONB с GIN-индексом внутри реляционной таблицы. Гибкость там, где нужна; строгость и FK — везде остальном.
  • «Нужна горизонтальная масштабируемость». Сначала измерьте текущий потолок. Затем: read-реплики, connection pooling, партиционирование, разгрузка кэшем. Только потом — смена парадигмы.
  • «NoSQL быстрее». На вашем профиле нагрузки — проверьте. Часто медленный ответ вызван отсутствием индекса, а не выбором СУБД. Как это выясняется — в статье про индексы и планы выполнения.

Дерево решений

Полиглотная персистентность и её главный риск

Вывод «под каждую задачу своё хранилище» звучит разумно и приводит к архитектуре из пяти баз. Главная опасность здесь — двойная запись (dual write): приложение пишет в PostgreSQL и в Elasticsearch, вторая запись падает, данные расходятся навсегда.

Правильные решения:

  • Transactional outbox. В той же ACID-транзакции, что и бизнес-изменение, пишем событие в таблицу outbox. Отдельный процесс читает её и публикует в брокер. Атомарность обеспечивает БД, доставку — ретраи. Разбор у Криса Ричардсона: microservices.io/patterns/data/transactional-outbox.html.
  • Change Data Capture. Читаем WAL/binlog первичной БД и транслируем изменения в остальные системы. Debezium — стандарт де-факто (debezium.io). Порядок гарантирован журналом, повторы идемпотентны.
-- Outbox в PostgreSQL: одна транзакция, две записи, ноль потерь.
BEGIN;

UPDATE orders
   SET status = 'paid', paid_at = now()
 WHERE id = '8f3a...' AND status = 'pending';

INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (gen_random_uuid(), 'order', '8f3a...', 'OrderPaid',
        jsonb_build_object('order_id', '8f3a...', 'amount', 4990.00));

COMMIT;
-- Дальше Debezium читает WAL и доставляет OrderPaid в Kafka,
-- откуда его читают Elasticsearch-индексер, биллинг и аналитика.
-- Ни одного шанса записать в одно место и не записать в другое.

Подробнее про потоковую интеграцию хранилищ — в треке data engineering.

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

Типичные ошибки в проде

  1. Выбор по хайпу и по бенчмарку вендора. Классика: MongoDB для сильно связанных финансовых данных, потому что «стартапы так делают». Через год — самописные JOIN в коде приложения и невозможность гарантировать баланс.
  2. Hot partition. Partition key date в Cassandra: весь трафик суток идёт на три узла из шестидесяти, остальные простаивают. Ключ должен распределять нагрузку, а не только группировать данные.
  3. Неограниченно растущая партиция. «Все сообщения чата в одной партиции» — на активном чате партиция пухнет до гигабайтов, чтение начинает таймаутиться. Нужен составной ключ с bucket по времени: ((chat_id, year_month), created_at).
  4. Tombstone-штормы. В Cassandra удаление — это запись маркера. Использование таблицы как очереди (вставили, прочитали, удалили) приводит к чтению десятков тысяч tombstone на каждый запрос и падению координатора. Cassandra не очередь.
  5. LWW на данных, где терять нельзя. Счётчик баланса с last write wins и рассинхроном часов на 200 мс — потерянные списания. Для счётчиков нужны либо CRDT-счётчики, либо транзакции.
  6. Кэш без стратегии инвалидации. Redis перед PostgreSQL без TTL и без событий инвалидации: данные протухают, а найти это невозможно, потому что ошибка не вызывает исключений. Паттерны разбираются в статье про Redis.
  7. Игнорирование восстановления. Кластер работает, бэкапы «настроены». Восстановление 4 ТБ Cassandra из снапшотов с последующим nodetool repair — это не «пара часов». Прогоняйте восстановление на учениях, иначе вы измеряете надежду.
  8. Отсутствие валидации на границе в schemaless-БД. Через два года в коллекции лежат документы пяти разных поколений схемы. Лечится JSON Schema-валидатором на уровне БД ($jsonSchema в MongoDB) с первого дня, а не после инцидента.
  9. Кворум, который не кворум. RF=2 вместо 3: кворум равен 2, значит отказ одного узла делает запись невозможной. Фактор репликации меньше 3 в AP-системе — почти всегда ошибка конфигурации.
  10. Мультирегион без понимания физики. Ожидание, что синхронная запись между континентами будет быстрой. 90 мс RTT — это скорость света в стекле, а не проблема настройки.

Мини-итог

  • NoSQL — это не отрицание SQL, а отказ от конкретных гарантий ради масштабирования, задержки или гибкости модели. Формулируйте отказ явно.
  • Центральная идея большинства NoSQL — агрегат: единица атомарности, репликации и шардирования одновременно. Она делает доступ по ключу почти бесплатным, а незапланированный запрос — почти невозможным.
  • CAP не про «два из трёх». Партиции нельзя выбрать — они случаются. Выбор состоит в поведении во время партиции. Пользуйтесь PACELC: он описывает и нормальный режим, где вы каждый день платите за консистентность задержкой.
  • Консистентность — спектр. Целиться стоит в read-your-writes и causal, а не в линеаризуемость: обычно этого достаточно, и стоит это в разы дешевле.
  • BASE перекладывает целостность на ваш код. Это работает ровно настолько, насколько дисциплинирована команда через два года после запуска.
  • Стоимость второго хранилища — не его цена, а цена согласования двух систем. Dual write запрещён: только outbox или CDC.
  • Дефолт остаётся прежним: PostgreSQL, пока не доказано измерением, что он не подходит. Доказательство — цифры с продакшена, а не архитектурная интуиция.

Источники

Что дальше

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

MongoDB и документные БД: модель, агрегации, транзакции, подводные камни

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

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

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

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