Мы пишем реляционную базу данных на 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», а «каким он обязан быть». Наш ответ — три требования, под которые мы и затачиваем каждый механизм:
-
Только в фоне и малыми порциями. Каждый воркер работает с бюджетом — столько-то страниц или записей за проход, — чтобы очистка никогда не конкурировала с пользовательской нагрузкой за всё 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 или просто хотите посмотреть на движок вблизи — напишите нам. Никаких обязательств: нам важна обратная связь от людей, которые понимают, что происходит под капотом.
ссылка на оригинал статьи https://habr.com/ru/articles/1047852/