2. 变更流底层原理与oplog机制

Change Streams 并不是 MongoDB 在数据集合之上单独维护的一套“事件总线”,它本质上是对副本集 oplog 的一种受控、可恢复、可过滤的读取封装。理解这一点,是掌握变更流行为边界的关键:为什么变更流只能在副本集或分片集群上使用、为什么它天然具备“至少一次”语义、为什么恢复令牌能实现断点续传,答案都藏在 oplog 的写入与读取机制里。

oplog 是什么:副本集的“操作日志环”

在副本集中,主节点(Primary)处理写请求时,会把每一次数据变更记录成一条幂等的操作条目,写入本地 local.oplog.rs 集合。从节点(Secondary)持续拉取这些条目并在本地重放,从而保持与主节点的数据一致。oplog 具有三个决定性特征:

  • 有序性:每条 oplog 条目都带有一个单调递增的时间戳 ts,形如 Timestamp(1700000000, 1),由秒级时间与序号两部分组成,保证全局顺序。
  • 幂等性:条目记录的是“把某文档改成什么样”,而不是“加一”这类相对操作,因此重复重放不会破坏一致性。
  • 有界性:oplog 是一个固定大小的环形集合(capped collection),写满后会覆盖最旧的条目。这意味着变更流能回溯的历史深度,受限于 oplog 窗口。

变更流读取的正是这些条目,但它并不直接暴露原始 oplog,而是经过一层“事件化”转换:把 i(insert)、u(update)、d(delete)等操作,翻译成带有 _id、operationType、fullDocument、ns、documentKey 等字段的变更事件文档。

逻辑时钟:ts 与集群时间的双重角色

oplog 条目的 ts 是变更流的逻辑时钟。它不只是排序依据,还承担两个职责:一是作为游标推进的“位置坐标”,二是作为恢复令牌的组成部分。与之配合的还有集群时间(clusterTime),它由主节点在每次写入时推进,并通过心跳在节点间传播,用于因果一致性会话。

需要区分两个概念:oplog ts 决定“事件在日志中的位置”,而集群时间决定“客户端会话看到的一致性快照”。变更流游标在打开时会记录一个起始位置,之后按 ts 顺序向后扫描;当客户端携带恢复令牌重连时,服务端就是靠令牌中的 ts 定位到“上次读到哪一条之后”。

恢复令牌:断点续传的凭证

每个变更事件都携带一个 _id 字段,它同时充当恢复令牌(resume token)。令牌内部封装了 ts、ns、uuid、documentKey 等信息,足以让服务端在 oplog 中精确定位。恢复令牌的语义是“我已经处理到这条事件,请从它的下一条继续”,因此它天然是“排他”的。

恢复令牌的有效性依赖 oplog 窗口。如果应用长时间停机,令牌指向的 ts 已被覆盖,重连时服务端会返回 ChangeStreamHistoryLost 错误,此时只能重新建立游标并从当前时间开始,历史事件无法补回。这是变更流工程中必须提前设计的容错点。

游标:从 oplog 到应用的事件管道

变更流游标(cursor)是客户端与服务端之间的有状态通道。它维护一个“当前位置”,服务端按批返回事件,客户端通过 getMore 持续拉取。游标支持 $match、$project、$addFields 等聚合阶段,过滤在服务端完成,减少网络传输。

游标还支持 fullDocument 策略:默认 update 事件只返回变更的字段差异(updateDescription),若需要完整文档,可设置 fullDocument: "updateLookup",服务端会额外查询当前文档。注意这带来的是“查询时刻”的文档,而非“变更时刻”的文档,二者可能不一致。

oplog → 变更流游标 → 应用 的依赖链路 Primary 主节点 写入数据变更 生成 oplog 条目 推进 clusterTime local.oplog.rs(有界环形集合) ts: Timestamp(1700000000, 1) ← 逻辑时钟 op: i / u / d ns: app.users 写满后覆盖最旧条目 → 决定可回溯窗口 Secondary 从节点 拉取并重放 oplog 保持数据一致 心跳传播 clusterTime Change Stream 游标层 按 ts 顺序扫描 oplog,事件化转换 $match / $project 服务端过滤 _id = resumeToken(含 ts / ns / uuid) 应用消费者 getMore 批量拉取事件 持久化 resumeToken,断点续传 携带 resumeToken 重连定位 令牌失效场景 ts 已被 oplog 覆盖 ChangeStreamHistoryLost 需重建游标

依赖关系与工程含义

把上述对象串起来,可以看到一条清晰的依赖链:Primary 写入 → oplog 条目(ts 逻辑时钟)→ 变更流游标按 ts 扫描 → 事件携带 resumeToken → 应用持久化令牌 → 重连时用令牌定位。这条链上任何一环的容量或时序假设被打破,都会直接影响变更流的可用性。

由此可以推导出几条实践准则。第一,oplog 大小必须与业务峰值写入速率和可容忍停机时间匹配。若每秒写入约 2000 条 oplog 条目、每条平均 200 字节,则每小时约产生 1.44 GB oplog;若希望容忍 4 小时停机,oplog 至少应配置到 6 GB 以上并留出余量。第二,resumeToken 必须与业务处理结果一起持久化,且要在业务处理成功之后再保存,否则会丢事件;若先保存令牌再处理,则可能重复处理,需要业务侧幂等。第三,不要依赖 fullDocument 的强一致快照,updateLookup 返回的是查询时刻文档,若业务需要变更时刻的完整状态,应在事件中携带必要字段或自行维护版本。

变更流的“至少一次”语义来自 oplog 读取与令牌持久化之间的窗口:崩溃可能发生在“事件已处理、令牌未保存”或“令牌已保存、事件未处理”之间。前者导致重复,后者导致丢失。工程上通常选择“先处理后保存令牌 + 业务幂等”来保证不丢,用幂等键(如 documentKey + ts)去重。

一个可观察的验证过程

要确认上述机制,可以在测试副本集上做一次最小验证。先查看 oplog 窗口:

rs.printReplicationInfo()
// configured oplog size:   2048 MB
// log length start to end: 3600 secs (1 hrs)
// oplog first event time:  Mon Nov 20 2023 10:00:00
// oplog last event time:   Mon Nov 20 2023 11:00:00

再打开一个变更流并打印事件与令牌:

const cs = db.users.watch([{ $match: { "operationType": "insert" } }]);
while (!cs.isClosed()) {
  const ev = cs.tryNext();
  if (ev) {
    printjson({ op: ev.operationType, key: ev.documentKey, token: ev._id });
  }
}

随后在另一会话插入文档,观察事件中的 _id 是否包含 ts 与 uuid;再手动停止消费者超过 oplog 窗口后重连,观察是否抛出 ChangeStreamHistoryLost。这两步能直观验证“令牌定位依赖 oplog 窗口”这一核心结论。

小结

变更流是 oplog 的读取视图,逻辑时钟 ts 决定顺序与定位,恢复令牌是断点续传的凭证,游标是承载过滤与拉取的状态通道。四者环环相扣:oplog 窗口决定可恢复范围,令牌决定从哪里恢复,游标决定以什么粒度消费。把这条依赖链理解清楚,后续设计幂等消费、监控滞后、规划 oplog 容量时,才能做出有依据的工程决策。