Контракт вместо настроек: чего я жду от OLTP-БД в 2026

После первой статьи в комментариях несколько раз прозвучало примерно одно и то же:
"Всё правильно, но это же про любую зрелую СУБД — что с этим делать?"

Я думал над этим вопросом несколько недель. И в итоге решил не искать ответ в виде
"возьмите правильный инструмент X" — а попробовать честно сформулировать:
какими свойствами OLTP-БД должна обладать сама по себе, независимо от того,
насколько хорош ваш оператор, консультант или runbook.

Здесь две части:

  • Что я называю "контрактом" БД — и почему это важнее списка фич.

  • Сценарии из реальной эксплуатации — и какие механизмы их предотвращают.

А в конце — самое интересное, к чему это все меня привело...

Что такое "контракт" — и почему это не маркетинг

Попробую объяснить не через определение, а через ощущение.

Когда вы покупаете автомобиль, вы не читаете инструкцию к тормозам каждое утро.
Вы просто знаете: нажал педаль — машина тормозит. Это контракт. Он не зависит от того,
правильно ли вы настроили тормозную жидкость этим утром или не забыли включить
"режим торможения" в меню.

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

  • правильно настроено,

  • правильно задеплоено,

  • и команда не забыла включить нужную опцию.

Это не контракт. Это договорённость, которая легко рассыпается при смене команды,
росте нагрузки или очередном обновлении.

Контракт БД — это проверяемые гарантии, которые либо выполняются всегда,
либо система явно сообщает об отказе. Без тихих исключений, без
"ну мы же предупреждали в документации".

Чтобы контракт был настоящим, а не декларативным, он должен быть:

  • формализован — можно проверить программно, не только "на глаз";

  • наблюдаем — есть метрики/события на каждое нарушение;

  • fail-closed по умолчанию — если условие не выполнено, система отказывает предсказуемо;

  • одинаково понятен разработчику, DBA и SRE — не только автору конфига.


10 свойств, с которых должен начинаться продакшен

Это не "идеальный мир будущего". Это минимум, ниже которого начинаются
повторяющиеся инциденты и "магия настройки". Я разбил их на четыре группы —
так проще думать, какие из них у вас уже есть, а какие держатся на runbook'ах.

Безопасность: нельзя "забыть включить"

1. Нет контекста — нет запроса. Если для выполнения DML/DDL нужен security context
(например, tenant_id), а он не задан — запрос просто не исполняется.
Это звучит жёстко, но альтернатива хуже: "невинный" admin-скрипт без контекста
превращается в источник утечки между клиентами. Да, это поднимает планку
для ad-hoc запросов и миграций — но это сознательный компромисс.

2. Аудит нельзя выключить незаметно. Аудит — не флаг, который включают по настроению.
Это часть контракта исполнения: попытка обойти или отключить фиксируется и алертит.
Без этого после инцидента вы гарантированно услышите "мы не можем восстановить картину".
Нужна политика ретенции и фильтрации, иначе аудит превратится в гору логов —
но это проблема операционная, а не архитектурная.

Предсказуемость под нагрузкой: "не должно быть сюрпризов"

3. Фоновые процессы не едят latency-бюджет бесконтрольно. Каждый, кто эксплуатировал
PostgreSQL на большом объёме, знает ощущение: "вакуум опять всё сожрал".
Maintenance (cleanup, compaction, freeze-подобные задачи) должен быть ограничен
по влиянию на интерактивный трафик. Фоновая уборка может быть медленнее — зато p99
не "срывается" в самый неподходящий момент.

4. Память предсказуема по классам нагрузки. 50 параллельных сортировок не должны
молча удвоить RSS и убить соседей. Тяжёлые запросы могут получить throttle
или явный отказ — но это лучше, чем OOM посреди пика.

5. Наплыв соединений — политика, а не "как получится". Queue, reject, backpressure —
что угодно, но явно описанное. Не "иногда повезло подключиться, иногда нет".
Часть клиентов получит предсказуемый отказ вместо зависания на 30 секунд — и это нормально.

6. Длинные транзакции и горячие конфликты под контролем ядра. Лимиты на длительность
и размер транзакции по классам workload. Раннее обнаружение lock-конфликтов.
Контролируемый abort с диагностикой, а не "всё зависло и непонятно почему".
Некоторые "длинные бизнес-операции" придётся разбивать — но это рефакторинг,
а не катастрофа.

7. Деградация планов — не сюрприз. Каждый DBA хотя бы раз видел:
"вчера быстро, сегодня медленно — ничего не менялось". На деле менялась статистика
или параметры, и план тихо "уехал". Должны быть управляемые рамки:
что может поменяться автоматически, а что — только по явному действию.
Часть агрессивных оптимизаций может быть отключена ради стабильности — и это ок.

Multi-tenant: "чужой шум — не моя проблема"

8. Изоляция арендаторов — примитив ядра. Если один "шумный" арендатор
может просадить latency у всех остальных, у вас нет SLA — у вас есть надежда.
Изоляция ресурсов (CPU/memory/IO/locks) должна быть встроена,
а не достраиваться оркестратором. Лимиты жёстче, квоты нужно явно выдавать —
но зато "noisy neighbor" перестаёт быть системным риском.

9. Режимы деградации описаны заранее. Что происходит на пике?
Что отбрасывается первым? Что приоритизируется? Если у системы нет ответа —
каждый постмортем будет начинаться с нуля. Бизнес должен принять,
что некоторые операции в критичной ситуации будут отвергнуты — но лучше заранее
договориться "что жертвуем", чем каждый раз "гадать, что упало".

Наблюдаемость: "мы думали, что включено"

10. Каждая гарантия наблюдаема. Это замыкающее свойство. Если у гарантий 1–9
нет метрик/событий, которые можно проверить до инцидента, — они существуют только
на бумаге. "Мы думали, что включено" — это не баг эксплуатации,
это архитектурный долг. Да, телеметрия стоит ресурсов и требует дисциплины
по SLO/алертам — но без неё вы летите вслепую.


7 историй о том, как p99 "срывается"

Теперь — практика. Ниже не абстрактные "failure modes", а узнаваемые ситуации,
которые повторяются в разных стеках. Если вы узнали хотя бы две — значит,
список гарантий выше не теоретический.


"Транзакция, которая не отпускает"

Пятница, вечер. Мониторинг показывает: p99 растёт волнами, таблицы медленно
раздуваются, фоновая уборка отстаёт. Никто ничего не деплоил. Через час
находите: забытая транзакция в idle-in-transaction, открыта 4 часа назад из
скрипта миграции, который давно закончился. Она держит snapshot, а значит —
ни vacuum, ни cleanup не могут убрать старые версии.

Что должно быть в ядре: жёсткие лимиты на длительность транзакции по классам
workload. Если транзакция приближается к лимиту — контролируемый abort
с понятной диагностикой. Остальной трафик продолжает работать.


"Релиз, который всё сломал (но CPU в норме)"

Понедельник после релиза. Часть API уходит в таймауты. Первое, что смотрят — CPU.
Нормально. Второе — диск. Нормально. Третье — "а что поменялось?"
Оказывается, новый код добавил UPDATE по горячей таблице в другом порядке,
и зона конфликта блокировок расширилась. Очередь ожиданий растёт,
а прямого "bottleneck" нет — есть только цепочка ожиданий.

Что должно быть в ядре: lock budget per query/txn. Раннее обнаружение
конфликтных графов. Политика: "если конфликт затянулся — abort наименее
приоритетной операции, критичный путь остаётся живым."


"Вакуум (или что-то похожее) опять всё съел"

Каждый день в 14:00 p99 на 5 минут уходит в небо. Потом "само" возвращается.
DBA говорит: "это вакуум". Ops говорит: "надо просто пережить".
Бизнес говорит: "а почему мы это обнаруживаем каждый раз случайно?"

Что должно быть в ядре: scheduler для фоновых задач, привязанный
к SLO интерактивного трафика. Если онлайн-нагрузка растёт — фоновая работа
автоматически дросселируется. Даже если это удлиняет обслуживание — предсказуемость
важнее скорости уборки.


"Все подключились одновременно"

Деплой микросервисов. 200 подов стартуют, каждый открывает по 10 коннектов.
2000 соединений за 30 секунд. БД не успевает — начинается каскад таймаутов.
Приложение ретраит — становится хуже. Через 5 минут всё лежит, хотя сама БД
"технически жива".

Что должно быть в ядре: admission control на входе. Очередь с TTL.
Явная политика: "новые низкоприоритетные подключения — reject,
существующие критичные потоки не деградируют".


"Один клиент положил всех"

Multi-tenant SaaS. У "тихих" арендаторов растёт latency.
Ничего не менялось — кроме того, что один крупный клиент запустил
массовый импорт. Его нагрузка забрала IO-бюджет, и "соседи" просели.

Что должно быть в ядре: tenant-aware планировщик. Лимиты на CPU/memory/IO
по арендаторам. Наблюдаемость: "кто у кого отнял бюджет".
"Шумный" арендатор получает throttle — а не деградацию всей популяции.


"Временное исключение стало постоянным"

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

Что должно быть в ядре: security context и аудит — неотключаемая часть
исполнения. Операция без требуемого контекста не выполняется. Точка.
Не "можно обойти, если знаешь как".


"Вчера быстро, сегодня медленно — ничего не менялось"

Тот же endpoint, похожие данные, но время ответа выросло в 5 раз.
"А что изменилось?" — "Ничего." На самом деле поменялась статистика
(или параметры), и оптимизатор выбрал другой план. Воспроизвести сложно,
откатить — непонятно куда.

Что должно быть в ядре: контролируемые режимы стабильности планов.
Обнаружение опасных регрессий до массового эффекта. Откат к стабильной стратегии,
даже если она не самая быстрая "в среднем" — потому что "в среднем" никого
не волнует, когда горит p99.


Граница между БД и платформой

Kubernetes-операторы, service mesh, внешние политики — всё это важно.
Но есть вещи, которые опасно оставлять снаружи:

  • контроль security context на уровне запроса;

  • инварианты аудита;

  • защита latency-бюджета от внутренних фоновых процессов;

  • изоляция арендаторов в shared deployment.

Если эти свойства не являются контрактом БД, платформа в лучшем случае
маскирует проблему, а не решает её.

Простой способ проверить себя: пройдитесь по списку из 10 свойств выше
и честно ответьте на один вопрос по каждому пункту — это свойство ядра
или это runbook. Не нужно считать баллы. Обычно достаточно двух-трёх ответов
"это runbook", чтобы понять, где именно у вас хрупкость.


Как я пришёл к мысли о проектировании БД с нуля

Оговорюсь: эти мысли я формулировал не параллельно с написанием статьи.
Всё описанное ниже — это ретроспектива примерно полугодового пути,
который привёл к конкретному решению. Статья — просто попытка выстроить
это в логику, понятную без контекста.

Параллельно с размышлениями о БД в свободное время от работы я несколько месяцев изучал Rust — не потому что "модно",
а потому что язык меня зацепил именно философией: он делает невозможными целые классы ошибок,
которые в других языках просто "иногда случаются".
Никаких скрытых аллокаций в неподходящий момент.
Никакого GC, который сам решает, когда ему удобно почистить память.
Явные инварианты прямо в системе типов, гарантии которые дает язык.
Отправной точкой для погружения в Rust стала статья https://habr.com/ru/companies/bitrix/articles/878912/.
И в какой-то момент два потока мыслей про проблемы СУБД и Rust сошлись: Rust на этапе компиляции запрещает написать код, который может упасть в рантайме из-за памяти. Он навязывает контракт разработчику. Я понял, что хочу того же от базы данных: чтобы она навязывала контракт приложению, запрещая запросы, которые могут положить БД.

Я описывал требования к "правильной БД" — предсказуемое поведение под нагрузкой,
контракты вместо настроек, никаких фоновых сюрпризов — и это звучало
как описание того, для чего Rust и создавался.
Язык, который не позволяет "случайно забыть" важное.
Именно это я хотел от базы данных.

Я стал смотреть: а есть ли что-то живое на Rust в мире OLTP?
Нашёл несколько экспериментов, пару заброшенных репозиториев,
один академический проект. Ничего, что выглядело бы как
"это решает проблему, которую я описал выше".

Следующий очевидный вопрос: "А почему не CockroachDB или YugabyteDB?"
Потому что они решают другую задачу — распределённость.
Мне нужна предсказуемая одноузловая OLTP с правильными примитивами в ядре.
"А почему не форк Postgres?" — потому что это значит бороться с
тридцатью годами обратной совместимости и C-кодовой базы.
Это не критика — это просто другой вектор.

Вот тогда и появилась мысль: а что если попробовать самому?
Не "написать базу данных" как самоцель —
а начать с честного списка архитектурных решений:
что должно быть в ядре обязательно, что сознательно остаётся за рамками v1,
и почему. К чему это все привело — расскажу уже в следующей статье.

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

P.S. Под p99 я в этой статье имею в виду 99-й персентиль latency, но важна оговорка: сам по себе p99 ничего не значит без ответа на вопрос “чего именно?”. Для OLTP меня интересует не средняя температура по больнице, а хвост деградации: время запроса, commit latency, ожидание блокировок, всплески на fsync/checkpoint. Иными словами, речь о предсказуемости под нагрузкой, а не об одной красивой цифре.