Learning Platform
Глоссарий Troubleshooting
Урок 11.03 · 18 мин
Начальный
row-storecolumn-storecompressionstorage

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 байт. Девяносто процентов ввода-вывода потрачено впустую.

Row-store: аналитический скан читает лишнее
ЗапросАналитике нужны 2 столбца: order_date и total_amount
но страница неделима
Страница 8 КБНа странице лежат целые строки: все 33 столбца вперемешку, нужные и ненужные
подняты все
90% выброшено31 ненужный столбец каждой строки прочитан с диска и отброшен — впустую потраченный I/O

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: скан читает только нужные столбцы
ЗапросТе же 2 столбца: order_date и total_amount
столбцы хранятся раздельно
2 областиЧитаются только две непрерывные колоночные области; остальные 31 не трогаются
column pruning
0% лишнегоС диска поднято ровно столько данных, сколько нужно запросу

Почему 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 практически бесплатна по сравнению со стоимостью дискового ввода-вывода. Меньше байт с диска плюс быстрая распаковка — суммарно быстрее.

NOTE

Сжатие — причина, по которой колоночные 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-storeColumn-store
Раскладка на дискестроки целиком, подрядстолбцы целиком, подряд
Чтение всех столбцов одной строкиодин I/O — быстромного I/O — медленно
Скан немногих столбцов многих строкподнимает лишнее — медленноcolumn pruning — быстро
Сжатиеслабое (соседи разнотипны)сильное, 5-10x (соседи однотипны)
Вставка одной строкидёшеводорого
Идеальная нагрузкаOLTPOLAP
Примеры системPostgreSQL, MySQL, OracleClickHouse, BigQuery, Snowflake, DuckDB, Parquet

Это не «один формат лучше другого». Это две точки на компромиссе, заданном физикой страничного ввода-вывода: row-store жертвует скоростью аналитического скана ради дешёвой точечной операции, column-store — наоборот. Разделение OLTP и OLAP на разные системы из первого урока теперь имеет физическое обоснование: разным нагрузкам выгодна разная раскладка байт на диске, и совместить их в одном файле нельзя.

Row vs Columnar: детальный разбор физики форматов Анатомия Part в ClickHouse: файлы столбцов на диске
TIP

Когда вы экспортируете данные в файл 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.


Проверка знанийKnowledge check
Почему column-store одновременно и быстрее читает аналитический запрос, и сильнее сжимает данные, чем row-store?
ОтветAnswer
Обе выгоды происходят из одного решения — хранить данные по столбцам, а не по строкам. Скорость чтения: данные поднимаются с диска страницами фиксированного размера, и важно не сколько данных нужно, а сколько страниц придётся прочитать. В row-store строка лежит целиком, поэтому скан, которому нужны 3 столбца из 33, всё равно поднимает страницы со всеми 33 столбцами и выбрасывает 90% поднятого. В column-store каждый столбец лежит отдельной непрерывной областью, поэтому запрос читает ровно нужные столбцы и ни байта больше — это column pruning. Сжатие: оно работает тем лучше, чем более похожи соседние данные. Внутри одного столбца все значения однотипны и часто повторяются (страны, статусы, монотонные даты), поэтому колоночный поток сжимается приёмами RLE, dictionary encoding и delta encoding в 5-10 раз; в row-store те же значения перемешаны с разнотипными соседями и сжимаются плохо. Сжатие усиливает первую выгоду: с диска поднимается ещё меньше байт, а распаковка на CPU почти бесплатна по сравнению со стоимостью ввода-вывода. Обратная сторона: точечное чтение или вставка одной строки в column-store дороги, потому что значения строки разбросаны по многим колоночным областям — поэтому OLTP остаётся за row-store.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое column pruning и почему он ускоряет аналитический запрос?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5