Data engineering
OLTP против OLAP: разделение нагрузок, определяющее data engineering
Команда роста выкатывает дашборд: «выручка по странам за последние 90 дней». Он смотрит в prod-реплику Postgres, потому что «это же просто чтение». В 9 утра запрос разворачивает SUM по 40M строк orders, вытесняет из shared_buffers горячие страницы B-дерева, нужные каждому оформлению заказа, и забивает I/O на 30 секунд. Checkout p99 прыгает с 40ms до 2s, лаг реплики растёт до 90s, а дежурного будят с «база лежит». Ничего не легло. Один аналитический скан отравил транзакционную систему. Индекс бы не помог — не было WHERE, чтобы сузить выборку; запросу нужна была каждая строка.
Две нагрузки, две противоположные формы
OLTP — Online Transaction Processing — это база приложения: INSERT заказа, UPDATE баланса, выборка одного пользователя по id. Паттерн доступа — точечные чтения и мелкие записи по нескольким строкам, тысячи раз в секунду, каждое за единицы миллисекунд. OLAP — Online Analytical Processing — это машина вопросов: средний чек по региону по месяцам, когорты удержания, «какие 1% пользователей дают 40% выручки». Она сканирует миллионы строк, но касается лишь горстки колонок таблицы, и запрос, идущий секунды, — это нормально.
Это не две настройки одной ручки. Они тянут физический дизайн в противоположные стороны. OLTP оптимизирует latency записи и выборку одной строки; OLAP оптимизирует пропускную способность скана по нескольким колонкам всей таблицы. Схема, индекс, формат хранения, отличный в одном, структурно плох в другом. Именно это напряжение — а не какой-то конкретный инструмент — и есть то, чем занимается весь остальной data engineering.
Row-store против column-store: дело в том, что лежит рядом с чем
Row-store (Postgres, MySQL, большинство OLTP-движков) держит колонки строки на диске подряд: [id, user, country, total, created_at] для строки 1, потом вся строка 2, и так далее. Это идеально для OLTP — чтение или запись одного целого заказа касается одной страницы. Но запроси SUM(total) по 40M строк, и движок обязан прочитать каждую страницу таблицы, протащив id, user, country, created_at через память лишь чтобы добраться до total. Ты платишь I/O за 50 колонок, чтобы использовать 1.
Column-store (ClickHouse, Redshift, BigQuery, Snowflake) переворачивает layout: все значения total лежат вместе, все значения country вместе. Теперь SUM(total) читает только колонку total — column pruning. Запрос, касающийся 3 из 50 колонок, читает примерно 6% байтов, которые прочитал бы row-store. Это одно свойство — большая часть разрыва.
| Измерение | Row-store (OLTP) | Column-store (OLAP) |
|---|---|---|
| Физический layout | Все колонки строки вместе | Все значения колонки вместе |
| Хорош в | Точечное чтение/запись целых строк | Скан + агрегат нескольких колонок |
| Цена большого агрегата | Читает каждый байт каждой строки | Читает только нужные колонки (~6%) |
| Сжатие | ~1.5–3× (смешанные типы на странице) | ~5–10× (один тип в колонке) |
| Запись одной строки | Дёшево — одна строка, одно место | Дорого — касается каждого файла колонки |
Почему колоночное сжимается в 5–10× и работает векторизованно
Хранить одну колонку вместе — значит хранить рядом значения одного типа, часто низкой кардинальности, и это сжимается куда лучше, чем мешанина строки из int, string, timestamp, float. Колонка country — это несколько сотен различных строк, повторённых миллионы раз: dictionary-кодируешь их в маленькие int, затем run-length-кодируешь прогоны, затем LZ4/ZSTD сверху. Row-store обычно достигают 1.5–3×; column-store берут 5–10× на аналитических данных и 30×+ на колонках низкой кардинальности. На 100M-строчном ClickBench ClickHouse держит датасет в ~9 GiB там, где Postgres нужно ~100 GiB. Меньше данных на диске — меньше I/O, а I/O — это узкое место для сканов, поэтому разрыв в сжатии расширяет разрыв в пропускной способности до 50–100×.
Второй множитель — vectorized execution. Row-движок обрабатывает запрос строка-за-строкой: на каждую строку — вызов функции, проверка типа, ветвление. Column-движок обрабатывает колонку батчами по тысячи подряд идущих значений одного типа, поэтому CPU крутит плотные циклы по массивам, дружественные к SIMD, с предсказуемыми ветвлениями и тёплым кэшем. Та же арифметика, на порядок меньше накладных расходов на значение. Pruning, сжатие и векторизация складываются: column-store обгоняют row-store в 10×–1000× на аналитических запросах — и «добавь индекс» этого не закроет, потому что у агрегата по всей таблице нечего искать.
Почему это работает
«Просто добавь индекс» — инстинкт, который тут проваливается, и стоит знать почему именно. Индекс превращает скан в seek, когда запрос выбирает малую долю строк. Аналитический агрегат — SUM, AVG, COUNT(*), свёртка за 90 дней — нуждается в большинстве или всех строках, поэтому планировщик правильно игнорирует индекс и делает последовательный скан; индекс лишь добавил бы цену записи. Индексы чинят селективность, а не объём скана. Фикс объёма скана — layout, читающий меньше байтов на строку: column-store.
Режим отказа: аналитика на prod OLTP-реплике
Hook — это режим отказа сеньорского уровня целиком. Read-реплика — всё ещё row-store с буферным кэшем, рассчитанным на транзакционный рабочий набор. Направь на неё долгий аналитический скан, и сразу пойдёт не так три вещи. Первое — вытеснение буферного кэша: скан тянет холодные страницы через shared_buffers и вытесняет горячие OLTP-страницы, поэтому транзакционные запросы, что были попаданиями в память, начинают бить в диск и тормозят в 10–100×. Второе — конкуренция за блокировки и ресурсы: устойчивый CPU, память под hash join и пропускная способность I/O — игра с нулевой суммой; аналитический запрос морит голодом транзакционные потоки на том же движке. Третье — лаг реплики: долгий запрос, держащий ресурсы, задерживает применение потока WAL, поэтому реплика отстаёт от primary, и всё, что её читает, отдаёт устаревшие данные.
Фикс архитектурный, а не флаг тюнинга: дай аналитике свой дом. Подними отдельное OLAP-хранилище (колоночный warehouse — Redshift/BigQuery/Snowflake — или ClickHouse) и питай его из OLTP непрерывно через CDC (change data capture, например Debezium, стримящий WAL в Kafka) или батчевый ETL/ELT. У двух систем теперь независимое хранилище, независимые кэши, независимые домены отказа. Дашборд бьёт в warehouse; checkout этого не чувствует. Цена, которую ты берёшь на себя, — это pipeline в эксплуатацию и от секунд до минут data latency, почти всегда верный обмен.
Продукт хочет дашборд «выручка по странам за 90 дней» по таблице orders на 40M строк в prod Postgres. Выбери архитектуру.
Запрос считает SUM(total) по 40M строк, но читает только колонку total. Почему column-store здесь громит row-store?
Дашборд аналитики, направленный на prod OLTP read-реплику, вызывает скачок checkout p99. Какую первопричину сеньор называет первой?
Расставь шаги, чтобы правильно убрать тяжёлую аналитику с prod OLTP-базы:
- 1 Распознать, что запрос — это OLAP: скан/агрегат по всей таблице, а не селективная выборка, которую чинит индекс
- 2 Поднять отдельное OLAP-хранилище (колоночный warehouse или ClickHouse) со своим хранилищем и кэшами
- 3 Питать его из OLTP непрерывно через CDC (например Debezium → Kafka) или батчевый ETL/ELT
- 4 Направить дашборды/аналитиков в OLAP-хранилище, оставив буферный кэш OLTP под транзакции
- 5 Принять лаг данных от секунд до минут как обмен на изолированный домен отказа
- 01Коллега спрашивает, почему нельзя просто добавить индекс, чтобы 90-дневный агрегат выручки летал на Postgres. Объясни.
- 02Почему гонять дашборд аналитики на prod read-реплике — это сбой, ждущий своего часа, и какая архитектура правильная?
OLTP и OLAP — две нагрузки с противоположными физическими нуждами: OLTP хочет крошечные индексированные чтения и записи целых строк, поэтому использует row-store, где колонки строки лежат подряд; OLAP хочет сканировать и агрегировать несколько колонок по всей таблице, поэтому использует column-store, где подряд лежит каждая колонка. Этот layout даёт column-store три множителя — column pruning (читать ~6% байтов для запроса, касающегося 3 из 50 колонок), сжатие 5–10×, потому что значения одного типа пакуются плотно, и векторизованное батчевое исполнение, — которые вместе делают аналитические запросы быстрее в 10×–1000×, и этот разрыв не закроет ни один индекс, потому что у агрегата по всей таблице нечего искать. Классический сеньорский сбой — гонять тяжёлую аналитику на prod OLTP-реплике, где один скан вытесняет горячий буферный кэш, конкурирует за I/O и CPU и гонит лаг реплики, превращая «это же просто чтение» в сбой checkout. Фикс архитектурный: отдельное колоночное OLAP-хранилище, питаемое из OLTP через CDC или ETL, с принятием небольшого лага данных в обмен на изолированные хранилище, кэши и домены отказа. HTAP-системы, пытающиеся обслужить обе на одном движке, существуют, но ни один production HTAP не устраняет конкуренцию за ресурсы — ею можно управлять, не избежать, поэтому раздел на два хранилища остаётся дефолтом.