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:
- Атомарность бесплатна внутри агрегата и дорога между агрегатами. Обновить заказ вместе со всеми позициями — одна операция без координации. Списать деньги у одного пользователя и начислить другому — распределённая транзакция со всеми её проблемами.
- Шардирование становится тривиальным. Агрегат целиком лежит на одном узле, значит, любой запрос по ключу — это один сетевой хоп к одному узлу. Никаких распределённых JOIN.
- Модель проектируется от запросов, а не от предметной области. В реляционке вы нормализуете, а потом строите индексы под нагрузку. В агрегатной — вы сначала выписываете список запросов приложения, а потом рисуете структуры так, чтобы каждый запрос был чтением по ключу. Это принципиально другой порядок мышления, и он ломается, когда список запросов меняется.
- Незапланированный запрос стоит очень дорого. «Покажи все заказы, содержащие товар 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).
Второй выбор важнее первого, потому что он работает всегда. Если вы хотите, чтобы чтение видело последнюю запись, нужно либо читать с лидера, либо собирать кворум — и то и другое стоит сетевого обхода. В мультирегиональной инсталляции обход между Франкфуртом и Вирджинией — это ~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, множества читающих и писавших узлов обязаны пересечься, и чтение увидит хотя бы одну свежую копию.
перегрузка, GC-пауза или потеря пакета C->>Co: GET cart:42 Co->>R2: read Co->>R3: read R2-->>Co: v=7, timestamp 1200 R3-->>Co: v=6, timestamp 1100 Co-->>C: v=7 (побеждает свежая версия) Co->>R3: read repair: записать v=7 Note over Co,R3: расхождение чинится в фоне;
дополнительно работают hinted handoff
и anti-entropy по деревьям Меркла
Что важно понимать про кворумы честно:
- Кворум не даёт линеаризуемости. Классический контрпример из 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),
)
Конфликты: три способа и один антипаттерн
Если система принимает записи на нескольких узлах, конкурентные изменения одного ключа неизбежны. Варианты:
- Last Write Wins (LWW). Побеждает запись с большей меткой времени. Просто, дёшево и молча теряет данные: две конкурентные записи — одна исчезает без следа. Хуже того, метки времени берутся с разных машин, а часы расходятся. Aphyr в разборе Cassandra показал потерю подтверждённых записей именно из-за расхождения часов. LWW допустим только там, где перезапись семантически корректна (кэш, последнее известное состояние датчика).
- Версионирование и разрешение на клиенте. Dynamo хранит все конкурентные версии (siblings) с векторными часами и отдаёт их приложению. Классический пример из статьи — корзина покупок: конфликт разрешается объединением, и «удалённый товар может вернуться», зато купленный не потеряется. Amazon сознательно выбрал вернуть товар, а не потерять заказ.
- 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).
Когда реляционка действительно не подходит
Честный список. Признак должен быть измеренным, а не предполагаемым.
- Write throughput выше того, что даёт один узел, при естественно шардируемых данных. Телеметрия устройств, лента событий, метрики. Признак: пишете больше ~50–100 тыс. строк в секунду устойчиво, данные разделяются по ключу без кросс-шардовых транзакций.
- Данные по природе — иерархический документ с переменной структурой. Товарный каталог, где у каждой категории свои атрибуты; конфигурации; ответы внешних API. EAV-схема в реляционке — известный антипаттерн; JSONB в PostgreSQL закрывает большинство таких случаев, но при десятках миллионов документов с активным поиском по вложенным полям документная БД честнее.
- Требование доступности на запись при разрыве между регионами. Если бизнес говорит «принимай заказ даже когда Европа не видит Америку», это прямое требование AP, и реляционная БД с синхронной репликацией его не выполнит по теореме.
- Обходы графа глубины 3+ по данным с высокой связностью. Рекомендации, антифрод, права доступа в иерархии. Рекурсивный CTE в PostgreSQL работает, пока граф небольшой; на десятках миллионов рёбер и глубине 5 разница с Neo4j — порядки.
- Полнотекстовый поиск с релевантностью, синонимами, фасетами и морфологией.
tsvectorв PostgreSQL — прекрасная вещь до момента, когда продукт запрашивает ранжирование, подсветку и агрегации по фасетам. Тогда нужен Elasticsearch. - Аналитические сканы по сотням миллионов строк. Row-store физически читает всю строку ради двух колонок. ClickHouse даёт выигрыш в 10–100 раз на тех же данных за счёт колоночного хранения и сжатия.
- Кэш и эфемерное состояние. Сессии, rate limiting, очереди задач, leaderboard. Держать это в основной БД — значит тратить самый дорогой ресурс на самые дешёвые данные.
- ANN-поиск по эмбеддингам при сотнях миллионов векторов. pgvector с HNSW закрывает до нескольких десятков миллионов; дальше специализированные системы.
И обратный список: когда кажется, что не подходит, а на самом деле подходит
- «У нас будет очень много данных». Сколько именно? PostgreSQL спокойно живёт на 5–20 ТБ с грамотным партиционированием. Ваш прогноз на три года вперёд, скорее всего, завышен на порядок.
- «Схема будет часто меняться».
ALTER TABLE ADD COLUMNв PostgreSQL с версии 11 не переписывает таблицу и выполняется мгновенно. А в schemaless-БД схема всё равно меняется — только миграцию вы пишете сами и в рантайме. - «Нам нужна гибкая структура для части полей». Колонка
JSONBс GIN-индексом внутри реляционной таблицы. Гибкость там, где нужна; строгость и FK — везде остальном. - «Нужна горизонтальная масштабируемость». Сначала измерьте текущий потолок. Затем: read-реплики, connection pooling, партиционирование, разгрузка кэшем. Только потом — смена парадигмы.
- «NoSQL быстрее». На вашем профиле нагрузки — проверьте. Часто медленный ответ вызван отсутствием индекса, а не выбором СУБД. Как это выясняется — в статье про индексы и планы выполнения.
Дерево решений
нужны произвольные запросы
и целостность?"} Q1 -->|"да"| Q2{"Один узел справляется
по записи и объёму?"} Q2 -->|"да"| PG["PostgreSQL
плюс JSONB, где нужна гибкость"] Q2 -->|"нет"| Q3{"Готовы платить
1 RTT за запись
ради SQL и ACID?"} Q3 -->|"да"| NSQL["CockroachDB / YugabyteDB / Spanner"] Q3 -->|"нет"| SH["Ручное шардирование PostgreSQL
по естественному ключу тенанта"] Q1 -->|"нет"| Q4{"Какая форма доступа
доминирует?"} Q4 -->|"только по ключу,
сверхнизкая задержка"| KV["Redis / DynamoDB"] Q4 -->|"иерархический документ
целиком"| DOC["MongoDB / Couchbase"] Q4 -->|"диапазон внутри партиции,
огромный поток записи"| WC["Cassandra / ScyllaDB"] Q4 -->|"скан и агрегация
по колонкам"| OLAP["ClickHouse / Druid"] Q4 -->|"релевантность,
фасеты, морфология"| SRCH["Elasticsearch / OpenSearch"] Q4 -->|"обход связей
глубины 3 и больше"| GR["Neo4j / Dgraph"] Q4 -->|"близость векторов"| VEC["pgvector / Qdrant / Milvus"] KV --> W["Проверить: это ЕДИНСТВЕННОЕ
хранилище истины или дополнение?"] DOC --> W WC --> W OLAP --> W SRCH --> W GR --> W VEC --> W W -->|"дополнение"| POLY["Полиглотная персистентность:
нужен надёжный способ синхронизации"] W -->|"единственное"| BACK["Нужен план на бэкап, миграцию схемы
и восстановление после сбоя"]
Полиглотная персистентность и её главный риск
Вывод «под каждую задачу своё хранилище» звучит разумно и приводит к архитектуре из пяти баз. Главная опасность здесь — двойная запись (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.
Ключевое правило полиглотной архитектуры: ровно одно хранилище является источником истины для каждого куска данных. Остальные — производные проекции, которые можно в любой момент удалить и перестроить с нуля. Если перестроить нельзя — у вас не проекция, а вторая истина, и рано или поздно они разойдутся.
Типичные ошибки в проде
- Выбор по хайпу и по бенчмарку вендора. Классика: MongoDB для сильно связанных финансовых данных, потому что «стартапы так делают». Через год — самописные JOIN в коде приложения и невозможность гарантировать баланс.
- Hot partition. Partition key
dateв Cassandra: весь трафик суток идёт на три узла из шестидесяти, остальные простаивают. Ключ должен распределять нагрузку, а не только группировать данные. - Неограниченно растущая партиция. «Все сообщения чата в одной партиции» — на активном чате партиция пухнет до гигабайтов, чтение начинает таймаутиться. Нужен составной ключ с bucket по времени:
((chat_id, year_month), created_at). - Tombstone-штормы. В Cassandra удаление — это запись маркера. Использование таблицы как очереди (вставили, прочитали, удалили) приводит к чтению десятков тысяч tombstone на каждый запрос и падению координатора. Cassandra не очередь.
- LWW на данных, где терять нельзя. Счётчик баланса с
last write winsи рассинхроном часов на 200 мс — потерянные списания. Для счётчиков нужны либо CRDT-счётчики, либо транзакции. - Кэш без стратегии инвалидации. Redis перед PostgreSQL без TTL и без событий инвалидации: данные протухают, а найти это невозможно, потому что ошибка не вызывает исключений. Паттерны разбираются в статье про Redis.
- Игнорирование восстановления. Кластер работает, бэкапы «настроены». Восстановление 4 ТБ Cassandra из снапшотов с последующим
nodetool repair— это не «пара часов». Прогоняйте восстановление на учениях, иначе вы измеряете надежду. - Отсутствие валидации на границе в schemaless-БД. Через два года в коллекции лежат документы пяти разных поколений схемы. Лечится JSON Schema-валидатором на уровне БД (
$jsonSchemaв MongoDB) с первого дня, а не после инцидента. - Кворум, который не кворум.
RF=2вместо 3: кворум равен 2, значит отказ одного узла делает запись невозможной. Фактор репликации меньше 3 в AP-системе — почти всегда ошибка конфигурации. - Мультирегион без понимания физики. Ожидание, что синхронная запись между континентами будет быстрой. 90 мс RTT — это скорость света в стекле, а не проблема настройки.
Мини-итог
- NoSQL — это не отрицание SQL, а отказ от конкретных гарантий ради масштабирования, задержки или гибкости модели. Формулируйте отказ явно.
- Центральная идея большинства NoSQL — агрегат: единица атомарности, репликации и шардирования одновременно. Она делает доступ по ключу почти бесплатным, а незапланированный запрос — почти невозможным.
- CAP не про «два из трёх». Партиции нельзя выбрать — они случаются. Выбор состоит в поведении во время партиции. Пользуйтесь PACELC: он описывает и нормальный режим, где вы каждый день платите за консистентность задержкой.
- Консистентность — спектр. Целиться стоит в read-your-writes и causal, а не в линеаризуемость: обычно этого достаточно, и стоит это в разы дешевле.
- BASE перекладывает целостность на ваш код. Это работает ровно настолько, насколько дисциплинирована команда через два года после запуска.
- Стоимость второго хранилища — не его цена, а цена согласования двух систем. Dual write запрещён: только outbox или CDC.
- Дефолт остаётся прежним: PostgreSQL, пока не доказано измерением, что он не подходит. Доказательство — цифры с продакшена, а не архитектурная интуиция.
Источники
- Sadalage P., Fowler M. NoSQL Distilled — короткая и до сих пор лучшая книга по таксономии и агрегатам: martinfowler.com/books/nosql.html
- Kleppmann M. Designing Data-Intensive Applications, 2nd ed. — главы 5, 6, 9 закрывают репликацию, партиционирование и консистентность строже всех: dataintensive.net
- Gilbert S., Lynch N. Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services, 2002: users.ece.cmu.edu/~adrian/731-sp04/readings/GL-cap.pdf
- Abadi D. Consistency Tradeoffs in Modern Distributed Database System Design (PACELC), IEEE Computer, 2012: cs.umd.edu/~abadi/papers/abadi-pacelc.pdf
- DeCandia G. et al. Dynamo: Amazon’s Highly Available Key-value Store, SOSP 2007: allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf
- Chang F. et al. Bigtable: A Distributed Storage System for Structured Data, OSDI 2006: research.google/pubs/bigtable
- Brewer E. CAP Twelve Years Later: How the Rules Have Changed, 2012: infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed
- Kleppmann M. Please stop calling databases CP or AP: martin.kleppmann.com/2015/05/11/please-stop-calling-databases-cp-or-ap.html
- Jepsen — независимые тесты корректности распределённых БД под отказами, обязательное чтение перед выбором: jepsen.io/analyses
- Shapiro M. et al. A comprehensive study of Convergent and Commutative Replicated Data Types, INRIA, 2011: inria.hal.science/inria-00555588
- Viotti P., Vukolić M. Consistency in Non-Transactional Distributed Storage Systems, 2016 — каталог из 50+ моделей консистентности: arxiv.org/abs/1512.00168
- YCSB — отраслевой бенчмарк для key-value и cloud-хранилищ: github.com/brianfrankcooper/YCSB
Что дальше
Мы построили карту и научились выбирать класс хранилища. Дальше идём вглубь по каждому классу, начиная с самого популярного представителя документных БД — там разберём модель документов и решение «встраивать или ссылаться», конвейер агрегаций, мультидокументные транзакции и то, во что реально обходится неудачный shard key.
MongoDB и документные БД: модель, агрегации, транзакции, подводные камни