这是 MongoDB 分片集群写入路径系列的第 5/6 篇。
本文回答的问题:primary 连一个写入都完成不了,为什么不发生自动 failover?Flow Control 和 Storage Node Watchdog 为什么抓不住这种状态?那么应该监控什么?
基准版本:MongoDB 4.2(官方文档
v4.2分支,服务器源码r4.2.12标签)。7.0 中的变化在相应章节另行注明。
在 MongoDB 4.2 中,能对 primary 异常自动作出反应的机制有三个。这三个机制看的是 primary 是否存活、secondary 是否落后。primary 能应答却完成不了写入的状态,不符合其中任何一个判定标准。
| 机制 | 看什么 | 遇到写入停住的 primary 时 |
|---|---|---|
| 自动 failover(选举) | heartbeat 是否应答 | heartbeat 有应答,所以不发生选举 |
| Flow Control | majority commit 延迟(lag) | lag 接近 0,判定为正常 |
| Storage Node Watchdog | 文件系统无响应 | 文件系统正常,所以不会触发 |
所以运维人员必须自己监控进展(progress)。要监控的问题不是“是否应答”,而是“是否完成写入”。
造成这种状态的原因在存储引擎 WiredTiger(以下简称引擎)内部。具体来说,是 WiredTiger 快照 pause 循环的 livelock。livelock 是线程一直占用 CPU 运转、但操作却完成不了的状态。这个原因在第 3/6 篇中讨论。本文讨论三个机制为何对这种状态没有反应,以及如何设计监控。
副本集成员之间相互发送 heartbeat。4.2 文档这样写道。
Replica set members send heartbeats (pings) to each other every two seconds. If a heartbeat does not return within 10 seconds, the other members mark the delinquent member as inaccessible.
| 项目 | 值 |
|---|---|
| heartbeat 周期 | 2 秒 |
| 无应答多久视为不可访问 | 10 秒 |
| 引发选举的断连时间 | electionTimeoutMillis,默认 10 秒 |
当 secondary 与 primary 断连超过 electionTimeoutMillis 时,选举开始。选举规则的输入只有“heartbeat 是否返回”这一项。primary 是否在处理写入,不在输入之中。
处理 heartbeat 命令的代码既不获取全局锁,也不获取 ticket。ticket 是限制可同时进入引擎的操作数的入场券。处理函数是 src/mongo/db/repl/repl_set_commands.cpp(r4.2.12) 中的 CmdReplSetHeartbeat::run。这个函数解析参数后只调用 processHeartbeatV1。
普通写入则不同。insert·update·delete·findAndModify 以 IX(写意向)模式获取全局锁,同时领取一个 write ticket(src/mongo/db/concurrency/lock_state.cpp:262-266 (r4.2.12))。4.2 的 write ticket 默认值是 128 个(src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp:474-475 (r4.2.12))。
即使领到 128 个 write ticket 的操作全都停在引擎内,heartbeat 也不等待 ticket。所以即使引擎停住,heartbeat 也可能正常应答。这一结论是阅读源码得出的推论,没有亲自复现。
例如,向 store_inventory 集合写入的 writer 服务连一个写入都完成不了的这段时间里,secondary 依然每 2 秒收到一次 heartbeat 应答。选举规则的输入没有任何变化。
Flow Control 是 4.2 引入的功能。majority commit 延迟是 primary 已应用的写入被过半数成员确认之前落后的时间。Flow Control 为了把这个延迟保持在某个值以下,会放慢 primary 的写入速度。
With flow control enabled, as the lag grows close to the flowControlTargetLagSeconds, writes on the primary must obtain tickets before taking locks to apply writes.
默认值是 enableFlowControl: true、flowControlTargetLagSeconds: 10。需要 FCV(featureCompatibilityVersion)为 4.2 并开启 majority read concern 才会生效。
lag 的计算式在源码中(src/mongo/db/storage/flow_control.cpp:77-82 (r4.2.12))。
return static_cast<std::uint64_t>(durationCount<Milliseconds>(myLastApplied - lastCommitted));
myLastApplied 是 primary 最后应用的写入的时刻。lastCommitted 是过半数成员确认的最后一次写入的时刻。这个差值小于阈值(flowControlTargetLagSeconds)时,Flow Control 判定为正常并增加 ticket(同一文件第 328-350 行)。两个时刻之间几乎没有操作时,也会判定为正常。
primary 写不进去时,会这样演变。
myLastApplied 停住。lastCommitted 就与 myLastApplied 相同。上述过程是从计算式推导出的推论。用示例值来推算。primary 在 10:00:00 应用了最后一次写入。secondary 复制了它,lastCommitted 也变为 10:00:00。此后如果 primary 什么都应用不了,lag 就固定在 10:00:00 − 10:00:00 = 0초,远低于 10 秒的阈值。
即使 lag 变大、Flow Control 开始放慢写入,这种状态也不会解除。Flow Control 只是在引擎入口减少进入的写入量。已经拿到 ticket 进入引擎的操作,它碰不到。
Storage Node Watchdog(以下简称 Watchdog)从 4.2 起 Community 版也提供。监控对象是以下目录的文件系统无响应。
--dbpath 目录--dbpath 内的 journal 目录(开启 journaling 时)--logpath 文件所在的目录--auditPath 文件所在的目录文件系统无响应时,Watchdog 会终止 mongod(退出码 61)。如果那个 mongod 是 primary,就会发生 failover。
Watchdog 默认关闭。watchdogPeriodSeconds 的默认值是 -1(禁用),要开启必须在 mongod 启动时给出 60 以上的整数。
CPU 被引擎内的循环绑住时,磁盘和文件系统依然正常应答。所以即使开启了 Watchdog,在这种情况下也不会触发。这一结论是从文档所列的监控对象推导出的推论。
除此之外,在 4.2 文档中没有找到在引擎无法推进时让 primary 降级的功能。没有对文档和参数做全面检查。
能应答却完成不了工作的结构,在 Kubernetes liveness probe 中也很常见。假设 probe 只检查 /health。只要 HTTP 线程能应答,即使作业队列停住,probe 也会判定为正常。MongoDB 的 heartbeat 起的正是这种 /health probe 的作用。
要看进展,需要同时看三个信号。
只看一个会产生误报。只看吞吐量,会在写入本来就少的凌晨报警。只看等待队列,会在正常的瞬时突增时也报警。
以下路径是 db.serverStatus() 结果中的路径。在每个 shard 的 primary 上读取。
| serverStatus 路径 | 含义 | 停住的 primary 上预期的样子 |
|---|---|---|
wiredTiger.concurrentTransactions.write.available |
剩余的 write ticket 数 | 停留在 0 附近 |
opcounters.insert·.update·.delete |
累计处理条数 | 停止增长或骤减 |
opLatencies.writes.latency·.ops |
累计写入延迟·条数 | 区间平均延迟激增 |
globalLock.currentQueue.writers |
等待中的写入数 | 持续增加 |
主机上预期的样子有三种。
iowait 接近 0,说明 CPU 不是在等待磁盘,而是用于执行指令。上下文切换减少,是因为 pause 循环不做系统调用,线程不会主动交出 CPU。这两种解读是从第 3/6 篇的引擎源码分析推导出的推论。
globalLock.currentQueue.writers 把锁等待和 ticket 等待一起计数。在 r4.2.12 源码中,这个值是状态为 kQueuedWriter 的客户端数(src/mongo/db/stats/lock_server_status_section.cpp:76-83)。在 lock_state.cpp 中进入这个状态的情况有三种。
不能因为这个值增加就断定是锁争用。ticket 耗尽时同一个值也会上升。
下面是伪代码。指标名用 serverStatus 路径书写。没有确认采集器(exporter)输出的实际指标名,请按环境替换。阈值全部是示例值。
# 의사 코드. 각 shard primary마다 평가한다. 임계치는 예시값.
ALERT MongoPrimaryWriteStalled
WHEN
# 1) 엔진 입구가 막혔다
wiredTiger.concurrentTransactions.write.available <= 2
# 2) 들어오려는 쓰기가 있다
AND globalLock.currentQueue.writers > 10
# 3) 처리량이 늘지 않는다 (1분 증가율이 지난주 같은 시각의 10% 미만)
AND rate(opcounters.insert + opcounters.update + opcounters.delete, 1m)
< 0.1 * baseline_same_hour_last_week
# 4) CPU는 명령 실행에 묶여 있다
AND host.cpu.user >= 90%
AND host.cpu.iowait < 5%
FOR 2m
SEVERITY page
NOTE "heartbeat가 정상이어도 자동 failover는 일어나지 않는다. 대응 절차 문서를 연다."
区间平均写入延迟用累计值的差求得。
avg_write_latency_us(1m) =
delta(opLatencies.writes.latency, 1m) / delta(opLatencies.writes.ops, 1m)
一个写入都完成不了时,分母为 0。这时就把完成条数为 0 这一事实本身作为信号。
从 7.0 起,第 1 个条件不能照搬。7.0 会动态调节 ticket 数。所以 7.0 文档写道,即使剩余 ticket 数(ticket availability)长时间为 0,也不视为过载信号。在 6.0 及以下,同样的情况是过载信号。在 7.0 及以上,以第 2~4 个条件为主来看。
serverStatus 指标是间接信号。周期性地发送小写入、测量其是否完成的探针(probe)更直接。下面是未经实际运行验证的 mongo shell 示例。
// 예시: 외부 감시 프로세스가 30초마다 실행한다.
// ops_probe 데이터베이스의 probe 컬렉션에 문서 한 건을 갱신한다.
const started = new Date();
db.getSiblingDB("ops_probe").probe.updateOne(
{ _id: "writer-probe" },
{ $set: { at: started } },
{ upsert: true }
);
const elapsedMs = new Date() - started;
// elapsedMs가 예시 임계치(2000ms)를 넘거나 클라이언트 타임아웃으로 실패하면 알린다.
maxTimeMS 是协作式中断。操作必须自行确认中断请求才会停下,所以切断不了停在引擎内的操作(参见第 4/6 篇)。很多时候,应用程序比 primary 指标更早出现异常。按一般规律预期的顺序如下。
应用程序重试在服务器上堆积更多等待操作的结构,在第 2/6 篇中讨论。
- 不能只看 heartbeat 和
rs.status()的绿灯就把数据库排除在原因之外。 heartbeat 不经过引擎,所以 primary 的状态显示也会保持正常(基于源码的推论)。- 修改 Flow Control 设置也解除不了这种状态。 Flow Control 只是在 lag 大时在入口处放慢写入。
- 开启 Watchdog 也检测不到这种状态。 Watchdog 只看文件系统无响应。
- 单独针对吞吐量下降的告警误报很多。 要与等待队列增加组合起来。
- 没有全面检查文档和参数,确认 4.2 是否真的没有根据引擎进展让 primary 降级的功能。
- 引擎停住期间 heartbeat 仍会应答这一结论,是阅读 r4.2.12 源码得出的推论,没有亲自复现。
- 没有把
opcounters·opLatencies.writes的字段定义与 4.2 文档原文对照。- 没有确认 mongos 拿不到 shard 连接时所报错误的确切代码和名称。
- 没有确认采集器(exporter)使用的实际指标名。
- 没有验证在分片集群中为每个 shard 设置写入探针的具体方法和副作用。
- 没有确认 7.0 之后的版本中 heartbeat 处理路径和 Flow Control 计算式是否有变化。