Справочник DBA: базы данных изнутри читается по порядку

Статья 12.6 · раздел 12 «Архитектуры популярных СУБД» · 92 из 109

Архитектура MongoDB: BSON, кэш и вытеснение WiredTiger, checkpoint раз в 60 с, журнал 100 мс и выбор плана

опубликовано
читать
≈ 11 мин
автор
среда и версия
MongoDB 8.3.13 (mongo:8, WiredTiger 12.0.0) в контейнере --memory 2g с --wiredTigerCacheSizeGB 0.25; коллекция из 1,2 млн документов по ~190 байт (217,6 МБ BSON) с индексом по user; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

В db.serverStatus().wiredTiger.cache копятся грязные байты, потоки вытеснения переписывают страницы на диск, а explain выбирает не тот индекс, что ждал разработчик. За этим стоит одна машина. Документ хранится как BSON, где у каждого поля есть длина. WiredTiger держит страницы b-tree в кэше вместе с цепочками обновлений и пишет их на диск двумя путями: при вытеснении и в checkpoint. Журнал защищает записи между checkpoint, а планировщик выбирает индекс пробным запуском всех кандидатов. Версии записи в цепочке и history store разобраны в статье про старые версии строк, а checkpoint и fsync в PostgreSQL — в статьях про checkpoint и всплески I/O и путь записи до диска. Репликация и шардирование MongoDB — в отдельной статье.

DBA / 01

WiredTiger: страница в памяти, reconciliation, checkpoint и журнал

Страница WiredTiger проходит reconciliation при вытеснении и в checkpoint, журнал сбрасывается каждые 100 мс Грязная страница в кэше с цепочкой WT_UPDATE превращается функцией __wt_reconcile в страницу на диске до 32 КБ со сжатием snappy: за 7 секунд записи вытеснено 33 901 страница, 217,6 МБ BSON заняли 159,9 МБ. Два checkpoint шли на 16-й и 76-й секундах с интервалом 60 секунд и длились 95 и 103 мс. Журнал вызывал flush 1 288 раз за 130 секунд и выполнил 216 fsync. страница в памятицепочка WT_UPDATEдо 10 МБ в памятигрязных до 34,5 МБ reconciliation__wt_reconcileevict | checkpoint33 901 страница за 7 с страница на дискелист до 32 КБсжатие snappy217,6 → 159,9 МБcheckpoint раз в 60 с 60 сt=16 с · 95 мсt=76 с · 103 мс130 с журнал: flush каждые 100 мс 100 мс 1 288 flush и 216 fsync за 130 с j:true: 200 вставок дали 200 fsync
Изменения копятся в цепочках на страницах кэша. На диск страница попадает одной и той же функцией __wt_reconcile: при вытеснении под давлением грязных байт или в checkpoint по таймеру. Между checkpoint записи защищает журнал, который сбрасывается каждые 100 мс.

Стенд

Контейнер с кэшем WiredTiger 0,25 ГБ, чтобы давление на кэш проявилось на сотнях мегабайт. Каждый скрипт ниже сохрани в файл, скопируй в контейнер (docker cp <файл> dba-mongodb-wiredtiger-architecture:/tmp/) и запусти через docker exec dba-mongodb-wiredtiger-architecture mongosh --quiet /tmp/<файл>:

docker run -d --name dba-mongodb-wiredtiger-architecture --memory 2g mongo:8 --wiredTigerCacheSizeGB 0.25
docker exec dba-mongodb-wiredtiger-architecture mongosh --quiet --eval 'print(db.version(), db.serverStatus().wiredTiger.uri)'
docker exec dba-mongodb-wiredtiger-architecture mongosh --quiet --eval 'printjson(db.adminCommand({getParameter:1, syncdelay:1, journalCommitInterval:1}))'
вывод
8.3.13 statistics: WiredTiger 12.0.0: (November 15, 2024)
{ syncdelay: 60, journalCommitInterval: 100, ok: 1 }

Код сверен по тегу r8.3.13, совпадающему с db.version(). gitVersion собранного образа (3b8a5e1f…) в публичном репозитории не нашёлся, поэтому строки кода сверены по тегу, а не по этому хешу. Числа 60 и 100 подтверждаются getParameter выше, документацией и кодом: journalCommitIntervalMs{100} в storage_options.h и описание syncdelay («every 60 seconds») в storage_parameters.idl.

BSON, страница коллекции и пороги кэша

Документ — последовательность элементов: байт типа, имя поля с нулём на конце и значение. Первые четыре байта документа — его общая длина, последний байт — ноль. Размер видно прямо. Вторую и третью команды запускай после опыта 1: им нужна коллекция lab.t и кэш под нагрузкой.

for (const d of [{}, { a: 1 }, { a: 1, s: "hi" }, { a: 1.5 }]) print(JSON.stringify(d), "->", bsonsize(d), "байт");
const t = db.getSiblingDB("lab").t;
print(t.stats().wiredTiger.creationString.split(",").filter(x => /^(leaf_page_max|memory_page_max|block_compressor|key_format|value_format)/.test(x)).join(" "));
const th = db.serverStatus().wiredTiger.cache, p = (k) => th["eviction threshold " + k + " multiplied by 100 for precision"] / 100;
print("кэш, МиБ:", th["maximum bytes configured"] / 1048576, " dirty target/trigger, %:", p("dirty target"), p("dirty trigger"), " cache target/trigger, %:", p("cache full target"), p("cache full trigger"));
вывод
{} -> 5 байт
{"a":1} -> 12 байт
{"a":1,"s":"hi"} -> 22 байт
{"a":1.5} -> 16 байт
block_compressor=snappy key_format=q leaf_page_max=32KB memory_page_max=10M value_format=u
кэш, МиБ: 256  dirty target/trigger, %: 5 20  cache target/trigger, %: 80 95

Пустой документ — 5 байт: длина и завершающий ноль. {a: 1} — 12: к ним добавились тип (1), имя a с нулём (2) и int32 (4). Строка "hi" стоит 10 байт: тип, имя, длина строки (4), два символа и ноль. Двойное число 1.5 занимает 8 байт вместо 4, поэтому документ выходит на 16. Строка с key_format — как WiredTiger хранит коллекцию: ключ q (64-битное целое, у коллекции это RecordId), значение u (BSON как есть), лист на диске не больше 32 КБ (leaf_page_max), страница в памяти растёт до 10 МБ (memory_page_max), сжатие snappy. Последняя строка — пороги вытеснения. По документации WiredTiger memory_page_max — размер, до которого страница может вырасти в памяти до записи на диск.

Опыт 1: запись, грязные байты и вытеснение

Скрипт вставляет 1,2 млн документов пачками по 5 000 в коллекцию с индексом по user (1 000 значений). Кэш 256 МиБ, а данные плюс индекс заметно больше. Каждые 100 000 документов скрипт печатает грязные байты, размер кэша, число вытесненных изменённых и неизменённых страниц и число checkpoint:

const w = () => db.serverStatus().wiredTiger;
const row = (t0, docs) => { const s = w(), k = s.cache;
  return [((Date.now()-t0)/1000).toFixed(0).padStart(4), String(docs).padStart(8),
    (k["tracked dirty bytes in the cache"]/1048576).toFixed(1).padStart(7),
    (k["bytes currently in the cache"]/1048576).toFixed(1).padStart(7),
    String(k["modified pages evicted"]).padStart(7), String(k["unmodified pages evicted"]).padStart(7),
    String(s.checkpoint["total succeed number of checkpoints"]).padStart(5)].join(" "); };
const d = db.getSiblingDB("lab"); d.dropDatabase();
d.t.createIndex({ user: 1 });
const pool = []; for (let i = 0; i < 2000; i++) pool.push(Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
print("  с  докум.  dirtyМБ  кэшМБ  evict_m evict_u  ckpt");
const t0 = Date.now(); print(row(t0, 0));
const N = 1200000, B = 5000;
for (let n = 0; n < N; n += B) {
  const docs = []; for (let i = 0; i < B; i++) { const x = n + i;
    docs.push({ n: x, user: x % 1000, status: x % 5, pad: pool[x % 2000] + pool[(x*7) % 2000] + pool[(x*13) % 2000] + pool[(x*29) % 2000] + pool[(x*31) % 2000] + pool[(x*37) % 2000] }); }
  d.t.insertMany(docs, { ordered: false });
  if ((n + B) % 100000 == 0) print(row(t0, n + B));
}
print("итог:", JSON.stringify({ count: d.t.countDocuments(), dataMB: +(d.t.stats().size/1048576).toFixed(1), storageMB: +(d.t.stats().storageSize/1048576).toFixed(1), indexMB: +(d.t.stats().totalIndexSize/1048576).toFixed(1) }));
вывод
  с  докум.  dirtyМБ  кэшМБ  evict_m evict_u  ckpt
   0        0     0.0     0.1       0       0     0
   1   100000    15.1    22.2      42       0     0
   1   200000     9.7    27.0     407       0     0
   2   300000    15.0    43.0     775       0     0
   2   400000    27.0    65.7    1310       0     0
   3   500000    20.0    71.5    2587       0     0
   3   600000    17.5    84.2    3787       0     0
   4   700000    16.7    95.1    5533       0     0
   4   800000    34.5   117.8    7409       0     0
   5   900000    22.3   127.8   12739       0     0
   6  1000000    26.6   154.4   19786       0     0
   7  1100000    24.3   177.6   26151       0     0
   7  1200000    28.7   204.2   33901       0     0
итог: {"count":1200000,"dataMB":217.6,"storageMB":159.9,"indexMB":20.1}

Пороги вытеснения из последней строки вывода выше: грязные данные — цель 5 % кэша (12,8 МиБ) и порог 20 % (51,2 МиБ), весь кэш — цель 80 % (204,8 МиБ) и порог 95 %. Таблица читается по ним:

  • Грязных байт 10–35 МиБ: выше цели 5 %, ниже порога 20 %. Вытеснение изменённых страниц идёт, evict_m растёт до 33 901.
  • evict_u равен нулю всё время: неизменённые страницы не вытесняются, пока кэш не дойдёт до цели 80 %. Кэш подошёл к этой цели (204,2 из 204,8 МиБ) только в последней строке.
  • 217,6 МБ BSON заняли на диске 159,9 МБ, индекс — 20,1 МБ: так выглядит сжатие snappy.
  • checkpoint за 7 секунд не было: счётчик остался нулём. Вытеснение шло само, без таймера.

Откуда эти 33 901 вытеснение? Статистика по таблице и по индексу:

const s = db.getSiblingDB("lab").t.stats({ indexDetails: true });
print("коллекция: modified evicted =", s.wiredTiger.cache["modified pages evicted"], " pages written =", s.wiredTiger.cache["pages written from cache"]);
const i = s.indexDetails.user_1.cache;
print("индекс user_1: modified evicted =", i["modified pages evicted"], " pages written =", i["pages written from cache"]);
print("сервер: modified evicted =", db.serverStatus().wiredTiger.cache["modified pages evicted"]);
вывод
коллекция: modified evicted = 161  pages written = 4615
индекс user_1: modified evicted = 34194  pages written = 32566
сервер: modified evicted = 34461

Почти всё вытеснение — страницы индекса: 34 194 из 34 461 по серверу, у коллекции 161. Объяснение следует из ключей: коллекция растёт по возрастающему RecordId, то есть пишется в правый край дерева. В индексе user 1 000 групп ключей, и каждая принимает свои вставки, поэтому грязными становятся листья по всему дереву. Это чтение чисел, а не отдельное измерение механизма.

Вытеснение и checkpoint записывают страницу одной функцией (__wt_reconcile, тег r8.3.13). Она собирает из страницы с цепочками WT_UPDATE образ для диска; причина записи видна в отладочном сообщении:

/*
 * __wt_reconcile --
 *     Reconcile an in-memory page into its on-disk format, and write it.
 */
int
__wt_reconcile(WT_SESSION_IMPL *session, WT_REF *ref, WT_SALVAGE_COOKIE *salvage, uint32_t flags)
...
    __wt_verbose_debug1(session, WT_VERB_RECONCILE, "%p reconcile %s (%s%s)", (void *)ref,
      __wt_page_type_string(page->type), LF_ISSET(WT_REC_EVICT) ? "evict" : "checkpoint",
      LF_ISSET(WT_REC_HS) ? ", history store" : "");

Пропущены строки 33–41. Флаг WT_REC_EVICT отличает запись при вытеснении от записи в checkpoint (строки 42–44), а WT_REC_HS добавляет к сообщению пометку «history store».

Опыт 2: checkpoint и журнал

Два вопроса: правда ли checkpoint идёт раз в 60 секунд и журнал сбрасывается раз в 100 мс. Скрипт пишет 130 секунд по 2 000 документов в секунду и фиксирует момент, когда растёт счётчик checkpoint, а затем сравнивает вызовы log flush и log sync:

const w = () => db.serverStatus().wiredTiger, d = db.getSiblingDB("lab");
const s0 = w(), t0 = Date.now();
let prev = s0.checkpoint["total succeed number of checkpoints"], x = 5000000;
for (let sec = 1; sec <= 130; sec++) {
  const docs = []; for (let i = 0; i < 2000; i++, x++) docs.push({ n: x, user: x % 1000, status: x % 5, pad: "p" + x });
  d.t.insertMany(docs);
  const c = w().checkpoint["total succeed number of checkpoints"];
  if (c != prev) { print("t=" + sec + " с: checkpoint " + prev + " -> " + c + ", " + w().checkpoint["most recent time (msecs)"] + " мс"); prev = c; }
  sleep(Math.max(0, sec * 1000 - (Date.now() - t0)));
}
const s1 = w();
print("за " + ((Date.now() - t0) / 1000).toFixed(0) + " с: log flush", s1.log["log flush operations"] - s0.log["log flush operations"], " log sync", s1.log["log sync operations"] - s0.log["log sync operations"]);
вывод
t=16 с: checkpoint 7 -> 8, 95 мс
t=76 с: checkpoint 8 -> 9, 103 мс
за 130 с: log flush 1288  log sync 216

Два checkpoint с разницей 60 секунд по секундным отметкам скрипта, каждый занял около 0,1 с (на этом объёме грязных данных). Счётчик начинается не с нуля: контейнер к этому моменту уже работал. Вызовов log flush 1 288 за 130 секунд — около десяти в секунду, то есть раз в 100 мс; fsync (log sync) выполняется реже, 216 раз. Код Checkpointer ждёт syncdelay секунд после завершения предыдущего checkpoint, поэтому период — 60 секунд плюс длительность самого checkpoint:

            // Wait for 'storageGlobalParams.syncdelay' seconds; or until either shutdown is
            // signaled or a checkpoint is triggered.
...
            _sleepCV.wait_for(lock,
                              stdx::chrono::seconds(
                                  static_cast<std::int64_t>(storageGlobalParams.syncdelay.load())),
                              [&] { return _shuttingDown || _triggerCheckpoint; });

(Checkpointer::run, строки 100–104 с логированием пропущены; комментарий к syncdelay говорит о паузе «после завершения предыдущего».) Журнал сбрасывает отдельный поток: в JournalFlusher::run срок следующего сброса равен Date_t::now() + Milliseconds(storageGlobalParams.journalCommitIntervalMs.load()), и поток просыпается раньше, если кто-то запросил сброс немедленно. Так делает запись с j: true. Следующий скрипт сравнивает 200 вставок по одной с j: false и j: true:

const w = () => db.serverStatus().wiredTiger;
const j = db.getSiblingDB("lab").j; j.drop();
for (const wc of [{ w: 1, j: false }, { w: 1, j: true }]) {
  const a = w(), t = Date.now();
  for (let i = 0; i < 200; i++) j.insertOne({ i: i }, { writeConcern: wc });
  const ms = Date.now() - t, b = w();
  print(JSON.stringify(wc), "200 вставок:", ms, "мс; log sync:", b.log["log sync operations"] - a.log["log sync operations"], " log flush:", b.log["log flush operations"] - a.log["log flush operations"]);
}
вывод
{"w":1,"j":false} 200 вставок: 111 мс; log sync: 3  log flush: 1
{"w":1,"j":true} 200 вставок: 1069 мс; log sync: 200  log flush: 200

Запись j: true не ждёт такта в 100 мс: 200 вставок прошли за 1 069 мс при 200 fsync, то есть каждая вставка получила свой сброс, в среднем по 5,3 мс. Время зависит от диска: в трёх прогонах этого скрипта было 370, 467 и 1 069 мс, а число fsync каждый раз равнялось 200. С j: false те же 200 вставок заняли 111 мс и 3 fsync: подтверждение уходит до сброса журнала, и до следующего такта (по документации — каждые 100 мс) запись может быть только в памяти.

Опыт 3: планировщик выбирает по пробному запуску

На той же коллекции два индекса, user_1 и status_1, и запрос по обоим полям. status принимает 5 значений, user — 1 000, и все 1 200 документов с user: 7 имеют status: 2. Скрипт печатает план из explain("allPlansExecution") и запись в кэше планов:

const t = db.getSiblingDB("lab").t;
t.createIndex({ status: 1 });
const q = { user: 7, status: 2 };
const show = (e) => {
  const ex = e.executionStats, w = e.queryPlanner.winningPlan;
  const stage = (p) => p.stage + (p.indexName ? "(" + p.indexName + ")" : "") + (p.inputStage ? " > " + stage(p.inputStage) : "");
  print("winningPlan:  ", stage(w));
  e.queryPlanner.rejectedPlans.forEach((p) => print("rejectedPlans:", stage(p)));
  print("nReturned:", ex.nReturned, " keysExamined:", ex.totalKeysExamined, " docsExamined:", ex.totalDocsExamined);
  (ex.allPlansExecution || []).forEach((p) => { const s = p.executionStages; print("  кандидат", stage(s), " works=" + s.works, "advanced=" + s.advanced, "isEOF=" + s.isEOF); });
};
print("--- индексы user_1 и status_1");
show(t.find(q).explain("allPlansExecution"));
print("--- planCache после запуска");
t.find(q).itcount(); t.find(q).itcount();
t.aggregate([{ $planCacheStats: {} }]).forEach((e) => print("кэш плана: isActive=" + e.isActive, "works=" + e.works, "индекс=" + e.cachedPlan.inputStage.indexName, "запрос=" + JSON.stringify(e.createdFromQuery.query)));
вывод
--- индексы user_1 и status_1
winningPlan:   FETCH > IXSCAN(user_1)
rejectedPlans: FETCH > IXSCAN(status_1)
nReturned: 1200  keysExamined: 1200  docsExamined: 1200
  кандидат FETCH > IXSCAN(user_1)  works=101 advanced=101 isEOF=0
  кандидат FETCH > IXSCAN(status_1)  works=101 advanced=1 isEOF=0
--- planCache после запуска
кэш плана: isActive=true works=101 индекс=user_1 запрос={"user":7,"status":2}

Оба кандидата отработали по 101 шагу (works) и остановились: пробный запуск заканчивается, когда один из планов вернул 101 результат (internalQueryPlanEvaluationMaxResults = 101 по умолчанию; лимит шагов internalQueryPlanEvaluationWorks = 10 000). Дальше план оценивается долей «продвинутых» шагов (advanced из works):

    double calculateProductivity(const PlanStageStats* stats) const final {
        tassert(
            11321201, "Expected plan stats works to be greater than 0", stats->common.works != 0);
        return static_cast<double>(stats->common.advanced) /
            static_cast<double>(stats->common.works);
    }

(plan_ranker.cpp.) У user_1 это 101/101, у status_1 — 1/101: в первых 101 шаге обхода документов со status: 2 под user: 7 попал один. Победитель попал в кэш планов вместе с works=101.

Что из этого следует

  • Каждый вторичный индекс — ещё одно дерево в кэше. В опыте 1 на 1,2 млн вставок коллекция дала 161 вытеснение изменённых страниц, индекс по user — 34 194. Индекс с разбросанными ключами делает запись дороже самой коллекции.
  • Следи за tracked dirty bytes in the cache относительно 5 % и 20 % кэша (12,8 и 51,2 МиБ при кэше 256 МиБ) и за modified pages evicted: это счётчики того, что дальше уйдёт на диск.
  • w: 1 без j подтверждает запись до сброса журнала, окно — до journalCommitInterval (100 мс). j: true на каждую вставку стоит fsync на каждую (200 из 200); подтверждение с журналом дешевле запрашивать на пачку документов, чем на каждый; что к этому добавляет w: majority, разобрано в статье про репликацию.
  • Читай allPlansExecution, а не только winningPlan: по advanced/works видно, насколько победитель оторвался от rejectedPlans (101/101 против 1/101).
  • Лицензия сервера — SSPL. Раздел 13 требует открыть исходный код сервиса тем, кто предлагает функциональность MongoDB третьим лицам как сервис; в тексте среди примеров — удалённое взаимодействие с ней по сети. Спорные случаи разбирает юрист.

Чек-лист

  1. Выведи db.serverStatus().wiredTiger.cache на рабочей нагрузке и сравни tracked dirty bytes in the cache с 5 % и 20 % от maximum bytes configured.
  2. Для самого записываемого индекса сравни modified pages evicted по stats({indexDetails: true}) с коллекцией: найди индексы, которые дают основной объём.
  3. Проверь db.adminCommand({getParameter: 1, syncdelay: 1, journalCommitInterval: 1}): значения 60 и 100 менять не нужно: документация просит оставить 60 и предупреждает, что при 0 журнал со временем займёт весь диск.
  4. Для медленных запросов смотри explain("allPlansExecution"): если у победителя и отвергнутых планов близкие advanced/works, выбор неустойчив.
  5. Убери стенд вместе с томом: docker rm -f -v dba-mongodb-wiredtiger-architecture.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru