这是 MongoDB 分片集群写入路径系列的第 3/6 篇。
本文回答的问题:MongoDB 的 CPU 达到 100%,却连一个写入都完成不了。磁盘也很空闲,也不是死锁。是什么在占用 CPU?
简短回答:WiredTiger 每次开始事务都会创建快照。此时如果其他会话正在获取事务 ID,创建快照的线程就只会反复执行 x86pause指令来等待。如果正在获取 ID 的线程被挤出 CPU,等待的线程们会占满 CPU,那个线程就很难再回来。所有线程都很忙,却没有一个能向前推进,这就是 livelock。
基准版本:MongoDB 4.2(源码标签r4.2.12,随附的 WiredTigermongodb-4.2分支)。WiredTigerdevelop分支于 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。
serverStatus 的 wiredTiger.concurrentTransactions.write 给出三个值。
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 指令(gcc.h:160)。
#define WT_PAUSE() __asm__ volatile("pause\n" ::: "memory")
这个循环里没有 yield(把 CPU 交还给操作系统的调用),没有 sleep,也没有重复次数上限。所以等待的线程在被操作系统强制换下之前,会一直占着 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 issue 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"
同一 issue 中给出的测量值是约 4% 的吞吐量损失。比较对象是用互斥锁把快照创建串行化的情况。条件是 YCSB 50read50update、32 个线程、16 个 CPU。在这个条件下吞吐量没有崩溃。线程远多于核心时,上述顺序是否会导致吞吐量崩溃,没有找到相关测量的公开资料。
| 分类 | 死锁 | 本文的 livelock |
|---|---|---|
| 等待方式 | 睡眠中等待对方的锁 | 以 pause 循环醒着等待 |
| CPU | 几乎不用 | 占满核心 |
| 锁循环 | 有 | 无(等待的对象是 is_allocating 标记) |
| 解除条件 | 必须强制切断一方 | 被换下的线程重新拿到 CPU 后解除 |
CPU 达到 100% 却看不到锁循环等待时,就要怀疑这种 livelock。
以下是从机制推导出的一般趋势,不设数值标准。
pause 是不等待磁盘的用户态指令。pause 循环不做系统调用,所以不会主动交出 CPU。如果是 rwlock 等待,yield 和条件变量等待本应让上下文切换增加。pause 循环中,保持可运行状态。把 vmstat 的 r 列与 concurrentTransactions.write.out 放在一起看。currentQueue 增加可能晚于 CPU 饱和出现。 因为未完成的操作一直持有 ticket。解读时要同时考虑两点。
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 发生的概率可能会降低。这是推论。
截至 2026-11-05,pause 循环的修复状态如下。
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(例如 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
developperf中显示的实际符号名。如果编译器把__wt_txn_get_snapshot内联,可能会显示为其他函数名