Перейти до вмісту

Wide-column і часові ряди

Найпопулярніший товар «Крамниці» на 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.

PRIMARY KEY ((customer_id), placed_at, order_id): customer_id дає хеш і вузол, рядки однієї партиції лежать підряд, упорядковані за placed_at від новішого до старішогоPRIMARY KEY ((customer_id), placed_at, order_id)ключ партиціїключі кластеризаціїпартиція customer_id = 2740token -7199772981098574948placed_atorder_idstatus2025-09-02 14:48:21170803delivered2024-05-14 09:03:5630370deliveredпорядок на диску: placed_at DESCпартиція customer_id = 7token 1634052884888577606placed_atorder_idstatus2024-08-25 16:15:5455520delivered2024-08-22 14:10:4754496delivered2024-03-11 17:26:5515177cancelledtoken визначає вузол
Дві партиції таблиці orders_by_customer (medium) з реальними токенами. Партиція покупця лежить на одному вузлі, рядки в ній від новішого до старішого.
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 have
unpredictable performance. If you want to execute this query despite the performance
unpredictability, use ALLOW FILTERING

ALLOW 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 Count
50% 372 24
99% 11864 770
Max 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).

Тепер про те, як таблиці пишуть на диск. B-дерево для кожної зміни знаходить листок, переписує 8-кілобайтну сторінку заради кількох десятків байтів і додає запис у WAL. Адреси випадкові, тож і записи випадкові; на SSD це ще й write amplification (курс ОС). LSM-дерево (log-structured merge-tree, О’Ніл та співавтори, 1996) нічого не змінює на місці, пише послідовно й відкладає впорядкування.

LSM-дерево: запис іде в commit log й memtable, memtable скидається в незмінні SSTable, компакція зливає SSTable в більший; читання питає memtable, потім bloom-фільтри SSTableЗаписINSERT / UPDATE /DELETE від клієнтаcommit logдиск, лише додаванняmemtableпам’ять, відсортованаскидання на дискSSTable 1SSTable 2SSTable 3SSTable 4файли не змінюютьсякомпакціязлитий SSTableстарі версії йtombstone прибраноЧитанняmemtablebloom-фільтріндексданіпо черзі для кожного SSTable, відсутній ключ відсікає bloom-фільтр
Запис іде в commit log і memtable. Повна memtable скидається в новий незмінний SSTable, у фоні компакція зливає SSTable. Читання питає memtable, потім кожен SSTable через його bloom-фільтр.

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, доки її не прибере компакція. Тому запис ніколи не читає: «прочитати, змінити, записати» замінює «дописати».

Рядок партиції може лежати в 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: чотири SSTable подібного розміру зливаються в один більший, діапазони ключів перекриваються. Leveled: на кожному рівні файли з неперекривними діапазонами, рівень у десять разів більший за попереднійSize-tiered (STCS)ярус малих файлівзлиття чотирьохярус більшихкожен файл містить ключі з усього діапазонуLeveled (LCS)L0: свіжі скидання, діапазони перекритіL1: 10 файлів, діапазони не перекритіL2: 100 файлів, діапазони не перекритіна рівні ключ лежить в одному файлі
Size-tiered зливає файли подібного розміру, діапазони ключів у них перекриваються. Leveled тримає файли кожного рівня з неперекритими діапазонами, тож ключ на рівні лежить в одному файлі.
Стратегія Як зливає Читання Запис Місце
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) у трасуванні), а пропущена колонка нічого не створює. Не будувати чергу «записати, прочитати, видалити»: читач завжди починає з купи маркерів.

Подія з view_events типова для часового ряду: рядок лише дописується, читають вікно за часом, з віком цінність падає. Звідси три потреби: партиціювання за часом, щоб запит за тиждень не читав роки; ретеншн (термін зберігання), що прибирає старе цілими блоками; даунсемплінг, що замінює старі події агрегатами.

PostgreSQL: партиції за часом

Section titled “PostgreSQL: партиції за часом”

Декларативне партиціювання з модуля 13: партиція на місяць за occurred_at.

СпробуйPostgreSQLCtrl+Enter — виконати

Межі задано в UTC, тож київська доба на межі місяців зачепить дві партиції. Запит за тиждень читає одну: планувальник відкидає решту (partition pruning).

СпробуйPostgreSQLCtrl+Enter — виконати

На 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.

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, і денний звіт не перераховує події:

СпробуйPostgreSQLCtrl+Enter — виконати

На small 60 718 подій стали 1094 рядками, дашборд за місяць читає 16 буферів замість 712. Унікальний індекс потрібен для REFRESH MATERIALIZED VIEW CONCURRENTLY, що не блокує читачів. Оновлення перераховує все, тож для великих рядів агрегат ведуть таблицею з дописуванням нового дня. Підступ: count(DISTINCT session_id) за дні не складається в місячний, бо сесія могла бути у два дні.

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).

Усередину Scylla дивляться через nodetool tablestats (SSTable, memtable, bloom-фільтр, найбільша партиція), nodetool tablehistograms (розміри партицій), nodetool compactionhistory (злиття з bytes_out) і TRACING ON у cqlsh (кеш чи SSTable, скільки рядків прочитано й скільки мертві). Експеримент із tombstone відтворює ./dataset/loaders/scylla/tombstones.sh.

Допис «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).

Перевір себе

1. Чому LSM-дерево пише швидше за B-дерево?
2. Запит за ключем, якого немає, повертається за 0.6 мс при пʼяти SSTable. Чому диск не читався?
3. У партиції видалили по одному 36 000 найстаріших подій, і запит «10 найстаріших» сповільнився. Що найточніше це пояснює?
4. Потрібне «замовлення за пунктом видачі й днем», а таблиця `orders_by_customer` має ключ `customer_id`. Що робити?
5. Коефіцієнт реплікації 3. Яка пара рівнів гарантує читання останнього підтвердженого запису й переживає відмову одного вузла?

L9. Одна задача в трьох моделях: та сама предметна область у реляційній моделі, в MongoDB і в моделі «таблиця на запит», з новими вимогами посеред роботи.

  • 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.