Том III. Внутренние механизмы
Уровень: для понимающих архитектуру; мост между «как пользоваться» и «как настраивать и отлаживать». Цель тома: вы объясняете поведение libmdbx через её устройство: B+tree и mmap, MVCC, конвейер коммита, GC, геометрию, вложенные транзакции, файл блокировок и восстановление.
Глава 13. Архитектура хранения: B+tree и mmap
13.1. B+tree: ветви, листья, overflow
Данные libmdbx хранит в B+tree — сбалансированном дереве, где все значения лежат в листьях, а внутренние узлы (branch) содержат только разделители-ключи и указатели на дочерние страницы.
[branch: M]
/ \
[branch: B] [branch: V]
/ \ / \
[a..b] [c..m] [n..r] [s..z] <- листья: пары ключ→значение
Свойства:
- поиск — спуск от корня к листу, O(log N);
- все листья одного уровня связаны — обход диапазона идёт по соседям;
- при переполнении листа — split (деление пополам), рекурсивно вверх, включая новый корень;
- при опустошении ниже порога — rebalance/merge с соседом (порог
merge_threshold).
Значения, не помещающиеся в лист (больше примерно половины страницы), уходят на overflow-страницы:
контигуальный прогон P_LARGE-страниц; в листе остаётся указатель {pgno, npages}.
13.2. Почему B+tree, а не B-tree или LSM
- B-tree — данные и в листьях, и в ветвях: поиск может остановиться на любом уровне, но обход требует перехода вверх/вниз. B+tree с данными только в листьях даёт предсказуемый спуск и линейный обход.
- LSM (LevelDB/RocksDB) оптимизирован на запись (append-only + фоновое сжатие), но чтение страдает от многоуровневых просмотров. B+tree даёт честный O(log N) на чтение и запись без фоновых процессов.
13.3. Страница — единица хранения и I/O
Размер страницы — 256…65536 байт (по умолчанию 4096), выбирается при создании базы и далее неизменен. Типы:
| Тип | Назначение |
|---|---|
| branch | Внутренний узел: разделители и указатели |
| leaf | Лист: пары ключ→значение |
| large / overflow | Контигуальный прогон больших значений |
| meta | Одна из трёх мета-страниц (снапшот состояния базы) |
| dupfix / subpage | Плотная упаковка мультизначений |
Каждая страница несёт метку транзакции (txnid), создавшей её текущую версию — это основа
CoW и MVCC.
С базой можно связать пользовательский канарейник (MDBX_canary, 32 байта — четыре uint64_t:
x, y, z, v; поле v всегда равно номеру транзакции): mdbx_canary_put(txn, &canary)
записывает его в мету, mdbx_canary_get(txn, &canary) читает. Обновлённые значения видны другим
процессам только после коммита. Это не данные таблиц, а произвольная метка приложения (например,
версия схемы), переживающая открытия базы и доступная даже при монтировании только для чтения.
13.4. mmap: виртуальная память как окно в файл
Файл отображается в адресное пространство целиком. Чтение — разыменование указателя; страницу подтянет ядро (page cache). Отдельного буферного кэша в библиотеке нет.
Следствия:
- чтение без копирования (вы получаете адрес прямо в mmap);
- страничный кэш ОС управляет резидентностью;
- объём базы ограничен адресным пространством (на 32-бит — реально ~1–3 ГБ);
- при БД ≫ ОЗУ растут накладные на PTE в ядре.
Для области mmap применяется madvise(MADV_NOHUGEPAGE) (отказ от THP) — огромные страницы мешают
точной оценке резидентности.
Фрагмент из examples/c++/18-tree-height.c++ — оценка дерева
через mdbx_env_stat_ex(): высота, страницы leaf/branch/overflow и число записей; значение больше
размера страницы создаёт overflow-страницы:
{
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (int i = 0; i < 5000; ++i)
txn.insert(table, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
// Значение больше размера страницы (4096) уходит на overflow-страницы.
const std::string big(64 * 1024, 'x');
txn.insert(table, mdbx::slice("big"), mdbx::slice(big));
txn.commit();
}
const auto stat = env.get_stat(); // mdbx_env_stat_ex()
std::cout << "stat: depth=" << stat.ms_depth << " leaf=" << stat.ms_leaf_pages
<< " branch=" << stat.ms_branch_pages << " overflow=" << stat.ms_overflow_pages
<< " entries=" << stat.ms_entries << "\n";
Полный код: 18-tree-height.c++ · C-версия.
13.5. Инвариант достижимости
Каждая не-мета страница до указателя first_unallocated достижима ровно один раз: либо
принадлежит какому-то дереву (обычной, главной или GC-таблице), либо перечислена в записи GC.
На этом инварианте строится аудит целостности и инструмент проверки базы (mdbx_chk).
Примеры к главе:
examples/c++/18-tree-height.c++· C-версия.
13.6. Резюме главы 13
- B+tree: данные в листьях, ветви — разделители; split/merge поддерживают баланс.
- Большие значения — overflow-прогоны страниц.
- mmap вместо буферного кэша: чтение без копирования, плата — адресное пространство/PTE.
- Инвариант достижимости — фундамент целостности.
13.7. Упражнения
- Объясните, почему
mdbx_getна 64-бит практически не зависит от объёма базы. - Что меняется для читателя, если дерево выросло с высоты 3 до высоты 5?
13.8. Чек-лист главы 13
- [ ] Могу объяснить, почему все значения лежат в листьях B+tree, а в ветвях — только разделители, и что это даёт для поиска и обхода диапазонов.
- [ ] Знаю, куда уходит значение, не помещающееся в лист (overflow-прогон), и что остаётся в листе вместо него.
- [ ] Понимаю, почему libmdbx не заводит собственный буферный кэш, и какие следствия это имеет (чтение без копирования, резидентность управляется ядром, рост PTE при БД ≫ ОЗУ).
- [ ] Могу назвать ограничение объёма базы адресным пространством и его следствие на 32-битных платформах.
- [ ] Знаю назначение канарейки (
MDBX_canary) и что полеvвсегда равно номеру транзакции. - [ ] Умею сформулировать инвариант достижимости и объяснить, что на нём построено (аудит целостности,
mdbx_chk).
Глава 14. MVCC и снапшоты
14.1. Версионность страниц, снапшот читателя
Каждая страница помечена номером транзакции, создавшей её текущую версию. Читатель при старте фиксирует снапшот — состояние базы на момент начала транзакции. Всё, что он видит после, — непротиворечивая картина этого момента, независимо от последующих коммитов.
14.2. Тройка мета-страниц и двухфазный коммит
В базе всегда три мета-страницы (NUM_METAS = 3). Это позволяет держать два валидных снапшота
(«свежий» и «steady» — с гарантированно сброшенными на диск данными) и один хвостовой слот для
перезаписи.
Обновление меты — двухфазное: сначала инвалидируется половина (запись номера транзакции с обнулением второй), затем заполняются поля и валидируется вторая половина. Читатель видит либо старую цельную мету, либо новую — никогда «полуобновлённую».
14.3. Конечный автомат тройки
Состояние каждой меты кодируется независимо; полная таблица переходов покрывает 216 (6³) комбинаций и исчерпывающе проверяется внутренней валидацией. Это защита от «невозможных» состояний после сбоев.
14.4. Таблица читателей (RLT)
Читатель регистрирует номер снапшота в таблице читателей (в файле блокировок). Слот содержит: номер снапшота, pid/tid владельца, и поля, описывающие, сколько страниц читатель «пинит».
Старейший активный снапшот — детент — вычисляется сканированием RLT (lock-free, с кэшированием). Детент — водораздел переиспользования страниц (глава 16).
14.5. Wait-free чтение и safe64
На операциях чтения читатель не берёт блокировок — он просто ходит по mmap. Единственное «записывающее» действие — регистрация слота при старте транзакции.
64-битные поля (номера транзакций в RLT и мете) читаются через специальный атомарный протокол safe64: значения пишутся «сначала младшее слово, затем старшее», чтение повторяется при обнаружении «обрывка». Это защита от разорванных 64-битных чтений на слабых моделях памяти.
14.6. Писатель: глобальный мьютекс, front txnid, грязный список
В любой момент времени одна write-транзакция (глобальный мьютекс писателя в файле
блокировок). При старте писатель получает предварительный номер (front txnid = txnid + 1):
новые и CoW-копии страниц метятся им и не видны читателям до коммита. Изменённые страницы
скапливаются в грязном списке (DPL).
Фрагмент из examples/c++/19-readers-lag.c++ — читатель
удерживает снапшот; коммиты писателя увеличивают его «отставание» (lag), а mdbx_reader_list
показывает слоты читателей с txnid и lag:
// Коммиты после создания снапшота увеличивают lag читателя.
for (int i = 3; i < 8; ++i) {
auto txn = env.start_write();
auto wtable = txn.open_map(nullptr);
txn.insert(wtable, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
txn.commit();
}
// Перечислить читателей: слот читателя имеет lag > 0.
struct visitor {
int operator()(const mdbx::env::reader_info &ri, int) {
std::cout << "reader slot: pid=" << ri.pid << " tid=" << ri.thread << " txnid=" << ri.transaction_id
<< " lag=" << ri.transaction_lag << "\n";
return mdbx::continue_loop;
}
} v;
env.enumerate_readers(v);
Полный код: 19-readers-lag.c++.
Примеры к главе:
examples/c++/19-readers-lag.c++.
14.7. Резюме главы 14
- MVCC: страница помечена txnid; читатель видит свой снапшот.
- Тройка мет с двухфазным обновлением и конечным автоматом 216 состояний.
- RLT и детент — основа безопасности переиспользования.
- Wait-free чтение; safe64 против разорванных чтений.
- Один писатель; front txnid скрывает незакоммиченное.
14.8. Упражнения
- Зачем нужны именно три мета-страницы, а не две?
- Что произойдёт, если писатель упадёт в середине двухфазного обновления меты?
14.9. Чек-лист главы 14
- [ ] Объясняю, почему читатель видит непротиворечивую картину независимо от последующих коммитов (снапшот + метка
txnidна страницах). - [ ] Знаю, зачем нужны именно три мета-страницы и какие два валидных снапшота они держат.
- [ ] Могу описать двухфазное обновление меты и объяснить, почему читатель никогда не видит «полуобновлённую» мету.
- [ ] Понимаю, как детент вычисляется сканированием RLT и почему он — водораздел переиспользования страниц.
- [ ] Знаю, что чтение wait-free и не берёт блокировок, и зачем нужен протокол
safe64. - [ ] Могу объяснить, почему незакоммиченные страницы писателя не видны читателям (
front txnid, грязный список).
Глава 15. Copy-on-Write и конвейер коммита
15.1. Жизненный цикл страницы при записи
Состояние страницы определяется сравнением её метки mp->txnid с номером транзакции:
| Состояние | Условие | Смысл |
|---|---|---|
| frozen | txnid < txn->txnid |
Старая версия, видима читателям; менять нельзя — только копировать |
| spilled | txnid == txn->txnid |
Уже записана на диск текущим писателем |
| shadowed | txnid > txn->txnid |
Есть более новая версия; эта — устаревшая |
| modifiable | txnid == front txnid |
Новая версия текущего писателя; можно менять на месте |
Механизм touch (CoW): если страница «заморожена» — выделяем новую (из GC или хвоста файла),
копируем содержимое, метим front txnid, кладём в грязный список; старую освобождаем. Родитель,
указывавший на старую, тоже становится «изменяемым» — CoW распространяется вверх к корню.
Курсоры, ссылавшиеся на старую версию, переводятся на новую.
15.2. Грязный список (DPL)
В пределах одной транзакции страница попадает в грязный список один раз, сколько бы раз её ни меняли — это ключевой фактор сдерживания WAF (write amplification factor — коэффициент усиления записи; подробно в томе IV, глава 21).
15.3. Спилл
Если объём грязных страниц превышает лимит (dp_limit, по умолчанию ≈ 1/42 ОЗУ), включается
спилл: часть страниц выгружается на диск заранее. Страницы, на которые ссылаются курсоры,
спиллу не подлежат; порядок выбора управляется опциями-знаменателями (минимум/максимум доли).
Нюанс: если спиллнутая страница затем снова изменяется — она будет записана повторно (дополнительная амплификация). Это компромисс «память vs WAF».
15.4. Loose-страницы и refund
- Loose-страницы — небольшой кэш освобождённых в текущей транзакции грязных страниц (лимит
loose_limit, по умолчанию 64) для быстрого переиспользования без обращения к GC. - Refund — возврат освобождённых хвостовых страниц в неразмеченное пространство (а не в GC); снижает WAF и даёт онлайн-сжатие.
15.5. Конвейер коммита
- Закрыть/проверить курсоры.
- GC-обработка: освобождённые страницы складываются в записи GC-дерева.
- Refund: хвостовые освобождения возвращаются в неразмеченное пространство.
- Спилл: при переполнении грязного списка — выгрузка заранее.
- Аудит (в режимах повышенной проверки): сверка сумм страниц.
- Двухфазное обновление меты + синхронизация по режиму долговечности.
- Снять блокировку писателя, обновить кэш детента, вычистить мёртвых читателей.
Каждая стадия измерима через mdbx_txn_commit_ex() → MDBX_commit_latency
(preparation/gc_wallclock/audit/write/sync/ending/whole).
Фрагмент из examples/c++/20-commit-latency.c++ — сбор
MDBX_commit_latency по стадиям через commit_get_latency() для серии коммитов и усреднение:
for (int i = 0; i < commits; ++i) {
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
txn.insert(table, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
const auto lat = txn.commit_get_latency(); // возвращает MDBX_commit_latency
prep += lat.preparation;
gc += lat.gc_wallclock;
audit += lat.audit;
write += lat.write;
sync += lat.sync;
ending += lat.ending;
whole += lat.whole;
}
std::cout << "avg latency (us): preparation=" << (prep / commits) << " gc=" << (gc / commits)
<< " audit=" << (audit / commits) << " write=" << (write / commits) << " sync=" << (sync / commits)
<< " ending=" << (ending / commits) << " whole=" << (whole / commits) << "\n";
Полный код: 20-commit-latency.c++.
Примеры к главе:
examples/c++/20-commit-latency.c++.
15.6. Резюме главы 15
- CoW: frozen → копия → modifiable; путь к корню переписывается целиком.
- DPL дедуплицирует страницы внутри транзакции — главный рычаг WAF.
- Спилл — компромисс память/WAF; loose и refund снижают запись.
- Конвейер коммита: GC → refund → спилл → аудит → мета → sync → разблокировка.
15.7. Упражнения
- Почему изменение одного байта в листе переписывает весь путь до корня?
- Как
mdbx_txn_commit_exпоможет увидеть, что «застряло» — спилл или sync?
15.8. Чек-лист главы 15
- [ ] Умею по метке
mp->txnidопределить состояние страницы (frozen/spilled/shadowed/modifiable) и что с ней можно делать. - [ ] Объясняю, почему CoW переписывает путь до корня и как курсоры переводятся на новые версии страниц.
- [ ] Знаю, что страница попадает в грязный список один раз за транзакцию, и почему это сдерживает WAF.
- [ ] Могу описать, когда включается спилл (
dp_limit≈ 1/42 ОЗУ), какие страницы он не трогает и чем плох повторный спилл. - [ ] Различаю loose-страницы (быстрое переиспользование,
loose_limit= 64) и refund (возврат хвостовых страниц в неразмеченное пространство). - [ ] Воспроизвожу порядок конвейера коммита и знаю, какие его стадии видны в
MDBX_commit_latency.
Глава 16. GC — сборщик мусора внутри БД
16.1. Почему нет free-list
У libmdbx нет классического списка свободных страниц. Освобождённые страницы учитываются персистентно, внутри файла данных, в специальном GC-дереве (отдельная таблица, корень — в мете).
16.2. Формат записи GC
- Ключ записи — номер транзакции, освободившей страницы.
- Значение — список номеров страниц (сжато, диапазонами).
- Одна запись физически ограничена (~1000 номеров при 4 КБ странице).
Формат GC заморожен и не менялся с v11.3 — важная гарантия совместимости.
16.3. Детент: безопасность переиспользования
Страницы из записи GC с ключом T можно переиспользовать только когда T ≤ детент — старейший
активный снапшот. Выше детента записи «заморожены» до завершения соответствующего читателя.
Детент вычисляется сканированием RLT (без блокировок, с кэшированием результата).
16.4. FIFO vs LIFO
| Политика | Механизм | Эффект |
|---|---|---|
| FIFO (по умолчанию) | Берутся страницы из самой старой подходящей записи | Список живёт дольше, остывает на диске |
LIFO (MDBX_LIFORECLAIM) |
Самые свежие освобождения первыми | Минимально короткий круг циркуляции; страницы ещё «тёплые»; на системах с write-back кэшем — рост производительности записи в разы |
Нюанс:
MDBX_LIFORECLAIMпочти не даёт эффекта сSAFE_NOSYNC/UTTERLY_NOSYNC— там длину цикла определяет частотаenv_sync(). Для поиска плотных последовательностей используются векторные (SIMD) ядра: SSE2/AVX2/AVX512/NEON.
16.5. BigFoot
Одна запись GC вмещает ~1000 номеров страниц (~4 МБ при 4 КБ). Если транзакция освобождает больше (например, заменяя огромное значение), записи оформляются цепочкой на последовательных txnid — режим BigFoot. Читатели обязаны быть новее всей цепочки. Цена — больше записей, выше GC-дерево.
BigFoot включён по умолчанию на 64-битных сборках (MDBX_ENABLE_BIGFOOT); полностью совместим с
форматом базы.
16.6. Рекурсивность обновления GC
GC — тоже CoW-дерево: изменение GC требует выделения страниц, что влияет на список страниц, попадающий в GC. Поэтому перед обновлением формируется оперативный запас свободных страниц: нехватка → нерациональный рост БД, избыток → оверхед.
16.7. rp_augment_limit и gc_time_limit
Поиск плотных последовательностей (для больших значений) может быть дорогим при фрагментации. Два ограничителя стоимости:
rp_augment_limit— предел накопления списков при поиске; превышение → дешевле дописать новые страницы в хвост файла;gc_time_limit— тайм-лимит (в 1/65536 секунды) на поиск в течение пишущей транзакции.
Правило: уменьшайте
rp_augment_limitтолько вместе сgc_time_limit; слишком малыйrp_augmentне лечит рост GC, а усугубляет его ради скорости вставки длинных записей.
Фрагмент из examples/c++/22-gc-limits.c++ — установка и
чтение лимитов GC: rp_augment_limit (запас страниц для рекламации) и gc_time_limit (1/65536
секунды на поиск последовательностей):
env.set_extra_option(opt::rp_augment_limit, 128 * 1024);
// gc_time_limit задаётся в 1/65536 долях секунды (16.16 fixed point):
// 2500 ≈ 38 мс на поиск последовательностей страниц в GC внутри транзакции.
env.set_extra_option(opt::gc_time_limit, 2500);
// Нагрузка с длинными значениями: заставляет GC работать активнее.
{
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (int i = 0; i < 2000; ++i) {
const auto key = "k" + std::to_string(i);
const std::string value(4096 + (i % 7) * 512, 'v');
txn.insert(table, mdbx::slice(key), mdbx::slice(value));
}
txn.commit();
auto del = env.start_write();
auto dtable = del.open_map(nullptr);
for (int i = 0; i < 2000; i += 2)
del.erase(dtable, mdbx::slice("k" + std::to_string(i)));
del.commit();
}
std::cout << "rp_augment_limit = " << env.extra_option(opt::rp_augment_limit) << "\n";
std::cout << "gc_time_limit = " << env.extra_option(opt::gc_time_limit) << "\n";
Полный код: 22-gc-limits.c++.
16.8. Ранняя очистка GC (2025) и не-отложенная очистка (devel)
- Ранняя очистка (2025): переработанные записи GC начинают обрабатываться раньше.
- Не-отложенная очистка (devel, 0.14.x): переработанные записи GC удаляются сразу после чтения, а не при фиксации; накладные расходы становятся пропорциональны объёму операций. Это же открыло путь к явной дефрагментации без копирования (0.14.2+).
Примеры к главе:
examples/c++/21-gc-observe.c++;examples/c++/22-gc-limits.c++.
16.9. Резюме главы 16
- GC — дерево записей «txnid → список страниц» внутри файла (не free-list).
- Переиспользование только новее детента.
- FIFO по умолчанию;
MDBX_LIFORECLAIM— LIFO. - BigFoot — цепочки для больших освобождений.
rp_augment_limit/gc_time_limit— контроль стоимости поиска.
16.10. Упражнения
- Объясните, почему долгий читатель «замораживает» переработку, используя понятие детента.
- Когда LIFO даст выигрыш, а когда нет?
16.11. Чек-лист главы 16
- [ ] Могу объяснить, почему в libmdbx нет free-list и где персистентно учитываются освобождённые страницы.
- [ ] Описываю формат записи GC: ключ — txnid освободителя, значение — список страниц диапазонами; формат заморожен с v11.3.
- [ ] Объясняю условие переиспользования через детент и что происходит с записями GC выше детента.
- [ ] Знаю разницу FIFO и LIFO и когда
MDBX_LIFORECLAIMдаёт выигрыш, а когда нет. - [ ] Понимаю механизм BigFoot: цепочки записей на последовательных txnid и требование к читателям быть новее всей цепочки.
- [ ] Могу объяснить, почему обновление GC рекурсивно (GC — тоже CoW-дерево) и зачем нужен оперативный запас страниц.
- [ ] Знаю назначение
rp_augment_limitиgc_time_limitи правило их совместного уменьшения.
Глава 17. Рост, сжатие и дефрагментация БД
17.1. Почему БД растёт
- Долгие читатели замораживают детент → GC не переиспользует страницы → писатель берёт новые из хвоста файла. Даже цикл put→del «распухает».
MDBX_SAFE_NOSYNC— постоянный квази-долгий читатель (последний steady-коммит).- Фрагментация и нехватка контигуальных последовательностей для больших значений.
- Забытые читатели (крэш потока, неправильный TLS).
17.2. Геометрия: growth_step, upper, shrink_threshold
Файл растёт автоматически, кратными growth_step, до upper. Усечение возможно только до
последней используемой страницы; одна занятая страница у конца может блокировать сжатие
неопределённо долго. Сжатие требует гистерезиса (шаг сжатия > шага роста), иначе файл «дышит».
17.3. MDBX_MAP_FULL и разрешающие механизмы
Когда GC пуст/заморожен и файл упёрся в upper, срабатывают, по возможности: новый
steady-point (сдвиг детента) → HSR-колбэк (HSR — Handle-Slow-Readers, механизм вытеснения
застрявших читателей; подробно в томе V, гл. 29) → выселение припаркованных читателей →
и лишь затем MDBX_MAP_FULL.
Фрагмент из examples/c++/23-map-full.c++ — воспроизведение
MDBX_MAP_FULL (в примере жёсткая геометрия ограничивает файл 512 КБ) и разрешение через
увеличение size_upper:
size_t inserted = 0;
try {
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (;;) {
const auto key = "k" + std::to_string(inserted);
const std::string value(1024, 'x');
txn.insert(table, mdbx::slice(key), mdbx::slice(value));
++inserted;
}
} catch (const mdbx::db_full &ex) {
std::cout << "MDBX_MAP_FULL after " << inserted << " inserts\n";
}
// Увеличиваем верхнюю границу — и продолжаем с того же места.
mdbx::env::geometry bigger;
bigger.size_upper = 8 * 1024 * 1024;
try {
env.set_geometry(bigger);
} catch (const mdbx::db_unable_extend &) {
// На 32-битной Windows адресный диапазон сразу за отображением может
// оказаться занят: переоткрываем ту же БД с увеличенной геометрией.
mdbx::env::geometry reopened = geo;
reopened.size_upper = bigger.size_upper;
env.close();
env = mdbx::env_managed(path,
mdbx::env_managed::create_parameters().set_geometry(reopened),
mdbx::env::operate_parameters());
}
Увеличение upper на живой БД требует переотображения: на большинстве платформ оно выполняется
на месте, но на 32-битной Windows рядом с текущим отображением может не оказаться свободного
диапазона адресов — тогда возникает MDBX_UNABLE_EXTEND_MAPSIZE, а переносимый путь — закрыть
и переоткрыть БД с увеличенной геометрией (новое отображение создаётся сразу с нужным upper).
Полный код: 23-map-full.c++.
17.4. Авто-компактификация (implicit shrink)
При каждом коммите освобождённые хвостовые страницы возвращаются в неразмеченное пространство
(refund). Когда свободный хвост превышает shrink_threshold (+ небольшой запас), коммит
дополнительно: сбрасывает «лишний» диапазон через madvise(MADV_DONTNEED/REMOVE) и усекает файл.
Ограничения: не работает, пока долгие читатели удерживают хвост или файл используют несколько
процессов; при SAFE_NOSYNC ограничено частотой steady-point'ов.
17.5. Явная дефрагментация
mdbx_env_defrag() / утилита mdbx_defrag (0.14.2+) — аккуратная перестановка страниц к началу
файла циклами; каждый цикл — отдельная коммитнутая транзакция. Параметры: defrag_atleast/
defrag_enough (минимальное/достаточное сокращение), time_atleast/time_limit (1/65536 с),
acceptable_backlash, preferred_batch. Код результата — MDBX_defrag_enough_threshold.
Гарантированное уменьшение файла — также mdbx_copy -c (компактифицированная копия) или
dump+load (-a).
17.6. Оценка ёмкости
Деление размера файла на размер элемента переоценивает реальную ёмкость: страничная гранулярность, путь к корню, GC и гистерезис добавляют накладные. Оценивайте по худшему случаю заморозки переиспользования.
Примеры к главе:
examples/c++/23-map-full.c++;examples/c++/24-defrag.c++.
17.7. Резюме главы 17
- Рост = не переиспользуемые страницы (читатели/SAFE_NOSYNC/фрагментация).
upper— жёсткий предел; MAP_FULL разрешается steady/HSR/выселением.- Implicit shrink — бесплатная компактификация в составе коммитов.
- Явная дефрагментация циклами; гарантированное сжатие — copy -c / dump+load.
17.8. Упражнения
- Почему «в базе 10 ГБ, значит 10 ГБ данных» — ошибка оценки?
- Опишите сценарий, где авто-компактификация не сработает.
17.9. Чек-лист главы 17
- [ ] Называю причины роста БД: долгие читатели,
MDBX_SAFE_NOSYNC, фрагментация, забытые читатели. - [ ] Понимаю, почему усечение возможно только до последней используемой страницы и зачем нужен гистерезис шага сжатия.
- [ ] Воспроизвожу цепочку разрешения
MDBX_MAP_FULL: steady-point → HSR-колбэк → выселение припаркованных читателей → ошибка. - [ ] Знаю ограничения авто-компактификации (implicit shrink) и когда она не сработает.
- [ ] Могу описать явную дефрагментацию циклами и гарантированные пути уменьшения файла (
mdbx_copy -c, dump+load). - [ ] Объясняю, почему деление размера файла на размер элемента переоценивает реальную ёмкость.
Глава 18. Вложенные транзакции
18.1. Зачем нужны
Вложенная транзакция — дочерняя write-транзакция внутри родительской, позволяющая группировать изменения с возможностью частичного отката.
18.2. Реализация
Дочерняя транзакция работает на том же номере транзакции и front txnid. Она наследует у родителя: пространство страниц и грязный список (с тенью родительского), retired-списки (списки уже освобождённых, но ещё удерживаемых от переиспользования страниц — см. 19.4), буферы GC, дескрипторы таблиц и курсоры (через механизм «спаренных» курсоров).
18.3. Commit (join) vs Abort (undo)
- Коммит: грязные страницы дочерней сливаются в родительский список; счётчики GC переносятся. Если дочерняя «чистая» — быстрый путь без слияния.
- Abort: все изменения отбрасываются; утилизированные страницы «рас-утилизируются»; хендлы и курсоры восстанавливаются.
Фрагмент из examples/c++/25-nested-txn.c++ — вложенная
транзакция: start_nested() + abort() откатывает её изменения (undo); симметричный commit()
вливает их в родителя (join):
// Вложенная транзакция с откатом (undo).
{
auto nested = txn.start_nested();
auto ntable = nested.open_map(nullptr);
nested.insert(ntable, mdbx::slice("k2"), mdbx::slice("v2"));
nested.abort(); // откат вложенной
}
std::cout << "parent sees k1 after nested abort: " << txn.get(table, mdbx::slice("k1")).as_string() << "\n";
try {
(void)txn.get(table, mdbx::slice("k2"));
std::cerr << "FAIL: k2 survived nested abort\n";
return EXIT_FAILURE;
} catch (const mdbx::not_found &) {
std::cout << "k2 rolled back by nested abort: absent\n";
}
Полный код: 25-nested-txn.c++.
18.4. Судьба таблиц
- Таблица, созданная во вложенной: при commit остаётся, при abort исчезает.
- Таблица, удалённая во вложенной: при commit удаляется, при abort «оживает».
18.5. Анти-паттерн: вложенная транзакция в каждой функции
Вызывать mdbx_txn_begin(parent, ...) в каждой маленькой функции — анти-паттерн: теряется смысл
группировки, растут накладные. Вкладывайте осознанно, по границам логических операций.
18.6. Workaround нехватки места
Вложенные транзакции — известный workaround для сценариев нехватки места (можно частично откатиться, не теряя предыдущие изменения).
Нюанс: вложенные транзакции не сочетаются с
MDBX_WRITEMAP.Примеры к главе:
examples/c++/25-nested-txn.c++.
18.7. Резюме главы 18
- Вложенная транзакция = дочерняя write-транзакция с частичным откатом.
- Commit — слияние в родителя; Abort — полный откат.
- Судьба созданных/удалённых таблиц следует правилам CoW.
- Не вкладывайте «на каждую функцию»; не сочетайте с WRITEMAP.
18.8. Упражнения
- Напишите пример «группа обновлений с откатом последнего шага» через вложенную транзакцию.
- Проверьте судьбу таблицы, созданной и удалённой во вложенной транзакции при abort.
18.9. Чек-лист главы 18
- [ ] Объясняю, что дочерняя транзакция работает на том же номере транзакции, и что она наследует у родителя.
- [ ] Различаю commit (join): слияние грязных страниц в родителя, и abort (undo): полный откат и «рас-утилизация» страниц.
- [ ] Знаю судьбу таблиц, созданных и удалённых во вложенной транзакции, при commit и при abort.
- [ ] Умею показать частичный откат через
start_nested()+abort(). - [ ] Могу обосновать, почему вложенная транзакция «в каждой функции» — анти-паттерн.
- [ ] Знаю, что вложенные транзакции — workaround нехватки места, и что они не сочетаются с
MDBX_WRITEMAP.
Глава 19. Файл блокировок и межпроцессная синхронизация
19.1. Зачем отдельный файл
Рядом с файлом данных живёт файл блокировок (.lck): глобальный мьютекс писателя, таблица
читателей (RLT), статистика, кэши старейшего снапшота. Он нужен для координации между
процессами.
Фрагмент из examples/c++/26-two-processes.c++ —
межпроцессный доступ: процесс-писатель коммитит данные, процесс-читатель (потомок через fork())
открывает ту же БД и читает их:
// Писатель: родительский процесс коммитит данные и закрывает окружение.
{
auto env = example::env_open(path);
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
txn.insert(table, mdbx::slice("k"), mdbx::slice("v1"));
txn.commit();
std::cout << "parent wrote: k=v1" << std::endl;
}
const pid_t pid = fork();
if (pid < 0) {
std::cerr << "fork failed\n";
return EXIT_FAILURE;
}
if (pid == 0) {
// Читатель: потомок открывает ту же БД и читает данные.
auto env = example::env_open(path);
auto txn = env.start_read();
auto table = txn.open_map(nullptr);
std::cout << "child reads: " << txn.get(table, mdbx::slice("k")).as_string() << std::endl;
txn.abort();
_exit(EXIT_SUCCESS);
}
Полный код: 26-two-processes.c++.
19.2. Реализации блокировок
Опция сборки MDBX_LOCKING:
| Flavour | Примитивы | Сигнатура LCK |
|---|---|---|
POSIX2008 |
Надёжные мьютексы (Linux по умолчанию) | 0x8017 |
POSIX2001 |
Общие мьютексы | 0x8017 |
SYSV |
Семафоры (macOS по умолчанию) | 0xF18D |
WIN32FILES |
Windows (LockFileEx) |
0xF10C |
POSIX1988 |
(legacy) | 0xFC29 |
Нюанс: почему у
POSIX2001иPOSIX2008одна сигнатура — это не опечатка. Оба флавора хранят в LCK межпроцессный примитив одного и того же типа —pthread_mutex_t, поэтому их on-disk формат совпадает байт-в-байт, и сигнатура у них общая. Различие — только в используемых возможностях API: надёжные (robust) мьютексы появились в POSIX.1-2008, режим 2001 работает с обычными process-shared мьютексами. Следствие: базу, открытую в одном из этих двух режимов, можно продолжить в другом — а сSYSV,WIN32FILESиPOSIX1988нельзя (у них другие сигнатуры, и LCK такого формата будет отвергнут).
При этом фактическая проверка совместимости при открытии использует не сырую сигнатуру, а
производный хеш MDBX_LOCK_FORMAT — он смешивает сигнатуру флавора с sizeof(reader_slot_t)
и офсетами ключевых полей структуры lck_t, так что приведённые константы — лишь «база»,
уникальная для формата примитива, а полный паспорт учитывает и раскладку остальных полей.
Формат LCK версионирован отдельно от формата данных: изменение структуры ломает только совместный доступ к уже открытой базе, не сам файл данных.
19.3. Почему LockFileEx на Windows, а не именованные мьютексы
Файловые блокировки (LockFileEx) выбраны осознанно: работают на сетевых дисках и защищают от
некомпетентных действий. Цена — в наивных бенчмарках с множеством мелких транзакций libmdbx может
отставать от LMDB (захват/освобождение файловой блокировки — сотни микросекунд).
19.4. Слот читателя (RLT)
txnid (atomic) номер снапшота (INVALID_TXNID = свободен)
tid владелец; PARKED = UINT64_MAX, OUSTED = UINT64_MAX-1
pid процесс-владелец
snapshot_pages_used first_unallocated на момент снапшота
snapshot_pages_retired сколько страниц удерживает от переиспользования
Живость слотов проверяется средствами ОС (защита от повторного использования pid/tid). stale-слоты не сканируются поштучно — таблица целиком реинициализируется, когда LCK открывает единственный процесс.
19.5. Восстановление блокировок после сбоя процесса
После краха процесса ОС освобождает его блокировки; мёртвые слоты читателей очищаются механизмами
живости и mdbx_reader_check().
19.6. Режим без файла блокировок
Возможен режим «один процесс, эксклюзивно» без .lck (опция сборки) — когда межпроцессный доступ
не нужен.
Примеры к главе:
examples/c++/26-two-processes.c++.
19.7. Резюме главы 19
- LCK: мьютекс писателя + RLT + статистика; отдельно версионируется.
- Реализации: POSIX/SysV/WIN32FILES; сигнатуры flavour'ов.
- LockFileEx — выбор ради сетевых дисков; медленнее для мелких транзакций.
- Слот RLT: txnid/tid/pid + пининг-поля; живость через ОС.
- Возможен режим без LCK (один процесс).
19.8. Упражнения
- Почему удалять
.lckпри открытой базе — ошибка? - Чем
mdbx_reader_checkотличается от «автоматической» очистки?
19.9. Чек-лист главы 19
- [ ] Могу перечислить, что живёт в файле блокировок, и зачем он нужен при межпроцессном доступе.
- [ ] Знаю, что формат LCK версионируется отдельно от формата данных, и что это значит для совместимости.
- [ ] Объясняю, почему на Windows выбран
LockFileEx, и чем за это платим в бенчмарках с мелкими транзакциями. - [ ] Описываю слот читателя RLT и поля, фиксирующие, сколько страниц читатель удерживает от переиспользования.
- [ ] Понимаю, как проверяется живость слотов средствами ОС и что очищает мёртвые слоты после краха процесса (
mdbx_reader_check()). - [ ] Знаю, когда возможен режим без файла блокировок (один процесс, эксклюзивно).
Глава 20. Долговечность и восстановление
20.1. Двухфазное обновление меты: weak vs steady
- weak — данные в файле, но не гарантированно сброшены на постоянный носитель;
- steady — данные сброшены на диск; снапшот переживает сбой системы.
20.2. Восстановление без WAL
WAL отсутствует намеренно. После сбоя при открытии выбирается последняя цельная и валидная мета (согласованная тройка, совпадение половин txnid, контрольная сумма). «Полузаписанные» транзакции, не успевшие опубликовать мету, просто не существуют.
mdbx_txn_checkpoint(txn, weakening_durability, &latency) — вариант коммита: фиксирует
транзакцию и возвращает латенси по стадиям (MDBX_commit_latency), позволяя ослабить долговечность
через weakening_durability. В заголовке помечено как «может измениться в будущих релизах» —
используйте для диагностики, а не как стабильный API. Принудительное продвижение steady-точки
выполняется mdbx_env_sync_ex(env, force=true, ...) (Том II, глава 10), а не checkpoint'ом.
20.3. Open for recovery
mdbx_env_open_for_recovery() открывает базу с выбором целевой мета-страницы (target_meta) под
эксклюзивной блокировкой, чтобы разрешить «застрявшую» тройку. Recovery-проверки с 0.12.7+
не изменяют базу. Код MDBX_WANNA_RECOVERY возвращается при read-only открытии базы,
требующей восстановления.
Внимание: в заголовке функция помечена как внутренний API утилиты
mdbx_chk, «subject to change at any time» — в прикладном коде она не рекомендуется; штатный путь — read-write открытие илиmdbx_chk.
Фрагмент из examples/c++/27-recovery.c++ — падение писателя
(_exit без commit/abort) откатывается при повторном открытии (steady), а
open_for_recovery() открывает конкретную meta-страницу:
// Повторное открытие: незакоммиченные изменения откатились.
{
auto env = example::env_open(path);
auto txn = env.start_read();
auto table = txn.open_map(nullptr);
const std::string value(txn.get(table, mdbx::slice("key")).as_string());
std::cout << "crashed writer changes rolled back: key=" << value << ", newkey ";
try {
(void)txn.get(table, mdbx::slice("newkey"));
std::cout << "present\n";
return EXIT_FAILURE;
} catch (const mdbx::not_found &) {
std::cout << "absent\n";
}
txn.abort();
}
// Режим восстановления: открыть конкретную meta-страницу (0..2).
{
auto recovery = mdbx::env_managed::open_for_recovery(path.c_str(), 0 /* target_meta */, true);
std::cout << "open_for_recovery: ok\n";
}
Полный код: 27-recovery.c++.
20.4. boot_id: семантика и поведение в LXC
boot_id — идентификатор загрузки ОС, учитываемый при откате слабых мет. В LXC boot_id может
отсутствовать/быть общим для контейнеров — контроль отката слабых мет учитывает это. Это важно
при сбоях внутри контейнеров.
20.5. Некогерентность unified page cache
Известная проблема issue #269: некогерентность объединённого page cache между процессами. Защита —
MDBX_FORCE_CHECK_MMAP_COHERENCY (опция сборки, дефолт 0); внесена в сериях 0.11.5–0.11.6;
счётчик срабатываний виден в диагностике.
20.6. mdbx_chk: проверка целостности
mdbx_chk — глубокая валидация: меты/тройка, деревья, упорядоченность, инвариант достижимости,
GC. Режимы: -w (read-write, откат к steady), -d (постраничный обход — без него нельзя найти
lost/double-used страницы), -i (игнор ложных ошибок порядка при кастомных компараторах),
-0/-1/-2 (конкретная мета), -vvvvv (гистограммы). Exit-код: 0 = чисто.
20.7. Что переживёт сбой питания — по режимам
| Режим | После сбоя |
|---|---|
MDBX_SYNC_DURABLE |
Все закоммиченные данные на месте |
MDBX_NOMETASYNC |
Потеря последних коммитов (мета отложенно) |
MDBX_SAFE_NOSYNC |
Откат к последнему steady |
MDBX_UTTERLY_NOSYNC |
Никаких гарантий; возможна порча |
Примеры к главе:
examples/c++/27-recovery.c++.
20.8. Резюме главы 20
- Двухфазная мета: weak/steady; восстановление — выбор последней цельной меты.
- Open-for-recovery с target_meta; с 0.12.7 проверки не изменяют базу.
- boot_id важен в LXC; page-cache несоогласованность — #269 + FORCE_CHECK.
mdbx_chk— основной инструмент проверки; режимы см. выше.
20.9. Упражнения
- Опишите, что произойдёт с базой после kill -9 посреди коммита, в режиме
DURABLE. - Зачем
mdbx_chk -dнужен для поиска lost-unused страниц?
20.10. Чек-лист главы 20
- [ ] Различаю weak и steady снапшоты и знаю, какой из них переживает сбой системы.
- [ ] Объясняю, как работает восстановление без WAL: выбор последней цельной и валидной меты; «полузаписанные» транзакции просто не существуют.
- [ ] Знаю назначение
mdbx_env_open_for_recovery()сtarget_metaи почему в прикладном коде она не рекомендуется. - [ ] Понимаю роль
boot_idпри откате слабых мет и его особенности в LXC. - [ ] Знаю о некогерентности unified page cache (#269) и защите
MDBX_FORCE_CHECK_MMAP_COHERENCY. - [ ] Умею выбрать режимы
mdbx_chk(-w,-d,-i,-0/-1/-2) под задачу и понимаю, зачем нужен-d. - [ ] Могу для каждого режима долговечности сказать, что переживёт сбой питания.
Итог тома
Вы понимаете внутреннее устройство libmdbx: от страницы и B+tree до меты, GC и восстановления. Теперь вы можете объяснять любое наблюдаемое поведение через архитектуру.
Что дальше: Том IV — производительность и оптимизация: WAF, выбор конфигурации под сценарий, микрооптимизации, get-cached, массовые операции, бенчмарки.