В предыдущей статье я доходчиво рассказал как Cassandra хранит данные. Настоятельно рекомендую хотя бы пробежаться глазами. В этой статье мы создадим простенькую БД, чтобы использовать её в следующей статье, которая будет полностью посвящена выборке/поиску данных.
Задача
Допустим у нас есть ad network, который откручивает рекламу. Люди кликают на баннеры, заказчик рекламы платит, мы (сеть), реселлеры (распространители) и хостеры рекламного места имеем на этом доход. Реселлеры рекламного места работают за 20%. Этот процент растёт из-за различных факторов, самое главное, что он не постоянен и новый процент может применяться, например, на клики месячной давности.
Нужно: быстро уметь считать доход каждого реселлера за любой промежуток дней, вести график кликов в режиме реального времени.
Disclaimer
- Статья НЕ служит единственно верным путеводителем для моделирования ad network задач.
- Каждый конкретный случай требует своего конкретного моделирования. Серебрянной пули не существует.
Ликбез
- Cassandra (далее C*) — распределённая NoSQL БД.
- cqlsh — коммандная строка для C* CQL.
- CQL — SQL-подобный язык запросов. Аббревиатура от Cassandra Query Language.
- Keyspace — аналог базы данных в реляционных СУБД.
- Основная единица хранения — строка. Строка может динамически раширяться до 2 миллиардов колонок. Это важно.
- Главный ключ (Primary Key) — уникальный идентификатор строки. Обязан быть у каждой строки. Именно он используется для поиска той ноды, в которой хранится строка, а также для поиска внутри самой строки.
- Распределительный ключ — первая часть главного ключа.
- Кластерный ключ — вторая часть главного ключа.
- Нода (node) — java процесс Кассандры. Все ноды равнозначны. Нет master-slave зависимостей.
- Кластер — несколько нод работающих друг с другом как единое целое.
- Во время записи нет возможности прочитать текущее значение записываемой ячейки (это почти правда).
Давайте считать, что у нас 6 нод.
cqlsh
Запустим cqlsh
.
Connected to Test Cluster at localhost:9160.
[cqlsh 4.1.0 | Cassandra 2.0.2 | CQL spec 3.1.1 | Thrift protocol 19.38.0]
cqlsh>
Создадим keyspace
Создадим keyspace (базу данных).
CREATE KEYSPACE ad_network WITH replication = {
'class': 'SimpleStrategy',
'replication_factor': '3'
};
USE ad_network;
replication_factor
— это количество нод, которые будут хранить строку.
Создаём модель
Таблица reseller
Создадим таблицу реселлеров и заполним данными. В таблице будем хранить историю изменений процентной ставки реселлера.
CREATE TABLE reseller (
id text,
effective_since text, -- day in the format of 'YYYY-MM-DD'
reward_percent float, -- value from 0.0 to 1.0
PRIMARY KEY (id, effective_since)
)
WITH CLUSTERING ORDER BY (effective_since DESC);
INSERT INTO reseller (id, effective_since, reward_percent) VALUES ('supaboobs', '2011-02-13', 0.2);
INSERT INTO reseller (id, effective_since, reward_percent) VALUES ('supaboobs', '2012-01-22', 0.25);
INSERT INTO reseller (id, effective_since, reward_percent) VALUES ('supaboobs', '2013-11-30', 0.3);
Кажется, что мы создали три строки. Но те, кто читал предыдущую статью, знают, что мы создали одну строку с распределительным ключом 'supaboobs'
и тремя кластерными ключами: '2011-02-13'
, '2012-01-22'
и '2013-11-30'
. Эта строка, и все последующие, будет храниться на трёх из наших шести нод.
Посмотрим содержимое:
cqlsh:ad_network> SELECT * FROM reseller WHERE reseller='supaboobs';
id | effective_since | reward_percent
-----------+-----------------+----------------
supaboobs | 2013-11-30 | 0.3
supaboobs | 2012-01-22 | 0.25
supaboobs | 2011-02-13 | 0.2
В дальнейшем, когда нам понадобится текущая процентная ставка, мы выполним следующее:
cqlsh:ad_network> SELECT * FROM reseller WHERE id = 'supaboobs' LIMIT 1;
id | effective_since | reward_percent
-----------+-----------------+----------------
supaboobs | 2013-11-30 | 0.3
Таблица ad_click
В этой будем хранить клики на наши баннеры.
Колонки: ID реселлера, день (для ускорения поиска), дата+время клика, ID баннера, полная стоимость клика.
CREATE TABLE ad_click (
reseller_id text,
day text, -- day in the format of 'YYYY-MM-DD'
time timestamp,
ad_id text,
amount float,
PRIMARY KEY ((reseller_id, day), time, ad_id)
)
WITH CLUSTERING ORDER BY (time DESC);
Добавим немного данных.
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-11-28', '2013-11-28 02:16:52', '890_567_234', 0.005);
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-11-28', '2013-11-28 07:17:35', '890_567_234', 0.005);
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-11-29', '2013-11-29 17:18:51', '890_567_211', 0.0075);
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-11-29', '2013-11-29 22:20:37', '890_567_211', 0.0075);
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-11-30', '2013-11-30 11:21:56', '890_567_234', 0.005);
INSERT INTO ad_click (reseller_id, day, time, ad_id, amount) VALUES ('supaboobs', '2013-12-01', '2013-12-01 12:21:59', '890_567_010', 0.01);
Посмотрим на них.
cqlsh:ad_network> SELECT * FROM ad_click;
reseller_id | day | time | ad_id | amount
-------------+------------+---------------------+-------------+--------
supaboobs | 2013-12-01 | 2013-12-01 12:21:59 | 890_567_010 | 0.01
supaboobs | 2013-11-30 | 2013-11-30 11:21:56 | 890_567_234 | 0.005
supaboobs | 2013-11-28 | 2013-11-28 07:17:35 | 890_567_234 | 0.005
supaboobs | 2013-11-28 | 2013-11-28 02:16:52 | 890_567_234 | 0.005
supaboobs | 2013-11-29 | 2013-11-29 22:20:37 | 890_567_211 | 0.0075
supaboobs | 2013-11-29 | 2013-11-29 17:18:51 | 890_567_211 | 0.0075
Так как распределительный ключ у нас составной — (reseller_id, day)
, то здесь фактически создалось 4 строки (если сложно понять почему, то прочтите предыдущую статью, и всё встанет на свои места). Получается, что для каждого реселлера мы будем создавать каждый день новую строку и заполнять её данными. Кластерный ключ тоже составной — time, ad_id
.
Таблица amount_by_day
Так как процентная ставка не может меняться чаще, чем раз в день, то можно на этом выиграть немного миллисекунд и процессорного времени. Создадим ещё одну таблицу, которая будет хранить те же деньги, но без разбивки на клики:
CREATE TABLE amount_by_day (
reseller_id text,
day text, -- day in the format of 'YYYY-MM-DD'
amount double,
PRIMARY KEY (reseller_id, day)
)
WITH CLUSTERING ORDER BY (day DESC);
Она должна будет заполнятся раз в сутуки. Отдельный код в нашей системе будет собирать данные из ad_click
, суммировать и записывать в amount_by_day
.
Taблицы подсчета количества кликов
Естественно, нам важно знать сколько раз кликнули на какой баннер. Но так как запускать SELECT COUNT(0) FROM ad_click WHERE ad_id='...'
по всем шести нодам — это было бы слишком накладно (да и не существует операции COUNT
в CQL), то в C* есть такая вещь как counter-ы.
Counter — это тип колонки, т.е. синтаксически используется точно так же как timestamp
, text
, double
, и пр. Но есть ограничения. Если в таблице есть хоть один counter, то все остальные колоки тоже должны быть типа counter (исключая PRIMARY KEY, конечно же). Создадим же таблицу:
CREATE TABLE clicks_per_ad (
ad_id text,
clicks counter,
PRIMARY KEY (ad_id)
);
А вот так в таблице можно изменять значение колонки clicks
.
cqlsh:ad_network> SELECT * FROM clicks_per_ad;
(0 rows)
cqlsh:ad_network> UPDATE clicks_per_ad SET clicks = clicks + 1 WHERE ad_id = '890_567_234';
cqlsh:ad_network> SELECT * FROM clicks_per_ad;
ad_id | clicks
-------------+--------
890_567_234 | 1
(1 rows)
cqlsh:ad_network> UPDATE clicks_per_ad SET clicks = clicks + 1 WHERE ad_id = '890_567_234';
cqlsh:ad_network> SELECT * FROM clicks_per_ad;
ad_id | clicks
-------------+--------
890_567_234 | 2
(1 rows)
cqlsh:ad_network> UPDATE clicks_per_ad SET clicks = 0 WHERE ad_id = '890_567_234';
cqlsh:ad_network> SELECT * FROM clicks_per_ad;
ad_id | clicks
-------------+--------
890_567_234 | 0
(1 rows)
Таким образом можно считать что угодно, если оно signed int
. Т.е. можно считать исключительное целые числа, но зато в диапазоне -2^63 — +2^63.
Примечательно, что сначала не было строк в таблице, но после команды UPDATE
одна вдруг появилась. Это особенность CQL. INSERT
и UPDATE
— суть одна и та же комманда. Оговорюсь, что в С* есть возможность не обновлять/вставлять данные если они уже (или 'ещё не') существуют. Она называется «лёгкие транзакции» (lightweight transactions), которые работают медленно относительно обычной операции записи данных.
Конечно, количество кликов можно собирать по любым критериям (ключам). Например:
CREATE TABLE clicks_per_reseller_per_day (
reseller_id text,
day text, -- day in the format of 'YYYY-MM-DD'
clicks counter,
PRIMARY KEY ((reseller_id, day))
);
CREATE TABLE clicks_per_reseller (
reseller_id text,
clicks counter,
PRIMARY KEY (reseller_id)
);
Немного о текстовых ID
В RDBMS мы привыкли назначать строкам уникальный идентификатор типа int. Почему бы не делать идентификаторы текстовыми, которые бы означали что-либо осмысленное? Да потому, что производительность тогда пострадает. Лично меня это сильно угнетало. Мы привыкли, что ID наших водительских прав — цифры, ID страхового полиса — цифры. Но ведь часто приходится примешивать буквы, например ID пасспорта — две буквы и 6 цифр, номера домов часто с буквами или дефисами, и пр.
В С* не принято использовать сухие цифры как ключи, потому что они не несут ускорения работы в отличие от RDBMS. Да и auto increment в С* отсутствует (зато есть timeuuid, если вдруг понадобится уникальный ID). Непривычным может показаться text
как тип колонки reseller_id
. В С* распределительный ключ (partition key) ищется путём сравнения хешей. Т.е. не происходит прямое сравнение строк, а значит не приседает производительность.
Запись данных
Чтобы записать один клик нам надо будет делать аж 4 UPDATE
операций. Я не сошел с ума. Запись в C* чересчур быстаря операция, производительность не пострадает. Скорость записи при 6-ти нодах будет примерно в 100 раз быстрее, чем в MongoDB или в 2-5 раз быстрее, чем в HBase, не говоря уж об RDBMS. Последние версии С* умеют самостоятельно оптимизировать место на диске (compaction, compression), поэтому и с местом на жестком диске всё будет хорошо.
Чтобы убедиться, что все INSERT-ы сработали, существует такое понятние как BATCH-и.
BEGIN BATCH
-- INSERT, UPDATE, DELETE ...
APPLY BATCH;
Batch-и — не замена транзакциям в RDBMS. С помощью них С* передаёт все команды одним пакетом, а не несколькими коммандами, таким образом оптимизируя работу сети. Существует два вида batch-ей.
- Unlogged —
BEGIN UNLOGGED BATCH
— обычный batch. Но если координирущая нода (coordinator node — та, которая ответственна за пришедшую к ней CQL комманду и за связь с другими нодами) умрёт посреди batch-а, то может испортиться целостность (consistency) данных. - Atomic (атомарные) —
BEGIN BATCH
— в этом случае С* удостоверится, что или все данные записаны, или ничего. Но эта операция примерно на 30% медленнее.
Чтение данных
Этой теме посвящена следующая статья. Операция SELECT ... FROM ... WHERE
имеет множество ограничений по сравнению с RDBMS, поэтому на это следует акцентировать особое внимание.
Заключение
Задачей этого поста было показать на сколько отличается подход к моделированию БД в Кассандре от RDBMS. Подход отличается кардинально, как можно заметить. И пусть вас не смущет схожесть CQL и SQL, на самом деле сопадает только синтаксис. Здесь было показано только несколько приёмов и отличительных особенностей, но их гораздо-гораздо больше.
Автор: koresar