Row-store vs column-store: физика чтения и сжатия
Прошлый урок закончился выводом: аналитическому запросу нужны 3 столбца из 33, а построчное хранилище всё равно поднимает с диска все 33. Это утверждение требует доказательства на уровне физики. Этот урок спускается «до железа» — к тому, как байты лежат на странице диска, — и показывает, почему один и тот же набор данных может читаться в десять раз быстрее или медленнее в зависимости от одного решения: хранить данные по строкам или по столбцам.
Это решение — не абстракция. Оно объясняет, почему PostgreSQL хорош для приложения, а ClickHouse, BigQuery, Snowflake, DuckDB и Parquet-файлы хороши для аналитики. И оно объясняет, почему размерная модель с её узкими fact-таблицами и широкими dimension-таблицами особенно хорошо ложится именно на колоночное хранение.
Страница — единица обмена с диском
Сначала базовый факт о любой СУБД: данные читаются с диска не байтами и не строками, а страницами (page, block) фиксированного размера. У PostgreSQL страница — 8 КБ. Когда движку нужна одна строка, он не может прочитать «только её» — он читает всю страницу, на которой эта строка лежит, целиком: 8 КБ за одну операцию ввода-вывода.
Из этого следует главный принцип производительности: важно не сколько данных вам нужно, а сколько страниц придётся поднять с диска, чтобы их достать. Если нужные вам байты разбросаны по тысяче страниц — вы прочитаете тысячу страниц, даже если суммарно полезных данных там на одну страницу. Вся разница между построчным и поколоночным хранением — в том, как они раскладывают данные по страницам, и, значит, сколько лишнего попадёт в каждое чтение.
Row-store: строка лежит целиком
Row-store (построчное хранилище) хранит данные так, как их рисуют в таблице: строка за строкой. Все столбцы одной строки лежат на диске подряд, физически рядом.
Логическая таблица orders:
order_id | customer_id | order_date | status | total_amount | ship_country
88001 | 4471 | 2025-01-03 | shipped | 149.90 | DE
88002 | 9920 | 2025-01-03 | paid | 38.00 | FR
88003 | 4471 | 2025-01-04 | shipped | 512.40 | DE
Физически на диске (row-store) — строки подряд:
[88001|4471|2025-01-03|shipped|149.90|DE][88002|9920|2025-01-03|paid|38.00|FR][88003|...]
\___________ страница ___________/
Для OLTP это идеально. Запрос WHERE order_id = 88002 приводит к одной строке — а вся строка целиком лежит на одной странице. Один ввод-вывод поднимает 8 КБ, и в них есть все нужные столбцы строки. Аналогично вставка новой строки — это дописать один непрерывный кусок в конец. Row-store оптимален, когда вам нужны все столбцы немногих строк.
Теперь аналитический запрос: SELECT SUM(total_amount) FROM orders WHERE order_date >= '2025-01-01'. Нужны два столбца: order_date и total_amount. Но в row-store они вкраплены между всеми остальными. Чтобы добраться до total_amount каждой строки, движок поднимает страницу целиком — а на странице вперемешку лежат order_id, customer_id, status, ship_country и ещё тридцать столбцов. Все они едут с диска в память. И выбрасываются. Из 8 КБ поднятой страницы полезны, может быть, 800 байт. Девяносто процентов ввода-вывода потрачено впустую.
Column-store: столбец лежит целиком
Column-store (поколоночное хранилище) переворачивает раскладку. Данные хранятся не по строкам, а по столбцам: все значения order_id лежат подряд, отдельно от них все значения customer_id подряд, отдельно все total_amount подряд.
Та же таблица, физически на диске (column-store) — столбцы подряд:
order_id: [88001, 88002, 88003, 88004, ...] <- одна непрерывная область
customer_id: [4471, 9920, 4471, 3310, ...] <- другая область
order_date: [2025-01-03, 2025-01-03, 2025-01-04, ...]
status: [shipped, paid, shipped, paid, ...]
total_amount: [149.90, 38.00, 512.40, 27.50, ...]
ship_country: [DE, FR, DE, IT, ...]
Теперь тот же аналитический запрос. Нужны order_date и total_amount — движок читает только две непрерывные области, область order_date и область total_amount. Остальные 31 столбец вообще не трогаются — они в других местах диска, и движок к ним не обращается. Это называется column pruning: запрос читает с диска ровно те столбцы, которые ему нужны, и ни байта больше.
Эффект драматичный. Если строка состоит из 33 столбцов, а запросу нужны 3 — column-store поднимает с диска примерно одну одиннадцатую данных по сравнению с row-store. Это не оптимизация на проценты, это разница в разы.
Почему column-store ещё и отлично сжимается
Меньший объём чтения — только первая половина выгоды. Вторая — сжатие, и она не менее важна.
Сжатие работает тем лучше, чем более похожи стоящие рядом данные. И вот ключевое наблюдение: внутри одного столбца все значения однотипны и часто повторяются. Столбец ship_country — это миллионы значений из крошечного набора DE, FR, IT, ES, .... Столбец status — из набора paid, shipped, delivered, cancelled. Столбец order_date — это монотонно растущие даты с маленькими шагами. В row-store эти значения перемешаны с разнотипными соседями. В column-store они лежат сплошным однородным потоком — и сжимаются превосходно. Три базовых приёма:
- Run-length encoding (RLE). Если столбец отсортирован или содержит длинные серии одинаковых значений, вместо
DE, DE, DE, DE, DEхранится пара «значение DE, повторов 5». Тысяча подряд идущихshippedсжимается до одной записи со счётчиком. - Dictionary encoding. Движок строит словарь уникальных значений:
{0: paid, 1: shipped, 2: delivered, 3: cancelled}— и хранит сам столбец как поток крошечных целых-кодов0,1,1,2,0,...вместо длинных строк. Столбец из миллионов строковыхstatusпревращается в миллионы двухбитных чисел. - Delta encoding. Для отсортированных числовых рядов (даты, последовательные id) хранятся не сами значения, а разницы между соседними: вместо
88001, 88002, 88003хранится88001, +1, +1. Маленькие дельты занимают мало бит.
Итог: типичная аналитическая колоночная таблица сжимается в 5-10 раз, а на «удобных» столбцах и сильнее. Сжатие даёт двойной выигрыш. Во-первых, данные занимают меньше места на диске. Во-вторых — и это важнее — с диска поднимается меньше байт: сжатый столбец читается быстрее, а распаковка на современном CPU практически бесплатна по сравнению со стоимостью дискового ввода-вывода. Меньше байт с диска плюс быстрая распаковка — суммарно быстрее.
Сжатие — причина, по которой колоночные warehouse спокойно держат fact-таблицы в миллиарды строк. Атомарный grain (одна строка на каждое мельчайшее событие — об этом следующий модуль) порождает гигантские таблицы. Без колоночного сжатия 5 миллиардов строк были бы неподъёмны; с ним — рабочая повседневность.
Обратная сторона: почему column-store плох для OLTP
Если column-store так хорош, почему приложения по-прежнему работают на row-store? Потому что у колоночной раскладки есть симметричная слабость, и проявляется она ровно на OLTP-нагрузке.
Точечное чтение строки дорого. Запрос WHERE order_id = 88002 хочет всю строку — все 33 столбца. В column-store эти 33 значения лежат в 33 разных местах диска. Чтобы собрать одну строку, движок делает 33 отдельных обращения и склеивает значение из каждой колоночной области. То, что в row-store — один ввод-вывод, в column-store — 33. Для аналитики это неважно (она читает много строк, а не одну), для OLTP — катастрофа.
Вставка и обновление дороги. Вставить одну строку в row-store — дописать один непрерывный кусок. Вставить строку в column-store — затронуть все 33 колоночные области, в каждую добавить по одному значению, потенциально пересчитать сжатие блоков. Обновление одного поля — тоже работа в сжатом блоке. Поэтому колоночные системы любят пакетную загрузку (миллионы строк разом, эффективно) и не любят поток одиночных транзакций.
Получается чёткое разделение, замыкающее первый урок модуля:
| Свойство | Row-store | Column-store |
|---|---|---|
| Раскладка на диске | строки целиком, подряд | столбцы целиком, подряд |
| Чтение всех столбцов одной строки | один I/O — быстро | много I/O — медленно |
| Скан немногих столбцов многих строк | поднимает лишнее — медленно | column pruning — быстро |
| Сжатие | слабое (соседи разнотипны) | сильное, 5-10x (соседи однотипны) |
| Вставка одной строки | дёшево | дорого |
| Идеальная нагрузка | OLTP | OLAP |
| Примеры систем | PostgreSQL, MySQL, Oracle | ClickHouse, BigQuery, Snowflake, DuckDB, Parquet |
Это не «один формат лучше другого». Это две точки на компромиссе, заданном физикой страничного ввода-вывода: row-store жертвует скоростью аналитического скана ради дешёвой точечной операции, column-store — наоборот. Разделение OLTP и OLAP на разные системы из первого урока теперь имеет физическое обоснование: разным нагрузкам выгодна разная раскладка байт на диске, и совместить их в одном файле нельзя.
Row vs Columnar: детальный разбор физики форматов Анатомия Part в ClickHouse: файлы столбцов на дискеКогда вы экспортируете данные в файл Parquet — вы пишете их в колоночном формате со сжатием. Поэтому Parquet — стандарт хранения аналитических датасетов и основа форматов lakehouse (Delta Lake, Iceberg). А CSV — это, по сути, row-store в текстовом виде: ни column pruning, ни сжатия по столбцам.
Попробуй сам
Возьмите воображаемую таблицу событий веб-аналитики: 50 столбцов, 200 миллионов строк. Среди столбцов — event_type (около 20 уникальных значений), country (около 200 значений), event_timestamp (растёт монотонно), user_id, url, revenue и так далее.
Ответьте письменно на четыре вопроса. Первый: запрос SELECT country, COUNT(*) FROM events GROUP BY country — сколько столбцов из 50 он реально читает, и во сколько раз меньше данных поднимет column-store против row-store? Второй: какие три столбца из перечисленных сожмутся лучше всего и каким приёмом (RLE, dictionary, delta) для каждого? Третий: запрос приложения «достань событие по его id целиком» — почему он будет медленным на column-store? Четвёртый: сформулируйте одним предложением, почему таблицу событий веб-аналитики разумно хранить в column-store, а таблицу активных корзин интернет-магазина — в row-store.