Как мы строим OLTP-ядро: от API-контрактов до eBPF-проб

от автора

В предыдущих статьях я подробно разбирал фундамент нашей новой OLTP-СУБД: принцип Restrictive by Default (fail-closed вместо деградации), отказ от VACUUM в пользу UNDO-лога и концепцию Unified Storage. Кроме того, мы изначально отказались идти по пути PostgreSQL в вопросах конфигурации: вместо десятков ручных параметров (вроде shared_buffers или work_mem), требующих квалифицированного DBA, мы заложили архитектуру Adaptive Tuning (Resource Broker), где база сама динамически распределяет выделенные ей бюджеты RAM и I/O под текущую нагрузку. К этому механизму автотюнинга мы еще обязательно вернемся в будущих статьях.

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

Архитектура на бумаге это лишь половина дела. Настоящие компромиссы и инженерные решения проявляются тогда, когда принципы переносятся в код. В этой статье мы спустимся на уровень реализации. Мы разберем, как устроены API между внутренними слоями движка, почему размер страницы данных настраивается гранулярно, как мы управляем десятками настроек в runtime и как встраиваем механизмы диагностики (USDT-пробы и eBPF) прямо в бинарный файл базы данных.

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


1. Фундаментальные настройки: PAGE_SIZE, облака и bare-metal

Один из первых вопросов при проектировании storage-движка — выбор размера страницы (Page Size). Для чистого OLTP с короткими транзакциями часто оптимальны 4-8 KB (меньше write amplification). Для смешанных нагрузок (HTAP) и современных NVMe-накопителей лучше подходят 16-32 KB (выше пропускная способность при сканировании).

В первой статье цикла мы говорили про Unified Storage как про единый файл на всю базу. Однако это была ранняя архитектурная гипотеза, которую нам пришлось осознанно пересмотреть после столкновения с реальными ограничениями инфраструктуры (bare-metal NVMe vs облачные диски с лимитами IOPS). Из-за этой разницы мы эволюционировали нашу storage-модель: теперь Unified Storage означает единый storage-формат и общий брокер ресурсов, но физически каждое табличное пространство (tablespace) — это отдельный файл со своим размером страницы (per-tablespace page size). Вы сможете держать горячие OLTP-таблицы в пространстве с 8 KB страницами, а тяжелые архивы в пространстве с 32 KB. По умолчанию при создании tablespace используется 16 KB — оптимальный баланс для NVMe-накопителей по нашим бенчмаркам. Размер страницы неизменяем (immutable) после создания пространства; изменить его можно только через логическую миграцию данных.

Как это работает в API? Наш PageId — это композитный идентификатор ([tablespace_id:16][page_index:48]). Это позволяет BufferPool прозрачно маршрутизировать запросы в нужный саб-кэш, выделенный под конкретный размер страницы. Как именно BufferPool динамически управляет памятью между саб-кэшами разных размеров мы детально разберём в одной из следующих статей.

Для защиты от повреждения данных мы используем строгий startup guard на уровне табличных пространств. Метаданные каждого tablespace (включая его PAGE_SIZE) хранятся в суперблоке в начале файла с фиксированным форматом, не зависящим от размера страницы. Это решает проблему “курицы и яйца”: мы можем прочитать конфигурацию до начала WAL-recovery. При каждом монтировании пространства ядро сверяет конфигурацию. При несовпадении база отказывается открывать это пространство с понятной ошибкой. Никакой тихой деградации или попыток «угадать» формат.


2. Архитектурные границы: API между слоями

Сложность базы данных может приводить к размыванию архитектурных границ, если детали одного слоя начинают просачиваться в другой. Чтобы этого избежать, мы разделили ядро на четыре изолированных слоя со строгими контрактами (trait-интерфейсами в Rust).

Слой 1: Сетевой адаптер (Adapter Layer)

Это единственный слой, который живет в асинхронном мире (tokio). Его задача терминировать TLS, управлять пулом соединений и реализовывать wire-протокол. Здесь нет логики выполнения запросов. Адаптер лишь принимает поток байт, формирует из него запрос и передает его ниже, дожидаясь ответа:

#[async_trait]pub trait NetworkAdapter {    // stream абстрагирован над транспортом (может быть уже обернут в TLS)    async fn handle_connection(&self, stream: Box<dyn ConnectionStream>) -> Result<(), NetworkError>;}

Слой 2: Anti-Corruption Layer (Слой совместимости)

Парсинг текстового SQL в AST происходит на входе CompatLayer — это первое, что он делает с принятым запросом, до трансляции во внутреннее промежуточное представление (IR). Как я писал в прошлой статье, именно здесь происходит эмуляция системных каталогов PostgreSQL (pg_catalog, information_schema), чтобы внешние драйверы и ORM могли работать с нашей базой без модификаций.

Именно на этом рубеже мы реализуем принцип «поддерживаемого подмножества». Если клиент присылает запрос с синтаксисом, который мы сознательно не поддерживаем (например, вызов PL/pgSQL триггера), слой совместимости возвращает честный код ошибки 0A000 (Feature not supported). Внутреннее ядро работает только с чистым, нормализованным IR.

pub trait CompatLayer {    /// Транслирует AST во внутреннее представление, отсекая неподдерживаемые фичи    fn translate_query(&self, ast: SqlAst) -> Result<LogicalPlan, CompatError>;}

Слой 3: Ядро выполнения (Core Engine)

Здесь живут планировщик запросов (Optimizer), исполнитель (Executor) и менеджер транзакций. Граница между асинхронным адаптером и синхронным ядром строго контролируется через каналы и пулы потоков:

pub trait ExecutionEngine {    /// Выполняется в пуле синхронных потоков (spawn_blocking)    fn execute_plan(&self, plan: LogicalPlan, session: &Session) -> Result<ResultSet, ExecutionError>;}

Структуры, замеряющие время выполнения или удерживающие блокировки (WaitEventGuard), архитектурно не могут пересекать точки .await. Это гарантирует, что в метрики ожидания блокировок не попадет время, пока асинхронная задача ждала своей очереди в пуле потоков ОС.

Слой 4: Хранилище (Storage)

В прошлой статье я упоминал низкоуровневые интерфейсы PageProvider и TransactionLogSink. На уровне ядра они скрыты за единым фасадом — StorageManager. Ядро выполнения не знает, как именно байты ложатся на диск. Оно запрашивает логические страницы через BufferPool и фиксирует изменения в WAL:

pub trait StorageManager {    /// Запрашивает страницу данных в память и накладывает блокировку    fn pin_page(&self, page_id: PageId, mode: LockMode) -> Result<PageGuard, StorageError>;        /// Записывает UNDO-запись для обеспечения MVCC    fn append_undo(&self, txn_id: TxnId, record: UndoRecord) -> Result<UndoPtr, StorageError>;}

Сам StorageManager выступает здесь как оркестрирующий фасад (orchestration facade). Порядок записи (WAL-before-data) и физические блокировки (latches) инкапсулированы внутри его реализации и контролируются отдельными контрактами ниже. Такое разделение позволяет нам прозрачно менять реализацию физического I/O (например, переходить на io_uring или Direct I/O), не затрагивая логику MVCC или B-деревьев.


3. Управление конфигурацией: Отказ от ручного тюнинга и Adaptive Tuning

Даже на ранних стадиях разработки движка мы столкнулись с классической проблемой баз данных — быстрым ростом числа параметров. Размер Buffer Pool, интервалы чекпоинтов, лимиты памяти на запрос, настройки фоновой очистки старых версий в UNDO-логе — уже сейчас у нас набралось около 60 параметров. Важное уточнение: мы отказываемся от тяжелого VACUUM основных таблиц и индексов как эксплуатационной обязанности, но, разумеется, сохраняем фоновую очистку (purge) старых версий внутри самого UNDO-лога как внутренний механизм освобождения места.

В традиционных СУБД настройка этих параметров остается искусством, требующим квалифицированного DBA. Администратор должен понимать, как shared_buffers конкурирует за память с work_mem, и вручную подбирать баланс. Мы решили не идти по этому пути.

Во-первых, мы жестко разделили все параметры на три уровня: 1) Ресурсные бюджеты (задает оператор), 2) Safety guardrails (внутренние защитные механизмы), 3) Expert overrides (точечные ручные переопределения). Мы стремимся к тому, чтобы подавляющее большинство настроек можно было изменять в runtime без остановки сервера. Мы строго следим за списком параметров, требующих рестарта инстанса, и сводим его к абсолютному минимуму.

Во-вторых, мы заложили архитектуру Adaptive Tuning (Resource Broker). Идея проста: оператор должен конфигурировать ресурсные бюджеты, а не внутренние механизмы.

Вы задаете высокоуровневые лимиты:

[resources]memory_budget = "16GB"cpu_budget    = "auto"io_iops       = 5000

А дальше в дело вступают внутренние адвайзеры (MemoryAdvisor, IoAdvisor, CpuAdvisor). База данных сама, с настраиваемым интервалом (по умолчанию раз в секунду), анализирует текущую нагрузку и динамически распределяет выделенный бюджет.

  • CpuAdvisor динамически подстраивает размер пула рабочих потоков и максимальную степень параллелизма (Degree of Parallelism) для тяжелых запросов, чтобы не допускать CPU throttling.

  • MemoryAdvisor следит за памятью: например, если идет плотный поток мелких чтений, система отдаст максимум памяти под Buffer Pool. Если запускается тяжелое построение индекса или batch-обновление, часть памяти “на лету” перейдет в кэш выполнения (аналог work_mem), чтобы сортировки не падали на диск.

  • IoAdvisor следит за IOPS-бюджетом: при приближении к лимиту он начинает плавно троттлить фоновые операции (checkpoint, purge UNDO-лога), отдавая приоритет транзакционным I/O пользователя. На облачных дисках с burst-кредитами он также реализует burst awareness — снижает агрессивность чекпоинтов при исчерпании burst budget.

Такая динамика может выглядеть как недетерминированное поведение, и избыточная адаптивность может вызвать осцилляции (бесконечное перекладывание памяти туда-сюда). Чтобы этого не произошло, мы заложили в алгоритмы жесткие защитные механизмы (guardrails):

  1. Защита от осцилляции (Hysteresis & Rate Limiting). Адвайзер не реагирует на секундные микро-спайки. Чтобы инициировать перераспределение, новый паттерн нагрузки должен стабильно держаться несколько циклов подряд. По умолчанию окно накопления составляет 5 циклов (5 секунд при дефолтном интервале 1 с). Параметр resource_broker.observation_window настраивается. Сама передача памяти идет небольшими шагами (не более 5% за раз), что предотвращает резкий сброс кэша (cache thrashing).

  2. Несгораемые остатки (Hard Floors). Ни одна критическая подсистема не может быть доведена до состояния нехватки ресурсов. У Buffer Pool и UNDO-лога есть жесткие минимальные лимиты. Hard Floor в 128 МБ — это значение по умолчанию, переопределяемое через storage.buffer_pool_min_bytes. Устанавливать ниже 64 МБ не рекомендуется: при такой конфигурации неизбежны постоянные eviction-циклы на любом реальном workload.

  3. Плавный переход (Graceful Transition). Если лимит памяти на запрос уменьшился, база не отбирает память у уже выполняющихся (in-flight) тяжелых запросов и не прерывает их. Новый лимит применяется только к новым аллокациям.

  4. Абсолютный приоритет оператора. Вычисленные адвайзером значения находятся на самом нижнем уровне приоритета. Если администратор жестко фиксирует параметр вручную (например, ALTER SYSTEM SET storage.max_cached_pages = ...), этот явный конфиг всегда перекрывает автоматику.

Таким образом, “из коробки” база стремится быть максимально автономной и адаптироваться под workload, но при этом остается предсказуемой и полностью подконтрольной инженеру.

Сейчас наш policy engine работает на базе строгих правил (rule-based) и опирается на конкретные сигналы ОС и движка. Как именно брокер координирует память между Buffer Pool и кэшем выполнения без разрушения локальности данных (cache locality) при резких пиках записи — мы детально покажем на trace-примерах в одной из следующих статей. В более далекой перспективе мы смотрим в сторону ML-моделей для проактивного предсказания нагрузки (например, чтобы база заранее, до начала ночных отчетов, начала плавно перераспределять память). Любой ML-компонент будет работать только как рекомендательный слой поверх существующих rule-based guardrails — он предлагает перераспределение, а guardrails решают, допустимо ли оно. Это исключает сценарий, когда деградировавшая модель получает полный контроль над ресурсами.


4. Observability как фундамент: Wait Events и eBPF

Сложная архитектура (гибридная асинхронность и динамический тюнинг ресурсов) имеет свою цену. Когда запрос переходит из асинхронного сетевого слоя в синхронное ядро, а фоновый адвайзер на лету меняет лимиты памяти, стандартные профилировщики теряют контекст. Если запрос тормозит, как понять, чего именно он ждет?

Чтобы эксплуатация базы не превращалась в поиск причин без инструментов, мы заложили наблюдаемость (observability) как жесткий контракт на уровне кода.

4.1. Строгая таксономия ожиданий (Wait Events)

Логи — это хорошо, но они не покажут микро-задержки. Чтобы понимать, где мы теряем время, мы внедрили строгую таксономию событий ожидания (Wait Events). Поток выполнения всегда находится в одном из взаимоисключающих состояний: либо он активно работает (CPU), либо ждет (IO, Lock, Net, Scheduler).

Это состояние обновляется атомарно в контексте сессии. Однако мгновенные снимки активности (системные views) могут упускать транзиентные блокировки длительностью в доли миллисекунды. Чтобы обеспечить прозрачность работы движка на уровне операционной системы, мы интегрировали поддержку USDT (User Statically-Defined Tracing) проб. Это позволяет напрямую коррелировать внутренние события СУБД с метриками ядра Linux. Требования минимальны: ядро Linux >= 4.4 для базовых USDT-проб и >= 5.8 для работы eBPF без root-прав (CAP_BPF).

4.2. Как это работает в коде: RAII и eBPF

В бинарный файл вшиты статические маркеры (probe points) для всех критических событий: начало I/O операции, ожидание блокировки, постановка задачи в планировщик.

Чтобы гарантировать, что мы никогда не забудем закрыть событие ожидания, мы связываем нашу таксономию Wait Events с USDT-пробами через паттерн RAII. Паника для нас — это всегда баг (bug-class event), а не штатный механизм управления потоком. Но если баг всё же произошел, panic = "unwind" гарантирует локальную уборку ресурсов (снятие блокировок, закрытие wait events) до того, как процесс упадет или поток будет перезапущен. В коде ядра это выглядит как структура WaitEventGuard:

pub struct WaitEventGuard<'a> {    session: &'a Session,    start: Instant,}impl<'a> WaitEventGuard<'a> {    pub fn enter_io(session: &'a Session) -> Self {        // Проверяем инвариант: вложенные ожидания запрещены        debug_assert!(!session.has_wait_state(), "Nested wait states are forbidden");                // Атомарно переводим сессию в состояние ожидания I/O        session.set_wait_state(WaitState::IO);                // Выстреливаем пробой о начале I/O        probe!(engine, io_start, session.id());                Self { session, start: Instant::now() }    }}impl<'a> Drop for WaitEventGuard<'a> {    fn drop(&mut self) {        let elapsed_us = self.start.elapsed().as_micros() as u64;        // Возвращаем сессию в активное состояние        self.session.set_wait_state(WaitState::CPU);        // Выстреливаем пробой о завершении с передачей длительности        probe!(engine, io_end, self.session.id(), elapsed_us);    }}

Примечание: Вложенные ожидания (nested wait states) в нашей модели строго запрещены архитектурно. Мы сознательно не стали использовать Typestate pattern для контроля состояний на этапе компиляции, так как это сильно ломает эргономику на границах асинхронного кода. Вместо этого мы полагаемся на debug_assert! и жесткое покрытие property-based тестами, которые гоняются в CI с включенными ассертами. Нарушение этого инварианта считается bug-class event и отлавливается до продакшена. Поэтому при выходе из enter_io мы всегда гарантированно возвращаемся в состояние CPU.

Макрос probe! вставляет NOP и записывает метаданные пробы в секцию .note.stapsdt бинарного файла. При активации через bpftrace ядро ставит uprobe на адрес пробы, перехватывает выполнение без модификации самого кода бинарника и затем вызывает eBPF-программу. Наш текущий целевой бюджет (target budget) для оверхеда на неактивные пробы составляет менее 1 наносекунды.

Но в случае необходимости системный администратор может использовать инструменты eBPF, чтобы “на лету” собрать данные. Например, чтобы построить гистограмму задержек I/O (в микросекундах) прямо на живой базе, достаточно выполнить в терминале:

bpftrace -e 'usdt:/usr/bin/oltp_server:engine:io_end { @io_latency = hist(arg1); }'

При остановке скрипта (Ctrl+C) оператор увидит наглядное распределение задержек. Например, для типичного NVMe-накопителя вывод будет выглядеть примерно так:

Attaching 1 probe...^C@io_latency:[16, 32)               5 |                                                    |[32, 64)             120 |@@@@@@@@@@                                          |[64, 128)            600 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|[128, 256)           150 |@@@@@@@@@@@@@                                       |[256, 512)            20 |@                                                   |[512, 1K)              2 |                                                    |

Важно понимать ограничения метода: такая гистограмма показывает сырое время выполнения I/O-вызова, но не различает page cache miss в ядре ОС и реальную задержку самого устройства (device latency) без дополнительной корреляции.

4.3. Гранулярная диагностика: Query Tags

Собирать трейсы по всем запросам подряд — плохая идея для высоконагруженных систем. Как отследить один тяжелый запрос среди тысяч других? Мы реализовали механизм тегирования (Targeted Diagnostics).

Вы можете пометить конкретный запрос тегом прямо в SQL, используя стандартный синтаксис комментариев, который перехватывается нашим парсером:

/* tag: investigation-001 */ SELECT * FROM orders WHERE total > 1000;

Или, если вам нужно отследить целую цепочку запросов от конкретного микросервиса, вы можете задать тег на уровне сессии:

SET LOCAL query_tag = 'load-test-batch-7';

Этот тег хэшируется алгоритмом xxh64 в 64-битное число (чтобы избежать аллокаций строк в памяти) и пробрасывается через все слои движка. Для тегированных запросов мы включаем расширенные Per-Operator пробы, которые показывают время выполнения каждого узла плана запроса (SeqScan, Sort, HashJoin).

Query Tags — это инструмент отладки и диагностики, а не SLA-атрибут. Для production-телеметрии с гарантированной идентификацией запросов используйте trace_id (128 бит, без коллизий в пределах планируемых нагрузок).

Чтобы минимизировать оверхед для остальных 99.9% нетегированных запросов, мы оборачиваем вызов пробы в простое условие:

if tag_hash != 0 {    probe!(engine, operator_start, session_id, tag_hash, op_type);}

Эта ветка хорошо предсказывается процессором (branch predictor) в типичном профиле нагрузки. Мы платим за детальную диагностику только тогда, когда она нам действительно нужна. eBPF-скрипт затем просто фильтрует события по нужному хэшу.


5. Security by Design: Zero Trust, изоляция и No PII

Безопасность в базах данных часто воспринимается как набор плагинов или внешних настроек (файлы pg_hba.conf, внешние прокси-серверы). Мы с самого начала закладываем безопасность как архитектурный фундамент (Security by Design).

5.1. Zero Trust и единая точка входа

Наш сетевой адаптер (Слой 1) — это единственная точка входа в систему. Архитектурно мы исходим из парадигмы Zero Trust: сеть по умолчанию считается враждебной.

По умолчанию профиль secure требует использования mTLS (Mutual TLS) для всех клиентских соединений. Аутентификация по паролю (SCRAM-SHA-256) — это отдельный режим совместимости, который должен быть явно разрешен оператором в конфигурации. Никаких устаревших MD5 или передачи паролей в открытом виде. Вся криптография и TLS-терминация происходят исключительно в асинхронном слое адаптера, не блокируя потоки выполнения запросов.

5.2. Изоляция слоев как защита от эскалации

Разделение на слои, о котором мы говорили во втором разделе, играет критическую роль в безопасности. Слой совместимости (Anti-Corruption Layer) отсекает структурно некорректные или неподдерживаемые запросы и предотвращает эскалацию привилегий внутри движка через crafted IR.

Защита от классических SQL-инъекций, как и в любой СУБД, лежит на стороне приложения. Мы поддерживаем как простой (simple query), так и расширенный (extended query) протоколы PostgreSQL, но для защиты от инъекций настоятельно рекомендуем использовать параметризованные запросы (prepared statements) на стороне драйвера.

5.3. Break-Glass и криптографический аудит

Одна из главных угроз безопасности исходит не от внешних хакеров, а от легитимных администраторов баз данных (DBA), имеющих полный доступ. Мы понимаем, что в критических ситуациях (инцидентах) DBA может потребоваться экстренный доступ к данным в обход стандартных политик Row-Level Security (RLS).

Для таких случаев мы закладываем механизм Break-Glass. Администратор может активировать экстренный режим, но архитектурный контракт гарантирует, что каждое действие в этом режиме будет записано в специальный Audit Log. Мы используем схему hash-chaining (каждая запись содержит HMAC предыдущей), а root-ключ хранится во внешнем KMS. Это делает любую подмену логов tamper-evident (заметной) при условии доверия внешнему KMS и неизменности audit policy. Детальную модель угроз (threat model) для этой схемы, включая изоляцию Control Plane и защиту ключей, мы разберём в отдельной статье про безопасность.

5.4. Безопасность данных в логах (No PII)

Наконец, мы защищаем данные пользователей от утечек через системы мониторинга. В нашем ядре категорически запрещен обычный текстовый вывод (printf-style). Мы используем только структурированное логирование, которое подчиняется строгим правилам:

  1. Zero-allocation в hot path. Если уровень логирования отключен (например, DEBUG), макрос компилируется так, что не происходит ни одной аллокации памяти.

  2. Безопасность данных (No PII). На уровне архитектурного контракта запрещено выводить в логи значения пользовательских данных или сырые тексты SQL-запросов. Для отладки в лог попадает только нормализованный fingerprint запроса (структура без конкретных значений параметров) и системные метаданные:

    // Правильно: логируем только системные идентификаторыtracing::info!(txn_id = %txn.id, table_id = %table.id, "Acquired exclusive lock");
  3. Fail-silent. База данных важнее логов. Если диск переполнен и мы не можем записать лог, ядро не имеет права паниковать. Ошибка записи лога просто инкрементирует внутренний счетчик потерянных сообщений, а транзакция продолжает работу. Это осознанное исключение из нашего глобального принципа Restrictive by Default (fail-closed): подсистема логирования намеренно проектируется как fail-silent, потому что потеря лог-сообщения не нарушает консистентность данных, тогда как паника убила бы работающие транзакции.

Чтобы это правило не осталось просто декларацией, мы enforce-им его технически: прямые текстовые SQL-логи запрещены через lint-правила в CI, а все структурированные поля проходят redaction-тесты. Для защиты от случайных утечек через сторонние зависимости (например, tokio или hyper) мы используем строгий allowlist крейтов и кастомный tracing subscriber, который на лету фильтрует и санитайзит все поля от не-core модулей. Это дает высокую уверенность в том, что пароли, номера кредитных карт или персональные данные не утекут в ElasticSearch или Grafana.


6. Инженерный процесс: Дизайн-документы и тестирование

Проектирование архитектуры базы данных представляет собой постоянный поиск компромиссов. Чтобы не загнать себя в угол, перед написанием кода мы составляем подробные дизайн-документы (Design Docs).

Из полезных практик, которые мы применяем, стоит выделить секцию анализа рисков. Мы стараемся заранее задать себе неудобные вопросы и зафиксировать ответы на них. Например:

  • “Что произойдет при коллизии хэшей тегов в USDT-пробах?” (Ответ: метрики двух запросов смешаются, но мы принимаем этот риск, так как альтернатива со строками нарушила бы гарантию отсутствия аллокаций).

  • “Как поведет себя система при внезапном уменьшении cgroup лимитов по памяти в Kubernetes?” (Ответ: адвайзер немедленно запустит экстренное сжатие кэшей (emergency shrink), жертвуя производительностью фоновых задач ради выживания процесса и предотвращения OOM kill).

  • “Как изменение PAGE_SIZE повлияет на применение WAL при восстановлении после сбоя?” (Ответ: startup guard на уровне tablespace просто не даст смонтировать пространство с неправильным размером страницы, поэтому применение “неправильного” WAL архитектурно невозможно).

Написание таких документов лежит в основе нашей инженерной культуры. Это заставляет команду заранее договариваться об интерфейсах и контрактах, а не переписывать ядро на ходу, когда выясняется, что два компонента не могут работать вместе. Любой RFC проходит через peer review, где другие инженеры могут оспорить выбранный подход или предложить более элегантное решение. Только после утверждения документа мы переходим к написанию кода.

Дизайн-документы фиксируют намерения, но не заменяют тестирование.

Во-первых, мы активно используем fault injection (инъекцию сбоев). Поскольку наш Storage-слой изолирован строгими трейтами (о которых мы говорили во втором разделе), мы можем легко подменить реализацию файловой системы в тестах. Это позволяет нам имитировать ошибки fsync, нехватку места на диске или частичную запись страниц (torn writes), проверяя, что база корректно восстанавливается. Конечно, mock-объекты служат лишь первым слоем для проверки логики API. Для реальных сценариев torn writes и power-loss мы используем интеграционные тесты на уровне блочных устройств (dm-flakey), где жестко проверяем инварианты WAL-before-data и redo idempotency.

Во-вторых, для таких вещей как USDT-пробы и метрики, у нас настроены performance-гейты. Мы регулярно прогоняем бенчмарки на смешанных нагрузках, чтобы убедиться, что добавление новых механизмов наблюдаемости не приносит измеримого оверхеда. Мы прогоняем эти бенчмарки не в обычных shared CI-раннерах (где слишком много шума), а в выделенной perf-lab лаборатории на закрепленном железе (bare-metal стенды с фиксированной частотой CPU). Наш целевой бюджет для деградации p50 latency для “спящих” (неактивных) проб на синтетическом OLTP-ворклоаде (сравнение по медиане из серии прогонов) не должен превышать 0.1%. Почему именно столько? Потому что в выключенном состоянии USDT-проба — это просто NOP-инструкция. Если оверхед превышает 0.1%, значит, мы допустили архитектурную ошибку: например, начали вычислять сложные аргументы или аллоцировать память до того, как проба реально выстрелила. Для активного же сбора метрик (с подключенным bpftrace) наш целевой бюджет оверхеда составляет не более 3%.


7. Заключение

В этой статье мы постарались показать, что разработка СУБД включает не только алгоритмы обхода деревьев или парсинг SQL, но и огромное количество архитектурных компромиссов. Фиксация контрактов между слоями ядра и закладывание eBPF-проб на самом раннем этапе — всё это попытка сделать систему предсказуемой и прозрачной в будущей эксплуатации.

Движок активно развивается: есть рабочие реализации базовых подсистем хранения, транзакций и сетевого взаимодействия, сейчас идёт их стабилизация и измерения. Мы сосредоточены на бенчмарках, оптимизации I/O и стабилизации одноузлового (single-node) сценария.

В одной из следующих статей мы разберём, почему наивная LRU-эвикция неизбежно приводит к cache pollution и разрушает p99 latency при смешанной write/read нагрузке. Мы покажем, как устроен наш Clock-sweep алгоритм в Buffer Pool, и почему концепция Unified Storage с общим брокером ресурсов делает checkpoint принципиально проще. А дальше по плану — детальный разбор SQL-совместимости с PostgreSQL и публикация первых масштабных бенчмарков.

Будем рады обсудить в комментариях ваш опыт: как вы проектируете границы между слоями в высоконагруженных проектах на Rust или C++? Приходилось ли вам отказываться от строгой типизации (Typestate) в пользу эргономики асинхронного кода? И как вы защищаете логи от утечек PII из сторонних зависимостей?

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