В двух предыдущих статьях я писал о том, почему эксплуатация современных баз данных всё чаще превращается в борьбу не с данными, а со сложностью самой системы:
Теперь хочу перейти от принципов к конкретике и показать, как эти идеи начинают превращаться в архитектурные решения.
Это не готовый продукт и не финальная архитектура. Это проект на раннем этапе, где зафиксированы базовые контракты, очерчены границы первого этапа и под это пишется код. Часть решений может измениться по мере того, как я буду сталкиваться с реальными ограничениями. Но мне кажется полезным зафиксировать текущее мышление и получить обратную связь именно сейчас, пока архитектура ещё подвижна.
Что уже есть в коде
Сразу отвечу на вопрос, который обычно возникает в таких текстах: это не только концепт.
На текущем этапе реализованы базовые части движка: 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 |
| Eviction (CLOCK), WAL-first flush | — |
TxnWriteSet |
| Reject DML |
|
UndoStore |
| Reject writes |
|
Connection pool |
| Reject new connections |
|
Statement timeout |
| Cancel query |
|
Snapshot age |
| Force-close stale snapshots |
|
Почему не 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 заранее», а стараться не закрывать себе дорогу к нему неправильными решениями сейчас.
В следующих статьях хочу отдельно разобрать то, что уже спроектировано на уровне конкретных подсистем.
Ссылки на проект, бенчмарки и т.д. будут чуть позже, как будут готовы к публичному доступу.
Вопросы к сообществу
Мне интересно, насколько описанные решения совпадают или спорят с вашим практическим опытом. Проект молодой, и содержательная критика для меня сейчас ценнее комплиментов.
UNDO-log MVCC vs Multi-version Heap: кто работал с обоими подходами — какие trade-off'ы оказались заметнее всего в эксплуатации?
Sync сейчас, async I/O потом: насколько вам такой путь близок, «сначала стабилизировать и измерить, потом усложнять»? Или это просто отложенная проблема?
Fail-closed vs fail-open: готовы ли вы к тому, что БД честно отклоняет запрос при нехватке ресурсов, вместо того чтобы деградировать молча? Или в вашей практике мягкая деградация всё-таки предпочтительнее?
Unified storage: какие проблемы вы видите в модели «много файлов», и что, наоборот, может потеряться при едином файле?
Без PL/pgSQL на старте: насколько вам реально нужен процедурный язык внутри БД, а не в application layer?
Если интересно пообщаться
Проект на раннем этапе, и мне в первую очередь интересна содержательная обратная связь от тех, кто реально живёт с OLTP-нагрузками в продакшне.
Если вам откликаются описанные проблемы — bloat, сложность эксплуатации, непредсказуемость под нагрузкой, рост обвязки вокруг базы — можно написать в личку или на почту.
