Skip to content

Том IV. Производительность и оптимизация

Уровень: для опытных разработчиков. Цель тома: вы умеете настраивать libmdbx под конкретный сценарий, понимаете источники write amplification и узкие места, умеете измерять и интерпретировать метрики.


Глава 21. WAF — write amplification factor

21.1. Что такое WAF и почему он важен

WAF (Write Amplification Factor) — отношение реально записанных на диск байт к объёму данных, принятых от приложения. WAF = 100 значит: на каждый записанный вами байт диск получает 100 байт. Высокий WAF означает износ SSD, меньшее время жизни диска и худшую пропускную способность.

21.2. Источники амплификации

  1. Страничная гранулярность. CoW не модифицирует старую страницу, а создаёт новую. Изменение одного байта в листе = запись целой страницы (например, 4 КБ).
  2. Путь к корню. Новая версия листа меняет указатель у родителя → родитель тоже становится новым → и так до корня. Точечное обновление = (высота+1) × pagesize байт записи.
  3. GC-записи и мета. Освобождённые страницы описываются в GC-дереве; двухфазная мета — ещё 1–3 страницы на коммит.
  4. Спилл. Спиллутая страница, затем снова изменённая, записывается повторно.
  5. Merge/rebalance. Слияния полупустых страниц могут переписывать соседние.

21.3. Батчинг — главный рычаг

В пределах одной транзакции страница попадает в грязный список один раз. N операций на M уникальных страницах записывают M × pagesize байт (+GC+мета). Поэтому:

  • одна операция на транзакцию: WAF = высота+1 ≈ 4–5 (и выше);
  • 10 000 операций в одной транзакции: WAF приближается к «1 страница на множество изменений».

Вывод: батчинг (100–10 000 операций на коммит) — самый мощный рычаг снижения WAF.

21.4. Влияние высоты дерева

Высота B+tree ~ log_B(N), где B — среднее число потомков в узле (сотни). Увеличение объёма данных в 2^k раз добавляет ~k уровней. Для point-update удельный WAF растёт логарифмически с объёмом — медленно.

21.5. Спилл как компромисс

Когда грязный список переполняет dp_limit (≈1/42 ОЗУ), часть страниц выгружается заранее. Если такая страница снова изменяется — она пишется повторно (лишний WAF). Это компромисс «память vs WAF».

21.6. prefer_waf_insteadof_balance и merge_threshold

Опция prefer_waf_insteadof_balance намеренно жертвует равномерностью заполнения страниц ради меньшего числа записываемых страниц (использовать уже изменённую страницу-соседа); включена по умолчанию. Порог слияния — MDBX_opt_merge_threshold (16.16 %; дефолт 33%, допустимый диапазон [12.5%..50%]). Оба — прямые рычаги WAF.

21.7. Расчёт WAF для типичных сценариев

Для point-update в дереве высоты h на странице 4 КБ: запись ≈ (h+1) × 4096 байт на операцию. При h=4 и значении 32 байта: 20 КБ / 32 Б ≈ WAF ≈ 640 (одиночные операции!). При батчинге 10 000 записей на те же 5 000 уникальных страниц: 5 000 × 4096 / (10 000 × 32) ≈ WAF ≈ 64. С дальнейшим ростом батча WAF продолжает падать.

Внимание: числа без контекста бесполезны — всегда указывайте режим синхронизации, размер страницы, высоту дерева и размер транзакции.

Фрагмент из examples/c++/28-waf-batching.c++ — замер записанных страниц (newly+cow из mi_pgop_stat, те же счётчики, что у mdbx_stat -p) для разных размеров батча записи:

  constexpr int total = 1000;
  const auto initial = env.get_info().mi_pgop_stat;
  for (int off = 0; off < total; off += (int)batch_size) {
    auto txn = env.start_write();
    auto table = txn.open_map(nullptr);
    for (int i = off; i < off + (int)batch_size && i < total; ++i)
      txn.insert(table, mdbx::slice("k" + std::to_string(i)), mdbx::slice("value"));
    txn.commit();
  }
  const auto final = env.get_info().mi_pgop_stat;
  const uint64_t pages = (final.newly - initial.newly) + (final.cow - initial.cow);
  std::cout << "batch=" << batch_size << ": pages=" << pages << " ops=" << total << " pages/op=" << (double)pages / total
            << "\n";

Полный код: 28-waf-batching.c++.

Примеры к главе: examples/c++/28-waf-batching.c++.

21.8. Резюме главы 21

  • WAF = записано на диск / принято от приложения.
  • Источники: гранулярность страницы, путь к корню, GC/мета, спилл, merge.
  • Батчинг — главный рычаг: страница пишется раз за транзакцию.
  • Рост с объёмом — логарифмический; большие значения — линейный вклад.

21.9. Упражнения

  1. Посчитайте WAF для point-update при высоте 3 и странице 4 КБ.
  2. Как повлияет батч из 1 000 обновлений на те же 500 страниц?

21.10. Чек-лист главы 21

  • [ ] Могу посчитать запись для point-update как (высота+1) × pagesize байт и получить из неё WAF.
  • [ ] Называю все источники амплификации: гранулярность страницы, путь к корню, GC/мета, спилл, merge/rebalance.
  • [ ] Объясняю, почему страница попадает в грязный список один раз за транзакцию и почему батчинг — главный рычаг снижения WAF.
  • [ ] Знаю, что удельный WAF растёт с объёмом логарифмически, и могу объяснить почему (высота ~ log_B(N)).
  • [ ] Понимаю компромисс спилла: dp_limit ≈ 1/42 ОЗУ, повторная запись спиллнутой страницы.
  • [ ] Знаю эффект prefer_waf_insteadof_balance и MDBX_opt_merge_threshold.
  • [ ] Привожу числа WAF только с контекстом: режим синхронизации, размер страницы, высота дерева, размер транзакции.

Глава 22. Выбор конфигурации под сценарий

22.1. Сценарий 1: Высокая пропускная способность записи (Ethereum-подобный)

  • Режим: MDBX_SAFE_NOSYNC + авто-sync (syncbytes/syncperiod), или NOMETASYNC.
  • Геометрия: upper с запасом; growth_step крупный (реже fsync).
  • Страница: 4–8 КБ.
  • Батчинг: десятки–тысячи операций на коммит.
  • Ожидаемые числа: сотни тысяч–миллионы вставок/с (зависит от железа и размера ключей).

22.2. Сценарий 2: Чтение-heavy с редкой записью (кэш, индекс)

  • Режим: по умолчанию (DURABLE); запись редкая, надёжность важна.
  • Страница: по размеру значений; короткие ключи.
  • maxreaders — по числу потоков-читателей.
  • Чтение линейно масштабируется по ядрам (wait-free).

22.3. Сценарий 3: Встраиваемое мобильное приложение (Isar-подобный)

  • Маленькая база; страница 4 КБ.
  • MDBX_EXCLUSIVE при необходимости монопольного доступа.
  • Аккуратные read-only транзакции; парковка для долгих чтений.
  • Низкий WAF (малый churn) — продлевает жизнь flash.

22.4. Сценарий 4: Аналитика, обход всей БД

  • Чтение full-scan; важно: страница 64 КБ для последовательного чтения? (Нет — читается всё; важна локальность и отсутствие долгих писателей.)
  • Курсоры + get_batch; оценка диапазонов через estimate_*.

22.5. Сценарий 5: Многопроцессное чтение (веб-сервер + воркеры)

  • Несколько процессов открывают одну базу read-only; один — write.
  • maxreaders учитывает все процессы; LCK-файл общий.
  • Аккуратно с boot_id в контейнерах; page-cache когерентность (#269).

22.6. Сценарий 6: Низкая задержка, маленькие транзакции

  • Каждая транзакция — одна операция (минимальная задержка).
  • Режим: NOMETASYNC/SAFE_NOSYNC, чтобы не платить fsync на каждую.
  • На Windows помнить про LockFileEx (мелкие транзакции дороги).
  • Ориентир накладных: ~0.5 мкс на транзакцию сверх I/O.

Фрагмент из examples/c++/29-scenario-configs.c++ — типовые конфигурации как функции, возвращающие operate_parameters: флаги и опции под каждый сценарий главы:

// Запись-heavy: ленивая синхронизация, достаточно именованных таблиц.
params write_heavy() { return params().lazy_weak_tail().set_max_maps(64); }

// Чтение-heavy: максимальная долговечность, много читательских слотов.
params read_heavy() { return params().robust_synchronous().set_max_readers(1024).set_max_maps(64); }

// Мобильное устройство: малый размер файла, бережное отношение к флеш-памяти.
params mobile() { return params().lazy_weak_tail().set_max_maps(64); }

// Аналитика: пакетная запись, не мелочиться с балансировкой.
params analytics() { return params().lazy_weak_tail().set_max_maps(64); }

// Многопроцессное чтение: много читательских слотов, стабильная геометрия.
params multi_process_read() { return params().set_max_readers(4096).set_max_maps(64); }

// Низкая задержка: NOMETASYNC (данные на диске, метаданные могут отставать).
params low_latency() { return params().half_synchronous_weak_last().set_max_maps(64); }

Полный код: 29-scenario-configs.c++.

Примеры к главе: examples/c++/29-scenario-configs.c++.

22.7. Резюме главы 22

  • Режим синхронизации выбирается по требованиям к durability.
  • Геометрия задаётся с запасом; upper не занижать.
  • Батчинг масштабируется под сценарий (latency vs throughput).
  • Платформенные особенности (Windows LockFileEx, LXC boot_id) влияют.

22.8. Упражнения

  1. Подберите конфигурацию для сценария «аналитика» и обоснуйте выбор страницы.
  2. Что изменится для «низкой задержки» на Windows по сравнению с Linux?

22.9. Чек-лист главы 22

  • [ ] Для каждого из шести сценариев могу обосновать выбор режима синхронизации (от DURABLE до SAFE_NOSYNC/NOMETASYNC).
  • [ ] Знаю, что геометрия задаётся с запасом и почему upper не занижают.
  • [ ] Умею подобрать размер батча под баланс latency/throughput конкретного сценария.
  • [ ] Понимаю, когда важен размер maxreaders (чтение-heavy, многопроцессное чтение).
  • [ ] Помню платформенные ловушки: LockFileEx на Windows, boot_id в контейнерах, когерентность page cache (#269).
  • [ ] Могу объяснить, почему для full-scan переход на большую страницу не панацея.

Глава 23. Микрооптимизации

23.1. SIMD-поиск свободных страниц

Поиск плотных последовательностей в GC использует векторные ядра: SSE2/AVX2/AVX512/NEON (ускорение ×4/×8/×16; выбор на этапе выполнения). Это внутренняя оптимизация — вы лишь влияете на неё через rp_augment_limit/gc_time_limit.

23.2. Branchless бинарный поиск (CMOV)

Бинарный поиск по разделителям branch-страниц использует безусловные переходы (CMOV) — нет ветвлений, предсказуемость. Выигрыш заметен в хот-путях поиска.

23.3. Radix-sort и сортировочные сети

Используются во внутренних процедурах (сортировка списков страниц, слияние PNL).

23.4. C11-атомики для слабых моделей памяти

На ARM/AArch64/PPC/MIPS/RISC-V применяются аккуратные атомики (acquire/release, CAS, safe64). Корректность гарантируется сборкой (проверка атомарности).

23.5. Минимизация системных вызовов

Хот-пути избегают syscall'ов; батчинг I/O через bulk iovec; mdbx_copy использует массовую запись.

23.6. Prefault-запись и mincore

MDBX_opt_prefault_write_enable — упреждающая запись страниц для устранения page-fault'ов и чтений с диска при первом обращении в MDBX_WRITEMAP. Резидентность отслеживается через mincore/madvise. Выигрыш — когда БД > ОЗУ и страницы часто вытесняются.

23.7. Auto-appending split и слияние с грязным соседом

  • Auto-appending split — эффективное добавление ключей в конец (append-сценарии).
  • Merge с уже грязным соседом — меньше CoW-копий (связано с prefer_waf_insteadof_balance).

Фрагмент из examples/c++/30-append-prefault.c++ — пакетная загрузка отсортированных ключей: prefault_write_enable + txn.append() (MDBX_APPEND):

static uint64_t load_append(const std::string &path, int count) {
  mdbx::env::remove(path);
  auto env = example::env_open(path);
  env.set_extra_option(mdbx::env::extra_runtime_option::prefault_write_enable, 1);

  const auto t0 = std::chrono::steady_clock::now();
  auto txn = env.start_write();
  auto table = txn.open_map(nullptr);
  for (int i = 0; i < count; ++i)
    txn.append(table, mdbx::slice(key_of(i)), mdbx::slice("value")); // MDBX_APPEND
  txn.commit();
  const auto t1 = std::chrono::steady_clock::now();
  return uint64_t(std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count());
}

Полный код: 30-append-prefault.c++.

23.8. Когда эти оптимизации важны, а когда — нет

SIMD/CMOV дают проценты-десятки процентов на конкретных путях. Для большинства приложений решают батчинг и режим синхронизации, а не микрооптимизации. Не оптимизируйте вслепую — измеряйте.

Примеры к главе: examples/c++/30-append-prefault.c++.

23.9. Резюме главы 23

  • Внутренние оптимизации (SIMD, CMOV, radix, атомики) уже встроены.
  • Ваши рычаги: prefault, auto-append, merge-политика.
  • Микрооптимизации вторичны к батчингу и режиму синхронизации.

23.10. Упражнения

  1. Когда включение prefault-записи оправдано? Сформулируйте условие (размер БД vs ОЗУ).
  2. Почему «не измерять, а оптимизировать» — анти-паттерн?

23.11. Чек-лист главы 23

  • [ ] Знаю, какие микрооптимизации уже встроены (SIMD-ядра, CMOV, radix-sort, C11-атомики), и что на SIMD-поиск влияю только через rp_augment_limit/gc_time_limit.
  • [ ] Могу сформулировать условие оправданности prefault-записи: БД > ОЗУ, частые вытеснения страниц, MDBX_WRITEMAP.
  • [ ] Понимаю выигрыш auto-appending split для append-сценариев и слияния с уже грязным соседом (связь с prefer_waf_insteadof_balance).
  • [ ] Знаю, что хот-пути минимизируют syscall'и (bulk iovec, массовая запись в mdbx_copy).
  • [ ] Могу объяснить, почему батчинг и режим синхронизации решают больше, чем микрооптимизации, и почему оптимизировать нужно по измерениям.

Глава 24. Кэш-поиск (get-cached)

24.1. Что такое get-cached

mdbx_cache_get() — сервис чтения «повторяющихся» ключей. Для ключа хранится адрес значения в mmap + версионные метки. При повторном чтении вместо полного спуска по B+tree выполняется ленивый (ранний) выход: спускаемся только пока встречаем страницы, изменённые после последнего подтверждения.

24.2. Механизм

Запись кэша: {trunk_txnid, last_confirmed_txnid, offset, length} (offset == 0 = «ключа нет»).

Первый вызов — полный поиск + заполнение записи. Последующие:

  1. спуск останавливается на первой странице, не изменённой после last_confirmed_txnid;
  2. если ничего не менялось — MDBX_CACHE_HIT после нескольких лёгких сравнений;
  3. если изменения есть — подтверждение актуальности (MDBX_CACHE_CONFIRMED) или полный поиск (MDBX_CACHE_REFRESHED).

24.3. Статусы

Статус Значение
HIT / CONFIRMED / REFRESHED Результат получен
DIRTY Значение на грязной (незакоммиченной) странице; валидно только в текущей write-транзакции
BEHIND Читатель старше версии в кэше; кэш обойти
UNABLE ABA-ситуация; поиск в обход кэша
RACE Параллельное обновление записи; поиск в обход
ERROR (MDBX_CACHE_ERROR) Ошибка, не связанная с отсутствием ключа

Фрагмент из examples/c++/31-get-cached.c++ — отображение всех статусов кэша get_cached: HIT/CONFIRMED/REFRESHED, DIRTY, BEHIND, UNABLE, RACE и ERROR:

const char *status_name(int status) {
  switch (status) {
  case MDBX_CACHE_ERROR:
    return "ERROR";
  case MDBX_CACHE_BEHIND:
    return "BEHIND";
  case MDBX_CACHE_UNABLE:
    return "UNABLE";
  case MDBX_CACHE_RACE:
    return "RACE";
  case MDBX_CACHE_DIRTY:
    return "DIRTY";
  case MDBX_CACHE_HIT:
    return "HIT";
  case MDBX_CACHE_CONFIRMED:
    return "CONFIRMED";
  case MDBX_CACHE_REFRESHED:
    return "REFRESHED";
  default:
    return "?";
  }
}

Полный код: 31-get-cached.c++.

24.4. Когда даёт ускорение

Кэш эффективен для повторяющихся ключей с малым числом изменений (конфигурация, счётчики, маркеры, lookaside-таблицы). Для уникальных ключей выгоды нет (первый поиск всё равно полный). Отношение полного поиска к HIT-пути легко достигает 10²–10³.

24.5. Когда не работает

  • Уникальные ключи (нет повторов).
  • Активные пишущие транзакции дают DIRTY (не кэшируются).
  • Многопоточность требует MDBX_NOSTICKYTHREADS для многопоточного варианта; однопоточный вариант (mdbx_cache_get_SingleThreaded) — дешевле.

24.6. История

Запрос многопоточного lockfree get-cached — Gabriel RABHI («Ghost Body Object»). Многопоточный вариант реализован; статус на текущем каноне — уточнять по мере развития API.

Примеры к главе: examples/c++/31-get-cached.c++.

24.7. Резюме главы 24

  • get-cached — ленивый кэш с ранним выходом и версионными метками.
  • HIT/…/RACE — статусы результата; DIRTY в write-транзакциях.
  • Эффективен для повторяющихся «горячих» ключей; до 1000× быстрее поиска.
  • Многопоточный вариант требует NOSTICKYTHREADS.

24.8. Упражнения

  1. Оцените: цикл из 1 000 000 чтений одного ключа — сколько сэкономит кэш?
  2. Почему DIRTY не кэшируется?

24.9. Чек-лист главы 24

  • [ ] Описываю запись кэша {trunk_txnid, last_confirmed_txnid, offset, length} и смысл offset == 0.
  • [ ] Объясняю механику ленивого выхода: спуск останавливается на первой странице, не изменённой после last_confirmed_txnid.
  • [ ] Различаю статусы HIT/CONFIRMED/REFRESHED/DIRTY/BEHIND/UNABLE/RACE/ERROR и знаю, когда поиск идёт в обход кэша.
  • [ ] Понимаю, что DIRTY валиден только в текущей write-транзакции и не кэшируется.
  • [ ] Умею оценить, когда кэш даст ускорение (повторяющиеся «горячие» ключи), а когда нет (уникальные ключи).
  • [ ] Знаю требование MDBX_NOSTICKYTHREADS для многопоточного варианта и более дешёвый однопоточный вариант (mdbx_cache_get_SingleThreaded).

Глава 25. Массовые операции

25.1. bunch_delete: удаление «гроздьями»

mdbx_cursor_bunch_delete() с режимом-действием (MDBX_bunch_action_t) удаляет текущее значение / всё мультизначение / всё до или после позиции / всё подряд. Вместо перебора элементов движок вырезает целые страницы и ветки — стоимость пропорциональна числу страниц, а не элементов.

25.2. get_batch: пакетное чтение

mdbx_cursor_get_batch() — симметричное массовое чтение партиями (bulk iovec).

25.3. GET/PUT_MULTIPLE

Работа с мультизначениями пакетами: MDBX_GET_MULTIPLE/MDBX_PUT_MULTIPLE для DUPFIXED-таблиц.

25.4. estimate_range/distance/move

Эвристическая оценка без сканирования: mdbx_estimate_range(), mdbx_estimate_distance(), mdbx_estimate_move(). Строится по общим страницам стеков двух позиций. Точность: в худшем случае расхождение до 4× на каждом уровне дерева (кроме первого и последнего); на практике — единицы процентов.

Фрагмент из examples/c++/32-bulk-ops.c++ — массовые операции: get_batch (чтение пачками), estimate (оценка числа записей) и bunch_delete (удаление «гроздьями» с вырезанием страниц):

    // Пакетное чтение: до 100 пар за вызов.
    size_t pairs = 0, chunks = 0;
    cur.to_first();
    bool is_last = false;
    while (!is_last) {
      auto batch = cur.get_batch(100, mdbx::cursor::move_operation::next, &is_last);
      pairs += batch.size();
      ++chunks;
    }
    std::cout << "batch reads: " << pairs << " pairs in " << chunks << " chunks\n";

    // Оценка количества записей (приблизительно).
    cur.to_first();
    auto est = cur.estimate(mdbx::cursor::move_operation::last);
    std::cout << "estimate: ~" << est.approximate_quantity << " keys\n";

    // Удаление «гроздьями»: всё от текущей позиции до конца.
    cur.to_key_exact(mdbx::slice("k500"));
    const size_t removed = cur.erase_bunch(mdbx::cursor::bunch_delete::delete_after_including);
    std::cout << "bunch delete removed " << removed << " items\n";

Полный код: 32-bulk-ops.c++.

25.5. txn_clone: параллельная обработка на одном снапшоте

mdbx_txn_clone() размножает read-only транзакцию: несколько обработчиков на одном снапшоте без повторного сканирования RLT.

25.6. Батчинг транзакций

100–10 000 операций на коммит — типичный диапазон для записи-heavy. Больше операций → ниже WAF и выше пропускная способность.

25.7. Когда «максимально большая транзакция» — анти-паттерн

  • Транзакция в миллионы операций может переполнить лимиты dirty-страниц (TXN_FULL), породить спилл и всплеск latency.
  • Долгая write-транзакция блокирует других писателей и увеличивает окно риска.
  • Дробите на разумные батчи (например, по 10 000 операций) — баланс между WAF и задержкой.

Примеры к главе: examples/c++/32-bulk-ops.c++.

25.8. Резюме главы 25

  • bunch_delete/delete_range — массовое удаление вырезанием страниц.
  • get_batch, GET/PUT_MULTIPLE — пакетное чтение/запись.
  • estimate_* — дешёвая оценка диапазонов (до 4×/уровень в худшем случае).
  • txn_clone — параллельная обработка на одном снапшоте.
  • Разумный батч лучше «максимально большой транзакции».

25.9. Упражнения

  1. Сравните посточное удаление диапазона и bunch_delete на 100 000 ключах.
  2. Когда estimate_range полезен до выполнения запроса?

25.10. Чек-лист главы 25

  • [ ] Объясняю, почему bunch_delete дешевле перебора элементов: вырезаются целые страницы и ветки, стоимость пропорциональна числу страниц.
  • [ ] Знаю симметричные массовые чтения/записи: get_batch, MDBX_GET_MULTIPLE/MDBX_PUT_MULTIPLE (DUPFIXED-таблицы).
  • [ ] Понимаю принцип estimate_*: оценка по общим страницам стеков двух позиций, в худшем случае до 4× на уровень дерева.
  • [ ] Умею применить txn_clone для параллельной обработки на одном снапшоте.
  • [ ] Могу назвать последствия «максимально большой транзакции»: TXN_FULL, спилл, всплеск latency, блокировка других писателей.
  • [ ] Выбираю разумный размер батча (например, ~10 000 операций) как баланс между WAF и задержкой.

Глава 26. Бенчмарки и измерения

26.1. ioarena

ioarena — стандартный бенчмарк-конвейер проекта. Запускается на готовых конфигурациях; показывает TPS записи/чтения для разных движков и режимов.

26.2. Что измерять

  • TPS записи — коммитов/сек в режимах синхронизации.
  • get/s чтения — операций/сек.
  • WAF — через счётчики страниц (mdbx_stat -p).
  • Задержку коммита — mdbx_txn_commit_ex() → MDBX_commit_latency по стадиям (preparation/gc_wallclock/audit/write/sync/ending/whole).

26.3. MDBX_ENABLE_PROFGC

Профилирование GC: собирается в LCK-файле, возвращается в commit_latency.gc_prof. Ключевые поля: work_rtime_monotonic/work_xtime_cpu (время GC), work_rsteps/work_xpages (фрагментация), work_majflt (page-faults), max_reader_lag/max_retained_pages (долгие читатели), kicks (HSR — Handle-Slow-Readers, вытеснение застрявших читателей; см. том V, гл. 29).

Фрагмент из examples/c++/33-profiler.c++ — чтение PROFGC-полей из commit_latency.gc_prof (wloops, coalescences, flushes, kicks, max_reader_lag, max_retained_pages); поля заполняются в сборках с MDBX_ENABLE_PROFGC:

    // Нагрузка, порождающая работу GC: вставки и удаления.
    for (int round = 0; round < 5; ++round) {
      auto txn = env.start_write();
      auto table = txn.open_map(nullptr);
      for (int i = 0; i < 500; ++i) {
        const auto key = "k" + std::to_string(round * 1000 + i);
        txn.insert(table, mdbx::slice(key), mdbx::slice("payload"));
      }
      txn.commit();
      auto del = env.start_write();
      auto dtable = del.open_map(nullptr);
      for (int i = 0; i < 500; ++i)
        del.erase(dtable, mdbx::slice("k" + std::to_string(round * 1000 + i)));
      del.commit();
    }

    auto txn = env.start_write();
    auto table = txn.open_map(nullptr);
    txn.insert(table, mdbx::slice("last"), mdbx::slice("v"));
    const auto lat = txn.commit_get_latency();

    const auto &gc = lat.gc_prof;
    std::cout << "gc_prof: wloops=" << gc.wloops << " coalescences=" << gc.coalescences << " flushes=" << gc.flushes
              << " kicks=" << gc.kicks << " max_reader_lag=" << gc.max_reader_lag
              << " max_retained_pages=" << gc.max_retained_pages << "\n";

Полный код: 33-profiler.c++.

26.4. Типичные ошибки бенчмаркинга

  • Ассерты: отладочная сборка (MDBX_CHECKING>0) замедляет кратно.
  • Некогерентность page cache (#269) на Linux при нескольких процессах.
  • Методология: «мелкие транзакции + DURABLE на Windows» — это LockFileEx, а не движок.
  • Сравнение без контекста: режим, страница, высота дерева, оборудование.

26.5. Почему libmdbx может быть «в 6–7 раз медленнее LMDB»

Обычно означает: включены ассерты, либо некорректный бенчмарк (мелкие транзакции на Windows с LockFileEx), либо измеряется single-op режим, где libmdbx сознательно платит за более строгие гарантии (двухфазная мета, проверки). При корректной методологии и тех же режимах разрыв исчезает или обращается в пользу libmdbx.

26.6. Ориентиры

  • Запись: ~200 TPS одиночных durable-транзакций на обычном SSD; с батчингом и SAFE_NOSYNC — десятки– сотни тысяч и миллионы операций/с.
  • Чтение: 1–3 млн get/с (короткие ключи, тёплый кэш); линейное масштабирование по ядрам.
  • Вставки: 20K–10M/с в зависимости от режима и железа.

Внимание: всегда указывайте контекст числа — режим sync, размер страницы, размер транзакции, железо.

26.7. Масштабирование чтения

Чтение wait-free и линейно масштабируется по ядрам — до упора в память (bandwidth/TLB). Узкое место обычно не движок, а кэш/память.

Примеры к главе: examples/c++/33-profiler.c++.

26.8. Резюме главы 26

  • ioarena — стандартный бенчмарк; TPS/get/s/WAF/latency.
  • commit_latency + PROFGC — диагностика узких мест.
  • Ассерты и методология — главные ловушки.
  • «6–7× медленнее LMDB» почти всегда ошибка измерения.
  • Ориентиры: 200 TPS durable, 1–3M get/s, 20K–10M вставок/с.

26.9. Упражнения

  1. Измерьте commit_latency на своей конфигурации и определите доминирующую стадию.
  2. Объясните, почему сравнение «одна транзакция на операцию» некорректно для оценки движка.

26.10. Чек-лист главы 26

  • [ ] Знаю, что измерять: TPS записи, get/s чтения, WAF (через счётчики страниц), задержку коммита по стадиям.
  • [ ] Умею читать MDBX_commit_latency (preparation/gc_wallclock/audit/write/sync/ending/whole) и определять доминирующую стадию.
  • [ ] Понимаю, о чём говорят поля gc_prof (max_reader_lag, max_retained_pages, kicks) — о долгих читателях и срабатываниях HSR.
  • [ ] Избегаю типичных ошибок: замер на сборке с ассертами, «мелкие транзакции + DURABLE на Windows», сравнение без контекста.
  • [ ] Могу объяснить, почему «в 6–7 раз медленнее LMDB» почти всегда ошибка измерения или плата за более строгие гарантии.
  • [ ] Знаю ориентиры (~200 TPS durable, 1–3 млн get/s) и всегда указываю контекст числа.
  • [ ] Объясняю, почему чтение масштабируется линейно по ядрам и где реальное узкое место (память/кэш, а не движок).

Итог тома

Вы умеете измерять и настраивать libmdbx: понимаете WAF, выбираете конфигурацию под сценарий, используете микрооптимизации, get-cached и массовые операции, читаете профиль GC и latency.

Что дальше: Том V — экспертные темы: правила и советы из базы знаний, платформенные нюансы, HSR, диагностика и отладка, миграция с LMDB, паттерны проектирования, roadmap.