Иерархический OLTP: измеряем следующий уровень унификации
Выносим разрезы аналитики на верхний уровень транзакции в квартетной модели: 1 млн транзакций, Postgres 16, честные цифры выигрышей и проигрышей.
В статье про масштабирование квартетной модели мы показали, что единая таблица id, up, t, val держит 31 млрд записей и 703 млн банковских транзакций. Там же есть неявное преимущество унификации, о котором стоит сказать вслух: пустые колонки мы не храним вовсе — отсутствующий реквизит просто не порождает строку, тогда как классическая широкая таблица тратит место на NULL в каждой из 50+ колонок.
Следующая идея звучала так: раз структура и так иерархична (up — родитель), почему бы не сделать OLTP с иерархией под разрезы аналитики? Берём список транзакций, выносим справочную часть и даты — филиал, валюту, дату, тип операции — на первый уровень («разрез»), а транзакционные детали оставляем вторым уровнем под ним. Повторяющиеся значения хранятся один раз на группу, агрегирующие выборки ходят по верхнему уровню, а уровней может быть и больше двух — их определяет частотность.
Гипотеза обещала сократить и хранение, и агрегирующие выборки, «ничем не жертвуя». Мы её измерили. Часть подтвердилась, часть — нет, и границы применимости оказались интереснее самой идеи.
Методика
Данные — как в статье про 703 млн: банковская транзакция с 54 реквизитами (PAN, суммы, даты, коды ISO 8583), из которых 45 заполнены. Сгенерирован 1 млн транзакций за 90 дней: 50 филиалов, 3 валюты, 10 MCC-типов, всё со скосами распределений. Postgres 16.4, одна машина (12 логических ядер, 16 ГБ RAM), индексы как в статье: (up, t) и (t, lower(left(val,127))). Замер — тёплый прогон (второй запуск подряд); скрипты генерации и замера открыты, всё воспроизводится.
Сравнивались пять раскладок одних и тех же данных:
| Раскладка | Что это | Строк | Разрезов | Транзакций на разрез |
|---|---|---|---|---|
wide | классическая широкая таблица, 54 колонки | 1,0 млн | — | — |
flat | плоские квартеты: транзакция + 45 реквизитов | 46,0 млн | — | — |
hier2 | разрез = дата + филиал | 44,0 млн | 4 500 | 222 |
hier4 | разрез = дата + филиал + валюта + тип | 42,5 млн | 102 423 | 9,8 |
hier8 | разрез = те же + ISO + ПК + POS + статус | 46,5 млн | 946 136 | 1,1 |
Результат 1: хранение — выигрыш есть, но скромный, и его легко потерять
| flat | hier2 | hier4 | hier8 | |
|---|---|---|---|---|
| Строк на транзакцию | 46,0 | 44,0 | 42,5 | 46,5 |
| Размер с индексами | 5,9 ГБ | 5,7 ГБ (−4%) | 5,5 ГБ (−7%) | 6,1 ГБ (+3%) |
Вынос четырёх полей из 45 экономит 7% — арифметика сходится: экономия ≈ доля вынесенных реквизитов, помноженная на (1 − 1/размер группы). Но hier8 — главный урок таблицы: восемь полей в разрезе дали 946 тысяч групп на миллион транзакций — по 1,1 транзакции на группу. Каждая группа — это её собственные строки, и хранение стало хуже плоского. Правило частотности из исходной идеи — не украшение, а условие выживания схемы: выносить поле в разрез можно только пока произведение кардинальностей разреза остаётся на порядки меньше числа транзакций.
Результат 1а: экономия зависит от профиля записи — на Ф110 это уже 40%
Семь процентов на банковской транзакции — следствие её профиля: у записи 45 заполненных полей, и почти все — высококардинальные детали (PAN, RRN, суммы), в разрез уходит лишь 4 из них. Экономия подчиняется простой формуле: доля вынесенных полей × (1 − 1/размер группы).
Противоположный профиль — иерархия из статьи Neoflex про BI на квинтетах: форма банковской отчётности Ф110. Форма (отчётная дата, точность) → коды расшифровки (код, сумма в рублях, сумма в валюте). У показателя всего три своих поля, а вынесенные дата и точность — это 2 поля из 5. Мы прогнали и этот профиль: 4470 отчётных дат (как в статье Neoflex), 200–600 кодов на форму, 1,79 млн показателей:
| плоско | иерархия | |
|---|---|---|
| Строк на показатель | 5,0 | 3,0 (−40%) |
| Размер с индексами | 1174 МБ | 731 МБ (−38%) |
| Сумма ₽ по датам, весь корпус (4470 дат) | 4,3 с | 7,8 с (проигрыш 1,8×) |
| Сумма ₽ по датам, один год (~8% корпуса) | 1,05 с | 0,47 с (выигрыш 2,2×) |
| Динамика одного кода | 2 мс | 1 мс |
| Все показатели одной даты | 2 мс | 2 мс |
Главный выигрыш Ф110-профиля — хранение: минус 40% строк. С агрегатами по мере картина двусторонняя, и она уточняет вывод из транзакционного замера: на селективном диапазоне (год из 12 лет) иерархия быстрее — 365 форм находятся дёшево, а их дети лежат компактно; на полном скане корпуса она медленнее — путь «форма → показатель → сумма» длиннее на один прыжок, и при полном охвате это не окупить ничем, кроме накопителей. Признаёмся: первая версия этого замера показала «12×» в пользу иерархии — это оказался артефакт запроса с LIMIT, который иерархический план исполнил потоково по трём формам, а плоский — целиком; после снятия LIMIT числа выше.
Диапазон по хранению, который стоит запомнить: от 7% (широкая запись, узкий разрез) до 40% (узкая запись, ёмкий разрез) — и знак меняется на минус, если нарушить правило частотности.
Результат 2: агрегаты по размерностям — кратное ускорение
«Сколько транзакций по каждому филиалу за неделю», «сколько по каждому типу за месяц» — запросы, которые читают только размерности:
| Запрос | flat | hier2 | hier4 |
|---|---|---|---|
| Счёт по филиалам, 7 дней | 2,6 с | 0,05 с (53×) | 0,19 с (14×) |
| Счёт по типам мерчанта, 30 дней | 10,5 с | — | 3,6 с (2,9×) |
Здесь иерархия делает ровно то, что обещала: вместо миллиона строк-реквизитов запрос ходит по тысячам строк разреза. Обратите внимание на порядок цифр hier2 против hier4 в первой строке: чем точнее разрез совпадает с формой запроса, тем сильнее выигрыш.
Результат 3: агрегаты по мерам — честный проигрыш и как его выкупить
«Сумма оборота по филиалам» читает не только размерности, но и меру — amount, а она осталась на нижнем уровне:
| Запрос | wide | flat | hier4 | hier4 + накопители |
|---|---|---|---|---|
| Сумма по филиалам, 7 дней | 0,3 с | 2,7 с | 9,5 с | 0,8 с |
| Сумма по валютам, 90 дней | 1,3 с | 28,3 с | 40,1 с | 4,7 с |
Иерархия здесь медленнее плоской раскладки в 1,4–3,5 раза. Причина — локальность: в плоской раскладке реквизиты транзакции лежат рядом с ней и читаются последовательно, а путь «разрез → транзакции → мера» скачет по таблице случайным доступом.
Лечится это третьей частью исходной идеи — выносом агрегатов наверх, только выносить надо не размерность, а накопитель меры: реквизит разреза «сумма детей», инкрементируемый при вставке. Тогда сумма по филиалам считается вообще без спуска на нижний уровень — 0,8 с против 2,7 с у плоского, 4,7 с против 28,3 с на квартале. Цена: +1 обновление на каждую вставку транзакции и горячая строка на популярной группе — это уже не «ничем не жертвуя», и мы это прямо фиксируем.
Результат 4: OLTP не страдает
Точечный поиск транзакции по PAN и выборка полной карточки — 3 мс во всех квартетных раскладках, включая иерархические (карточка собирается из двух уровней — свои реквизиты плюс подъём к разрезу). Вставка в иерархию требует найти-или-создать группу — это один индексный поиск по (t, val).
Честные границы
- Широкая таблица всё ещё вне конкуренции по компактности и простым агрегатам: 0,46 ГБ против 5,5–6,1 ГБ и 0,3–1,3 с на запросах. Унификация покупает другое — схему, которая меняется без DDL и одинакова для всех приложений. Иерархия сокращает разрыв на аналитике, не отменяя его.
- Замер — один корпус, одна машина, 1 млн транзакций без партиций; на 703 млн с партициями абсолютные числа будут другими (относительные соотношения — проверять там же).
- Разрез затачивается под форму запросов:
hier2ускоряет «по филиалам» в 53 раза, но «по типам» для него — как у плоского. Уровней может быть больше двух — (дата, филиал) → (валюта, тип) — этот вариант мы ещё не мерили. - Генерация данных синтетическая (поля независимы); на реальных корреляциях («филиал работает в одной валюте») групп меньше, а выигрыш больше.
Выводы
Иерархический OLTP — это не «бесплатная оптимизация», а инструмент с тремя режимами:
- Размерности в разрез — кратное ускорение агрегатов по этим размерностям и экономия строк от 7% (широкая транзакция) до 40% (Ф110-профиль), пока соблюдается правило частотности.
- Слишком мелкий разрез — проигрыш по всем статьям; правило частотности проверяется до внедрения простым подсчётом кардинальностей.
- Накопители мер на разрезе — превращают квартеты в честную HTAP-схему: OLTP внизу, OLAP по верхнему уровню, ценой инкремента на вставке.
С данными в такой структуре работают ИИ-агенты, и им действительно «не сложно расковырять» её и накликать запрос — правила выбора разреза мы оформили в базу знаний как проверяемый чек-лист. Скрипты генерации и замера — в репозитории, числа перепроверяются одной командой.