Memos 实时协作冲突解决:基于 CRDT 与 OT 的同步引擎设计
当前限制与协作需求分析
Memos 作为一款自托管的隐私优先笔记服务,在 GitHub issue #3782 中明确记录了用户对实时协作功能的强烈需求。当前 Memos 采用严格的单用户编辑模型:const allowEdit = memo && currentUser?.name === memo.creator,这意味着只有备忘录的创建者才能编辑内容,即使是管理员也无法修改他人的备忘录。
这种设计虽然保证了数据所有权清晰,但在团队协作场景中形成了明显瓶颈。用户从 Google Keep 迁移到 Memos 时,最常反馈的缺失功能就是实时协作编辑。实现这一功能需要解决三个核心工程问题:
- 权限系统扩展:在现有创建者模型基础上增加协作者权限层级
- 网络同步机制:处理多用户并发编辑时的数据一致性
- 冲突检测与解决:确保最终状态收敛且符合用户预期
OT 与 CRDT 的技术权衡
操作转换(OT)的工程实现
OT 算法将每个编辑操作(插入、删除、格式化)视为独立操作单元。当并发操作发生时,OT 通过数学变换确保操作顺序不影响最终结果。Google Docs、Etherpad 等早期协作工具均采用 OT 方案。
OT 核心变换规则表:
- INSERT vs INSERT:基于位置偏移 + 时间戳决胜
- INSERT vs DELETE:根据删除范围调整插入位置
- DELETE vs INSERT:根据插入位置调整删除范围
- DELETE vs DELETE:范围重叠分析与合并
OT 的优势在于低延迟场景下的实时响应,但存在显著工程复杂度:
- 需要中央服务器维护操作日志和变换逻辑
- 边缘情况处理困难(如重叠编辑、嵌套操作)
- 撤销 / 重做历史非线性,实现复杂
- 操作日志随使用时间线性增长,存储压力大
无冲突复制数据类型(CRDT)的架构优势
CRDT 通过数据结构设计本身消除冲突可能性,每个副本独立更新后通过数学合并函数达到最终一致。Automerge、Yjs 等现代协作库均基于 CRDT 实现。
CRDT 冲突解决策略:
- 位置排序:相同逻辑位置的字符按唯一 ID 排序(如
alice:100 < bob:200) - 最后写入获胜:高时间戳覆盖低时间戳
- 添加获胜:新增元素优先于删除(除非删除观察到特定添加)
- 词典序决胜:相同时间戳时按节点 ID 字母序排序
- 因果优先:保持操作间的因果依赖关系
CRDT 的核心优势在于去中心化架构:
- 支持 P2P 同步,无需中央协调服务器
- 天然支持离线编辑和网络分区恢复
- 实现相对简单,冲突解决内置于数据结构
- 扩展性强,节点数量不影响算法复杂度
基于 CRDT 的 Memos 协作引擎设计
架构层设计
┌─────────────────────────────────────────────┐
│ 前端层 (React) │
│ • 实时编辑器组件 (CodeMirror/ProseMirror) │
│ • CRDT客户端库 (Yjs/Automerge) │
│ • 用户状态同步 (Presence/Awareness) │
└─────────────────┬───────────────────────────┘
│ WebSocket/WebRTC
┌─────────────────▼───────────────────────────┐
│ 同步层 (Go后端) │
│ • WebSocket连接管理 (Gorilla/gorilla/websocket)│
│ • CRDT操作广播与合并 │
│ • 权限验证中间件 │
└─────────────────┬───────────────────────────┘
│ PostgreSQL/SQLite
┌─────────────────▼───────────────────────────┐
│ 持久化层 │
│ • CRDT文档快照存储 │
│ • 操作日志压缩与归档 │
│ • 协作者权限关系表 │
└─────────────────────────────────────────────┘
关键实现参数
1. CRDT 选型参数:
- 数据结构:Yjs 的 Y.Text 类型(针对文本优化)
- 唯一 ID 生成:
{nodeId}:{logicalClock}格式,如user123:1672531200000 - 合并频率:每 500ms 或累积 10 个操作执行一次合并
- 快照间隔:每 1000 个操作或 5 分钟生成完整快照
2. 网络同步参数:
- 心跳间隔:30 秒(检测连接状态)
- 重连策略:指数退避,最大重试 5 次
- 操作批处理:最大批大小 50 个操作,超时 100ms 发送
- 冲突检测窗口:200ms 内的并发操作视为潜在冲突
3. 存储优化参数:
- 操作日志压缩:使用 delta 编码,压缩比目标≥70%
- 内存缓存:最近活跃文档保留在内存中,LRU 策略,最大 100 个文档
- 快照版本:保留最近 10 个版本,支持时间旅行
权限系统扩展设计
在现有memos表基础上增加协作关系表:
CREATE TABLE memo_collaborators (
id SERIAL PRIMARY KEY,
memo_id INTEGER NOT NULL REFERENCES memos(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission INTEGER NOT NULL DEFAULT 0, -- 0: READ, 1: WRITE, 2: ADMIN
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(memo_id, user_id)
);
-- 权限枚举
-- 0: READ - 可查看、评论
-- 1: WRITE - READ + 可编辑内容
-- 2: ADMIN - WRITE + 可修改权限、删除备忘录
权限验证中间件逻辑:
func canEditMemo(userID, memoID int, permissionRequired int) bool {
// 创建者始终有ADMIN权限
if isCreator(userID, memoID) {
return true
}
// 检查协作权限
perm := getCollaboratorPermission(userID, memoID)
return perm >= permissionRequired
}
冲突解决的具体实现
文本编辑冲突场景处理
场景 1:同时插入相同位置
Alice: 在位置5插入"Hello" (ID: alice:100)
Bob: 在位置5插入"World" (ID: bob:200)
结果: "HelloWorld" (按ID排序: alice:100 < bob:200)
场景 2:插入与删除重叠
原始: "The quick brown fox"
Alice: 删除位置10-15 ("brown")
Bob: 在位置12插入"red "
结果: "The quick red fox" (删除范围调整)
场景 3:同时删除重叠范围
原始: "ABCDEFGHIJKLMNOP"
Alice: 删除位置5-10 ("FGHIJ")
Bob: 删除位置8-13 ("IJKLM")
结果: 合并删除范围5-13 ("FGHIJKLM")
CRDT 合并算法实现
type CRDTDocument struct {
ID string
Content Y.Text
Version VectorClock
Operations []Operation
}
func (doc *CRDTDocument) Merge(other *CRDTDocument) error {
// 1. 合并向量时钟
doc.Version = doc.Version.Merge(other.Version)
// 2. 应用远程操作(已按因果顺序排序)
for _, op := range other.Operations {
if !doc.Version.HasSeen(op.ID) {
doc.applyOperation(op)
doc.Version = doc.Version.Increment(op.NodeID)
}
}
// 3. 生成新快照(如果操作数达到阈值)
if len(doc.Operations) > 1000 {
doc.createSnapshot()
}
return nil
}
func (doc *CRDTDocument) applyOperation(op Operation) {
switch op.Type {
case "insert":
// 位置调整:考虑已应用的插入/删除
adjustedPos := adjustPosition(op.Position, doc.Operations)
doc.Content.Insert(adjustedPos, op.Text)
case "delete":
adjustedStart := adjustPosition(op.Start, doc.Operations)
adjustedEnd := adjustPosition(op.End, doc.Operations)
doc.Content.Delete(adjustedStart, adjustedEnd-adjustedStart)
}
}
监控与可观测性指标
核心监控指标
- 同步延迟:操作从产生到所有副本确认的时间(P95 < 200ms)
- 冲突率:需要特殊处理的冲突操作比例(目标 < 1%)
- 内存使用:CRDT 文档内存占用(每个文档 < 5MB)
- 网络流量:操作同步带宽消耗(平均 < 10KB / 用户 / 分钟)
- 合并性能:CRDT 合并操作耗时(P99 < 50ms)
Prometheus 指标示例
# CRDT相关指标
crdt_operations_total{type="insert", document="memo_123"}
crdt_operations_total{type="delete", document="memo_123"}
crdt_conflict_resolutions_total{resolution_type="position_sort"}
crdt_merge_duration_seconds_bucket{le="0.05", document="memo_123"}
# 网络同步指标
websocket_connections_active
websocket_messages_sent_total
websocket_reconnect_attempts_total
# 存储指标
crdt_snapshot_size_bytes
crdt_operation_log_entries
storage_compression_ratio
告警规则配置
groups:
- name: crdt_alerts
rules:
- alert: HighConflictRate
expr: rate(crdt_conflict_resolutions_total[5m]) / rate(crdt_operations_total[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "冲突解决率超过5%,可能影响用户体验"
- alert: SyncLatencyHigh
expr: histogram_quantile(0.95, rate(crdt_sync_latency_seconds_bucket[5m])) > 0.5
for: 3m
labels:
severity: critical
annotations:
summary: "同步延迟P95超过500ms"
回滚与灾难恢复策略
数据一致性保障
- 操作日志持久化:所有 CRDT 操作写入 WAL(Write-Ahead Log)
- 定期检查点:每小时生成一致性检查点
- 版本快照:保留最近 24 小时每小时快照,最近 7 天每天快照
回滚流程
func rollbackDocument(docID string, targetVersion VectorClock) error {
// 1. 查找最近的有效快照
snapshot := findNearestSnapshot(docID, targetVersion)
// 2. 从快照重建文档
doc := loadFromSnapshot(snapshot)
// 3. 重放快照后的操作(直到目标版本)
ops := getOperationsAfter(snapshot.Version, targetVersion)
for _, op := range ops {
doc.applyOperation(op)
}
// 4. 验证最终状态
if !doc.Version.Equals(targetVersion) {
return errors.New("rollback failed: version mismatch")
}
// 5. 更新持久化存储
return saveDocument(doc)
}
灾难恢复预案
场景 A:CRDT 合并逻辑错误
- 立即停止新操作接收
- 回滚到最近已知一致快照
- 分析错误操作,修复合并算法
- 逐步恢复服务,监控冲突率
场景 B:网络分区导致状态分叉
- 检测到分区时记录分歧点
- 分区恢复后执行三向合并
- 无法自动合并时提示用户手动解决
- 记录分叉历史供审计分析
场景 C:存储损坏
- 从备份恢复最新快照
- 使用操作日志重建最终状态
- 验证数据完整性哈希
- 增量同步期间只读模式运行
性能优化建议
内存优化
- 操作日志压缩:使用 delta 编码和 LZ4 压缩
- 惰性加载:非活跃文档从内存移除,保留磁盘快照
- 共享数据结构:相同前缀的文本块共享内存引用
网络优化
- 操作批处理:累积小操作批量发送
- 增量同步:只发送差异而非完整状态
- 连接复用:同一用户多个文档共享 WebSocket 连接
存储优化
- 分层存储:热文档 SSD,冷文档 HDD
- 压缩策略:根据访问频率动态调整压缩级别
- 索引优化:为版本查询和权限检查建立专门索引
实施路线图
阶段一:基础架构(1-2 个月)
- 集成 Yjs CRDT 库到前端编辑器
- 实现 WebSocket 同步层
- 建立基础权限系统
- 完成单文档协作 MVP
阶段二:生产就绪(2-3 个月)
- 实现操作日志持久化
- 添加监控和告警系统
- 优化性能和内存使用
- 完成多文档并发测试
阶段三:高级功能(3-4 个月)
- 实现离线编辑支持
- 添加时间旅行和版本对比
- 集成高级格式协作(表格、图片)
- 优化移动端体验
总结
为 Memos 实现实时协作功能需要从单用户模型向多用户协作架构转型。CRDT 技术相比 OT 更适合 Memos 的分布式、隐私优先的设计理念,能够在保证最终一致性的同时支持离线编辑和去中心化同步。
关键成功因素包括:
- 渐进式部署:从可选功能开始,逐步完善
- 性能监控:密切跟踪同步延迟和冲突率
- 用户教育:帮助用户理解协作编辑的预期行为
- 回滚能力:确保任何问题都能安全恢复
通过本文设计的架构,Memos 可以在保持现有隐私优势的同时,提供媲美商业协作工具的实时编辑体验,真正成为团队知识管理的完整解决方案。
资料来源:
- GitHub issue #3782 - Collaborative Memos (https://github.com/usememos/memos/issues/3782)
- Conflict resolution using OT and CRDT algorithms (https://www.nitinkumargove.com/blog/conflict-resolution-using-ot-crdt)