Wide-column і часові ряди
Навіщо це
Section titled “Навіщо це”Найпопулярніший товар «Крамниці» на medium має 36 202 події перегляду, 5.9 % усіх. У Scylla команда вирішила прибрати старі події й лишила 200 найновіших. Запит «10 найстаріших подій товару» до видалення виконувався за 0.7 мс. Після видалення по одному рядку він читає 36 012 рядків, із них 36 002 видалені, і триває 27 мс, а на холодному кеші 104 мс. Файл на диску виріс: 552 КБ проти 430 КБ до видалення.
Бази цього класу не змінюють файли на місці, тож видалення для них теж запис, і прибирає його лише фонова робота. Модуль 8 показав, що B-дерево дорого пише: кожен UPDATE чіпає сторінки таблиці й усіх індексів. Тут ціну розкладено інакше: на читання, злиття файлів і місце на диску. Спершу модель даних, потім як база пише, наприкінці часові ряди.
Передумови. B-дерево, ціна запису і BRIN: модуль 8. WAL: модуль 11. Кворуми: модуль 12. Консистентне хешування й консистентність: модуль 13. SSD і write amplification: модуль 14 курсу «Операційні системи».
Партиція й кластеризація
Section titled “Партиція й кластеризація”Таблиця в Cassandra й Scylla схожа на SQL-таблицю, але ключ має дві частини. У PostgreSQL схема описує дані, а індекси під запити додають потім (модулі 5 і 9). Тут ключ задають разом із запитом: він визначає і де лежать дані, і як вони впорядковані.
Ключ партиції хешується (Murmur3) у token, за яким партицію обслуговує певний вузол, а межі токенів розподіляє схема з модуля 13. Ключі кластеризації впорядковують рядки всередині партиції, тож діапазон партиції читається послідовно. «Партиція» тут одиниця розподілу між вузлами, а не декларативна партиція PostgreSQL.
CREATE TABLE shop.orders_by_customer ( customer_id int, placed_at timestamp, order_id bigint, status text, total_amount decimal, pickup_point_id int, promo_code text, PRIMARY KEY ((customer_id), placed_at, order_id)) WITH CLUSTERING ORDER BY (placed_at DESC, order_id DESC);Запит «замовлення покупця за датою» читає одну партицію, сортування вбудоване:
cqlsh> SELECT customer_id, placed_at, order_id, status, total_amount FROM shop.orders_by_customer ... WHERE customer_id = 2740 AND placed_at >= '2024-05-01' AND placed_at < '2025-01-01'; customer_id | placed_at | order_id | status | total_amount-------------+---------------------------------+----------+-----------+-------------- 2740 | 2024-05-14 09:03:56.000000+0000 | 30370 | delivered | 1168.00Таблиця на запит. Як і в DynamoDB (модуль 15), схему проєктують від запитів: кожному відповідає таблиця. У завантажувачі замовлення лежить у двох таблицях, orders_by_customer і orders_by_id, і застосунок пише в обидві: це денормалізація без обмежень цілісності. JOIN немає, бо рядки двох партицій лежать на різних вузлах, а модель гарантує швидкість лише запиту до однієї партиції.
Довільного WHERE немає з тієї самої причини: умову можна ставити лише на ключ партиції повністю й на ключі кластеризації зліва направо. Решту база відхиляє:
cqlsh> SELECT * FROM shop.view_events_by_product WHERE event_type = 'purchase';InvalidRequest: … Cannot execute this query as it might involve data filtering and thus may haveunpredictable performance. If you want to execute this query despite the performanceunpredictability, use ALLOW FILTERINGALLOW FILTERING не додає індексу: він дозволяє прочитати все й відкинути зайве. На medium такий count(*) без ключа партиції проходить усі 610 011 рядків за 0.7–2.7 с (три запуски), а той самий фільтр за event_type = 'add_to_cart' у партиції product_id = 18893 (36 202 рядки) триває 36–41 мс. Перший росте з таблицею, другий ні: в межах партиції фільтрувати можна, по всій таблиці це повне сканування. Новий запит, якого модель не передбачала, означає нову таблицю й заповнення її з наявних даних; це й перевіряє L9.
Розмір партиції і bucketing
Section titled “Розмір партиції і bucketing”Партиція лежить на вузлі цілком. Жорсткої межі немає: Scylla лише попереджає про партицію більшу за compaction_large_partition_warning_threshold_mb (типово 1000 МБ). orders_by_customer безпечна: найбільша партиція 14 237 байт. view_events_by_product небезпечна: події пишуться безкінечно, а популярність товарів іде за Ципфом. nodetool tablehistograms показує це прямо:
Percentile Partition Size (bytes) Cell Count50% 372 2499% 11864 770Max 2816159 152321Медіанна партиція 372 байти, а гарячий товар 18893 має 2.8 МБ і 152 321 комірку, 78 байт на подію. До попередження далеко, але партиція росте без меж, а її трафік лягає на одні й ті самі репліки: це гаряча партиція (модуль 13).
Лікує bucketing: ключ партиції доповнюють відрізком часу. У view_events_by_product_month ключ ((product_id, month), occurred_at, event_id), і найбільша партиція стала 182 785 байтів (11 864 комірки), у 15 разів меншою. Ціна: «останні 100 подій товару» можуть потребувати кількох партицій, і застосунок обходить місяці від нового до старого.
Налаштовувана консистентність
Section titled “Налаштовувана консистентність”Партиція лежить на N вузлах (коефіцієнт реплікації RF). Клієнт задає рівень консистентності: скільки реплік мають відповісти. ONE потребує однієї, QUORUM більшості (⌊N/2⌋ + 1), ALL усіх; окремо для запису (W) і читання (R). Коли R + W > N, репліки читання перетинаються з репліками останнього підтвердженого запису, і читач його бачить: це кворум з модуля 12. Інакше можна прочитати стару репліку, і лишається eventual consistency (модуль 13).
N = 3 |
W |
R |
R + W |
Наслідок |
|---|---|---|---|---|
ONE / ONE |
1 | 1 | 2 | можна прочитати стару версію; живе з двома лежачими вузлами |
QUORUM / QUORUM |
2 | 2 | 4 | читання бачить підтверджений запис; переживає втрату одного вузла |
Лічильник переглядів терпить ONE, стан замовлення читають за QUORUM. Нерівність не дає лінеаризованості: паралельні записи розв’язує мітка часу, тож зсув годинників може дати перемогу старішому запису, а запис, що не набрав W підтверджень, може лишитися на частині реплік.
Наш compose піднімає один вузол із RF = 1, тож ONE, QUORUM, ALL поводяться однаково. Видно лише недосяжний рівень:
cqlsh> CONSISTENCY THREE;cqlsh> SELECT * FROM shop.orders_by_id WHERE order_id = 1;NoHostAvailable: … Unavailable('Error from server: code=1000 [Unavailable exception]message="Cannot achieve consistency level for cl THREE. Requires 3, alive 1" …Відмову вузла, застаріле читання й read repair на одному вузлі не показати (модуль 12).
Як база пише: LSM-дерево
Section titled “Як база пише: LSM-дерево”Тепер про те, як таблиці пишуть на диск. B-дерево для кожної зміни знаходить листок, переписує 8-кілобайтну сторінку заради кількох десятків байтів і додає запис у WAL. Адреси випадкові, тож і записи випадкові; на SSD це ще й write amplification (курс ОС). LSM-дерево (log-structured merge-tree, О’Ніл та співавтори, 1996) нічого не змінює на місці, пише послідовно й відкладає впорядкування.
Commit log це WAL з модуля 11: запис дописується в кінець файлу, щоб після аварії відновити memtable. У цьому образі commitlog_sync = periodic із періодом 10 000 мс (system.config): запис підтверджується до fsync, який виконується раз на 10 с. Це ближче до synchronous_commit = off, ніж до типового PostgreSQL. Memtable це відсортована структура в пам’яті, куди йде запис. Коли вона розростається або оператор викликає nodetool flush, її вміст пишеться послідовно в новий SSTable (sorted string table), і файл більше не змінюється. Завантажено 20 000 рядків у порожню таблицю lsm_w:
$ nodetool tablestats shop.lsm_w (до скидання) SSTable count: 0 Memtable cell count: 20000 Memtable data size: 12041440$ nodetool flush shop lsm_w (після скидання) SSTable count: 1 Space used (live): 1171958 Memtable cell count: 0Дванадцять мегабайтів у пам’яті стали 1.2 МБ на диску (SSTable стиснуто). Він складається з файлів: Data.db (933 237 байт), Index.db (202 945), Filter.db (bloom-фільтр, 25 016) та службові.
UPDATE і DELETE теж нові записи: кожна комірка має мітку часу, і з двох версій перемагає більша (last-write-wins). Стара версія лежить у старому SSTable, доки її не прибере компакція. Тому запис ніколи не читає: «прочитати, змінити, записати» замінює «дописати».
Читання і bloom-фільтри
Section titled “Читання і bloom-фільтри”Рядок партиції може лежати в memtable і в кількох SSTable, кожен зі своєю версією. Читання збирає їх і лишає найновішу. Щоб не відкривати кожен файл, кожен SSTable має bloom-фільтр: компактну структуру, що про ключ каже «тут точно немає» або «можливо є». Хибне «можливо» трапляється з імовірністю bloom_filter_fp_chance (типово 0.01), хибного «немає» не буває. Фільтр дешевий: 25 016 байт на 20 000 ключів вище, близько 10 біт на ключ.
У lsm_demo п’ять SSTable з 100 000 версіями 20 000 рядків. Після перезапуску Scylla (кеш порожній) TRACING ON показує різницю між наявним і відсутнім ключем:
SELECT id, v FROM shop.lsm_demo WHERE id = 100; -- ключ є Reading key {…} from sstable lsm_demo-…-big-Data.db …-Index.db і …-Data.db: по одному читанню з диска Request complete 1433 мкс
SELECT id, v FROM shop.lsm_demo WHERE id = 99999999; -- ключа немає Page stats: 0 partition(s) (0 live, 0 dead) … Request complete 568 мксВідсутній ключ не торкнувся жодного SSTable: фільтри п’яти файлів відповіли «ні». Наявний ключ прочитав лише один файл із п’яти (у кожному була повна версія рядка); коли колонки розкидано по різних SSTable, доведеться читати кілька. Scylla має власний кеш рядків (Querying cache for range у трасуванні): «холодне» читання нижче це перше після перезапуску, «прогріте» повторне.
Компакція: чим платить LSM-дерево
Section titled “Компакція: чим платить LSM-дерево”SSTable не змінюються, тож їх кількість росте, а з нею число файлів на читання й застарілих версій. Компакція у фоні зливає SSTable: це злиття відсортованих файлів, що лишає найновішу версію й викидає перекриті версії та прострочені tombstone. Вона дорога: байти переписуються знову й знову, і це та сама write amplification з модуля 8 і курсу ОС. Read, write і space amplification (файли на запит, переписані байти, зайняте місце) тягнуть у різні боки, а стратегія вибирає, чим пожертвувати.
| Стратегія | Як зливає | Читання | Запис | Місце |
|---|---|---|---|---|
| Size-tiered (STCS) | кілька файлів подібного розміру | можливо багато файлів | найменше переписувань | потрібен запас під велике злиття |
| Leveled (LCS) | рівні, кожен у 10 разів більший | ключ в одному файлі на рівень | більше: переписування на кожному рівні | мало зайвого |
| Time-window (TWCS) | кожне часове вікно окремо | старі вікна не чіпає | мінімум | старі файли видаляються цілком |
Коефіцієнт 10 між рівнями LCS і запас місця під злиття STCS (у гіршому разі половина диска) взято з документації. Типова стратегія в цьому образі IncrementalCompactionStrategy (DESCRIBE TABLE): той самий size-tiered, але кожен SSTable поділено на фрагменти фіксованого розміру, і злиття звільняє місце частинами, тож запас потрібен значно менший.
Вимірювання: 12 скидань по 50 000 нових рядків (64.8 МБ), для LCS sstable_size_in_mb = 5. STCS переписала 270.7 МБ (4.2 обсягу скидань) і лишила 2 SSTable, LCS 292.5 МБ (4.5) і 12 SSTable на трьох рівнях. На такому обсязі різниці між стратегіями немає (результат залежить від моменту фонової компакції), але щоб утримати 65 МБ, база записала ще близько 280 МБ.
Tombstone: чому видалення сповільнює читання
Section titled “Tombstone: чому видалення сповільнює читання”У SSTable рядок не стерти, тож DELETE записує tombstone: запис із міткою часу, за яким читання ховає старіші версії. Його не можна викинути одразу: репліка, недоступна під час DELETE, «воскресила» б рядок. Тому tombstone живе щонайменше gc_grace_seconds (типово 864 000, десять діб) і зникає при першій компакції після цього. Маркери бувають на комірку, рядок, діапазон і партицію; прострочений TTL теж стає маркером.
Експеримент відтворює dataset/loaders/scylla/tombstones.sh: події гарячого товару копіюються в три таблиці. У двох видаляють усе, крім 200 найновіших: у tomb_rows по одному DELETE на ключ, у tomb_range одним DELETE … WHERE product_id = 18893 AND occurred_at < '…'. Обидві компактують і перезапускають Scylla. Запит бере найстаріші події: рядки йдуть від нових до старих, тож читання починається з кінця партиції, де лежать видалені.
запит «10 найстаріших подій»: перше читання (холодний кеш), потім повторне tomb_base прочитано рядків 10, із них живих 10, видалених 0, 6.5 мс tomb_base прочитано рядків 10, із них живих 10, видалених 0, 0.7 мс tomb_rows прочитано рядків 36012, із них живих 10, видалених 36002, 104.0 мс tomb_rows прочитано рядків 36012, із них живих 10, видалених 36002, 26.8 мс tomb_range прочитано рядків 10, із них живих 10, видалених 0, 6.0 мс tomb_range прочитано рядків 10, із них живих 10, видалених 0, 0.4 мсрозмір на диску: tomb_base 429689, tomb_rows 551523, tomb_range 8226 байтЦіна читання залежить від числа маркерів на шляху, а не від числа живих рядків: десять живих читалися 27–104 мс, бо перед ними стояло 36 002 мертвих (у трасуванні чотири сторінки на кшталт 10000 clustering row(s) (0 live, 10000 dead)). По-рядкове видалення зайняло більше місця, ніж видалені дані: кожен маркер має ключ і мітку часу. Діапазонний маркер дає той самий результат без втрат, але лише після компакції, що викидає затінені рядки: до неї tomb_range теж читала 36 010 рядків (21 мс). Компакція не прибрала 36 002 маркери в tomb_rows, бо gc_grace_seconds не минув.
За документацією Cassandra, читання, що натрапило на tombstone_failure_threshold (типово 100 000) маркерів, завершується помилкою. У Scylla наш запит на 36 002 маркери минув без помилки й без запису в журналі контейнера.
Звідси правила моделі. Не видаляти по одному рядку: старе має йти цілими блоками (бакети вище, TTL нижче). Не писати явних NULL: вставка (1, 'x', null) створює мертву комірку (2 cell(s) (1 live, 1 dead) у трасуванні), а пропущена колонка нічого не створює. Не будувати чергу «записати, прочитати, видалити»: читач завжди починає з купи маркерів.
Часові ряди
Section titled “Часові ряди”Подія з view_events типова для часового ряду: рядок лише дописується, читають вікно за часом, з віком цінність падає. Звідси три потреби: партиціювання за часом, щоб запит за тиждень не читав роки; ретеншн (термін зберігання), що прибирає старе цілими блоками; даунсемплінг, що замінює старі події агрегатами.
PostgreSQL: партиції за часом
Section titled “PostgreSQL: партиції за часом”Декларативне партиціювання з модуля 13: партиція на місяць за occurred_at.
Межі задано в UTC, тож київська доба на межі місяців зачепить дві партиції. Запит за тиждень читає одну: планувальник відкидає решту (partition pruning).
На small це 53 буфери проти 712 для view_events без індексу за часом (Seq Scan on view_events_2025_06), на medium 260 проти 6784 і 1.8 мс проти 32.4 мс. Усередині партиції лишається повне сканування: додають BRIN з модуля 8. Партиції на майбутнє створюють наперед (за розкладом або розширенням pg_partman), інакше запис за межею діапазону падає з no partition of relation … found for row.
Ретеншн партиціями
Section titled “Ретеншн партиціями”DELETE місяця створює мертві версії (MVCC, модуль 10), пише їх у WAL і лишає файл тієї ж довжини до VACUUM. Партицію відчіплюють і скидають як файл. На medium один місяць 2024 року, 23 264 рядки:
| Спосіб | Час | WAL | Розмір таблиці |
|---|---|---|---|
DELETE місяця зі звичайної таблиці |
48 мс | 1278 КіБ | 66 МБ, без змін |
ALTER TABLE … DETACH PARTITION і DROP TABLE |
17 мс разом | 15 КіБ | менший на розмір партиції |
DROP не залежить від кількості рядків, а DELETE пропорційний їй і лишає роздуту таблицю. DETACH PARTITION … CONCURRENTLY не блокує читачів; без нього береться сильне блокування батьківської таблиці.
Даунсемплінг матеріалізованим представленням
Section titled “Даунсемплінг матеріалізованим представленням”Матеріалізоване представлення з модуля 4 зберігає результат GROUP BY, і денний звіт не перераховує події:
На small 60 718 подій стали 1094 рядками, дашборд за місяць читає 16 буферів замість 712. Унікальний індекс потрібен для REFRESH MATERIALIZED VIEW CONCURRENTLY, що не блокує читачів. Оновлення перераховує все, тож для великих рядів агрегат ведуть таблицею з дописуванням нового дня. Підступ: count(DISTINCT session_id) за дні не складається в місячний, бо сесія могла бути у два дні.
TimescaleDB, InfluxDB і Scylla
Section titled “TimescaleDB, InfluxDB і Scylla”TimescaleDB автоматизує те саме для PostgreSQL: таблиця розбита за часом на фрагменти (chunks), політики ретеншну, безперервні агрегати, стиснення старих даних. У образі pgvector/pgvector:0.8.6-pg18-trixie з кореневого compose його немає: pg_available_extensions повернув лише vector, тож тут його не ставили. InfluxDB окрема база для часових рядів зі своєю моделлю (вимір, теги, поля).
У Scylla те саме дають бакет у ключі, TTL і стратегія TWCS, що тримає кожне вікно окремим файлом: файл із повністю простроченими рядками видаляється цілком, без маркерів. Таблиця з default_time_to_live = 7776000 (90 діб) і 'compaction_window_unit': 'DAYS', 'compaction_window_size': 7 створилась, рядок із USING TTL 5 зник через 7 с, а в решти TTL(event_type) показував 7776000. З вікном в одну добу Scylla таку таблицю відхиляє: 90 вікон перевищують twcs_max_window_count (50).
Як це насправді
Section titled “Як це насправді”Усередину Scylla дивляться через nodetool tablestats (SSTable, memtable, bloom-фільтр, найбільша партиція), nodetool tablehistograms (розміри партицій), nodetool compactionhistory (злиття з bytes_out) і TRACING ON у cqlsh (кеш чи SSTable, скільки рядків прочитано й скільки мертві). Експеримент із tombstone відтворює ./dataset/loaders/scylla/tombstones.sh.
Розбір: Discord, 2023
Section titled “Розбір: Discord, 2023”Допис «How Discord Stores Trillions of Messages» (Бо Інґрем, 6 березня 2023) описує перехід сховища повідомлень з Cassandra на ScyllaDB.
Повідомлення партиціонували за каналом і бакетом, статичним вікном часу: та сама схема, що в view_events_by_product_month, де канал відіграє роль товару. У 2017 році було 12 вузлів Cassandra, на початку 2022-го кластер cassandra-messages мав 177 вузлів і трильйони повідомлень. Проблемою були гарячі партиції: сервер із сотнями тисяч учасників пише на порядки більше, ніж сервер друзів, і затримка гарячої партиції поширюється на весь кластер. Компакція відставала, тож вузол виводили з ротації, щоб він скомпактувався без трафіку, і повертали назад: інженери звуть це «gossip dance».
Після міграції 177 вузлів Cassandra (у середньому по 4 ТБ) замінили 72 вузли ScyllaDB по 9 ТБ. Затримка читання історії (99-й процентиль) упала з 40–125 мс до 15 мс, вставки з 5–70 мс до 5 мс. Дані переносив мігратор на Rust: оцінка для Spark була три місяці, для Rust дев’ять днів, до 3.2 мільйона повідомлень на секунду; закінчили в травні 2022. Останні діапазони токенів читалися з тайм-аутами, бо містили величезні діапазони tombstone, які Cassandra так і не прибрала компакцією: той самий механізм, що в нашому експерименті, лише в іншому масштабі.
Успіх не зводиться до заміни бази. Перед базами стоять сервіси на Rust з об’єднанням запитів (request coalescing): коли багато користувачів одночасно просять один рядок, до бази йде один запит. Консистентне хешування за ідентифікатором каналу збирає запити одного каналу на одному екземплярі. Тому «було / стало» змішує кілька змін: СУБД, більші вузли, нові сервіси.
Типові помилки розуміння
Section titled “Типові помилки розуміння”«Видалив рядки, і місце та читання звільнилися». DELETE пише маркер: у нашому експерименті 36 002 маркери зайняли більше місця, ніж дані, а десять живих рядків читалися в 16–38 разів довше. Старе прибирають блоками: бакет, TTL, діапазонне видалення.
«ALLOW FILTERING це індекс». Він лише знімає заборону. Без ключа партиції запит читає всю таблицю: 0.7–2.7 с проти 36–41 мс і росте разом з нею.
«QUORUM на запис і читання дає лінеаризованість». R + W > N лише гарантує перетин реплік: паралельні записи розв’язують мітки часу, а запис, що не набрав W, може лишитись на частині реплік (модуль 13).
Перевір себе
Лабораторна
Section titled “Лабораторна”L9. Одна задача в трьох моделях: та сама предметна область у реляційній моделі, в MongoDB і в моделі «таблиця на запит», з новими вимогами посеред роботи.
Джерела
Section titled “Джерела”- P. O’Neil, E. Cheng, D. Gawlick, E. O’Neil, The Log-Structured Merge-Tree (LSM-Tree), Acta Informatica, 1996.
- G. DeCandia et al., Dynamo: Amazon’s Highly Available Key-value Store, SOSP 2007: кворуми, підказані передачі, відновлення читанням.
- Документація ScyllaDB (CQL, компакція, TTL, консистентність) і Apache Cassandra.
- M. Kleppmann, Designing Data-Intensive Applications, розділи 3 і 5.
- B. Ingram, How Discord Stores Trillions of Messages, Discord Engineering Blog, 6 березня 2023.
- PostgreSQL 18, документація: Table Partitioning.