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

Теперь хочу перейти от принципов к конкретике и показать, как эти идеи начинают превращаться в архитектурные решения.

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

Что уже есть в коде

Сразу отвечу на вопрос, который обычно возникает в таких текстах: это не только концепт.

На текущем этапе реализованы базовые части движка: unified storage с одним файлом на базу (ну почти, но про это отдельно), disk-backed storage path, UNDO-log MVCC, WAL и recovery в стиле ARIES, общий BufferPool, in-memory engine и PostgreSQL-compatible pgwire-интерфейс.

Это работающий код, но ранний. Многое ещё не прошло серьёзных нагрузочных тестов, не все edge cases покрыты, и ряд подсистем пока в состоянии «работает, но требует стабилизации». Где что-то остаётся следующим шагом или гипотезой — я стараюсь это проговаривать.


Принципы проекта

Прежде чем писать код, я зафиксировал несколько принципов. Для меня это не декларации «за всё хорошее», а правила принятия решений. Если новое решение противоречит одному из них, это нужно отдельно обосновывать.

Насколько эти принципы выдержат столкновение с реальностью — покажет время. Но пока они помогают не расплываться.

1. Restrictive by Default

Главная проблема многих систем в продакшне выглядит так: они слишком долго делают вид, что всё нормально.

Пока хватает ресурсов, это незаметно. Когда нагрузка растёт, система начинает деградировать молча: latency ползёт вверх, хвосты распухают, а приложение получает непонятную ошибку, а размазанное по времени ухудшение всего сразу.

Поэтому я закладываю принцип: каждый важный компонент должен иметь явные границы допустимого поведения, и при их нарушении система выбирает fail-closed, а не fail-open.

Компонент

Граница

При нарушении

SQLSTATE

BufferPool

buffer_pool_size_mb — RAM-бюджет на кеш страниц

Eviction (CLOCK), WAL-first flush

TxnWriteSet

txn_max_write_set_mb — лимит на транзакцию

Reject DML

54023

UndoStore

undo_max_size_mb — лимит UNDO-истории на диске

Reject writes

53100

Connection pool

max_connections

Reject new connections

53300

Statement timeout

statement_timeout_ms

Cancel query

57014

Snapshot age

max_snapshot_age

Force-close stale snapshots

40001

Почему не throttling? Мой опыт подсказывает, что throttling часто скрывает реальную проблему. Клиент ещё ждёт, система ещё пытается, а по факту всё уже давно вышло за безопасные рамки. В итоге вместо одного чёткого отказа получается каскад таймаутов, зависаний и неочевидных симптомов. Возможно, для некоторых сценариев мягкий backpressure будет уместнее — но как стартовая позиция мне ближе явный отказ.

Fail-closed неприятнее в моменте, но, на мой взгляд, честнее в эксплуатации. Клиент получает понятный SQLSTATE, а приложение может сразу принять решение: retry, circuit breaker, fallback или отказ пользователю.

Для OLTP это, как мне кажется, важнее «гибкости». Когда система работает у границы ресурсов, предсказуемость почти всегда ценнее, чем иллюзия, что она ещё что-то «дотянет».

2. Contract-First: контракт — это код, а не слова

В архитектуре есть старая проблема: документ можно написать один раз и забыть. Код всё равно продолжит меняться.

Поэтому для меня архитектурный контракт — это не презентация и не markdown-файл сами по себе, а то, что реально удерживает реализацию в рамках. В моей БД таким механизмом становятся Rust-трейты (traits) и типовые границы между подсистемами.

TableEngine, PageProvider, TransactionLogSink, StorageIo — это не просто интерфейсы «для красоты». Это попытка зафиксировать, что именно подсистема обязана уметь, где проходят её границы и какие инварианты должны выдерживаться.

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

Contract-first подход нужен как раз для того, чтобы важные вещи проверялись не на словах, а автоматически. Насколько это получится удержать по мере роста кодовой базы — вопрос открытый, но как ориентир мне это помогает.

3. Rust как осознанный выбор

Для OLTP-движка критичны предсказуемая латентность, контроль над аллокациями и снижение числа классов ошибок, которые могут добраться до production.

Поэтому проект пишется на Rust.

Почему не C++? C++ тоже позволяет строить высокопроизводительные системы без GC. Но в Rust мне проще выражать и удерживать часть архитектурных инвариантов на уровне типов и границ API: Send/Sync на async/sync-границах, более строгую работу с владением, явные ошибки вместо неявных runtime-сценариев. В C++ этого тоже можно добиться, но обычно ценой большей дисциплины, code review и внутренних соглашений.

Почему не Go? Для сетевых сервисов Go часто более чем достаточен. Но для OLTP с жёсткими требованиями к хвостовой латентности влияние GC и runtime scheduler уже становится не второстепенной деталью, а частью поведения системы под нагрузкой.

То есть выбор Rust здесь не идеологический. Я не пытаюсь доказать, что это «лучший язык вообще». Мне важнее, что в контексте движка БД он помогает сместить больше ошибок и больше дисциплины в compile time.

4. Бизнес-логика в приложении, БД — data engine

Ещё один сознательный выбор: на старте без триггеров, PL/pgSQL и хранимых процедур.

Причина простая. Чем больше пользовательской логики живёт внутри ядра БД, тем тяжелее её отдельно тестировать, нормально версионировать, наблюдать и разбирать в инцидентах. База данных начинает выполнять роль не только data engine, но и application runtime со всеми вытекающими.

Это не значит, что расширяемость не нужна совсем. User-defined functions возможны на следующих этапах, но только при жёстких ограничениях: sandboxing, понятная наблюдаемость, отсутствие побочных эффектов на старте.

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

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

5. Гибридная асинхронность: сначала стабилизировать, потом ускорять

Это один из тех пунктов, где легко уйти в крайность.

С одной стороны, делать всё синхронным — плохая идея для сетевого слоя. Модель process-per-connection или thread-per-connection начинает плохо масштабироваться по соединениям: растёт footprint, возрастает роль scheduler'а, сложнее удерживать предсказуемость.

С другой стороны, делать весь движок async с первого дня — тоже не подарок. Особенно если ты параллельно строишь storage engine, WAL, recovery, MVCC и buffer manager.

Поэтому выбран гибридный путь:

  • сетевой и протокольный слой — async;

  • ядро выполнения, storage, WAL и транзакции — sync;

  • граница между ними — явная и односторонняя.

То есть async-слой может вызывать sync-слой через bridge, но sync-слой не должен тянуть в себя runtime сетевого мира.

Почему так? Потому что на раннем этапе главная задача storage path — корректность, наблюдаемость и дебажимость. Линейный стек вызовов, отсутствие .await внутри чувствительных участков и меньшее число подвижных частей здесь реально упрощают жизнь. По крайней мере, мне так кажется исходя из текущего опыта отладки.

При этом StorageIo изначально оформлен как абстракция. Это нужно не ради абстракции самой по себе, а чтобы позже можно было перейти к async I/O backend без переписывания логики транзакций и MVCC.

Иными словами: сначала построить стабильный baseline, потом уже честно измерить, что именно даёт более сложный I/O backend. Возможно, замеры покажут, что для типичных OLTP-нагрузок разница не так велика. А может быть, наоборот. Пока я не хочу оптимизировать то, что ещё не измерено.

Слой

Runtime

Примеры

Network / Protocol

async

pgwire accept, TLS, response send

Replication / CDC

async, следующий шаг

WAL shipping, CDC stream

Query Execution

sync

plan, execute, IR processing

Storage / WAL

sync сейчас, async I/O позже

HeapStore, BufferPool, UndoStore, WAL write

MVCC / Transactions

sync

snapshot management, lock manager

Для in-memory операций это, кстати, не мешает иметь быстрый путь без disk I/O и без лишнего переключения модели исполнения.

6. PostgreSQL-wire совместимость через boundary, а не через копирование PostgreSQL

Совместимость с PostgreSQL-экосистемой для меня важна. Но важна именно как совместимость на уровне подключения и интеграции, а не как обязательство скопировать PostgreSQL изнутри.

Почему pgwire? Потому что это позволяет подключать существующие драйверы, ORM и инструменты без переписывания клиентской стороны. Для нового движка это критично: иначе ты сначала строишь БД, а потом ещё и уговариваешь людей переписать всё вокруг неё.

Но быть PostgreSQL-compatible на уровне протокола (wire) и быть PostgreSQL внутри (internally) — не одно и то же.

Внутри у PostgreSQL есть свои сильные стороны и свои исторические компромиссы: multi-version heap, VACUUM, определённая модель каталогов, расширений и так далее. Я не хочу механически переносить эти решения в ядро только потому, что они есть у PostgreSQL. Хотя, возможно, какие-то из них окажутся правильными и для моей архитектуры — тогда я их осознанно приму, а не унаследую по инерции.

Поэтому совместимость делается через адаптерный слой. Ядро живёт со своей внутренней моделью, а boundary-слой занимается переводом PG shape'ов и ожиданий клиента во внутренние вызовы.

Идея в том, чтобы получить понятный migration bridge для приложений, не лишая себя архитектурной свободы внутри движка. Насколько глубокой окажется эта совместимость на практике — будет видно по мере того, как появятся реальные пользователи с реальными приложениями.

7. Linux-only

На раннем этапе это Linux-only проект.

Здесь причина не в нелюбви к другим ОС, а в желании не размывать инженерный фокус. Если хочешь использовать io_uring, eBPF и другие Linux-native возможности, то поддержка нескольких платформ довольно быстро перестаёт быть «вопросом пары #ifdef».

Это отдельные ветки поведения, отдельный QA, отдельные компромиссы по API и ограничения на архитектуру.

Кроссплатформенность почти всегда выглядит красиво в презентации, но дорого обходится в ядре. Для раннего проекта я предпочитаю одну платформу, которую можно хорошо понять, хорошо измерить и хорошо контролировать.

8. Надёжность важнее «магии»

Есть соблазн строить новые системы вокруг красивых acceleration-story: здесь ускорим, тут схитрим, вот тут потом «дополируем». Но у ядра БД другие приоритеты.

  • Data corruption недопустим.

  • Любая оптимизация должна иметь безопасный путь отката.

  • Пользовательский ввод не должен валить сервер.

  • Ошибка лучше неявной деградации.

  • Безопасность должна быть встроена в контракт, а не прикручена потом.

Отсюда растут no-panic policy для production code, жёсткое отношение к recovery path и идея, что шифрование at-rest, если включено, должно покрывать не только heap, но и историю версий.

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


Границы первого этапа

После принципов нужно было очень чётко сузить рамки. Иначе новый движок слишком легко превращается в список хотелок.

Что входит

  • PostgreSQL-совместимость, но не «полная во что бы то ни стало». Поддерживаемое подмножество фиксируется явно: что входит — работает по контракту, что не входит — возвращает 0A000 (feature not supported в Postgres).

  • Unified storage: один файл на БД. Общее адресное пространство страниц, общий BufferPool, более простой backup path.

  • UNDO-log MVCC. Актуальные версии в heap, история в отдельном UNDO-store.

  • Pluggable storage engines. Граница, которая позволяет не цементировать ядро под один-единственный формат хранения.

  • Security baseline на уровне storage. Если включено шифрование at-rest, оно должно защищать не только «основные» данные.

Что сознательно не входит

  • PL/pgSQL, триггеры, хранимые процедуры;

  • полная экосистема PostgreSQL extensions;

  • полноценные HA- и replication-сценарии на первом шаге;

  • Windows/macOS.

Это не попытка сделать продукт «как можно меньше». Это попытка довести до внятного состояния именно тот фундамент, на который потом действительно можно будет опереться. По крайней мере, такова ставка.


Архитектура и слои

В high-level виде архитектура выглядит так:

┌─────────────────────────────────────────────────────────────────┐
│  CLIENTS / DRIVERS (PostgreSQL compatible)                      │
└───────────────────────┬─────────────────────────────────────────┘
                        │ pgwire protocol
┌───────────────────────▼─────────────────────────────────────────┐
│  ADAPTER LAYER (async)                                          │
│  - pgwire frontend, connection management                       │
│  - аутентификация, TLS termination                              │
└───────────────────────┬─────────────────────────────────────────┘
                        │ bridge
┌───────────────────────▼─────────────────────────────────────────┐
│  COMPAT / ANTI-CORRUPTION LAYER                                 │
│  - PG shapes/objects → internal handlers                        │
│  - pg_catalog / information_schema как маппинг над sys_catalog  │
│  - Normalize (AST → IR): supported vs 0A000                     │
└───────────────────────┬─────────────────────────────────────────┘
                        │ normalized/internal calls
┌───────────────────────▼─────────────────────────────────────────┐
│  CORE ENGINE (sync)                                             │
│  - query execution                                              │
│  - transaction manager                                          │
│  - StorageManager                                               │
└───────────────────────┬─────────────────────────────────────────┘
                        │ DML/reads → storage engines
┌───────────────────────▼─────────────────────────────────────────┐
│  STORAGE                                                        │
│  - unified per-DB file                                          │
│  - shared BufferPool per DB                                     │
│  - HeapStore + UndoStore                                        │
│  - WAL + ARIES-style recovery                                   │
│  - Memory engine                                                │
│  - StorageIo abstraction                                        │
└─────────────────────────────────────────────────────────────────┘

Но главное тут не сама схема, а несколько архитектурных решений, которые определяют поведение движка на практике.

1. UNDO-log MVCC вместо Multi-version Heap

Это, пожалуй, главный сознательный отход от привычной PostgreSQL-модели.

В PostgreSQL старые версии строк живут в основных таблицах. Это делает модель прозрачной, но приводит к побочному эффекту: heap и индексы со временем обрастают мёртвыми версиями, а VACUUM становится обязательной частью жизни системы.

UNDO-модель меняет это:

  • heap хранит только актуальные версии;

  • история изменений уходит в append-only UndoStore;

  • чтение старого snapshot'а делается через размотку UNDO-цепочки;

  • recovery строится по схеме Analysis → Redo → Undo + CLR.

Почему я выбрал этот путь для OLTP? Потому что такая модель, в теории, лучше удерживает heap компактным и убирает сам класс проблем, связанных с накоплением мёртвых кортежей в основных страницах. Этот подход не нов — его используют Oracle, InnoDB/MySQL, MS SQL Server. То есть это не экспериментальная идея, а хорошо изученный trade-off.

Да, здесь есть своя цена. Старые версии читать сложнее, чем в multi-version heap: их нужно восстанавливать по цепочке, а не читать «как есть». Кроме того, UNDO-store сам по себе требует управления жизненным циклом — это не бесплатный компонент. Но в OLTP-мире с короткими транзакциями это выглядит разумной ценой за отказ от VACUUM как постоянного эксплуатационного фона. Насколько этот trade-off окупится на практике — покажут нагрузочные тесты, которые я планирую опубликовать в будущем.

Для более тяжёлых аналитических чтений логичнее иметь другой путь — columnar/HTAP, а не пытаться одним и тем же механизмом идеально обслуживать всё сразу.

2. Unified storage и единое адресное пространство

Все таблицы одной базы хранятся в едином файле (назовем его условно .db-файл), а страницы адресуются в общем пространстве через PageId = [table_id:16][local_page_id:48].

Почему не делать «файл на таблицу»? Потому что такая модель удобна до определённого масштаба, а потом вместе с ней приходят file descriptors, усложнение backup path, лишняя координация между файловыми объектами и конкуренция за память между разрозненными кусками данных. По крайней мере, так выглядит мой анализ существующих решений.

Один файл на БД ничего не делает «магически лучше» сам по себе, но он упрощает несколько важных вещей сразу:

  • общий BufferPool;

  • единый memory budget;

  • более прямой backup/restore path;

  • меньше сущностей, которые нужно синхронизировать и учитывать.

Trade-off здесь тоже честный: качество I/O backend и планировщика становится ещё важнее, потому что storage path концентрируется вокруг единого файлового пространства. Кроме того, при очень большом количестве баз на одном инстансе единый файл может стать узким местом — но для целевого OLTP-сценария мне этот trade-off кажется приемлемым.

Мне ближе этот путь, чем раскладывать сложность по множеству файлов и потом собирать консистентность снаружи. Хотя, возможно, со временем потребуется гибридный подход.

3. Физическая переносимость данных как архитектурное требование

Есть ещё один принцип, который кажется скучным, пока не наступает авария.

Я хочу, чтобы в базовом сценарии оператор мог остановить инстанс, перенести данные и журналы на другой сервер, поднять совместимый бинарь и продолжить работу. Не через обязательный dump/restore, а через перенос самих on-disk артефактов.

Это требует дисциплины в формате хранения, versioning'е, page layout, согласованности WAL и fail-closed поведения при несовместимости.

Почему я считаю это важным уже сейчас? Потому что если этот инвариант не заложить рано, позже очень легко построить систему, которая вроде бы «работает», но по факту жёстко привязана к окружению, layout'у или недокументированным шагам восстановления. По крайней мере, именно это я наблюдал в нескольких production-системах, с которыми работал.

Для production-системы физическая переносимость — не мелочь. Это часть доверия к on-disk контракту. Удастся ли реализовать это полноценно — зависит от множества деталей, но как архитектурное ограничение я закладываю с самого начала.


Что дальше

Сейчас фокус предельно приземлённый: стабилизация single-node OLTP path, storage, MVCC, WAL/recovery и PostgreSQL-compatible интерфейса.

Дальше естественным образом напрашиваются:

  • replication и WAL shipping;

  • CDC;

  • columnar path для аналитических сценариев;

  • online DDL;

  • развитие storage I/O backend;

  • затем уже более сложные HA- и scale-out-сценарии.

Но тут важно не перечисление красивых будущих фич, а то, чтобы каждая следующая ступенька действительно опиралась на предыдущую. Репликация не бывает хорошей без хорошего WAL. HTAP не появляется «потом поверх» без заранее заложенной engine boundary. Scale-out не добавляется к ядру безнаказанно, если single-node путь уже зацементирован случайными локальными решениями.

Поэтому большая часть текущей работы — это не «делать distributed заранее», а стараться не закрывать себе дорогу к нему неправильными решениями сейчас.

В следующих статьях хочу отдельно разобрать то, что уже спроектировано на уровне конкретных подсистем.
Ссылки на проект, бенчмарки и т.д. будут чуть позже, как будут готовы к публичному доступу.


Вопросы к сообществу

Мне интересно, насколько описанные решения совпадают или спорят с вашим практическим опытом. Проект молодой, и содержательная критика для меня сейчас ценнее комплиментов.

  1. UNDO-log MVCC vs Multi-version Heap: кто работал с обоими подходами — какие trade-off'ы оказались заметнее всего в эксплуатации?

  2. Sync сейчас, async I/O потом: насколько вам такой путь близок, «сначала стабилизировать и измерить, потом усложнять»? Или это просто отложенная проблема?

  3. Fail-closed vs fail-open: готовы ли вы к тому, что БД честно отклоняет запрос при нехватке ресурсов, вместо того чтобы деградировать молча? Или в вашей практике мягкая деградация всё-таки предпочтительнее?

  4. Unified storage: какие проблемы вы видите в модели «много файлов», и что, наоборот, может потеряться при едином файле?

  5. Без PL/pgSQL на старте: насколько вам реально нужен процедурный язык внутри БД, а не в application layer?


Если интересно пообщаться

Проект на раннем этапе, и мне в первую очередь интересна содержательная обратная связь от тех, кто реально живёт с OLTP-нагрузками в продакшне.

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

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


  1. kmatveev
    24.03.2026 07:13

    С предыдущими статьями мне было сложно, там была критика существующих решений в духе "MVCC плохо, блокировки плохо". И не очень понятно, а как хорошо? Тут хоть конкретика появилась.

    Я правильно понял, что txn_max_write_set_mb определяет, что в одной транзакции не получится обновить больше, чем сколько-то строк таблицы? Если так, то это очень напрягает. Я понимаю про OLTP, но даже в OLTP базе нужно делать maintenance-операции, и они бывают большими.

    Я сейчас, возможно, глупости буду говорить. Я не понимаю взаимосвязи BufferPool и того, что база в одном файле. BufferPool - это же про управление ресурсами, чтобы не просить память у операционки динамически и не отдавать ей эту память обратно. Он же не хранится в файлах базы, это временные данные. Какая связь со структурой файлов базы на диске?

    Насчёт "UNDO-log MVCC vs Multi-version Heap" : я знаю ещё про подход Firebird, у них не новая версия строки сохраняется в новом месте, а предыдущая копируется в новое место, а новая версия перезаписывается. Это наверняка быстрее undo log для транзакций в режиме read commited и repeatable read, но тоже требует уборки мусора.


    1. anishukserg Автор
      24.03.2026 07:13

      Спасибо за глубокие вопросы! По порядку.

      1. Про txn_max_write_set_mb и maintenance:
        Да, лимит жесткий. Если транзакция разрастается за отведенный RAM-бюджет, она падает с ошибкой 54023. Почему так сурово? Потому что гигантский UPDATE на миллионы строк в OLTP-системе — это гарантированная просадка p99 для всех остальных. Он раздувает Undo-log, держит блокировки и может спровоцировать OOM.
        Правильный паттерн для maintenance — это батчинг (обновление чанками по 5-10к строк). А для системных вещей (типа COPY или перестроения индексов) этот лимит не применяется, так как там данные пишутся напрямую в страницы в обход классического MVCC.

      2. Связь BufferPool и одного файла:
        Связь неочевидная, но прямая возникает на уровне I/O и управления вытеснением (eviction). Если у нас 10 000 таблиц, то в модели Postgres — это десятки тысяч открытых файловых дескрипторов. Когда BufferPool сбрасывает грязные страницы на диск, ему нужно делать I/O по множеству разных файлов, упираясь в ulimit и размазывая нагрузку на ФС.
        В едином файле BufferPool работает со сквозным PageId = [table_id][local_page_id]. Это позволяет нам: а) не зависеть от лимитов ОС на дескрипторы; б) очень эффективно использовать io_uring (один кольцевой буфер на один файл) для пакетного сброса страниц.

      3. Про подход Firebird:
        То, что вы описали («старая копируется в новое место, новая перезаписывается») — это и есть классический Undo-log с In-place update! Именно так работают Oracle, InnoDB и в разрабатываемом для Postgres движке OrioleDB / zheap.

        Postgres делает наоборот: оставляет старую строку на месте, а новую пишет в конец страницы (что раздувает таблицы и ломает индексы).
        Да, наш подход тоже требует уборки мусора. Но чистить отдельный append-only Undo-log (просто сдвигая watermark и удаляя старые сегменты) на порядки дешевле и предсказуемее, чем гонять тяжелый VACUUM по всем таблицам и индексам, пытаясь выковырять мертвые строки из "живых" страниц.


      1. kmatveev
        24.03.2026 07:13

        По пункту 1 не совсем понял, какая связь у транзакции и RAM-бюджета? Я бы ожидал, что транзакция обновляет страницы таблицы в кеше, который сбрасывается на диск, пишет undo-лог на диск.

        Насчёт батчинга: вам будет непросто объяснить, почему вместо delete from orders where date < today - 10 вы предложите выбирать идентификаторы, нарезать их пачками и удалять, перечисляя эти идентификаторы.

        Про Firebird я, похоже, недостаточно подробно описал. Там нет undo log-а в append-only виде, предыдущие версии строк размазаны в страницах той же таблицы.


        1. anishukserg Автор
          24.03.2026 07:13

          Спасибо за диалог, давайте углубляться! Тем более, учитывая ваш крутейший цикл статей про внутренности Firebird, обсуждать такие детали вдвойне интереснее.

          1. Про транзакции и RAM-бюджет
          В классическом подходе вы абсолютно правы: транзакция меняет страницы в глобальном кэше и пишет логи. Но мы (как и некоторые другие современные движки) идем через концепцию локального Write Set.
          Транзакция в процессе работы накапливает список измененных строк/блокировок (и формируемые Undo-записи) локально в памяти коннекта (тот самый TxnWriteSet), чтобы на каждый микро-апдейт не брать тяжелые глобальные локи.
          Если вы обновляете миллион строк одним запросом, этот локальный стейт транзакции (метаданные локов, буферы) раздувается до гигабайтов. txn_max_write_set_mb — это fail-closed предохранитель ядра. Движок честно говорит: «я не могу гарантировать стабильность системы, если одна транзакция пытается удержать в памяти стейт на половину базы».

          2. Про боль батчинга (DELETE FROM orders where date < ...)
          Тут вы бьете в самую больную точку — Developer Experience. Да, «продавать» батчинг разработчикам тяжело.
          Но давайте посмотрим со стороны движка: чтобы сделать такой гигантский DELETE, базе нужно поднять с диска кучу старых страниц, вымыв из BufferPool реально горячие данные, и сгенерировать мегабайты мусора. В итоге страдает p99 всех соседних OLTP-запросов.
          Правильный архитектурный паттерн для удаления старых данных по дате — это не DELETE, а партиционирование и DROP PARTITION. Это O(1) операция с метаданными. А если партиционирования нет, то DML-батчинг — это просто суровая эксплуатационная необходимость.

          3. Про подход Firebird
          Да, спасибо за уточнение, теперь понял вашу мысль до конца! Firebird действительно пишет новую версию in-place, а старую вытесняет в другие страницы той же самой таблицы, формируя back-version цепочку.
          Но архитектурно это как раз тот компромисс, от которого мы хотим уйти. Из-за того, что старые версии «размазаны» по соседним страницам таблицы, файл данных всё равно страдает от исторического мусора и фрагментации (что в Firebird лечится фоновым процессом Sweep).
          Мы же используем строгую изоляцию: актуальные версии лежат плотно в Heap-страницах, а старые версии выносятся в физически отдельное append-only пространство — UndoStore. Мусор вообще не смешивается с актуальными данными, а очистка Undo-лога — это просто сдвиг указателя (watermark) и удаление старых сегментов, что на порядки дешевле поиска мертвых версий по страницам данных.


          1. kmatveev
            24.03.2026 07:13

             мы (как и некоторые другие современные движки) идем через концепцию локального Write Set.

            Да, чувствуется, что вы ориентируетесь на какие-то движки. Расскажите поподробнее, чем вы вдохновляетесь?

            Транзакция в процессе работы накапливает список измененных строк/блокировок (и формируемые Undo-записи) локально в памяти коннекта (тот самый TxnWriteSet), чтобы на каждый микро-апдейт не брать тяжелые глобальные локи

            Не совсем понял, какие глобальные локи? Если что, то Firebird, например, берёт read-лок на метаданные таблицы (чтобы заблочить того, кто захочет поменять структуру таблицы), и этот лок лёгкий. Для записи строки берётся лок на страницу, я бы не назвал его глобальным.

            Если я вас правильно понял, и write set сохраняется в момент commit-а транзакции, то, получается, сами операции со строками занимают меньше времени, а commit более тяжёлый. Вообще выглядит, что write set подходит для двух вещей: быстрый откат маленьких транзакций, и оптимизация записи нескольких строк в одну страницу.

            Но давайте посмотрим со стороны движка: чтобы сделать такой гигантский DELETE, базе нужно поднять с диска кучу старых страниц, вымыв из BufferPool реально горячие данные, и сгенерировать мегабайты мусора.

            Эээ почему подъём старых страниц должен вымывать из BufferPool-а горячие данные? Почему нельзя обработать страницу и сказать, что она больше не нужна, чтобы она не оставалась BufferPool ? Хотя я думаю, что Firebird так не умеет, про Postgres не скажу.

            Ещё пара соображений. Некоторые разработчики современных баз данных утверждают, что SSD стали очень быстрыми, и всё это управление буферами нафиг не сдалось, оно было нужно в эру HDD. Что вы думаете об этом?


            1. anishukserg Автор
              24.03.2026 07:13

              Вы подняли очень интересные темы.

              1. Чем вдохновляемся
              Если говорить про In-place update и Undo-log, то главные референсы — это Oracle, InnoDB, а из современного: Huawei GaussDB (где они переписали ядро Postgres под Undo), TiDB и OrioleDB. А по векторному вычислителю мы смотрели на работы Мюнхенского технического университета (TUM), архитектуру HyPer и DuckDB.

              2. Про глобальные локи и Write Set
              Вы правы, я немного упростил. Я имел в виду не глобальный лок базы (Global Lock), а Latch Contention (конкуренцию за мьютексы) в общих структурах. Да, берется лок на страницу (Page Latch). Но когда одна огромная транзакция обновляет миллионы строк, она постоянно дергает эти page latches, заставляя другие потоки ждать.
              Локальный Write Set позволяет нам:

              • Защитить базу от OOM (через лимит памяти на коннект).

              • Сформировать батч изменений локально, а фазу применения сделать атомарной.

              Да, вы всё поняли абсолютно верно: при таком подходе фаза Execution становится очень быстрой (мы никого не блокируем), а фаза Commit (или Apply) берет на себя всю тяжесть. Это осознанный trade-off, который отлично показывает себя в распределенных системах и современном OLTP.

              3. Про вымывание BufferPool при DELETE
              Вы затронули классическую проблему. Да, в идеале база должна сказать странице: "ты мне больше не нужна, уходи". В Postgres для Bulk-операций (например, гигантский Seq Scan или VACUUM) даже есть механизм Ring Buffer: выделяется маленький локальный буфер (скажем, 256 KB), через который прогоняются страницы, не загрязняя основной shared_buffers.
              НО! Если вы делаете DELETE по Range-индексу (по дате), база не всегда понимает, что это массовая операция. Она начинает затягивать страницы с диска обычным путем, двигает им счетчики "горячести" в алгоритме CLOCK/LRU, и в итоге вытесняет реально нужные клиентские данные.

              4. Про "смерть BufferPool'а" в эру NVMe SSD
              Это интересный вопрос (видел, что разработчики Aerospike или ScyllaDB работают через O_DIRECT в обход Page Cache ОС).
              С Page Cache операционки действительно можно (и часто нужно) прощаться. Но внутренний BufferPool базы данных мы считаем убивать нельзя, и вот почему:

              • Задержки (Latency): Как бы ни был быстр NVMe (микросекунды), чтение горячей страницы из RAM (наносекунды) — это разница в 2-3 порядка. Для тяжелого OLTP это критично.

              • Износ и Write Amplification: Вы не можете напрямую писать каждую измененную строку на SSD. Размер блока на NVMe часто 4KB или 16KB. Если вы делаете микро-апдейт и сразу шлете его на диск (Direct I/O), вы убьете SSD. BufferPool выступает как амортизатор: мы "пачкаем" страницу в памяти тысячу раз, а на диск сбрасываем ее всего один раз (группировка I/O). Так что мы у себя BufferPool оставляем.


  1. akardapolov
    24.03.2026 07:13

    UNDO-log MVCC vs Multi-version Heap

    Для OLTP на малых и средних нагрузках без разницы.

    На high-load с настоящим OLTP первый вариант показывает лучшие результаты. Да и это в общем верное направление - разделять собственно данные и их копии для обеспечения согласованности.

    В любом случае будет вариант, когда будут использовать БД и для аналитики - тогда придется мириться с проблемами в обоих продходах или на уровне системы в целом разводить транзакционную и аналитические нагрузки. Но в случае UNDO-log MVCC их все-таки поменьше будет из-за самой архитектуры.

    Предложил бы запустить полноценный R&D по этой теме, с чтением papers, разных подходов уже реализованных где-нибудь (понятно, что двигаться нужно в сторону UNDO-log MVCC, тут без вариантов).


    1. anishukserg Автор
      24.03.2026 07:13

      Согласен на 100%, спасибо за комментарий!

      Про аналитику и HTAP:
      Вы попали в самую суть. Пытаться заставить строковый движок (даже с UNDO-логом) быстро молотить тяжелые OLAP-запросы это путь к боли. Разматывать цепочки старых версий по Undo-логу в памяти дорого для больших сканов.
      Именно поэтому в архитектуре (на схеме в статье) заложена абстракция TableEngine (Pluggable storage). План не в том, чтобы сделать универсальную структуру «для всего», а в том, чтобы реализовать полноценный ColumnarStore. Он будет жить рядом с HeapStore и асинхронно догоняться из общего WAL. Это классический паттерн распределения нагрузки в HTAP-системах, когда транзакции пишут в row-store, а аналитика читает из column-реплики, не блокируя друг друга.

      Про R&D и papers:
      Мы именно так и стараемся двигаться — от академической базы к коду. Текущий дизайн во многом родился из ресерча. Если говорить о конкретных papers, на которые мы опирались при проектировании MVCC и HTAP, то базовыми были:

      1. Neumann et al. (2015, CMU/TUM) — "Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems". Очень крутая работа про то, как реализовывать MVCC с минимальными накладными расходами без потерь для Snapshot Isolation. Оттуда же мы брали идеи по архитектуре HyPer (dual row/column storage).

      2. Freitag et al. (2023, VLDB) — свежая работа про то, как memory-optimized MVCC позволяет disk-based системам достичь пропускной способности, близкой к in-memory базам.

      3. По архитектуре разделения мы внимательно смотрели работы по TiDB (Huang et al. 2021, VLDB "TiDB: A Raft-based HTAP Database"), где подробно разбирается консистентность между OLTP primary и OLAP replica.

      Но тема действительно огромная, и нестандартных подходов много (тот же zheap в Postgres или Ustore в GaussDB). Если у вас есть на примете крутые академические статьи или разборы реализаций, которые стоит покрутить, буду очень благодарен за ссылки!


  1. vlad4kr7
    24.03.2026 07:13

    Я обеими руками за! Rust движок БД, но современные подходы больше топят за кластерную структуру, когда основная проблема нехватки ресурсов решается добавлением ноды. А ACID решается годами разработки (существующего) движка.

    Проблемы замедления или реакции - вкрутите таймаут, у будет вам fail-fast.


    1. anishukserg Автор
      24.03.2026 07:13

      Спасибо за поддержку! Вы затронули серьезные архитектурные вопросы, попорядку:

      1. Про кластеры и добавление нод
      Добавление нод (Scale-out) это круто, но это не бесплатная магия. Распределенные транзакции (2PC, консенсус) добавляют огромный сетевой оверхед.
      К тому же кластер не лечит проблемы базового движка, он их мультиплицирует. Если у вас на одной ноде непредсказуемо скачет p99 из-за очистки мусора или неэффективного использования памяти, в кластере это приведет к лавине таймаутов между узлами.
      Современное железо (100+ ядер, терабайты RAM, NVMe-диски) способно переварить 99% всех нагрузок в мире на одной ноде. Нужно просто эффективное ядро. Поэтому мой подход: сначала выжать максимум предсказуемости из single-node ядра, а уже потом (как и описано в статье в разделе "Что дальше") прикручивать шардирование и кластеризацию.

      2. Про "вкрутите таймаут и будет fail-fast"
      Таймаут (например, statement_timeout) — это необходимая вещь, и он у нас, конечно, есть. Но таймаут лечит симптомы, а не причину.
      Представьте: пришел тяжелый кривой запрос. База честно пытается его выполнить. В течение 30 секунд (пока не сработает таймаут) этот запрос жрет CPU, держит блокировки строк и активно вымывает горячие данные из BufferPool на диск. Через 30 секунд запрос отваливается по таймауту. В итоге: полезной работы ноль, ресурсы сожжены, соседние "хорошие" транзакции притормозили.
      А fail-closed контракты (например, лимит на размер Write Set) убивают запрос ровно в ту миллисекунду, когда он превысил отведенный ему бюджет памяти. Система защищает себя превентивно, не тратя время на обреченную работу.

      3. Про "ACID решается годами"
      Тут спорить бессмысленно вы абсолютно правы, это долгий марафон. Но именно поэтому в архитектуре не используется магия. Мы берем фундаментально доказавшие себя подходы (восстановление в стиле ARIES, Undo-log из классических СУБД) и реализуем их на современном стэке (Rust, строгая система типов). Дорогу осилит идущий!


      1. vlad4kr7
        24.03.2026 07:13

        К тому же кластер не лечит проблемы базового движка, он их мультиплицирует. Если у вас на одной ноде непредсказуемо скачет p99 из-за очистки мусора или неэффективного использования памяти, в кластере это приведет к лавине таймаутов между узлами.

        Кроме того, что можно взять более отшлифованный движок, кластер логику, тоже можно сделать чуть умнее чем 2РС на все-все ноды.

        Кстати диалект SQL, будет тоже свой?


        1. anishukserg Автор
          24.03.2026 07:13

          Про кластерную логику:
          Согласен, современный NewSQL (тот же TiDB или Cockroach) давно ушел от классического тяжелого 2PC в сторону Raft/Paxos и умного роутинга. Но даже там, если storage-нода уходит в долгий GC, страдает весь кворум. В любом случае, распределенные фичи — это следующий этап. Сначала нужно сделать так, чтобы одна нода работала как швейцарские часы.

          Про диалект SQL:
          Нет, придумывать свой диалект (как это когда-то сделал ClickHouse или Tarantool) — это самоубийство для современного OLTP-движка. Никто не захочет переписывать свои ORM, драйверы и BI-тулзы под новый синтаксис.

          Поэтому мы делаем PostgreSQL-совместимый интерфейс (pgwire).
          Цель в том, чтобы вы могли взять стандартный драйвер (psycopg2 в Python, Npgsql, JDBC, или стандартные пулы из Rust/Go) и подключиться к нашей базе точно так же, как к обычному Постгресу, отправляя стандартные SQL-запросы.
          На уровне парсера и планировщика мы транслируем эти запросы (IR) в наши внутренние форматы и сканы. Конечно, на раннем этапе мы не поддерживаем 100% экзотических функций Постгреса или все его типы индексов, но базовый ANSI SQL и ключевые типы данных работают нативно.


          1. vlad4kr7
            24.03.2026 07:13

            Но даже там, если storage-нода уходит в долгий GC, страдает весь кворум.

            не понятно, про storage ноду, в настоящем М2М кластере, вроде все - одинаковые мастер. но да, ноды могут зависнуть.

            DDL формат - наверняка свой? будет интересно переписать https://github.com/vkrinitsyn/schema_guard