MVCC без VACUUM: что нам дал UNDO-лог, какую цену мы заплатили и зачем нам 5 механизмов сборки мусора

от автора

Мы пишем реляционную базу данных на 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 reaperheap (.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 или просто хотите посмотреть на движок вблизи — напишите нам. Никаких обязательств: нам важна обратная связь от людей, которые понимают, что происходит под капотом.

ссылка на оригинал статьи https://habr.com/ru/articles/1047852/