Обновить

Как я проектирую OLTP-БД с нуля: принципы, trade-off'ы и архитектурные решения

Уровень сложностиСложный
Время на прочтение13 мин
Охват и читатели3.9K
Всего голосов 5: ↑5 и ↓0+5
Комментарии9

Комментарии 9

С предыдущими статьями мне было сложно, там была критика существующих решений в духе "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. Про 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 не совсем понял, какая связь у транзакции и RAM-бюджета? Я бы ожидал, что транзакция обновляет страницы таблицы в кеше, который сбрасывается на диск, пишет undo-лог на диск.

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

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

Спасибо за диалог, давайте углубляться! Тем более, учитывая ваш крутейший цикл статей про внутренности 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) и удаление старых сегментов, что на порядки дешевле поиска мертвых версий по страницам данных.

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

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

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

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

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

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

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

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

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

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 оставляем.

UNDO-log MVCC vs Multi-version Heap

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

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

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

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

Согласен на 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). Если у вас есть на примете крутые академические статьи или разборы реализаций, которые стоит покрутить, буду очень благодарен за ссылки!

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации