Wide-column: query-first моделирование
Мы прошли документные базы (embedding vs referencing) и key-value (дизайн ключа). Третья большая категория NoSQL — wide-column базы, главные представители — Apache Cassandra и ScyllaDB. Они интереснее всего с точки зрения моделирования: внешне похожи на реляционные (таблицы, строки, столбцы, язык запросов CQL почти как SQL), но устроены и проектируются принципиально иначе. И именно здесь принцип «query-first» из предыдущих уроков становится строгим законом.
Главная идея урока: в wide-column базе схема проектируется от запросов, а не от связей. В реляционном мире вы строите нормализованную схему, а потом пишете любые запросы. В Cassandra наоборот: сначала вы точно знаете запросы, и только под них создаёте таблицы. Это переворот привычного порядка, и без него Cassandra работает плохо.
Почему wide-column устроена иначе
Cassandra похожа на SQL обманчиво. Есть таблицы, есть SELECT, есть WHERE. Но за этим — другая физика, продиктованная одной целью: масштабирование на много узлов.
Cassandra — распределённая база. Данные не лежат на одном сервере, они размазаны по кластеру из десятков машин. И ключевое следствие: нет JOIN. Совсем. В реляционной базе JOIN соединяет две таблицы; в распределённой Cassandra строки этих таблиц физически лежат на разных узлах, и соединять их на лету было бы катастрофически дорого. Cassandra просто не предоставляет JOIN.
Из «нет JOIN» вытекает всё остальное. Если нельзя соединять таблицы запросом, значит все данные, нужные одному запросу, должны лежать в одной таблице, на одном узле, готовыми к выдаче. А раз так — таблицу нельзя спроектировать «вообще»: её проектируют под конкретный запрос.
Partition key: на каком узле лежат данные
Центральное понятие wide-column моделирования — partition key (ключ партиционирования). Он отвечает на вопрос: на каком узле кластера физически хранится строка.
Механика такая. Cassandra берёт partition key строки и пропускает через хеш-функцию — получается число, называемое token. По диапазону токенов определяется узел. Все строки с одинаковым partition key дают один token и потому лежат вместе, на одном узле, в одной партиции.
-- partition key — первая часть PRIMARY KEY
CREATE TABLE orders_by_customer (
customer_id UUID, -- partition key: определяет узел
order_date TIMESTAMP,
order_id UUID,
amount DECIMAL,
PRIMARY KEY (customer_id)
);
-- Все заказы одного customer_id хешируются в один token
-- -> лежат на одном узле, в одной партиции
У partition key две задачи, и обе важны.
Первая — локализация данных запроса. Запрос Cassandra должен попадать в одну партицию: тогда он обслуживается одним узлом, быстро. Поэтому partition key выбирают так, чтобы данные, которые запрос читает вместе, оказались в одной партиции. «Все заказы клиента» -> partition key = customer_id, и все его заказы на одном узле.
Вторая — равномерное распределение. Хеш partition key должен раскидывать данные по кластеру ровно. Если выбрать плохой partition key — например, country для сервиса, где 90% клиентов из одной страны, — почти все данные попадут на один узел. Получится hot partition: один узел перегружен, остальные простаивают, масштабирование сломано. Хороший partition key — и локализует запрос, и распределяет нагрузку.
Hot partition — самая частая ошибка моделирования в Cassandra. Она возникает, когда partition key имеет низкую кардинальность или неравномерное распределение значений. Признаки: один узел в кластере нагружен сильно больше других. Partition key должен иметь достаточно много примерно равновероятных значений, чтобы хеш распределял данные ровно.
Clustering key: порядок внутри партиции
Partition key определил, на каком узле лежит партиция. Внутри партиции порядок строк задаёт второе понятие — clustering key (ключ кластеризации).
Внутри одной партиции строки физически отсортированы по clustering key. Это даёт две вещи: данные внутри партиции лежат упорядоченно (диапазонные запросы и сортировка по этому ключу — дёшевы), и clustering key обеспечивает уникальность строк внутри партиции.
CREATE TABLE orders_by_customer (
customer_id UUID, -- partition key
order_date TIMESTAMP, -- clustering key: сортировка внутри партиции
order_id UUID,
amount DECIMAL,
PRIMARY KEY (customer_id, order_date)
) WITH CLUSTERING ORDER BY (order_date DESC);
-- Внутри партиции каждого клиента заказы лежат отсортированными
-- по order_date по убыванию. "Последние 10 заказов клиента" —
-- дешёвый запрос: данные уже в нужном порядке
Полный primary key в Cassandra = partition key + clustering key. Первая часть (customer_id) решает «где лежит», остальные (order_date) — «в каком порядке внутри». Запрос «последние заказы клиента, отсортированные по дате» обслуживается мгновенно, потому что строки уже физически лежат в этом порядке — сортировать на лету не нужно.
Query-first на практике: одни данные, несколько таблиц
Теперь главное следствие, которое отличает wide-column от всего, что вы знали. Раз таблица проектируется под один запрос, а запросов к одним и тем же данным несколько — приходится хранить одни данные в нескольких таблицах, по таблице на запрос.
Пример. Приложению нужны два запроса про заказы:
- Q1: «все заказы клиента» — знаем
customer_id. - Q2: «заказ по его номеру» — знаем
order_id.
В реляционной базе хватило бы одной таблицы orders и двух разных WHERE. В Cassandra так нельзя: partition key один, и таблица обслуживает запросы только по нему. Q1 требует partition key customer_id, Q2 — partition key order_id. Это две разные таблицы:
-- Таблица под Q1: заказы клиента
CREATE TABLE orders_by_customer (
customer_id UUID,
order_date TIMESTAMP,
order_id UUID,
amount DECIMAL,
PRIMARY KEY (customer_id, order_date)
);
-- Таблица под Q2: заказ по номеру — ТЕ ЖЕ данные, другой partition key
CREATE TABLE orders_by_id (
order_id UUID,
customer_id UUID,
order_date TIMESTAMP,
amount DECIMAL,
PRIMARY KEY (order_id)
);
Один и тот же заказ записывается в обе таблицы. Это сознательное дублирование, и в Cassandra оно — норма, а не ошибка. Каждая таблица — это «представление» одних данных под свой запрос. Приложение при записи заказа кладёт его во все таблицы, которые его обслуживают.
Меняется и взгляд на дублирование. В реляционной модели дублирование — аномалия, его устраняет нормализация, потому что storage был дорог и согласованность важнее. В Cassandra storage дёшев, а JOIN недоступен — поэтому дублирование данных под разные запросы это правильное проектное решение. Цена — при изменении заказа нужно обновить его во всех таблицах; ответственность за это несёт приложение.
| Аспект | Реляционная база | Wide-column (Cassandra) |
|---|---|---|
| Порядок проектирования | Схема, потом запросы | Запросы, потом схема |
| JOIN | Есть | Нет |
| Одни данные | В одной таблице | В нескольких таблицах под разные запросы |
| Дублирование | Аномалия, убирается нормализацией | Норма, осознанное решение |
| Что определяет размещение | Индексы, оптимизатор | Partition key (хеш в token -> узел) |
Почему так: масштаб и предсказуемость
Зачем мириться с дублированием и проектированием под каждый запрос? Ради того же, что и key-value: линейный масштаб и предсказуемая скорость.
Поскольку каждый запрос попадает строго в одну партицию на одном узле, у Cassandra нет «дорогих» запросов: любой запрос — это локальная операция одного узла по отсортированным данным. Добавили узлов в кластер — пропорционально выросла и ёмкость, и пропускная способность, потому что партиции равномерно распределены по хешу. Cassandra держит огромные объёмы записи и чтения с малой задержкой именно потому, что отказалась от JOIN и гибких запросов в пользу строгого query-first.
Это завершает линию модуля. Document, key-value, wide-column — все три отказываются от реляционной гибкости в обмен на масштаб, и все три проектируются от запросов, а не от связей. Меняется лишь форма: в document это embedding/referencing, в key-value — структура ключа, в wide-column — partition key и таблица-на-запрос. В следующем уроке посмотрим на четвёртую модель — графовую, — а затем разберём, как фундаментальный выбор хранилища диктует модель.
Попробуй сам
Возьмите сервис доставки еды с сущностями: пользователь, ресторан, заказ.
- Выпишите 3-4 запроса, которые делает приложение: «заказы пользователя», «заказы ресторана за день», «заказ по номеру».
- Для каждого запроса определите partition key: по чему данные должны быть локализованы в одной партиции? Где нужен clustering key для сортировки?
- Заметьте, что «заказы пользователя» и «заказы ресторана» — это два partition key для одних данных. Спроектируйте две таблицы:
orders_by_userиorders_by_restaurant. - Возьмите partition key
cityдля сервиса, где 80% заказов в одном городе. Объясните, почему это hot partition и чем это вредно.