MongoDB シャードクラスタの書き込み経路シリーズの第3/6回である。
この記事が答える問い: MongoDB の CPU は100%なのに、書き込みが1つも終わらない。ディスクも暇で、デッドロックでもない。何が CPU を使っているのか?
短い答え: WiredTiger はトランザクションを開始するたびにスナップショットを作る。このとき別のセッションがトランザクション ID を受け取っている最中なら、スナップショットを作るスレッドは x86 のpause命令だけを繰り返して待つ。ID を受け取っていたスレッドが CPU から追い出されると、待っているスレッドたちが CPU を占有するので、そのスレッドは戻りにくくなる。全員が忙しいのに誰も前に進めない livelock である。
基準バージョン: MongoDB 4.2(ソースタグr4.2.12、同梱 WiredTiger のmongodb-4.2ブランチ)。WiredTiger のdevelopブランチは 2026-11-05 に確認した。
write ticket は、MongoDB がストレージエンジン(WiredTiger)に同時に入れる操作数の上限である。入場券のように券を受け取った操作だけがエンジンの中に入り、残りは列に並ぶ。
| 項目 | 4.2 基準 |
|---|---|
| デフォルト値 | 書き込み 128、読み取り 128(wiredtiger_kv_engine.cpp:474-475) |
| 設定 | wiredTigerConcurrentWriteTransactions |
| 受け取るタイミング | グローバルロック取得時(lock_state.cpp:262-266) |
| 指標 | wiredTiger.concurrentTransactions.write |
設定と指標の名前に WiredTiger が付いているが、管理しているのは MongoDB である。4.2 公式ドキュメントも "These settings are MongoDB-specific." と記している。設定は実行中に setParameter で変えられる。
insert・update・delete のように書き込みインテントロック(IX)を取る操作が、write ticket を1つずつ使う。
serverStatus の wiredTiger.concurrentTransactions.write は3つの値を出す。
out: いま使用中の ticket 数available: 残りの ticket 数totalTickets: 全体の ticket 数列に並んだ操作の数は globalLock.currentQueue で見る。4.2 のソースでは、この値は ticket を待つ操作とロックを待つ操作を合わせて数える(lock_state.cpp:296-303、lock_state.cpp:341)。この値だけでは両者を区別できない。
スナップショットは、トランザクションが開始した瞬間にまだコミットされていないトランザクション ID のリストである。トランザクションは、このリストに入っているトランザクションが書いた値を見ない。読み取りトランザクションも開始時にスナップショットを作る。
スナップショットを作る関数は __wt_txn_get_snapshot である(txn.c:148-248、r4.2.12)。順序は次のとおりである。
txn_global の読み取りロック(txn_global->rwlock)を取る。進行中の書き込みトランザクションがなければ、2〜3の手順を飛ばす(ソースコメント "For pure read-only workloads, avoid scanning.")。書き込みが混ざる負荷では、毎回配列全体を走査する。
書き込みトランザクションはロックなしで ID を受け取る(txn.i:912-918)。
is_allocating の印を立てる。is_allocating の印を下ろす。スナップショットを作るスレッドは配列を走査中に is_allocating が立ったセッションに出会うと、その場で待つ。以下は src/third_party/wiredtiger/src/txn/txn.c(r4.2.12)の抜粋である。
/* We're going to scan the table: wait for the lock. */
__wt_readlock(session, &txn_global->rwlock);
…
for (i = 0, s = txn_global->states; i < session_cnt; i++, s++) {
…
while (s != txn_state && (id = s->id) != WT_TXN_NONE && WT_TXNID_LE(prev_oldest_id, id) &&
WT_TXNID_LT(id, current_id)) {
/*
* If the transaction is still allocating its ID, then we spin here until it gets its
* valid ID.
*/
WT_READ_BARRIER();
if (!s->is_allocating) {
…
if (id == s->id) {
txn->snapshot[n++] = id;
…
break;
}
}
WT_PAUSE();
}
}
WT_PAUSE() は x86 では pause 命令1行である(gcc.h:160)。
#define WT_PAUSE() __asm__ volatile("pause\n" ::: "memory")
このループには yield(OS に CPU を返す呼び出し)も sleep も反復回数の上限もない。そのため待っているスレッドは、OS が強制的に降ろすまで CPU を握ったまま pause だけを繰り返す。古いトランザクション ID を計算する __txn_oldest_scan にも同じループがある(txn.c:278-299)。
同じ WiredTiger の中でも、rwlock そのものを待つコードは段階を踏んで CPU を手放す(mtx_rw.c:244-253)。
| 待つ対象 | 第1段階 | 第2段階 | 第3段階 |
|---|---|---|---|
| rwlock の取得 | pause 1,000回 |
yield 200回 | 条件変数での待機(最大 10ms)を繰り返す |
| 他のセッションの ID 割り当て | pause の無限反復 |
なし | なし |
ID の割り当ては数命令で終わる。普段はスナップショットを作るスレッドも、pause 数回でループを抜ける。実行待ちスレッド(CPU を受け取る順番を待っている実行可能状態のスレッド)がコアより多いと、次の順序が起きうる。
is_allocating を立てる。pause ループを回る。is_allocating を下ろせない。ところが、コアを占有しているスレッドがすべて A を待っている。A が再び CPU を受け取れば livelock は解ける。しかし4〜5の段階が繰り返されると、スループットが底をついた状態が長く続きうる。
この順序は、WiredTiger のチケット WT-8670 の作成者が書き残したものである。原文は "My understanding is that" で始まる仮説である。
"if an allocating thread gets scheduled out, it could take a long time for it to resume execution because all threads creating a snapshot will be spinning on that loop"
同じチケットに載っている測定値は約4%のスループット低下である。比較対象は、スナップショットの作成をミューテックスで直列化した場合である。条件は YCSB 50read50update、スレッド32個、CPU 16個だった。この条件ではスループットは崩壊しなかった。スレッドがコアよりはるかに多いときに、上の順序がスループットの崩壊につながるかを測った公開資料は見つけられなかった。
| 区分 | デッドロック | この記事の livelock |
|---|---|---|
| 待ち方 | 眠ったまま相手のロックを待つ | pause ループで起きたまま待つ |
| CPU | ほとんど使わない | コアを使い切る |
| ロックの循環 | ある | ない(待つ対象は is_allocating の印) |
| 解ける条件 | 片方を強制的に断ち切る必要がある | 追い出されたスレッドが再び CPU を受け取れば解ける |
CPU が100%なのにロックの循環待ちが見えなければ、この livelock を疑う。
以下はメカニズムから導いた一般的な傾向である。数値の基準は設けない。
pause はディスクを待たないユーザー空間の命令である。pause ループはシステムコールをしないので、自分から CPU を手放さない。rwlock 待ちであれば、yield と条件変数での待機のためにコンテキストスイッチが増えていたはずである。pause ループを回り、実行可能状態に残る。vmstat の r 列と concurrentTransactions.write.out を並べて見る。currentQueue の増加は、CPU 飽和より遅れて来ることがある。 終わらない操作が ticket を握っているからである。解釈するときは2つを合わせて見る。
perf や pstack で、多数のスレッドが __wt_txn_get_snapshot にいるかを確認する。txn global ロック待ちの指標も一緒に上がることがある。 スナップショットを作るスレッドは、txn_global->rwlock の読み取りロックを握ったまま pause ループを回る。古い ID を更新する側は、同じ rwlock の書き込みロックを待つ(txn.c:392-396)。この指標だけを見て rwlock の競合を原因と断定しない。MongoDB 7.0 からは、ticket 数を固定せず、スループットを見ながら調整する。アルゴリズムは storageEngineConcurrencyAdjustmentAlgorithm のデフォルト値 throughputProbing である。上限は128である。公式ドキュメントは目的を "to optimize database throughput during overload" と記している。
この機能がこの記事の livelock を減らすという公式の記述や測定は見つけられなかった。ticket が減れば、同時にスナップショットを作るスレッドも減るので、livelock が起きる確率は下がるかもしれない。これは推論である。
pause ループの修正状況は、2026-11-05 時点で次のとおりだった。
develop ブランチの src/txn/txn.c にも、is_allocating を待つ同じループが残っている。MongoDB 4.2 は 2023-04-30 にサポートが終了した(EOL)。
perf top -p <mongod pid> で __wt_txn_get_snapshot の比重を見る。ticket の枯渇を原因と断定しない。 ticket の枯渇は、CPU 飽和の後についてくる結果かもしれない。ticket だけを増やすと、
pauseループを回るスレッドが増えて状況が悪化しうる(推論)。
maxTimeMSとkillOpはpauseループ内のスレッドを止められない。 どちらも操作が自分で中断の要否を確認するときにだけ効くが、このループにはそのような確認がない(第4/6回)。
failover が起きないことがある。 heartbeat はエンジンを経由しない(第5/6回)。
実行で検証していない手順である。下の結果はソースから導いた予想である。
コア数の少ない VM に MongoDB 4.2 系の mongod を1つ立ち上げる(例: 2〜4 vCPU、例示値)。
YCSB でコア数よりはるかに多い同時書き込みをかける(例: スレッド256個、例示値)。
./bin/ycsb load mongodb -s -P workloads/workloada -p mongodb.url=mongodb://<host>:27017/ycsb
./bin/ycsb run mongodb -s -P workloads/workloada -p mongodb.url=mongodb://<host>:27017/ycsb -threads 256
別のターミナルで sudo perf top -p $(pgrep -x mongod) を実行してホットスポットを見る。
さらに別のターミナルで vmstat 1 を実行し、r(実行待ち)と cs(コンテキストスイッチ)の列を見る。
比較のために wiredTigerConcurrentWriteTransactions をコア数近くまで下げ(setParameter)、同じ負荷を再びかける。
予想される結果は次のとおりである。
perf top の上位に __wt_txn_get_snapshot が見える。vmstat で r がコア数よりはるかに大きく、wa は0に近い。cs は負荷の序盤より低くなる。__wt_txn_get_snapshot の比重と r が減る。WT-8670 の測定のように、低下が数%にとどまり、livelock まで至らないこともありうる。
- スレッドがコアの何倍多ければこのループがスループットの崩壊につながるかについての公開測定
- MongoDB 7.0 の
throughputProbingがこの livelock を実際に減らすか- MongoDB 7.0・8.0 のリリースに同梱された WiredTiger の該当コード。この記事は 4.2.12 と WiredTiger の
developだけを確認したperfに見える実際のシンボル名。コンパイラーが__wt_txn_get_snapshotをインライン化すると、別の関数名で見えることがある