Мы пишем реляционную базу данных на Rust. В предыдущих статьях цикла мы разбирали отдельные подсистемы: Buffer Pool с Clock-sweep и изоляцией сканов и векторизованный исполнитель с многоверсионными индексами. Это были статьи про то, что получилось.

Эта статья другая. Она про решение, которое мы приняли в самом начале проекта и с которым живём до сих пор: MVCC на UNDO-логе вместо версионирования в heap, как у PostgreSQL. Мы расскажем, что это решение нам реально дало, где наши собственные лозунги разошлись с реальностью, и какой кусок работы ещё впереди. Расскажем и про то, что MVCC-слоёв внутри движка на самом деле два и почему так вышло, — до этого дойдём ближе к концу.

Спойлер: фраза «нет VACUUM — нет bloat» из наших ранних материалов оказалась правдой примерно наполовину. Вторая половина — про то, чем за UNDO приходится платить.

Что реализовано на момент публикации: heap хранит только последнюю версию строки; история уходит в append-only UNDO store (в памяти и на диске, файлы .aud); чтение снапшотом разматывает UNDO-цепочку; rollback транзакции идёт по её UNDO-записям с CLR в WAL; ARIES-style undo pass на восстановлении; многоверсионные вторичные индексы с проверкой видимости на листовой странице; пропуск обслуживания индексов для UPDATE, не меняющих индексированные колонки; GC UNDO-сегментов по watermark; фоновые воркеры для мёртвых слотов heap и индексных tombstones. Что ещё не готово: единый GC-координатор (сейчас это несколько независимых механизмов); state-based триггер индексного GC; переиспользование слотов в in-memory арене версий.

Небольшая оговорка перед стартом. Все цифры в этой статье — предварительные внутренние замеры на нашем стенде: они зависят от железа, нагрузки и схемы данных и приведены ради порядка величины, а не как обещание производительности. Здесь они нужны только как иллюстрация к архитектурным решениям. Полноценные бенчмарки — с воспроизводимым стендом и методикой — мы сейчас готовим и опубликуем отдельно, когда они будут готовы.

Если читать некогда, вся статья в трёх строках: UNDO-модель действительно убирает bloat из heap — но не уничтожает его, а перераспределяет в индексы, мёртвые слоты и сам UNDO-лог. Главная эксплуатационная цена модели — долгоживущий снапшот, который молча останавливает очистку. А GC из одного механизма превратился в пять, и мы постепенно сводим их к единому координатору.


Как у нас устроено: heap хранит только настоящее

В PostgreSQL старая и новая версия строки лежат рядом в таблице. UPDATE — это INSERT новой версии плюс пометка старой, а VACUUM потом ходит и убирает мёртвые версии. Модель рабочая, но цена известна каждому, кто видел таблицу, распухшую вдвое после миграции данных.

Мы пошли по другой ветке (её же выбрали InnoDB и Oracle): heap хранит только последнюю версию строки, а всё, что нужно для чтения прошлого, уезжает в отдельный append-only UNDO store.

Heap (.adb)      только актуальная версия строки + метаданные видимости
UNDO (.aud)      append-only история: pre-image на каждый UPDATE/DELETE
Чтение snapshot  если версия в heap слишком новая — размотать UNDO-цепочку
GC               отрезать хвост UNDO по watermark, когда он никому не нужен

У каждого tuple в heap есть метаданные: created_commit, deleted_commit и undo_chain_head_ptr — указатель на голову его UNDO-цепочки. Сам указатель упакован в u64 как [segment_id:24][byte_offset:40]: сегмент и байтовое смещение внутри него.

UNDO-запись хранит не всегда полную строку. Для UPDATE, который меняет две колонки из тридцати, писать всю строку расточительно, поэтому есть два вида дельты:

pub enum UndoDelta {
    FullRow(Vec<Value>),
    ColumnDelta(Vec<(u16, Value)>), // (column_index, old_value)
}

Но цепочка из одних ColumnDelta означает, что для реконструкции старой версии надо размотать всю цепочку до конца. Поэтому каждые 16 версий мы принудительно пишем полный образ строки — это ограничивает длину разматывания константой. В код даже зашит предохранитель: глубина больше 2 × 16 + 4 шагов считается признаком повреждённой цепочки, и чтение завершается ошибкой, а не уходит в бесконечный цикл. Само число 16 — текущий компромисс между объёмом UNDO и worst-case стоимостью чтения; в будущем мы планируем сделать его адаптивным под профиль нагрузки.

Целиком картинка такая:

heap (.adb) — только актуальная версия
┌──────────────────────────────────────────────────┐
│ строка · created_commit · undo_chain_head_ptr ──┐ │
└─────────────────────────────────────────────────│─┘
                                                  ▼
UNDO (.aud) — append-only история, новее → старее
┌─────────────┐    ┌─────────────┐         ┌─────────────┐
│ ColumnDelta │ ─▶ │ ColumnDelta │ ─▶ … ─▶ │ FullRow     │
│ commit=42   │    │ commit=37   │         │ commit=21   │
└─────────────┘    └─────────────┘         └─────────────┘
                          полный образ каждые 16 версий —
                          дальше разматывать не нужно

Чтение под снапшотом — в псевдокоде (детали вроде дельт и проверок целостности опущены):

fn read_at(tid: TupleId, snapshot: u64) -> Option<Row> {
    let (row, meta) = heap.get(tid);
    if visible(&meta, snapshot) {
        return Some(row);                    // fast path: в UNDO не заглядываем
    }
    let mut ptr = meta.undo_chain_head_ptr;  // slow path: разматываем историю
    let mut image = row;
    while ptr != UNDO_PTR_NULL {
        let rec = undo.get(ptr);
        image = apply_delta(image, rec.delta);
        if rec.commit_ts <= snapshot {
            return Some(image);              // нашли версию, видимую снапшоту
        }
        ptr = rec.prev;
    }
    None  // на момент снапшота строки ещё не существовало
}

Ключевое следствие: за историю платит только тот, кто её читает. Транзакция, работающая со свежими данными, не видит UNDO вообще. В предварительных замерах на нашей эталонной базе UPDATE добавляет в UNDO около 159 байт — и это единственная цена, которую платит писатель.


Что UNDO-модель нам дала

Дальше — не теория, а конкретные свойства, которые мы получили в коде и за которые держимся.

Rollback — это работа, пропорциональная транзакции

ROLLBACK транзакции у нас не оставляет за собой мусора, который кто-то потом приберёт. Он берёт список UNDO-записей этой транзакции (от новейшей к старейшей), для каждой пишет CLR в WAL (compensation log record — отметка «этот откат уже сделан», чтобы recovery не откатывал дважды) и применяет дельту обратно к heap. Стоимость отката пропорциональна тому, сколько транзакция успела изменить, а не размеру таблицы.

Recovery знает, что делать с незакоммиченным

При рестарте после падения работает классическая трёхфазная схема: analysis, redo, undo. Undo pass проходит по незавершённым транзакциям и откатывает их через те же UNDO-записи. Отдельный неприятный случай — SIGKILL в момент, когда строка уже в heap, а её UNDO ещё не доехал: такие фантомные строки (created_commit выше последней закоммиченной версии) recovery находит и помечает удалёнными напрямую. Этот случай мы поймали тестами на грубое убийство процесса — SIGKILL-тесты у нас теперь часть обязательного suite именно потому, что аккуратный shutdown такие сценарии не воспроизводит.

Snapshot too old — честная ошибка, а не тихое враньё

UNDO нельзя хранить вечно, иначе он сам станет bloat’ом. Его хвост отрезается по watermark — минимальному снапшоту среди активных транзакций. Но что, если очень старая транзакция всё-таки попросит версию, которую GC уже отрезал?

Мы выбрали fail-closed: ошибка snapshot too old (SQLSTATE 72000) вместо тихой выдачи неправильных данных. Это соответствует принципу, который мы закладывали с самого начала: система должна говорить «не знаю» вместо того чтобы молча угадывать. Да, мы реализуем тот же паттерн, что и Oracle с ORA-01555 — и у нас по этому поводу тоже нет полного единодушия. Но альтернатива — блокировать GC ради долгих транзакций — нравится нам ещё меньше: одна забытая сессия с открытой транзакцией останавливает очистку для всех. Сейчас у нас есть и мягкая защита: метрика возраста старейшего снапшота и настраиваемые warn/hard лимиты на возраст транзакции. Если у вас есть мнение, какой из двух ядов правильнее, — нам правда интересно прочитать его в комментариях.

Стабильный TID: UPDATE, который не трогает индексы

Это кейс, который на UNDO-модель лёг почти бесплатно, а на heap-версионировании невозможен по построению.

При in-place UPDATE строка остаётся в своём слоте — её физический идентификатор (TID) не меняется. А вторичные индексы указывают именно на TID. Отсюда следствие: если UPDATE не тронул индексированные колонки, можно не трогать ни один вторичный индекс — все старые записи продолжают указывать на правильное место.

Многие помнят пост Uber 2016 года о переезде с PostgreSQL: одна из главных претензий была ровно к этому. В PG каждый UPDATE — это новый tuple с новым ctid, и если оптимизация HOT не сработала, обновляются все индексы таблицы, включая те, чьи колонки не менялись. А HOT там обусловлен физикой: новая версия должна влезть на ту же страницу heap. Страница заполнена — не повезло.

У нас условие пропуска чисто логическое: изменились ли индексированные колонки. Места на странице не требуется вовсе, потому что новая версия не должна сосуществовать со старой в heap — старая уехала в UNDO. Проверка делается сравнением pre-image и post-image по семантике IS DISTINCT FROM (отдельно пришлось решить случай NaN -> NaN: по стандарту IEEE 754 NaN не равен самому себе, но для базы данных это означает «значение не изменилось» — мы намеренно выбрали fail-closed и считаем такой переход изменением), для expression-индексов — fail-closed fallback с бюджетом на вычисление, а если строка всё-таки переехала в новый слот (выросла) — guard по TID отправляет её на стандартный путь обслуживания индексов. Флаг «индексы пропущены» записывается в UNDO-запись, чтобы откат знал, что индексы возвращать не нужно.

Цена скана не зависит от интенсивности обновлений

Раз heap содержит только живые строки, последовательный скан читает O(живых строк) — всегда. В heap-версионировании после массового UPDATE count(*) читает примерно вдвое больше страниц, пока вакуум не догонит: стоимость аналитики плавает в зависимости от фазы очистки. У нас аналитический запрос поверх OLTP-таблицы не дорожает от того, что её интенсивно обновляют. Для системы, которая с первого дня закладывалась как HTAP (аналитика рядом с транзакциями, об этом была прошлая статья), это не приятный бонус, а почти обязательное условие — и, пожалуй, главная причина, почему была выбрана UNDO-модель.

Индекс отвечает на вопрос видимости сам

Это следствие UNDO-модели, которое мы не до конца оценили на старте.

В PostgreSQL индексная запись не знает ничего о транзакциях: нашёл ключ — иди в heap и там разбирайся, видима ли строка твоему снапшоту (visibility map смягчает это, но не отменяет — даже Index-Only Scan вынужден обращаться к ней, чтобы понять, нужно ли всё-таки сходить в heap за подтверждением видимости; страница не помечена «все строки видимы» — идёшь). В нашей модели так делать нельзя было бы вдвойне: heap хранит только последнюю версию, и если бы индекс отдавал «голые» указатели, каждое попадание по ключу могло бы означать ещё и разматывание UNDO-цепочки — просто чтобы понять, видна ли строка.

Поэтому индекс у нас многоверсионный (подход известен в литературе как MV-PBT, multi-version B-Tree): каждая листовая запись несёт собственные границы видимости — аналог xmin/xmax прямо в индексе. UPDATE индексированной колонки добавляет новую запись и помечает старую xmax, а поиск (lookup_visible_at, range_scan_visible_at) фильтрует записи по снапшоту прямо на листовой странице. В heap индексный путь ходит за самой строкой, но не за вердиктом о видимости — на v2-пути счётчик «походов в heap ради проверки видимости» у нас держится на нуле.

Для диапазонных сканов под нагрузкой это принципиально: скан по индексу не превращается в случайные чтения heap на каждый ключ. Но у этого свойства есть цена, и о ней — следующий раздел.

Heap не пухнет от истории версий

Это свойство работает ровно так, как задумано: сколько бы раз вы ни обновляли строку, в heap она занимает одно место, а история живёт в UNDO и отрезается по watermark. На нашем профиле UNDO-файлы растут линейно от write-нагрузки и усекаются корректно.

А теперь — про вторую половину того спойлера.


Чем мы платим за UNDO

«Нет VACUUM — нет bloat» — так мы формулировали преимущество в ранних материалах. Для heap это правда: сколько ни обновляй, таблица не растёт. Но heap — только один из слоёв хранения, и у остальных счёт свой.

Главная статья расходов — вторичные индексы, обратная сторона того самого многоверсионного индекса из раздела выше. UPDATE добавляет в индекс запись для нового значения, а старая помечается xmax — то есть мёртвой. Помечается — но физически остаётся на листовой странице, пока её не уберёт отдельная очистка. UNDO-модель спасает heap, но индекс — это отдельная структура со своей собственной историей версий, и про его очистку UNDO-лог не знает ничего. Удобство «видимость решается на листе» оплачивается тем, что мёртвые записи копятся тоже на листе.

Мы узнали это не из теории. На одном из внутренних аудитов таблица в три миллиона строк под UPDATE-heavy профилем раздула файл данных примерно до 90 ГБ — при том что heap вёл себя прилично. Практически весь рост пришёлся на индексные страницы, а счётчик удалённых мёртвых записей — gc_compact_versions_removed_total — за весь прогон показывал ровно ноль: на тот момент индексный GC просто не существовал, мёртвые записи копились бессрочно. Сейчас очистка есть (о ней ниже), но порядок цифр стоит запомнить: под обновлениями быстрее всего растут именно индексы, а не таблица, и это свойство модели, а не баг.

Вторая статья расходов — мёртвые слоты heap. UPDATE той же длины перезаписывает слот на месте, DELETE — всегда на месте (soft-delete меняет только заголовок фиксированного размера). Но выросшая строка переезжает в новый слот, а старый помечается мёртвым и ждёт возврата. Это уже не MVCC, а механика страниц с записями переменной длины — от неё не свободны ни InnoDB, ни Oracle (row migration), — но убирать за ней всё равно кому-то надо.

Третья — сам UNDO: каждый UPDATE/DELETE дописывает pre-image, и история живёт ровно столько, сколько её удерживает самый старый активный снапшот. Чем это кончается, когда снапшот забывают закрыть, — отдельная глава ниже.

То есть bloat никуда не делся. Он переехал из категории «мёртвые версии строк», которую решает UNDO, в категории «мёртвые индексные записи» и «мёртвые слоты heap», которые UNDO не решает и решать не обязан.

Сейчас на оба слоя есть фоновые воркеры: один сканирует и возвращает мёртвые heap-слоты через reclaim-очередь, второй чистит индексные tombstones — плюс кооперативный режим, когда проходящий мимо скан прибирает мёртвые записи на листовой странице. Технически каждый воркер — это отдельный фоновый поток: просыпается по таймеру или по сигналу от DML-пути, обрабатывает N страниц или записей в рамках бюджета и снова засыпает; усечение UNDO, индексный GC и compaction колоночных сегментов крутятся каждый в своём потоке параллельно. И тут важно не приукрашивать: эти потоки делят те же ядра, что и обработчики запросов, — никакой магической изоляции по CPU нет. От того, чтобы очистка выгребла всё I/O разом и зажала пользовательскую нагрузку, спасает не отдельный пул, а именно бюджет: ограничение «столько-то страниц за проход» и сон между проходами. Плюс к этому триггер индексного GC пока только по времени, а не по доле мёртвых записей, и это означает, что под burst-нагрузкой он опаздывает.

Честно говоря, на старте мы сами смотрели на это не так глубоко: «убрали bloat из heap — значит убрали bloat». Оказалось, что когда вам говорят «архитектура X избавляет от bloat», стоит уточнять, про какой именно из слоёв речь. В базе их минимум четыре, и у каждого своя жизнь после смерти данных.


Утёкшая эпоха, или как одна сессия остановила GC

Вторая история — про watermark.

GC UNDO-сегментов может отрезать только то, что не нужно ни одному активному снапшоту. Для этого транзакции регистрируют свою «эпоху» при старте и снимают регистрацию при commit или abort. Watermark — минимум по активным эпохам.

А теперь вопрос: что происходит, если соединение умерло, не сказав ни commit, ни abort?

Правильный ответ: эпоха остаётся зарегистрированной, watermark замирает, UNDO перестаёт усекаться и растёт до бесконечности. Мы наступили на это в тестах под нагрузкой: один утёкший клиент — и через несколько часов файлы UNDO заняли всё, что им позволили занять. Сейчас это лечится комбинацией epoch reaper и warn-лимитов — они закрывают острый сценарий. Радикальное решение — детерминированное закрытие сессий на уровне протокола — остаётся в бэклоге.

Лечение получилось двухслойным. Первый слой — reaper (пока opt-in, по умолчанию выключен и включается флагом), который периодически находит эпохи без живого владельца и снимает их принудительно. Второй слой — те самые лимиты на возраст снапшота: сначала warning в метрики, потом принудительное закрытие. Полного автоматического закрытия брошенных сессий на уровне протокола у нас пока нет — reaper закрывает только сам факт утечки эпохи, но не предотвращает её. Это открытая задача в нашем бэклоге, и мы относимся к ней серьёзно: пока она не закрыта, watermark зависит от дисциплины клиентов, а не от гарантий движка.

Подозреваем, что у каждой MVCC-базы есть своя версия этой истории. У PostgreSQL это idle_in_transaction_session_timeout, который по умолчанию выключен, и вакуум, который молча перестаёт убирать. Наша версия отличается деталями, но не сутью: долгоживущий снапшот — это глобальный ресурс, и система обязана его видеть и ограничивать.


GC — это не механизм, а зоопарк. И это направление, а не приговор

Если бы меня год назад спросили, что такое GC в UNDO-MVCC базе, я бы ответил: «усечение UNDO-лога по watermark». Сейчас мы можем перечислить минимум пять независимых механизмов очистки, которые живут в нашем коде:

слой хранения       что копится после смерти данных    кто убирает
─────────────────   ────────────────────────────────   ─────────────────────────────────
UNDO (.aud)         хвост истории версий               усечение по watermark + epoch reaper
heap (.adb)         мёртвые слоты после переезда       фоновый reclaim-воркер
                    выросших строк
индексы             tombstones (записи с xmax)         фоновый GC + кооперативная уборка
                                                       проходящим сканом
columnar-сегменты   delete vectors, мелкие L0-блобы    compaction-воркер
in-memory арена     цепочки версий переходного слоя    очистка при сбросе таблицы
                    (о нём ниже)

Сначала мы воспринимали этот зоопарк как симптом болезни. Сейчас думаем иначе, и вот почему.

От bloat и от «вакуума» как явления нельзя избавиться — это концептуальное свойство любой многоверсионной системы. Версии данных где-то рождаются и где-то умирают, и кто-то обязан убирать за умершими. Можно выбрать, где они умирают (мы выбрали: не в heap), но нельзя выбрать «нигде». Каждый слой хранения — heap, UNDO, индексы, колоночные сегменты — имеет свою физику смерти данных, и универсального веника для всех четырёх не существует.

Поэтому вопрос не «как избавиться от GC», а «каким он обязан быть». Наш ответ — три требования, под которые мы и затачиваем каждый механизм:

  1. Только в фоне и малыми порциями. Каждый воркер работает с бюджетом — столько-то страниц или записей за проход, — чтобы очистка никогда не конкурировала с пользовательской нагрузкой за всё I/O сразу.

  2. Без блокировок таблиц и stop-the-world. Никаких аналогов VACUUM FULL, которые берут эксклюзивный лок и перестраивают таблицу, пока приложение ждёт. Кооперативный индексный GC — пример этого подхода: скан, который и так читает листовую страницу, заодно прибирает на ней мёртвые записи.

  3. Без остановки сервера. Главная цель всей этой механики — чтобы инстанс мог жить месяцами без даунтайма и без ручной «гигиены» от DBA. Если для здоровья хранилища нужен рестарт или maintenance window — мы считаем это багом дизайна, а не особенностью эксплуатации.

Через эту призму пять механизмов — не пять проблем, а пять инструментов, каждый заточен под физику своего слоя. Чего им сегодня действительно не хватает — они не знают друг о друге: у каждого свой watermark, свой воркер, свои метрики, и оператору приходится смотреть на пачку показателей (undo_file_bytes, gc_dead_tuples_reclaimed_total, возраст старейшего снапшота, lag watermark’а) вместо одного индикатора «здоровья очистки».

Финал этой эволюции — GC-координатор: один компонент с общим watermark и общим бюджетом, который знает обо всех слоях и раздаёт работу инструментам по приоритету и доступному I/O. Мы идём к нему поэтапно и сознательно не торопимся: каждый инструмент сначала должен стать скучным и предсказуемым по отдельности, иначе координатор будет дирижировать оркестром, в котором половина музыкантов фальшивит.

Вопрос к тем, кто через это проходил: как вы решали конфликт I/O-бюджетов между слоями в едином GC? Каждый слой хочет своего — UNDO хочет усекаться почаще, индексы просят compaction в пик нагрузки, heap-reclaim «терпит», пока не перестаёт. Приоритизировать статически — кажется слишком грубо, динамически — непонятно, по каким сигналам. Если у вас есть опыт или мнение — напишите в комментариях, нам сейчас это практически важно.


Важное техническое уточнение: внутри живут два MVCC-слоя

Об этом легко умолчать, но тогда статья будет приукрашенной.

UNDO-модель на heap появилась у нас не первой. Сначала был in-memory слой: версии строк лежат цепочками в арене, а для каждого RowId есть «голова» — указатель на самую свежую опубликованную версию, упакованный в атомарный u64. Новая версия публикуется через CAS: «замени голову на мою версию, но только если она всё ещё та, которую я видел в начале»:

pub fn try_install_head(
    &self,
    row_id: RowId,
    expected_prev: VersionPtr,
    new_ptr: VersionPtr,
) -> Result<VersionPtr, WriteWriteConflict> {
    let entry = self.heads.entry(row_id).or_insert_with(|| {
        AtomicU64::new(VersionPtr::NULL.0)
    });
    match entry.compare_exchange(
        expected_prev.0, new_ptr.0,
        Ordering::AcqRel, Ordering::Acquire,
    ) {
        Ok(_) => Ok(new_ptr),
        Err(_) => Err(WriteWriteConflict { row_id }),
    }
}

Это дало две вещи. Во-первых, путь записи без широкой блокировки таблицы: два писателя по разным строкам не встречаются вообще, очередь возникает только там, где есть реальный спор за одну строку. Во-вторых, явную семантику конфликта: проигравший CAS получает не молчаливое ожидание неизвестной длины, а WriteWriteConflict, который наружу выходит как 40001 serialization_failure — откат и повтор на стороне приложения. Это оптимистичная модель: писатель не ждёт чужой row lock, как PostgreSQL на Read Committed, а проверяет, не изменилась ли строка с момента прочитанной головы.

Одно уточнение к сказанному выше: сама карта голов — это DashMap<RowId, AtomicU64>, и доступ к ней не является академически lock-free (шарды синхронизируются, особенно при создании новой записи). CAS отвечает не за «нигде нет блокировок», а за то, что голова конкретной строки меняется только атомарно и только от версии, которую писатель действительно видел.

Потом пришёл дисковый heap с UNDO, и канонический путь чтения переехал на него.

Сегодня оба слоя живут одновременно. In-memory цепочки остаются на пути записи и для части таблиц, heap с UNDO — основное хранилище. Миграция идёт, legacy-путь сужается с каждым релизом, но слот-аллокатор арены, например, до сих пор append-only — слоты не переиспользуются до сброса всей таблицы.

Можно ли было сразу строить heap с UNDO и не делать промежуточный слой? Задним числом — наверное, да, и мы бы сэкономили заметный кусок миграционной работы. Но in-memory слой позволил нам отладить семантику снапшотов, конфликтов и изоляции на простой структуре данных, прежде чем привязывать всё это к страницам, WAL и recovery. Мы до сих пор не уверены, что это была ошибка. Это была цена итеративной разработки — и миграционная работа оказалась заметно дороже, чем выглядела в начале.


UNDO-лог как машина времени: AS OF SNAPSHOT и AS OF TIMESTAMP

Пока мы разбирались со всеми этими слоями, в голове постепенно созрела мысль: у нас уже есть полная история строк в UNDO — её строили ради rollback и снапшотного чтения в транзакциях. Но ведь это и есть готовая «машина времени». Осталось дать к ней прямой SQL-доступ.

Так появился SELECT ... AS OF SNAPSHOT <n> и SELECT ... AS OF TIMESTAMP '<ts>'.

Механика прямая: ты указываешь момент — снапшот по числовому ID или метку времени — и движок разматывает UNDO-цепочку ровно так же, как при обычном MVCC-чтении, только целевой снапшот задаёт не открытая транзакция, а твой запрос. Что читается под AS OF TIMESTAMP '2026-06-13 14:00:00' — то же самое heap + UNDO, просто курсор времени сдвинут назад.

Зачем это нужно на практике:

  • Ошибочный DELETE. Снёс не те строки — не нужен PITR всего инстанса и часы даунтайма. INSERT INTO orders SELECT * FROM orders AS OF TIMESTAMP '...' WHERE id IN (...) — и строки вернулись за минуты.

  • Разбор инцидента. «Что видел клиент в 14:03?» — прямой запрос к состоянию базы на нужный момент, без audit-таблиц и триггеров.

  • Консистентный многозапросный отчёт. Вместо долгой открытой транзакции (которая держит watermark и тормозит GC) — серия запросов с одним зафиксированным AS OF SNAPSHOT N. Они все согласованы между собой, а GC продолжает работать.

Немного контекста о том, как это устроено у других: PostgreSQL убрал time travel ещё в версии 6.2 — сегодня ближайшая альтернатива это PITR-рестор отдельного инстанса. Oracle Flashback Query — один из главных enterprise-аргументов Oracle, на который ссылаются при сравнениях. MariaDB поддерживает system-versioned tables, но требует изменения схемы DDL и хранит версии прямо в таблице — со всеми вытекающими для bloat.

У нас история уже существовала в UNDO как побочный продукт MVCC — добавить к ней SQL-интерфейс оказалось куда дешевле, чем строить с нуля. Overhead на сторону писателя минимальный: +8 байт в V2-заголовке tuple на per-row chain pointer, не более 3% по write-throughput на нашем стенде.

Внутри команды реакция на эту фичу примерно поровну: те, кто много писал прикладной SQL, от неё в восторге — «посмотреть, что было до миграции» или «разобраться, что именно поменялось вчера в 23:00» становится двумя строчками SQL вместо звонка DBA. Скептики правы в другом: retention — это не бесплатно, большое окно означает рост UNDO, и держать «сутки истории» на горячей OLTP-таблице с тысячью апдейтов в секунду — это осознанный компромисс, а не дефолтный режим. Поэтому фича по умолчанию выключена, и мы намеренно не пытаемся сделать её прозрачной для тех, кто не думал о retention. Но для разбора инцидентов, для сверки «до и после» при прикладных миграциях, для точечного восстановления нескольких строк — это именно тот инструмент, который мы сами хотели бы иметь в production.

Несколько практических деталей, которые стоит знать:

  • Retention настраивается. Конфиг undo_retention_seconds_max (по умолчанию 0 — то есть фича выключена, opt-in). Retention рассчитан на операционное окно: минуты–часы, не дни и не недели. Долгосрочный архив — это всё-таки backup.

  • Fail-closed за пределами окна. Запросили глубже, чем UNDO успел сохранить — получаете SQLSTATE 72000 с подсказкой «increase undo_retention_seconds_max (current: N)». Тихо вернуть неправильные данные нам кажется хуже, чем явная ошибка.

  • current_snapshot() и snapshot_at(ts) — вспомогательные функции, чтобы зафиксировать нужную точку до серии запросов. Взял снапшот, прошёлся по нескольким таблицам — все читают одно и то же состояние.

  • v0 работает через seq scan, индексный путь для AS OF — отдельная задача на будущее (см. раздел «Что ещё не сделано» ниже).


Что ещё не сделано

Список без приукрашивания:

  • Единый GC-координатор. Описан выше; целевой срок — следующая мажорная ветка.

  • State-based триггер индексного GC. Сейчас очистка tombstones запускается по таймеру; нужен триггер по доле мёртвых записей на странице.

  • WAL-запись для физического reclaim heap-слотов. Сейчас возврат слота не журналируется отдельным opcode; есть известное узкое окно при падении в момент усечения UNDO. Закрывается отдельной задачей.

  • Переиспользование слотов in-memory арены. Append-only до сброса; для долгоживущих таблиц с интенсивной записью это растущая память.

  • AS OF через индексный путь. AS OF SNAPSHOT / TIMESTAMP работает, но сейчас только через seq scan — индексный путь для исторического чтения это отдельная задача следующего цикла.

  • Полное закрытие брошенных сессий. Reaper эпох — это страховка, а не решение; нужен таймаут на уровне протокола.


Сравнение профилей хранения и обслуживания: Heap-MVCC (PostgreSQL) vs UNDO-MVCC

Если свести всё сказанное в одну таблицу, получится так:

Измерение

PostgreSQL (heap-MVCC)

UNDO-MVCC (наш вариант)

Где живёт история версий

в heap, рядом с актуальными данными

в отдельном append-only UNDO-логе

Цена UPDATE для писателя

новая версия tuple в heap

in-place перезапись + ~159 байт в UNDO

UPDATE неиндексированной колонки

обновляются все индексы, если HOT не сработал (нужно место на странице)

индексы не трогаются вовсе (условие логическое, а не физическое)

ROLLBACK

почти мгновенный — мёртвые версии остаются VACUUM’у

пропорционален размеру транзакции (применение UNDO-записей)

Скан после массового UPDATE

читает и мёртвые версии, пока VACUUM не догонит

O(живых строк) — всегда

Чтение старого снапшота

версия уже лежит в heap

разматывание UNDO-цепочки, ограничено константой

Долгая транзакция

блокирует VACUUM → пухнет heap

держит watermark → растёт UNDO, дальше snapshot too old

Где копится bloat

heap и индексы

индексные tombstones, мёртвые слоты, хвост UNDO

Чем чистится

VACUUM — один механизм с одним именем

пять фоновых механизмов → курс на единый координатор

Обратите внимание: строк, где у нас «бесплатно», и строк, где «бесплатно» у PostgreSQL, — сопоставимое количество. Это не таблица превосходства, это таблица перераспределения цен.


Вместо заключения

Главный вывод, который сложился за время работы с UNDO-MVCC: эта модель действительно убирает класс проблем «история версий распухает там же, где лежат данные». Но она не убирает необходимость очистки — она её перераспределяет. UNDO-лог нужно усекать, индексы нужно чистить, мёртвые слоты нужно возвращать, и каждое из этих «нужно» — отдельный механизм со своим watermark и своими способами сломаться.

VACUUM в PostgreSQL раздражает многих. Но у него есть одно достоинство, которое начинаешь ценить, только построив альтернативу: это один механизм с одним именем, про который написаны тонны документации и runbook’ов. Мы свой «зоопарк» только начинаем сводить к такому же уровню понятности для оператора. Ценность, которую мы в этом видим, — не в том, что наш подход «лучше», а в том, что у него другой профиль: heap не пухнет от истории, rollback предсказуем, аналитика не дорожает от write-нагрузки — и теперь ещё time travel почти бесплатно поверх инфраструктуры, которая уже была. Это осмысленный набор компромиссов, а не серебряная пуля.

Несколько вопросов, которые нас сейчас занимают — будем рады услышать production-опыт:

  • Кто-нибудь эксплуатировал InnoDB с многочасовыми отчётными транзакциями? Как вели себя undo tablespaces?

  • Какой интервал полных образов в undo-цепочке (наши текущие 16 версий) вы бы считали разумным и от чего бы его считали?

Пишите в комментариях — в том числе если считаете, что мы где-то свернули не туда. Споры про архитектуру хранения — это ровно то, ради чего этот цикл и пишется.

Параллельно мы ищем несколько команд для закрытой технической оценки. Если вы строите или эксплуатируете OLTP-систему с заметной write-нагрузкой, думаете о HTAP или просто хотите посмотреть на движок вблизи — напишите нам. Никаких обязательств: нам важна обратная связь от людей, которые понимают, что происходит под капотом.

Комментарии (7)


  1. hard_sign
    16.06.2026 08:18

    одна забытая сессия с открытой транзакцией останавливает очистку для всех.

    Ну почему же, тот undo, который относится к транзакциям, начавшимся позже и завершившимся раньше, вполне можно чистить. В этом случае длинная транзакция может наткнуться на строки, для которых не существует версии, актуальной на момент её начала. Но это лучше, чем держать ради неё undo (imho, разумеется)

    выросшая строка переезжает в новый слот, а старый помечается мёртвым и ждёт возврата. Это уже не MVCC, а механика страниц с записями переменной длины — от неё не свободны ни InnoDB, ни Oracle (row migration)

    У Oracle строки мигрируют только если меняется ключ секционирования. Если это обычная таблица, то в старой странице её корень остаётся навечно (до ALTER TABLE ... MOVE), и индекс указывает на корень. А корень в свою очередь указывает на то место, где лежат настоящие данные. Это явление называется chained rows.


    1. anishukserg Автор
      16.06.2026 08:18

      Ну почему же, тот undo, который относится к транзакциям, начавшимся позже и завершившимся раньше, вполне можно чистить. В этом случае длинная транзакция может наткнуться на строки, для которых не существует версии, актуальной на момент её начала. Но это лучше, чем держать ради неё undo (imho, разумеется)

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

      Дальше — вопрос политики. Можно разрешить этот случай в пользу очистки: тогда длинная транзакция наткнётся на строку, для которой нет версии на момент её старта, и получит «snapshot too old» — ровно ORA-01555 у Oracle. А можно в пользу транзакции — и тогда undo приходится держать. Мы выбрали второе: читающая транзакция получает консистентный снимок и не падает на ровном месте. ORA-01555 — боль хорошо известная, особенно на долгих отчётах и выгрузках, и тащить её к себе не хотелось.

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

      У Oracle строки мигрируют только если меняется ключ секционирования. Если это обычная таблица, то в старой странице её корень остаётся навечно (до ALTER TABLE ... MOVE), и индекс указывает на корень. А корень в свою очередь указывает на то место, где лежат настоящие данные. Это явление называется chained rows.

      Как мне кажется тут небольшая путаница в терминах. То, что вы описали — «голова» строки остаётся на месте, индекс указывает на неё, а она перенаправляет на новое расположение данных — это и есть row migration. Chained rows — про другое: когда строка целиком не влезает в один блок (длиннее блока или больше 255 столбцов) и нарезается на куски по нескольким блокам. Инструмент ANALYZE … LIST CHAINED ROWS ловит и те, и другие в один список.

      И мигрируют строки в Oracle не только при смене ключа секционирования. Обычный UPDATE, который вырастил строку так, что она перестала помещаться в свой блок с учётом PCTFREE, даёт ровно ту самую миграцию — секционирование тут ни при чём. А переезд между секциями при смене ключа (ROW MOVEMENT) — это уже отдельный, третий механизм, и там строка получает новый ROWID.

      Так что исходный тезис как раз про это и был: выросшая запись переезжает, старый слот остаётся висеть — это общая механика страниц с записями переменной длины, и Oracle от неё не свободен, как и InnoDB. К MVCC как таковому это отношения не имеет.

      К вопросу «а как у нас» мы от переезда выросшей строки тоже не свободны — записи переменной длины, тут чудес не бывает. Но решаем иначе, чем Oracle. У нас слотированная страница: если строка выросла и не влезла, старый слот помечается мёртвым, новая версия едет в новый слот, а вторичные индексы перенацеливаются на новый идентификатор строки. Forwarding-стаба, как у Oracle, не остаётся — а значит, нет и вечного лишнего I/O на чтении мигрировавшей строки до ALTER TABLE … MOVE. Размен прямой: мы платим один раз на записи (правка указателей в индексах) вместо постоянного чтения. Старая версия при этом уходит в undo-лог, а не дублируется на странице. Если же строка не растёт и индексные колонки не меняются — апдейт идёт на месте, индексы не трогаются вовсе.


  1. vagon333
    16.06.2026 08:18

    Цена UPDATE для писателя

    PGSQL : новая версия tuple в heap

    UNDO-MVCC (наш вариант) : in-place перезапись + ~159 байт в UNDO

    Напоминает подход SQL Server.


    1. anishukserg Автор
      16.06.2026 08:18

      Да, семейство то же — и это наш сознательный выбор. Update-in-place плюс старая версия в отдельный сегмент — так живут Oracle (UNDO tablespace), InnoDB (undo logs) и SQL Server (version store в tempdb). Мы в этом же лагере, напротив Postgres с его «новый tuple в heap + VACUUM». Если уж искать ближайшего родственника — это скорее Oracle/InnoDB, у которых undo нативный, а не приделанный сбоку; SQL Server приходит к тому же, но через версионник в tempdb.

      Только пара уточнений по нашей механике, чтобы картина была точной.

      In-place — это быстрый путь, когда новая версия укладывается в слот по размеру (типично для апдейта полей фиксированной ширины). Если строка выросла и не влезла — она переезжает в новый слот, старый помечается мёртвым; то самое row migration из соседней ветки. Так что «всегда in-place» — неверно: оно там, где размер не поехал.

      UNDO у нас дельта-кодирован — в запись идут не вся строка, а изменённые колонки плюс служебка (указатель на предыдущую версию, commit_ts, lsn, txn_id и т.п.). Поэтому ~159 байт — это про конкретный апдейт: на узком изменении запись короче, на «переписали всю строку» — длиннее.

      И отличие от tempdb-версионника SQL Server: там version store общий и живёт в tempdb, который от этого пухнет и временами становится бутылочным горлышком на ровном месте. У нас undo — собственный стор движка, а не общая свалка на всю инсталляцию. Цена при этом ровно та же, что у всего лагеря: VACUUM не нужен, но undo приходится держать, пока его видит хоть один активный снимок — ровно то, с чего началась ветка про длинную транзакцию.


      1. Ivan22
        16.06.2026 08:18

        а еще есть firebird с версиями в heap но без вакуума. Чиститься как у вас индексы - совместно с обычными чтениями


        1. mvv-rus
          16.06.2026 08:18

          а еще есть firebird с версиями в heap но без вакуума.

          А он точно без вакуума? А то ведь его предшественник, Interbase, имел внутри себя сборку мусора, правда под другим названием: sweep.


        1. anishukserg Автор
          16.06.2026 08:18

          Firebird тут стоит чуть особняком — он не совпадает с нами по двум осям.

          Первое — где лежат старые версии. Firebird держит их прямо в страницах данных: к записи цепляются back-версии, и heap пухнет от истории. По размещению это ближе к Postgres, чем к нам. У нас heap-страница несёт только последнюю версию, а вся история уходит в отдельный append-only undo-лог (Oracle/InnoDB-стиль). Так что в части хранения старых версий Firebird и AngaraBase расходятся: у одного история размазана по страницам данных, у другого вынесена в отдельный лог.

          Второе, и самое главное: сборка у нас не на пути чтения. Чтение лишь дёшево помечает «горячие» страницы как кандидатов и ставит в очередь; саму чистку — эксклюзивный лок страницы, синхронная запись в WAL, перестроение — делает фоновый воркер по watermark самого старого активного снимка. Кооперативная сборка прямо в SELECT — ровно то, от чего мы ушли: сначала и у нас так и было, и читающий запрос начинал брать эксклюзивные локи и писать в журнал, проседая на ровном месте. Это, собственно, известная цена firebird'овской модели — SELECT, который внезапно делает запись и I/O.

          А вот в чём совпадение полное: и у Firebird, и у нас сборку держит самый старый живой снимок. У Firebird это OIT — длинная «интересная» транзакция стопорит чистку back-версий; у нас тот же горизонт, с которого началась ветка про длинную транзакцию. Разные места хранения, разный момент сборки — но упирается всё в одно и то же.