«Сколько выручки принёс каждый магазин за январь?» — безобидный на вид вопрос, который способен положить рабочую базу приложения. Дело не в размере данных: это запрос из другого мира. Запросы к данным делятся на два класса, устроенных противоположно, — что хорошо для одного, мучительно для другого.
Построчная раскладка выигрывает, когда нужна вся строка по ключу, и проигрывает, когда нужен один столбец из всех строк: ненужные ячейки всё равно поднимаются с диска. Столбцовая раскладка читает ровно тот файл, который спросили, а однотипные значения внутри файла хорошо сжимаются.
Два способа обращаться к даннымспросят на собеседовании
OLTP (online transaction processing) — мир приложений, обычная работа с пользователями: кто-то оформил заказ, открыл профиль, обновил корзину. Каждый такой запрос трогает несколько строк, найденных по ключу: нашли по индексу, прочитали или изменили, ответили за миллисекунды. Данные тут — состояние мира прямо сейчас. Под это заточены B-деревья и вся дисциплина транзакций.
OLAP (online analytical processing) — мир аналитики. Вопрос задаёт уже не пользователь, а аналитик: «на сколько больше бананов продали во время акции, чем обычно?». Такой запрос пробегает по миллионам строк, но из каждой берёт два-три столбца и сворачивает их в один итог — сумму, количество, среднее. Данные тут — история событий за годы, и узкое место другое: не «как быстро найти строку», а «сколько байт в секунду мы прокачиваем через сканирование».
Разницу видно в запросах. Адресный запрос приложения:
SELECT id, status, total_amount FROM orders WHERE id = 42;
И свёртка аналитика:
живой пример
SELECT date_trunc('month', created_at) AS month,
count(*) AS orders,
sum(total_amount) AS revenue
FROM orders
WHERE status = 'PAID'
GROUP BY 1
ORDER BY 1;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Язык один, а нужно им разное: первому — индекс по ключу, второму — скорость сканирования. Поэтому и хранить данные под эти два вида запросов выгодно по-разному.
Склад данных и ETL
Пускать аналитиков в рабочую базу приложения — плохо с обеих сторон: тяжёлые сканы отъедают ресурсы у пользовательских транзакций, и в час пик это заметно. К тому же OLTP-систем в компании много — сайт, склад, доставка, CRM, — одним запросом их не опросишь.
Поэтому аналитику выносят в отдельную базу — склад данных (data warehouse): копию данных из всех OLTP-систем, собранную в одном месте и доступную только для чтения. Наполняет её процесс ETL — три шага: extract (извлечь), transform (привести к удобной для аналитики форме), load (загрузить в склад). Работает он либо периодическими выгрузками, либо непрерывным потоком событий.
Сегодня буквы чаще переставляют: ELT — сначала грузим данные в склад как есть, в сырой слой, а преобразуем уже внутри, его же силами. Так дешевле (склад считает быстрее отдельной машины) и спокойнее: если правило преобразования оказалось кривым, витрину пересчитывают из сырого слоя, а не выпрашивают выгрузку заново.
Маленькой компании склад не нужен: данные помещаются в обычный PostgreSQL. Склад появляется, когда OLTP-систем много, а истории накопились терабайты.
Аналитику собирают не из одной базы: копии из всех рабочих систем стекаются в общий склад, и только он отвечает на вопросы аналитика.
Схема «звезда»: факты и измерения
Аналитику нужен отчёт «продажи по регионам за квартал», а собирать его приходится из тридцати нормализованных таблиц приложения, и каждый новый отчёт это новый лабиринт соединений. Поэтому в мире приложений схемы разные под каждую задачу, а аналитические склады почти все устроены одинаково — по схеме «звезда».
В центре звезды — таблица фактов: одна строка на каждое событие («покупатель купил такой-то товар в такой-то момент»), столбцов сотни, строк миллиарды. Вокруг — таблицы измерений: товар, магазин, покупатель, дата, акция. Факт ссылается на них внешними ключами, а измерения отвечают «кто, что, где, когда и почему» про каждое событие.
Даже дата — отдельное измерение: строка на каждый календарный день с признаками вроде «праздник/будни», иначе не спросишь «как продажи в выходные против будней». Вариант, где измерения разбиты на подтаблицы, называют «снежинкой», но чаще выигрывает плоская звезда.
Смотрите на ключи: таблица фактов хранит только числа и ссылки, а расшифровка «кто, что, где, когда» лежит в измерениях вокруг неё.
Столбцовое хранение: почему аналитика летаетспросят на собеседовании
Обычные OLTP-базы хранят данные построчно — вся строка лежит на диске одним куском, и это идеально для «прочитай заказ целиком». Но аналитический запрос из таблицы в сотню столбцов трогает три, а построчная база всё равно поднимает строки целиком, со всеми ненужными столбцами.
Столбцовое хранилище переворачивает раскладку: каждый столбец лежит отдельным файлом, а строка восстанавливается по позиции — пятое значение в каждом файле относится к одной и той же пятой строке. Запрос читает только нужные столбцы — выигрыш в разы. Дальше включается второй эффект, ещё сильнее: значения внутри столбца похожи друг на друга (в столбце «товар» миллиарды строк, но уникальных значений тысяч десять), поэтому столбцы прекрасно сжимаются — в десятки раз, а по отсортированному столбцу и в сотни: одинаковые значения ложатся подряд. Фильтры превращаются в быстрые побитовые операции, и на прогретых данных сканирование упирается уже не в диск, а в скорость, с которой процессор разжимает блоки и складывает числа. Холодный скан, который впервые тянет данные с диска или по сети, ждёт по-прежнему их.
Ту же арифметику видно на программе: одна таблица в двух раскладках, считаем сумму столбца.
живой пример
import java.util.List;
public class ColumnarScan {
record Order(int id, String customer, String status, int amount) {}
public static void main(String[] args) {
List<Order> rows = List.of(
new Order(41, "Ann", "NEW", 120),
new Order(42, "Bob", "PAID", 340),
new Order(43, "Cat", "PAID", 90),
new Order(44, "Dan", "NEW", 75));
int rowCells = 0;
int rowSum = 0;
for (Order order : rows) {
rowCells += 4;
rowSum += order.amount();
}
int[] amountFile = {120, 340, 90, 75};
int colCells = 0;
int colSum = 0;
for (int value : amountFile) {
colCells++;
colSum += value;
}
System.out.println("построчно: сумма " + rowSum + ", поднято ячеек " + rowCells);
System.out.println("по столбцам: сумма " + colSum + ", поднято ячеек " + colCells);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
type order struct {
id int
customer string
status string
amount int
}
func main() {
rows := []order{{41, "Ann", "NEW", 120}, {42, "Bob", "PAID", 340}, {43, "Cat", "PAID", 90}, {44, "Dan", "NEW", 75}}
rowCells, rowSum := 0, 0
for _, o := range rows {
rowCells += 4
rowSum += o.amount
}
amountFile := []int{120, 340, 90, 75}
colCells, colSum := 0, 0
for _, value := range amountFile {
colCells++
colSum += value
}
fmt.Printf("построчно: сумма %d, поднято ячеек %d\n", rowSum, rowCells)
fmt.Printf("по столбцам: сумма %d, поднято ячеек %d\n", colSum, colCells)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const rows = [
{ id: 41, customer: 'Ann', status: 'NEW', amount: 120 },
{ id: 42, customer: 'Bob', status: 'PAID', amount: 340 },
{ id: 43, customer: 'Cat', status: 'PAID', amount: 90 },
{ id: 44, customer: 'Dan', status: 'NEW', amount: 75 },
];
let rowCells = 0, rowSum = 0;
for (const order of rows) { rowCells += 4; rowSum += order.amount; }
const amountFile = [120, 340, 90, 75];
let colCells = 0, colSum = 0;
for (const value of amountFile) { colCells++; colSum += value; }
console.log(`построчно: сумма ${rowSum}, поднято ячеек ${rowCells}`);
console.log(`по столбцам: сумма ${colSum}, поднято ячеек ${colCells}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: int
customer: str
status: str
amount: int
rows = [Order(41, "Ann", "NEW", 120), Order(42, "Bob", "PAID", 340),
Order(43, "Cat", "PAID", 90), Order(44, "Dan", "NEW", 75)]
row_cells = row_sum = 0
for order in rows:
row_cells += 4
row_sum += order.amount
amount_file = [120, 340, 90, 75]
col_cells = col_sum = 0
for value in amount_file:
col_cells += 1
col_sum += value
print(f"построчно: сумма {row_sum}, поднято ячеек {row_cells}")
print(f"по столбцам: сумма {col_sum}, поднято ячеек {col_cells}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Итог одинаковый, а поднято 16 ячеек против 4 — и это на таблице в четыре строки.
Так устроены ClickHouse, Vertica, Redshift и формат Parquet. Буквально «файл на столбец» — это про ClickHouse; в Parquet всё лежит в одном файле, но внутри он нарезан на группы строк, а каждая группа — на куски по столбцам. Идея одна: значения одного столбца лежат рядом, и читать их можно, не трогая соседей. Поэтому ClickHouse — не замена PostgreSQL, а инструмент другого мира. Плата за скорость чтения — дорогая запись: вставить строку «в середину» сжатых отсортированных столбцов нельзя, поэтому записи принимают пачками — пачка ложится отдельным куском, а куски сливаются в фоне, как в LSM-подходе.
Почему то же хранилище плохо читает одну строку
Механизм стоит проговорить, иначе вывод «колоночное для аналитики, строчное для операций» остаётся верой. Колоночное хранилище кладёт каждую колонку отдельно и сжимает её блоками: тысячи значений одного типа идут подряд, поэтому сжатие в разы, а скан одной колонки читает ровно её.
Теперь соберём одну строку целиком: нужно обратиться к файлу каждой колонки, найти в нём нужный блок, разжать его целиком (блок, а не значение) и достать один элемент. Таблица из пятидесяти колонок — это пятьдесят обращений и пятьдесят разжатий ради одной строки. Строчное хранилище прочитает ту же строку одним обращением к одной странице.
Отсюда и практическое правило: колоночное хранилище отвечает на «посчитай по миллиону строк» за доли секунды и на «покажи одну строку по идентификатору» — за десятки миллисекунд, то есть медленнее обычной базы на порядки. Точечные чтения в таких системах не делают вовсе, а если они нужны — держат рядом строчное хранилище, как и описано в статье.
Сортировка решает всё
Второй механизм, без которого непонятно, почему один и тот же запрос бывает быстрым и медленным: порядок строк на диске. У колоночных хранилищ есть ключ сортировки (в ClickHouse это ORDER BY, в хранилищах данных — ключ кластеризации), и данные физически лежат в этом порядке, а к каждому блоку хранится сводка: минимум и максимум по колонкам.
Поэтому запрос с условием по началу ключа сортировки читает несколько блоков вместо всей таблицы — это и есть главный инструмент ускорения в аналитике, аналог индекса. А запрос с условием по колонке, которой в ключе нет, честно сканирует всё, и единственное, что его спасает, — сводки по блокам (если значения коррелируют с порядком) или отдельные структуры вроде пропускающих индексов.
Отсюда следствие для проектирования: ключ сортировки выбирают от запросов и меняют его потом тяжело — обычно только пересозданием таблицы. Подробный разбор — в статье про моделирование в ClickHouse.
Когда склад не нужен
«Маленькой компании не нужен» — плохой критерий. Мерить стоит четырьмя числами.
Время самого тяжёлого отчёта в основной базе. Укладывается в секунды — склад не нужен. Минуты — стоит поискать материализованные сводки. Десятки минут и он мешает работе сервиса — разговор начинается.
Объём данных в сканах. До сотен миллионов строк обычная база при партиционировании и правильных индексах справляется. Миллиарды — уже её предел.
Число источников. Один источник — это отчёты по своей базе, и они делаются в ней же. Три и больше (основная база, платёжный провайдер, рекламный кабинет, журналы) — склад появляется не ради скорости, а ради того, чтобы данные вообще можно было соединить.
Частота. Отчёт раз в месяц можно считать хоть час ночью. Дашборд, который открывают сто раз в день, требует, чтобы данные уже были посчитаны.
Практический порядок: сначала материализованные сводки в основной базе, потом реплика для аналитики, потом отдельное колоночное хранилище — и только потом полноценный склад с моделью и конвейерами.
Как данные попадают в склад
Способов два, и они не взаимозаменяемы.
Выгрузки по расписанию. Ночью или раз в час данные выгружаются из источников и грузятся в склад. Просто, понятно, легко перезапустить; задержка равна периоду. Так строят большинство отчётности.
Поток изменений. Изменения из базы вылавливаются захватом изменений (CDC) и едут в склад почти сразу; задержка — секунды. Дороже в эксплуатации, зато данные свежие. Подробно — в статье про обработку потоков.
Выбирают по тому, какая задержка нужна отчёту, а не по тому, что интереснее строить. И почти всегда в зрелой системе живут оба: поток для того, что нужно свежим, выгрузки — для остального.
Материализованные сводки
Если тысяча запросов в день считает SUM(total_amount) по одним и тем же осям, сканировать всё каждый раз — расточительство. Ответ считают заранее и кладут в материализованное представление — таблицу с готовым результатом запроса (в PostgreSQL это materialized view). Крайняя форма идеи — OLAP-куб: сетка итогов по комбинациям измерений (дата × товар × магазин), где «выручка за вчера» — одно чтение ячейки.
Плата — гибкость: в кубе нельзя спросить то, чего нет среди его измерений (например, «долю продаж дешевле 100 ₽», если цена не заведена измерением). Поэтому склады хранят сырые события, а сводки держат сверху — как ускоритель частых запросов.
Где это применяется
Развилка всплывает раньше, чем кажется: первая же сводка «продажи по дням» — уже аналитический запрос. Пока данных немного, хватает читающей реплики PostgreSQL и материализованных представлений; когда сканы стали тяжёлыми и частыми — заводят столбцовое хранилище и поток событий в него.
Где спотыкаются начинающие:
- Гоняют аналитику по рабочей базе. Один тяжёлый скан в час пик — и время ответа пользовательских запросов уезжает в хвост.
- Строят одну «универсальную» схему на всё. Нормализованная OLTP-схема неудобна аналитику, а звезда — приложению: это два представления одних данных, и это нормально.
- Берут ClickHouse под точечные чтения («найди заказ по id»): на таком запросе столбцовое хранилище вчистую проигрывает PostgreSQL — и наоборот.
- Прячут все ответы в кубы. Сводка без сырых событий — тупик: новый вопрос потребует измерения, которого в кубе нет.
Глубже: озеро данных и сырой слойрасширенное
Схема «звезда» и склад данных выше предполагают, что кто-то заранее решил, какие факты и измерения нужны. Первый же новый вопрос аналитика («а сколько пользователей отменяли заказ после смены адреса?») упирается в то, что таких полей в витрине нет, а исходные события уже превращены в агрегаты. Ответ на это устроен слоями.
Сырой слой. События и выгрузки из источников складывают как пришли, без очистки и без выбора полей, в объектное хранилище файлами (сегодня это Parquet, о котором говорит статья про хранение файлов и базу): raw/orders/2026/09/24/part-0001.parquet. Слой неизменяемый: туда только дописывают. Смысл в том, что любую витрину можно построить заново, и вопрос, которого не ждали, отвечается из сырого слоя за часы, а не за квартал разработки. Это тот же принцип, что в статье про производные данные: источник правды один, производное пересчитываемо.
Очищенный слой. Из сырого делают таблицы с типами, дедупликацией, приведёнными справочниками; тут живут Iceberg или Delta, чтобы у таблицы были схема и снимки. Витрины это уже звезда под конкретные отчёты, и их может быть много, по одной на команду. Три слоя называют по-разному (bronze, silver, gold; raw, core, marts), но структура одна.
Так меняется порядок работы: не ETL (извлечь, преобразовать, загрузить в склад), а ELT: извлечь и загрузить как есть, а преобразовать уже внутри, запросами, которые лежат в репозитории и пересобирают слои по расписанию. Преобразования пишут на SQL, а движок к файлам подключают любой: ClickHouse, DuckDB, Trino, Spark. Для сервиса на бэкенде из этого следует одно: события отправляют в шину или в сырой слой целиком, с полным телом, а не «те три поля, которые сейчас нужны отчёту».
Где озеро не нужно: когда все вопросы известны, объём укладывается в реплику PostgreSQL и команды аналитиков нет. Витрины прямо из реплики дешевле, и статья про PostgreSQL против ClickHouse объясняет, где проходит граница.
Коротко
- OLTP — несколько строк по ключу, ответ за миллисекунды; OLAP — миллионы строк, два-три столбца из каждой, один итог на выходе.
- Аналитику уводят с рабочей базы в склад данных, наполняет его ETL (а чаще ELT: сырьё в сырой слой, преобразования внутри склада) — выгрузками по расписанию или потоком изменений, когда нужна свежесть.
- Склад устроен звездой: таблица фактов в центре, измерения — товар, магазин, дата — вокруг.
- Построчная раскладка поднимает строку целиком, столбцовая читает только нужные файлы, и они сжимаются в десятки раз.
- Плата за столбцовое хранение — дорогие точечные запись и чтение: одна строка собирается из файла каждой колонки с разжатием блока, поэтому ClickHouse не заменяет PostgreSQL.
- Кубы и материализованные представления ускоряют частые вопросы, но сырые события склад хранит всегда — иначе новый вопрос задать не из чего.
- Озеро данных: сырой неизменяемый слой файлами в объектном хранилище, очищенный слой с Iceberg или Delta, витрины под отчёты; ELT вместо ETL, события отправляют целиком.
- Главный инструмент ускорения в аналитике — порядок строк на диске плюс сводки по блокам, а не индекс; ключ сортировки выбирают от запросов и меняют пересозданием таблицы.
- Нужен ли склад, меряют четырьмя числами: время тяжёлого отчёта, объём сканов, число источников, частота; порядок действий — сводки, реплика, колоночное хранилище, склад.
Что почитать дальше
- B-деревья и LSM — движки под обоими мирами.
- PostgreSQL или ClickHouse — та же развилка на практике.
- Моделирование в ClickHouse — звезда в столбцовой базе.
- Материализованные представления PostgreSQL — сводки внутри рабочей базы.