操作日志必须包含docId、op、v、src、seq、transformedBy、timestamp字段,索引需建{docId:1,v:1}和{docId:1,"transformedBy":1},写入须用writeConcern:{w:"majority",j:true},并拆分presence事件至独立集合。

操作日志文档结构必须包含可变换的坐标与版本锚点
OT 要求每个操作能被无歧义地定位和转换,不能只存原始位置(如 pos: 5),否则在并发插入/删除后会漂移失效。MongoDB 中需显式记录操作作用时的上下文快照信息。
推荐字段设计:
-
docId:文档唯一标识,用于路由和分片 -
op:操作对象,含type("insert"/"delete"/"retain")、pos(操作发起时的逻辑位置)、text或delCount -
v:客户端本地版本号(非 MongoDB ObjectId),表示该操作基于哪个状态生成 -
src:操作来源用户 ID,用于识别冲突来源 -
seq:客户端自增序列号,配合src构成全局有序标识(如"user-a:12") -
transformedBy:数组,记录该操作已被哪些其他用户的操作转换过(如["user-b:9", "user-c:3"]),避免重复转换 -
timestamp:服务端写入时间,仅用于调试与排序兜底,**不可作为 OT 顺序依据**
索引策略直接影响 OT 变换性能
MongoDB 查询操作日志不是为了“读历史”,而是为了实时执行 transform 和 compose:服务端收到新操作后,需快速查出所有与之可能冲突的、尚未被该操作转换过的旧操作(即 v 小于当前操作 v 且未出现在其 transformedBy 中的操作)。
关键索引组合:
-
{ docId: 1, v: 1 }:按文档+版本范围扫描,支撑“获取某版本前所有操作” -
{ docId: 1, "transformedBy": 1 }:支持排除已转换操作(用$not+$elemMatch) - 避免对
op.text建全文索引——OT 不查内容语义,只做结构变换
注意:v 是逻辑版本,不是递增整数,不同客户端版本号不单调;因此不能依赖 _id 或 timestamp 排序替代 v 比较。
写关注与日志持久化必须设为 j: true
OT 系统一旦丢失操作日志,就会导致客户端状态永久不一致——因为后续所有基于该操作的转换都失效。MongoDB 默认的 writeConcern: { w: 1 }(仅写入主节点内存)在崩溃时可能丢数据。
必须强制落盘:
- 服务端写
db.operations.insertOne(...)时,显式指定writeConcern: { w: "majority", j: true } - 确认集群配置中
writeConcernMajorityJournalDefault: true已启用,否则w: "majority"不自动触发j: true - 不要依赖 WiredTiger 的 100ms 日志刷盘间隔——OT 要求每次操作原子落盘
如果使用 MongoDB 6.1+,storage.journal.enabled 已废弃,但 j: true 仍需显式声明,否则无法保证 oplog 同步前日志已刷盘。
避免把 OT 日志当通用事件总线用
常见误用:把所有光标移动、选区变化、用户上线等 presence 事件也塞进 operations 集合。这会导致两个问题:
- OT 变换函数需额外过滤非编辑操作,逻辑膨胀且易出错
- 集合膨胀过快,影响
v范围查询性能(即使加了索引,全量扫描transformedBy数组仍昂贵)
正确做法是拆分集合:
-
operations:纯编辑操作,强一致性要求,j: true,高频小文档 -
presence:用户状态事件,可用writeConcern: { w: 1 },TTL 索引自动过期(如{ expiresAt: 1 }+expireAfterSeconds: 30)
OT 的复杂性藏在变换逻辑里,不在存储层——别让 MongoDB 承担它不该管的语义职责。

















