← назад к разделу

«Сколько выручки принёс каждый магазин за январь?» — безобидный на вид вопрос, который способен положить рабочую базу приложения. Дело не в размере данных: это запрос из другого мира. Запросы к данным делятся на два класса, устроенных противоположно, — что хорошо для одного, мучительно для другого.

одна таблица orders, два запроса и две раскладки на диске построчно: строка лежит одним куском по столбцам: каждый столбец — свой файл id id customer customer status status amount amount 41 41 Ann Ann NEW NEW 120 120 42 42 Bob Bob PAID PAID 340 340 43 43 Cat Cat PAID PAID 90 90 44 44 Dan Dan NEW NEW 75 75 OLTP: WHERE id = 42 — нашли по индексу одну строку, ответ за миллисекунды OLAP: sum(amount) — построчно приходится поднять все 16 ячеек, нужен один столбец та же сумма по столбцовой раскладке читает 4 ячейки одного файла — и они сжимаются

Построчная раскладка выигрывает, когда нужна вся строка по ключу, и проигрывает, когда нужен один столбец из всех строк: ненужные ячейки всё равно поднимаются с диска. Столбцовая раскладка читает ровно тот файл, который спросили, а однотипные значения внутри файла хорошо сжимаются.

Обязательно

Два способа обращаться к даннымспросят на собеседовании

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-систем много, а истории накопились терабайты.

склад данных сайт заказы доставка статусы CRM клиенты платежи оплаты аналитик отчёты

Аналитику собирают не из одной базы: копии из всех рабочих систем стекаются в общий склад, и только он отвечает на вопросы аналитика.

Схема «звезда»: факты и измерения

Аналитику нужен отчёт «продажи по регионам за квартал», а собирать его приходится из тридцати нормализованных таблиц приложения, и каждый новый отчёт это новый лабиринт соединений. Поэтому в мире приложений схемы разные под каждую задачу, а аналитические склады почти все устроены одинаково — по схеме «звезда».

В центре звезды — таблица фактов: одна строка на каждое событие («покупатель купил такой-то товар в такой-то момент»), столбцов сотни, строк миллиарды. Вокруг — таблицы измерений: товар, магазин, покупатель, дата, акция. Факт ссылается на них внешними ключами, а измерения отвечают «кто, что, где, когда и почему» про каждое событие.

Даже дата — отдельное измерение: строка на каждый календарный день с признаками вроде «праздник/будни», иначе не спросишь «как продажи в выходные против будней». Вариант, где измерения разбиты на подтаблицы, называют «снежинкой», но чаще выигрывает плоская звезда.

таблица фактов товар product_id магазин store_id покупатель customer_id дата date_id акция promo_id

Смотрите на ключи: таблица фактов хранит только числа и ссылки, а расшифровка «кто, что, где, когда» лежит в измерениях вокруг неё.

Столбцовое хранение: почему аналитика летаетспросят на собеседовании

Обычные 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, события отправляют целиком.
  • Главный инструмент ускорения в аналитике — порядок строк на диске плюс сводки по блокам, а не индекс; ключ сортировки выбирают от запросов и меняют пересозданием таблицы.
  • Нужен ли склад, меряют четырьмя числами: время тяжёлого отчёта, объём сканов, число источников, частота; порядок действий — сводки, реплика, колоночное хранилище, склад.

Что почитать дальше