Мы пишем реляционную базу данных на 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», а «каким он обязан быть». Наш ответ — три требования, под которые мы и затачиваем каждый механизм:
Только в фоне и малыми порциями. Каждый воркер работает с бюджетом — столько-то страниц или записей за проход, — чтобы очистка никогда не конкурировала с пользовательской нагрузкой за всё I/O сразу.
Без блокировок таблиц и stop-the-world. Никаких аналогов
VACUUM FULL, которые берут эксклюзивный лок и перестраивают таблицу, пока приложение ждёт. Кооперативный индексный GC — пример этого подхода: скан, который и так читает листовую страницу, заодно прибирает на ней мёртвые записи.Без остановки сервера. Главная цель всей этой механики — чтобы инстанс мог жить месяцами без даунтайма и без ручной «гигиены» от 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, дальше |
Где копится 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)

vagon333
16.06.2026 08:18Цена UPDATE для писателя
PGSQL : новая версия tuple в heap
UNDO-MVCC (наш вариант) : in-place перезапись + ~159 байт в UNDO
Напоминает подход SQL Server.

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

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

mvv-rus
16.06.2026 08:18а еще есть firebird с версиями в heap но без вакуума.
А он точно без вакуума? А то ведь его предшественник, Interbase, имел внутри себя сборку мусора, правда под другим названием: sweep.

anishukserg Автор
16.06.2026 08:18Firebird тут стоит чуть особняком — он не совпадает с нами по двум осям.
Первое — где лежат старые версии. Firebird держит их прямо в страницах данных: к записи цепляются back-версии, и heap пухнет от истории. По размещению это ближе к Postgres, чем к нам. У нас heap-страница несёт только последнюю версию, а вся история уходит в отдельный append-only undo-лог (Oracle/InnoDB-стиль). Так что в части хранения старых версий Firebird и AngaraBase расходятся: у одного история размазана по страницам данных, у другого вынесена в отдельный лог.
Второе, и самое главное: сборка у нас не на пути чтения. Чтение лишь дёшево помечает «горячие» страницы как кандидатов и ставит в очередь; саму чистку — эксклюзивный лок страницы, синхронная запись в WAL, перестроение — делает фоновый воркер по watermark самого старого активного снимка. Кооперативная сборка прямо в SELECT — ровно то, от чего мы ушли: сначала и у нас так и было, и читающий запрос начинал брать эксклюзивные локи и писать в журнал, проседая на ровном месте. Это, собственно, известная цена firebird'овской модели — SELECT, который внезапно делает запись и I/O.
А вот в чём совпадение полное: и у Firebird, и у нас сборку держит самый старый живой снимок. У Firebird это OIT — длинная «интересная» транзакция стопорит чистку back-версий; у нас тот же горизонт, с которого началась ветка про длинную транзакцию. Разные места хранения, разный момент сборки — но упирается всё в одно и то же.
hard_sign
Ну почему же, тот undo, который относится к транзакциям, начавшимся позже и завершившимся раньше, вполне можно чистить. В этом случае длинная транзакция может наткнуться на строки, для которых не существует версии, актуальной на момент её начала. Но это лучше, чем держать ради неё undo (imho, разумеется)
У Oracle строки мигрируют только если меняется ключ секционирования. Если это обычная таблица, то в старой странице её корень остаётся навечно (до
ALTER TABLE ... MOVE), и индекс указывает на корень. А корень в свою очередь указывает на то место, где лежат настоящие данные. Это явление называется chained rows.anishukserg Автор
Тут как раз и проходит развилка. Undo, который нужен «зависшей» длинной транзакции для восстановления её снимка, выкинуть нельзя: если транзакция, что началась позже и закоммитилась раньше нашей длинной, поменяла строку — длинная не должна видеть её изменений и обязана собрать до-версию, а для этого как раз и нужен тот самый undo. Так что «лишним» он только кажется.
Дальше — вопрос политики. Можно разрешить этот случай в пользу очистки: тогда длинная транзакция наткнётся на строку, для которой нет версии на момент её старта, и получит «snapshot too old» — ровно ORA-01555 у Oracle. А можно в пользу транзакции — и тогда undo приходится держать. Мы выбрали второе: читающая транзакция получает консистентный снимок и не падает на ровном месте. ORA-01555 — боль хорошо известная, особенно на долгих отчётах и выгрузках, и тащить её к себе не хотелось.
При этом «одна забытая сессия стопорит всех» — не приговор. Горизонт держат только реально активные снимки, а забытую сессию с открытой транзакцией лечат таймаутом на простой в транзакции и мониторингом, а не ценой консистентности для остальных. По сути это настройка, а не закон природы.
Как мне кажется тут небольшая путаница в терминах. То, что вы описали — «голова» строки остаётся на месте, индекс указывает на неё, а она перенаправляет на новое расположение данных — это и есть 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-лог, а не дублируется на странице. Если же строка не растёт и индексные колонки не меняются — апдейт идёт на месте, индексы не трогаются вовсе.