这是 MongoDB 分片集群写入路径系列的第 4/6 篇。
本文回答的问题:primary 的写入停住时,为什么 killOp·maxTimeMS·replSetStepDown·正常关闭(SIGTERM)都不起作用?这时应该按什么顺序做什么?
基准版本:MongoDB 4.2。阅读了官方文档v4.2分支和服务器源码标签r4.2.12。最新版本未经确认。
MongoDB 停止操作的手段大多是协作式的。它们留下终止标记,等待目标线程确认这个标记。在存储引擎(WiredTiger)内运行的线程无法确认标记,所以 killOp、replSetStepDown、正常关闭都无法让这个线程停下。剩下的手段只有官方文档禁止的 kill -9。因此必须遵守先停下写入方(向集群发送写入的 API 服务器·批处理·消费者),再让 primary 降级的顺序。
interrupt point 是操作可以安全中断的位置。以下是 4.2 术语表中的定义。
"A point in an operation's lifecycle when it can safely abort. MongoDB only terminates an operation at designated interrupt points."
在服务器源码中,一个操作就是一个 OperationContext 对象。killOp 只是对这个对象调用 markKilled。根据源码注释,此后操作线程每次调用 checkForInterrupt,都会拿到指定的错误码(src/mongo/db/operation_context.h:261-272 (r4.2.12))。
killOp 文档的表述如下。
"Operations may not actually be terminated until they reach an appropriate interruption point."
操作是否结束,需要用 db.currentOp() 再次确认。killOp 返回的 ok: 1 只是标记成功的响应。
写操作进入引擎之前会领取 write ticket(并发写入的入场券,4.2 默认 128 个)。等待 ticket 期间是可以中断的。4.2 源码在等待 ticket 时传入 OperationContext,让操作在等待中也能确认终止标记(src/mongo/db/concurrency/lock_state.cpp:343-349 (r4.2.12))。
maxTimeMS 也是同样的方式。文档写道,截止时间过后 "MongoDB interrupts the operation at the next interrupt point"。
killOp 和 maxTimeMS 能切断的只到 ticket 等待队列中的操作为止。已经拿到 ticket 进入引擎的操作是切断不了的。WiredTiger 的快照等待循环中没有 checkForInterrupt 调用(src/third_party/wiredtiger/src/txn/txn.c:196-235 (r4.2.12))。这个循环如何无休止地运行,在 WiredTiger 快照 pause 循环 livelock 中讨论。
在分片集群中还有一个陷阱。从 mongos 发出的 killOp 不会把写操作传播到 shard。
"The killOp command from the mongos does not propagate to the shards when the operation to be killed is a write operation."
写操作必须直接连接该 shard 的 primary mongod 来切断。
先看两种锁模式。
| 模式 | 含义 | 能否被多个操作同时持有 |
|---|---|---|
| IX (intent exclusive) | 表明写意向的共享模式 | 可以 |
| X (exclusive) | 排他模式 | 只能独占 |
想要 X 的一方,要等持有 IX 的操作全部释放。例如有 128 个操作持有 IX,就要 128 个全部释放才能拿到 X。即使 127 个已经结束,只要还剩 1 个就拿不到。
在 4.2 中,primary 要降为 secondary,必须以 X 模式持有 RSTL(ReplicationStateTransitionLock,复制状态转换锁)。所有读写操作在获取全局锁(作用于整个 mongod 实例的锁)之前,都会以 IX 模式持有 RSTL。
secondaryCatchUpPeriodSecs(默认 10 秒)是等待 secondary 追上 primary 的时间。
replication_coordinator_impl.cpp (r4.2.12) 中的 stepDown 流程如下。
force 时为 secondaryCatchUpPeriodSecs,force: true 时为整个 stepDown 期间(第 2054-2058 行)。Starting to kill user operations,并给与写入冲突的操作打上终止标记(第 1887-1913 行、第 1946 行)。LockTimeout(Unable to acquire lock ... within ...)失败(lock_state.cpp:964-966)。第 3 步的标记也和 killOp 一样是协作式的。引擎内的线程无法确认标记,所以不会释放 RSTL IX。于是在第 4 步卡在截止时间上。force: true 只是延长截止时间,需要 RSTL X 这一点并没有变。
根据文档,在 secondaryCatchUpPeriodSecs 内没有追上的 secondary 时,stepDown 也会报错。如果错误信息是 Unable to acquire lock,说明卡住的不是 catch-up,而是 RSTL X。
4.2 文档写道,primary 收到 SIGTERM 时会在关闭前尝试 stepDown。即使 stepDown 失败,关闭也会继续(由 SERVER-38994 在 3.6.15·4.0.8·4.1.10 中引入)。
src/mongo/db/db.cpp (r4.2.12) 中的关闭路径按以下顺序进行。
Enqueuing the ReplicationStateTransitionLock for shutdown # RSTL X 요청
Killing all operations for shutdown # 전체 종료 표시
Acquiring the ReplicationStateTransitionLock for shutdown # RSTL X 대기, Date_t::max() (1013-1029행)
Acquiring the global lock for shutdown # 글로벌 X 대기, Date_t::max() (1102-1106행)
Date_t::max() 是不设期限的值。所以如果引擎内的线程不释放 RSTL 和全局锁,正常关闭就不会结束。这是阅读源码得出的推论。
发送 SIGKILL 后,操作系统不征求配合,直接结束进程。4.2 文档在警告框中这样写道。
"Never use kill -9 (i.e. SIGKILL) to terminate a mongod instance."
kill -9 之后的 journal 恢复行为未经确认。所以 kill -9 被放在完成判别(下文「应对顺序」第 1 步)之后才使用的最后手段。
| 命令 | 做什么 | 能否触及引擎内的线程 | 能切断的是 |
|---|---|---|---|
killOp |
给目标操作打终止标记 | 触及不到 | 等待 ticket 的操作 |
maxTimeMS |
截止后在下一个 interrupt point 中断 | 触及不到 | ticket 等待队列中的操作 |
replSetStepDown |
给写操作打终止标记后等待 RSTL X | 触及不到(截止后 LockTimeout) |
会走到 interrupt point 的写操作 |
| SIGTERM | 尝试 stepDown,全部打终止标记,无限期等待锁 | 触及不到(关闭不会结束,推论) | 会走到 interrupt point 的操作 |
kill -9 |
操作系统立即结束进程 | 能触及 | 所有进行中的操作 |
协作式手段无法让目标线程停下时,会出现以下现象。
ok: 1,但 db.currentOp() 中同一个 opid 一直还在。replSetStepDown 在截止时间(默认 secondaryCatchUpPeriodSecs)过后失败。错误是 LockTimeout,日志中留有 Starting to kill user operations。如果 primary 看起来停在了引擎内部,运维人员按以下顺序处理。
kill -9。 以各写入方的处理指标是否归零来确认。没有把握就推迟 kill -9 并等待。kill -9 之后 heartbeat 中断,secondary 们会开始选举(electionTimeoutMillis 默认 10 秒)。rs.status() 中查看是否有其他成员成为 PRIMARY。之后逐个开启写入方。每开启一个,就确认 write ticket 余量和吞吐量,再处理下一个。不应该做的事有三件。
不要在写入尚存的情况下只让 primary 降级。 跳过第 2 步直接 kill -9,同样的停滞可能会转移到新的 primary。
预先准备一页清单,写明“谁在向这个集群写入,各自如何停下”。下面是格式示例,所有值都是虚构的。
| 写入方 | 类型 | 停止方法 | 恢复顺序 |
|---|---|---|---|
store-inventory-writer |
Kafka 消费者 | 把消费者副本数缩减为 0 | 2 |
| 变更事件 CDC 连接器 | CDC | 暂停连接器 | 1 |
清单中还要一并写上确认已停止的指标。清单中遗漏的写入方,在应对过程中也会继续向新的 primary 写入。
这是未经实际运行验证的步骤。下面的结果是根据文档和源码做出的预期。
引擎内 spin 的复现在第 3/6 篇 WiredTiger 快照 pause 循环 livelock 中讨论。这里在测试用的 3 成员副本集上,观察各手段对经过 interrupt point 的长时间写入如何反应。
向集合插入 100 万条文档(示例值)。
use test
for (let b = 0; b < 100; b++)
db.store_inventory.insertMany(Array.from({ length: 10000 },
(_, j) => ({ _id: { productId: j % 1000, storeId: b * 10000 + j }, qty: 0 })))
在 shell A 中执行耗时的写入。
db.store_inventory.updateMany({}, { $inc: { qty: 1 } })
在 shell B 中找到 opid 并发送 killOp。
db.currentOp({ op: "update", ns: "test.store_inventory" }).inprog.map(o => o.opid)
db.killOp(<opid>)
预期结果:killOp 返回 ok: 1。shell A 的 updateMany 在下一个 interrupt point 以中断错误结束。
再次执行第 2 步,并在 shell B 中发送 stepDown。
db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 10 })
预期结果:日志中留下 Starting to kill user operations。updateMany 收到终止标记后结束。primary 降为 secondary。
再次执行第 2 步,并向该 mongod 发送 SIGTERM(kill -15)。
预期结果:关闭路径的四行依次出现在日志中,进程结束。
如果是分片集群,可以在 mongos 上执行第 2·3 步。预期结果是 shard primary 上的操作残留下来。
- 没有确认
kill -9之后 mongod 利用 journal 和最后一个检查点能恢复到什么程度。- SIGTERM 关闭不会结束这一结论,是阅读
db.cpp后得出的推论,没有看过实际日志。- 4.2 之后版本的 stepDown·关闭路径未经确认。