OT与CRDT协同编辑工程实践
面向需要设计、实现和上线多人实时协作系统的前端与全栈工程师。
文档状态:独立根目录工程手册,不自动同步到
agent-docs-site。官方资料核对日期:2026 年 7 月 27 日。
目录
- 1. 先给结论
- 2. 问题模型与术语
- 3. OT 的工作机制
- 4. CRDT 的工作机制
- 5. OT 与 CRDT 对比
- 6. 工程选型
- 7. 协作系统总体架构
- 8. 教学版文本 OT 实现
- 9. 教学版序列 CRDT 实现
- 10. 使用 Yjs 实现生产级协作
- 11. 使用 ShareDB 实现 OT 协作
- 12. 使用 Automerge Repo 实现 Local-first
- 13. 编辑器集成
- 14. 持久化、快照与压缩
- 15. Presence、光标与选区
- 16. 权限、安全与多租户
- 17. 性能与容量规划
- 18. 测试与质量门禁
- 19. 可观测性与故障处理
- 20. 迁移策略
- 21. 常见反模式
- 22. 上线检查清单
- 23. 权威资料
1. 先给结论
1.1 一句话理解
- OT(Operational Transformation):并发操作到达中心服务器后,根据其他并发操作对位置和含义进行转换,再按统一版本序列提交。
- CRDT(Conflict-free Replicated Data Type):通过带稳定身份、因果关系和确定性合并规则的数据结构,让副本以不同顺序接收更新后仍能收敛。
两者解决的核心都不是“怎么发 WebSocket”,而是:
多个用户基于不同旧状态同时修改同一份数据时,系统如何保留合理意图并最终得到一致结果。
1.2 默认选型建议
| 场景 | 优先方案 | 关键理由 |
|---|---|---|
| Web 富文本、白板、表格、代码编辑器,需要成熟生态 | Yjs | 编辑器绑定和 Provider 生态成熟,二进制增量同步,支持离线与 P2P / C/S 拓扑 |
| 强中心化、服务端掌握权威版本、JSON 文档与查询能力重要 | ShareDB / OT | 服务端版本链、数据库和权限中间件模型直接,便于审计与中心化治理 |
| Local-first、跨设备离线编辑、对象文档模型 | Automerge Repo | 文档、存储和网络适配器围绕离线优先设计 |
| 简单业务表单,只偶尔发生冲突 | 版本号 + 乐观锁 | 不要为了低频冲突引入完整协同数据结构 |
| 金额、库存、审批、权限状态 | 事务 / 领域命令 | 不能把 CRDT 的“可合并”误当成业务规则正确 |
1.3 最重要的工程判断
- OT / CRDT 只解决共享状态的一致性,不自动解决权限、审核、业务约束和数据删除。
- Presence 不应写进持久化文档。 在线状态、临时光标、正在输入提示应走可过期的独立通道。
- 协同数据结构不能替代领域 API。 “账户余额减 100”应该是服务端命令,而不是任意客户端合并字段。
- 富文本不能只按纯字符串处理。 格式标记、节点树、选区锚点和撤销栈都需要编辑器绑定层参与。
- 不要自研生产级 OT / CRDT。 本文的算法实现用于理解和测试模型,生产环境优先选成熟库。
2. 问题模型与术语
2.1 基本模型
假设初始文本为:
AC
用户 A 在位置 1 插入 B,用户 B 同时在位置 1 插入 X:
A: AC -> ABC
B: AC -> AXC
如果双方直接应用收到的远端位置 1,可能得到不同结果:
A 收到 B: AXBC
B 收到 A: ABXC
这是典型的并发编辑问题。网络可靠、有序并不能消除它,因为两个操作本来就基于同一旧版本生成。
2.2 必须区分的概念
| 概念 | 含义 |
|---|---|
| Operation | 用户意图对应的增量操作,例如插入、删除、设置字段 |
| Replica | 持有共享状态副本的客户端或服务端节点 |
| Causality | 操作之间“谁看见了谁”的先后关系 |
| Concurrent | 两个操作互相都未观察到对方 |
| Convergence | 所有副本接收同一组更新后最终状态一致 |
| Intention preservation | 转换或合并后尽量保留用户原始意图 |
| State vector / Version vector | 描述副本已知更新范围的紧凑因果摘要 |
| Tombstone | 删除后保留的身份或标记,用于维持引用和合并语义 |
| Snapshot | 某个时间点的完整物化状态 |
| Update / Delta | 从一个状态推进到另一个状态所需的增量数据 |
| Presence / Awareness | 在线用户、光标、选区等临时协作元数据 |
2.3 一致性不等于业务正确
例如两个离线客户端都把库存从 1 改成 0。副本最终都收敛为 0,并不代表两次下单都合法。
必须按数据性质拆分:
适合协同合并:正文、便签、画布元素、评论草稿、个人标注
需要领域事务:余额、库存、优惠额度、审批流、角色权限、唯一用户名
3. OT 的工作机制
3.1 核心流程
经典中心化 OT 通常维护一个服务端权威版本:
客户端通常同时维护:
- 服务端已确认状态;
- 一个已发送但未确认的 operation;
- 一个由后续本地输入累计形成的 buffer;
- 当前服务端 revision。
3.2 Transform 的含义
设并发操作为 A、B,需要计算:
A' = transform(A, B)
B' = transform(B, A)
使下面两条路径收敛:
apply(apply(S, A), B') == apply(apply(S, B), A')
只实现“位置加减”并不够。真实文本 OT 还要处理:
- 区间删除重叠;
- 同位置插入的稳定排序;
- Unicode、换行与组合字符;
- 富文本属性;
- 撤销 / 重做的意图保持;
- 客户端未确认操作与新远端操作的双向转换;
- 重连后的版本补齐。
3.3 OT 的优势
- 服务端权威版本清晰,历史 revision 易于审计。
- 服务端可以在提交前做权限、Schema 和领域校验。
- 增量操作通常较紧凑,不必在数据结构中长期保留大量身份元数据。
- 对已有中心数据库和强服务端架构较友好。
3.4 OT 的代价
- Transform 函数是正确性核心,操作类型越丰富,组合复杂度越高。
- 中心服务不可用时,跨客户端同步通常停止;客户端可继续缓存本地操作,但重连逻辑复杂。
- P2P、多主和长时间离线不是经典中心化 OT 的自然优势。
- 客户端和服务端必须严格使用兼容的 operation type 与版本协议。
4. CRDT 的工作机制
4.1 核心思想
CRDT 不要求所有更新以同一顺序到达。它通过以下信息实现确定性合并:
- 每个元素或操作的稳定唯一 ID;
- 因果上下文,例如逻辑时钟、版本向量或依赖集合;
- 对并发插入、删除和赋值的确定性规则;
- 幂等更新和重复消息去重;
- 对乱序消息和离线副本的合并能力。
4.2 两类 CRDT
State-based CRDT
副本传播整个状态或状态摘要,merge 通常要求满足:
- 交换律;
- 结合律;
- 幂等律。
适合状态不大或能生成高效 delta 的场景。
Operation-based CRDT
副本传播操作。操作通常要求唯一身份和因果投递条件;部分实现通过内部协议处理缺失依赖和乱序更新。
工程上不要仅凭库暴露的是 update 还是 document API 判断其理论分类,成熟库可能同时包含多种编码、同步和压缩层。
4.3 序列 CRDT 的直觉
对每个字符或结构元素分配稳定 ID,并记录它插入在哪个元素之后:
HEAD
├─ A(clientA:1)
│ ├─ B(clientA:2)
│ └─ X(clientB:1)
└─ ...
并发插入拥有相同前驱时,所有副本按同一个稳定规则排序。删除通常针对元素 ID,而不是针对“当前第 5 个字符”。
这使离线更新合并时不依赖易漂移的位置索引。
4.4 CRDT 的优势
- 天然适合 Local-first、离线编辑、跨标签页、跨设备和多种网络拓扑。
- 更新可重复投递,最终仍收敛。
- 不要求中心服务器逐条决定全局顺序。
- 同一文档可以同时通过 WebSocket、WebRTC、BroadcastChannel 和本地存储同步。
4.5 CRDT 的代价
- 元素身份、因果元数据和删除标记可能增加空间占用。
- GC / 压缩需要确认旧副本不会再引用被回收的历史身份。
- 收敛只保证数据结构层一致,不保证结果符合用户主观意图。
- 服务端拒绝某个已经在离线客户端本地生效的更新,比中心化 OT 更难处理。
- ACL 不能只做“能否订阅文档”,还要考虑是否允许更新文档中的特定字段或节点。
5. OT 与 CRDT 对比
| 维度 | OT | CRDT |
|---|---|---|
| 核心机制 | 对并发 operation 做转换 | 通过稳定身份、因果信息和合并规则收敛 |
| 常见拓扑 | 中心化 Client / Server | C/S、P2P、多 Provider、Local-first |
| 离线时长 | 可缓存,但重连与 rebase 复杂 | 通常是主要能力之一 |
| 服务端权威 | 强 | 可强可弱,取决于接入层和权限模型 |
| 审计方式 | revision + operation log 直观 | update / change log + snapshot,需要额外业务审计层 |
| 数据开销 | 操作通常较小 | 可能包含较多身份和历史元数据 |
| 算法难点 | Transform 正确性与客户端状态机 | 数据结构、因果同步、GC 与权限撤销 |
| 乱序 / 重复 | 由协议和 revision 处理 | 通常天然考虑幂等、乱序和缺失更新 |
| P2P | 不自然 | 较自然 |
| 典型实现 | ShareDB | Yjs、Automerge |
5.1 不应使用“谁绝对更先进”来选型
OT 与 CRDT 的实际效果高度依赖:
- operation / shared type 是否匹配业务模型;
- 编辑器绑定是否成熟;
- 是否需要长时间离线;
- 服务端是否必须在提交前拒绝非法变更;
- 文档大小、更新频率和在线人数;
- 是否需要历史回放、审核和法务删除;
- 团队是否有能力运维同步、持久化和压缩链路。
6. 工程选型
6.1 决策树
6.2 先确定共享数据模型
不要从“选 Yjs 还是 ShareDB”开始,而应先定义:
interface CollaborativeDocumentBoundary {
readonly persistedSharedState: readonly string[];
readonly ephemeralPresenceState: readonly string[];
readonly serverAuthoritativeState: readonly string[];
}
const documentBoundary: CollaborativeDocumentBoundary = {
persistedSharedState: ['title', 'content', 'canvasElements'],
ephemeralPresenceState: ['cursor', 'selection', 'typing', 'viewport'],
serverAuthoritativeState: ['ownerId', 'billingState', 'approvalStatus'],
};
如果所有字段都被塞进协作文档,权限和生命周期会迅速失控。
6.3 建议的 PoC 指标
选型 PoC 不应只验证“两台浏览器能同步”,至少测量:
- 首次加载 1 MB、10 MB、50 MB 文档耗时;
- 单文档 2、20、100 个在线客户端;
- 每秒 10、100、1000 个增量更新;
- 离线 1 小时、1 天后的补同步大小和耗时;
- 服务重启、重复消息、乱序消息、断线重连;
- 快照恢复与更新日志损坏;
- 大量删除后的存储增长;
- 光标更新与正文更新是否互相阻塞;
- 未授权更新是否在所有入口被拒绝;
- 编辑器输入法、撤销、复制粘贴和大段替换。
7. 协作系统总体架构
7.1 推荐分层
7.2 数据通道分离
| 通道 | 数据 | 可靠性 | 持久化 |
|---|---|---|---|
| Document updates | 正文、节点、画布对象 | 必须可靠、可补偿 | 是 |
| Presence | 光标、选区、在线状态 | 可丢、以最新值为准 | 否,或短 TTL |
| Domain commands | 发布、审批、扣费、改权限 | 强校验、幂等 | 是 |
| Telemetry | 延迟、队列、错误 | 允许采样 | 指标系统 |
不要把所有消息都包装成一个没有上限的 message 事件。
7.3 客户端生命周期
鉴权 -> 获取文档访问票据 -> 加载本地副本 -> 建立同步连接
-> 完成初次同步 -> 绑定编辑器 -> 发布 Presence
-> 增量编辑 -> 断线缓存 -> 重连补同步 -> 销毁 Provider / 监听器 / 定时器
React StrictMode 下初始化和销毁必须对称,避免重复连接、重复 observer 和重复提交更新。
8. 教学版文本 OT 实现
以下实现只支持“插入一段文本”和“删除一个 Unicode code point”,用于解释 transform 规则。它不处理富文本、区间删除、组合字符、撤销栈、客户端 ack / buffer 状态机和恶意操作,禁止直接用于生产。
8.1 类型与应用操作
export interface OperationId {
readonly clientId: string;
readonly sequence: number;
}
export interface InsertOperation {
readonly kind: 'insert';
readonly id: OperationId;
readonly position: number;
readonly text: string;
}
export interface DeleteOperation {
readonly kind: 'delete';
readonly id: OperationId;
readonly position: number;
}
export interface NoopOperation {
readonly kind: 'noop';
readonly id: OperationId;
}
export type TextOperation =
| InsertOperation
| DeleteOperation
| NoopOperation;
/** 比较操作 ID,为同位置并发插入提供全局稳定顺序。 */
export function compareOperationId(
left: OperationId,
right: OperationId,
): number {
const clientOrder = left.clientId.localeCompare(right.clientId);
return clientOrder !== 0 ? clientOrder : left.sequence - right.sequence;
}
/**
* 将教学版 OT 操作应用到文本。
* 位置使用 Unicode code point 索引,而不是 UTF-16 code unit 索引。
*/
export function applyTextOperation(
source: string,
operation: TextOperation,
): string {
if (operation.kind === 'noop') {
return source;
}
const characters = Array.from(source);
if (operation.position < 0 || operation.position > characters.length) {
throw new RangeError(`Invalid position: ${operation.position}`);
}
if (operation.kind === 'insert') {
if (operation.text.length === 0) {
throw new Error('Insert text must not be empty');
}
characters.splice(operation.position, 0, ...Array.from(operation.text));
return characters.join('');
}
if (operation.position === characters.length) {
throw new RangeError('Delete position must point to an existing character');
}
characters.splice(operation.position, 1);
return characters.join('');
}
8.2 Transform 实现
import type {
DeleteOperation,
InsertOperation,
TextOperation,
} from './text-operation';
import { compareOperationId } from './text-operation';
/**
* 将 operation 转换为“已经应用 against 后”仍表达相同意图的操作。
*/
export function transformOperation(
operation: TextOperation,
against: TextOperation,
): TextOperation {
if (operation.kind === 'noop' || against.kind === 'noop') {
return operation;
}
if (operation.kind === 'insert' && against.kind === 'insert') {
return transformInsertAgainstInsert(operation, against);
}
if (operation.kind === 'insert' && against.kind === 'delete') {
return {
...operation,
position:
operation.position > against.position
? operation.position - 1
: operation.position,
};
}
if (operation.kind === 'delete' && against.kind === 'insert') {
return {
...operation,
position:
operation.position >= against.position
? operation.position + Array.from(against.text).length
: operation.position,
};
}
return transformDeleteAgainstDelete(operation, against);
}
function transformInsertAgainstInsert(
operation: InsertOperation,
against: InsertOperation,
): InsertOperation {
const againstLength = Array.from(against.text).length;
const shouldShift =
operation.position > against.position ||
(operation.position === against.position &&
compareOperationId(operation.id, against.id) > 0);
return shouldShift
? { ...operation, position: operation.position + againstLength }
: operation;
}
function transformDeleteAgainstDelete(
operation: DeleteOperation,
against: DeleteOperation,
): TextOperation {
if (operation.position === against.position) {
return { kind: 'noop', id: operation.id };
}
return operation.position > against.position
? { ...operation, position: operation.position - 1 }
: operation;
}
8.3 收敛测试
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyTextOperation,
type TextOperation,
} from './text-operation';
import { transformOperation } from './transform-operation';
interface ConvergenceCase {
readonly source: string;
readonly left: TextOperation;
readonly right: TextOperation;
}
function assertConverges(testCase: ConvergenceCase): void {
const leftPrime = transformOperation(testCase.left, testCase.right);
const rightPrime = transformOperation(testCase.right, testCase.left);
const leftPath = applyTextOperation(
applyTextOperation(testCase.source, testCase.left),
rightPrime,
);
const rightPath = applyTextOperation(
applyTextOperation(testCase.source, testCase.right),
leftPrime,
);
assert.equal(leftPath, rightPath);
}
test('same-position concurrent inserts converge', () => {
assertConverges({
source: 'AC',
left: {
kind: 'insert',
id: { clientId: 'a', sequence: 1 },
position: 1,
text: 'B',
},
right: {
kind: 'insert',
id: { clientId: 'b', sequence: 1 },
position: 1,
text: 'X',
},
});
});
test('insert and delete converge', () => {
assertConverges({
source: 'AC',
left: {
kind: 'insert',
id: { clientId: 'a', sequence: 2 },
position: 1,
text: 'B',
},
right: {
kind: 'delete',
id: { clientId: 'b', sequence: 2 },
position: 1,
},
});
});
test('concurrent deletion of the same character becomes a noop', () => {
assertConverges({
source: 'ABC',
left: {
kind: 'delete',
id: { clientId: 'a', sequence: 3 },
position: 1,
},
right: {
kind: 'delete',
id: { clientId: 'b', sequence: 3 },
position: 1,
},
});
});
8.4 教学版中心 OT Server
import {
applyTextOperation,
type TextOperation,
} from './text-operation';
import { transformOperation } from './transform-operation';
interface SubmittedOperation {
readonly baseRevision: number;
readonly operation: TextOperation;
}
interface CommittedOperation {
readonly revision: number;
readonly operation: TextOperation;
}
export class InMemoryOtServer {
#text: string;
readonly #history: TextOperation[] = [];
public constructor(initialText = '') {
this.#text = initialText;
}
/** 提交一个基于历史 revision 创建的操作,并转换到最新状态。 */
public submit(input: SubmittedOperation): CommittedOperation {
if (
!Number.isInteger(input.baseRevision) ||
input.baseRevision < 0 ||
input.baseRevision > this.#history.length
) {
throw new RangeError('Invalid base revision');
}
const concurrentOperations = this.#history.slice(input.baseRevision);
const transformed = concurrentOperations.reduce<TextOperation>(
(current, committed) => transformOperation(current, committed),
input.operation,
);
this.#text = applyTextOperation(this.#text, transformed);
this.#history.push(transformed);
return {
revision: this.#history.length,
operation: transformed,
};
}
/** 返回当前权威快照。 */
public getSnapshot(): Readonly<{ text: string; revision: number }> {
return {
text: this.#text,
revision: this.#history.length,
};
}
}
真实客户端还必须实现 outstanding / buffer 状态机,不能在每次按键后等待服务端确认才继续输入。
9. 教学版序列 CRDT 实现
以下是简化的 RGA 风格 operation-based 序列,只用于解释稳定元素 ID、并发排序、幂等和 tombstone。生产项目应使用 Yjs、Automerge 等经过大量并发测试的实现。
9.1 Update 与节点模型
export interface ElementId {
readonly clientId: string;
readonly counter: number;
}
export interface InsertUpdate {
readonly kind: 'insert';
readonly id: ElementId;
readonly after: ElementId | null;
readonly value: string;
}
export interface DeleteUpdate {
readonly kind: 'delete';
readonly id: ElementId;
readonly target: ElementId;
}
export type SequenceUpdate = InsertUpdate | DeleteUpdate;
interface SequenceNode {
readonly id: ElementId;
readonly after: ElementId | null;
readonly value: string;
}
function elementKey(id: ElementId): string {
return `${id.clientId}:${id.counter}`;
}
function compareElementId(left: ElementId, right: ElementId): number {
const counterOrder = left.counter - right.counter;
return counterOrder !== 0
? counterOrder
: left.clientId.localeCompare(right.clientId);
}
function sameElementId(left: ElementId, right: ElementId): boolean {
return left.clientId === right.clientId && left.counter === right.counter;
}
9.2 可乱序、可重复应用的实现
export class CollaborativeSequence {
readonly #clientId: string;
#counter = 0;
readonly #nodes = new Map<string, SequenceNode>();
readonly #tombstones = new Set<string>();
readonly #seenUpdates = new Set<string>();
public constructor(clientId: string) {
if (clientId.length === 0) {
throw new Error('clientId must not be empty');
}
this.#clientId = clientId;
}
/** 在当前可见位置插入文本,并返回需要广播的 updates。 */
public insert(index: number, text: string): readonly InsertUpdate[] {
const values = Array.from(text);
if (values.length === 0) {
return [];
}
const visibleNodes = this.#visibleNodes();
if (index < 0 || index > visibleNodes.length) {
throw new RangeError(`Invalid insertion index: ${index}`);
}
let after = index === 0 ? null : visibleNodes[index - 1].id;
const updates: InsertUpdate[] = [];
for (const value of values) {
const update: InsertUpdate = {
kind: 'insert',
id: this.#nextId(),
after,
value,
};
this.apply(update);
updates.push(update);
after = update.id;
}
return updates;
}
/** 删除当前可见位置的元素,并返回需要广播的 update。 */
public delete(index: number): DeleteUpdate {
const target = this.#visibleNodes()[index];
if (!target) {
throw new RangeError(`Invalid deletion index: ${index}`);
}
const update: DeleteUpdate = {
kind: 'delete',
id: this.#nextId(),
target: target.id,
};
this.apply(update);
return update;
}
/** 幂等应用本地或远端 update;delete 可以先于 insert 到达。 */
public apply(update: SequenceUpdate): void {
this.#validateId(update.id);
const updateKey = `${update.kind}:${elementKey(update.id)}`;
if (this.#seenUpdates.has(updateKey)) {
return;
}
if (update.kind === 'insert') {
if (update.value.length === 0) {
throw new Error('Inserted value must not be empty');
}
if (update.after && sameElementId(update.after, update.id)) {
throw new Error('An element cannot reference itself');
}
const key = elementKey(update.id);
const existing = this.#nodes.get(key);
if (
existing &&
(existing.value !== update.value ||
elementKeyOrHead(existing.after) !== elementKeyOrHead(update.after))
) {
throw new Error(`Conflicting payload for element ${key}`);
}
this.#nodes.set(key, {
id: update.id,
after: update.after,
value: update.value,
});
} else {
this.#tombstones.add(elementKey(update.target));
}
this.#seenUpdates.add(updateKey);
}
/** 返回所有副本在接收相同 update 集合后都会得到的文本。 */
public toString(): string {
return this.#visibleNodes()
.map((node) => node.value)
.join('');
}
#nextId(): ElementId {
this.#counter += 1;
return { clientId: this.#clientId, counter: this.#counter };
}
#visibleNodes(): readonly SequenceNode[] {
const children = new Map<string, SequenceNode[]>();
for (const node of this.#nodes.values()) {
const parentKey = elementKeyOrHead(node.after);
const siblings = children.get(parentKey) ?? [];
siblings.push(node);
children.set(parentKey, siblings);
}
for (const siblings of children.values()) {
siblings.sort((left, right) => compareElementId(left.id, right.id));
}
const ordered: SequenceNode[] = [];
const visiting = new Set<string>();
const visit = (parentKey: string): void => {
for (const node of children.get(parentKey) ?? []) {
const key = elementKey(node.id);
if (visiting.has(key)) {
throw new Error('Cycle detected in sequence updates');
}
visiting.add(key);
if (!this.#tombstones.has(key)) {
ordered.push(node);
}
visit(key);
visiting.delete(key);
}
};
visit('HEAD');
return ordered;
}
#validateId(id: ElementId): void {
if (id.clientId.length === 0 || !Number.isSafeInteger(id.counter)) {
throw new Error('Invalid element id');
}
}
}
function elementKeyOrHead(id: ElementId | null): string {
return id ? elementKey(id) : 'HEAD';
}
9.3 CRDT 收敛测试
import assert from 'node:assert/strict';
import test from 'node:test';
import { CollaborativeSequence } from './collaborative-sequence';
test('concurrent inserts converge regardless of delivery order', () => {
const left = new CollaborativeSequence('left');
const right = new CollaborativeSequence('right');
const [leftUpdate] = left.insert(0, 'A');
const [rightUpdate] = right.insert(0, 'B');
left.apply(rightUpdate);
right.apply(leftUpdate);
assert.equal(left.toString(), right.toString());
});
test('duplicate updates are idempotent', () => {
const left = new CollaborativeSequence('left');
const right = new CollaborativeSequence('right');
const [update] = left.insert(0, 'A');
right.apply(update);
right.apply(update);
assert.equal(right.toString(), 'A');
});
test('delete may arrive before insert', () => {
const source = new CollaborativeSequence('source');
const replica = new CollaborativeSequence('replica');
const [insertUpdate] = source.insert(0, 'A');
const deleteUpdate = source.delete(0);
replica.apply(deleteUpdate);
replica.apply(insertUpdate);
assert.equal(replica.toString(), '');
});
9.4 这个教学实现没有解决什么
- 未验证远端
clientId身份是否被伪造; - 未处理缺失父节点的请求和长期 orphan 清理;
- 未做 tombstone GC;
- 未编码 state vector 或差量同步;
- 未优化树遍历和大文档性能;
- 未解决富文本 mark、block tree 和移动操作;
- 只按 Unicode code point,不等于用户感知的 grapheme cluster;
- 排序规则保证收敛,但不保证最佳的人类输入意图。
这些正是生产级 CRDT 库的主要价值。
10. 使用 Yjs 实现生产级协作
Context7 核对的官方能力包括:
Y.Doc管理共享类型;Y.Text、Y.Map、Y.Array表达共享状态;Y.encodeStateVector描述本地已知状态;Y.encodeStateAsUpdate(doc, targetStateVector)只编码对方缺失的差量;Y.applyUpdate可重复应用更新;- transaction origin 可用于识别更新来源并避免 Provider 回环;
- Awareness 与文档状态分离,不应作为持久化正文的一部分。
10.1 安装
npm install yjs y-websocket y-indexeddb
生产项目应锁定经过回归测试的具体版本,并在升级 Yjs、Provider 或编辑器绑定时执行兼容性测试。
10.2 浏览器端协作文档封装
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';
export interface CollaborationUser {
readonly id: string;
readonly name: string;
readonly color: string;
}
export interface YjsSessionOptions {
readonly documentId: string;
readonly websocketUrl: string;
readonly accessToken: string;
readonly user: CollaborationUser;
}
export class YjsCollaborationSession {
public readonly document: Y.Doc;
public readonly content: Y.Text;
public readonly metadata: Y.Map<unknown>;
readonly #provider: WebsocketProvider;
readonly #persistence: IndexeddbPersistence;
public constructor(options: YjsSessionOptions) {
this.document = new Y.Doc({ gc: true });
this.content = this.document.getText('content');
this.metadata = this.document.getMap<unknown>('metadata');
this.#persistence = new IndexeddbPersistence(
`collaboration:${options.documentId}`,
this.document,
);
this.#provider = new WebsocketProvider(
options.websocketUrl,
options.documentId,
this.document,
{
connect: true,
params: { token: options.accessToken },
},
);
this.#provider.awareness.setLocalStateField('user', options.user);
}
/** 等待 IndexedDB 本地状态完成装载。 */
public async waitForLocalState(): Promise<void> {
await this.#persistence.whenSynced;
}
/** 更新临时选区;它不会写入持久化文档。 */
public setSelection(
selection: Readonly<{ anchor: number; head: number }> | null,
): void {
this.#provider.awareness.setLocalStateField('selection', selection);
}
/** 注册正文变化监听并返回取消函数。 */
public observeContent(listener: (value: string) => void): () => void {
const observer = (): void => listener(this.content.toString());
this.content.observe(observer);
return () => this.content.unobserve(observer);
}
/** 对称释放连接、存储绑定和文档资源。 */
public destroy(): void {
this.#provider.awareness.setLocalState(null);
this.#provider.destroy();
this.#persistence.destroy();
this.document.destroy();
}
}
安全注意:查询参数中的短期 access token 可能进入反向代理访问日志。更稳妥的方式是使用受控 Cookie、WebSocket 子协议或连接后第一帧鉴权,并对日志做脱敏。具体方式取决于网关能力。
10.3 基于 State Vector 的差量同步
import * as Y from 'yjs';
/** 生成 remoteDocument 缺失的更新。 */
export function encodeMissingUpdate(
localDocument: Y.Doc,
remoteStateVector: Uint8Array,
): Uint8Array {
return Y.encodeStateAsUpdate(localDocument, remoteStateVector);
}
/** 将来自可信同步层的二进制更新应用到文档。 */
export function applyRemoteUpdate(
document: Y.Doc,
update: Uint8Array,
origin: object,
): void {
Y.applyUpdate(document, update, origin);
}
Provider 应使用稳定 origin 标识自己:
import * as Y from 'yjs';
export class CustomYjsProvider {
readonly #origin = this;
readonly #document: Y.Doc;
readonly #send: (update: Uint8Array) => void;
public constructor(
document: Y.Doc,
send: (update: Uint8Array) => void,
) {
this.#document = document;
this.#send = send;
this.#document.on('update', this.#handleLocalUpdate);
}
public receive(update: Uint8Array): void {
Y.applyUpdate(this.#document, update, this.#origin);
}
public destroy(): void {
this.#document.off('update', this.#handleLocalUpdate);
}
readonly #handleLocalUpdate = (
update: Uint8Array,
origin: unknown,
): void => {
if (origin !== this.#origin) {
this.#send(update);
}
};
}
10.4 服务端更新存储接口
export interface StoredYjsUpdate {
readonly sequence: number;
readonly payload: Uint8Array;
readonly createdAt: Date;
}
export interface YjsUpdateStore {
append(
documentId: string,
update: Uint8Array,
): Promise<Readonly<{ sequence: number }>>;
listAfter(
documentId: string,
sequence: number,
): Promise<readonly StoredYjsUpdate[]>;
replaceWithSnapshot(
documentId: string,
snapshot: Uint8Array,
compactedThrough: number,
): Promise<void>;
}
写入流程至少要保证:
- 鉴权和文档 ACL 已完成;
- 单条 update 大小受限;
- update 被成功持久化后再向其他节点确认;
- 广播可重复,接收端按 Yjs 更新语义幂等应用;
- 定期生成快照并记录压缩边界;
- 快照写入成功后,才能清理已覆盖的旧日志;
- 需要单独记录“谁在何时通过哪个会话提交”,因为二进制 CRDT update 不是完整业务审计日志。
10.5 Yjs GC 的边界
new Y.Doc({ gc: true }) 适合大多数在线协作文档,但以下场景必须单独验证:
- 是否需要永久恢复旧选区锚点;
- 是否支持从非常旧的离线副本回来;
- 是否需要完整历史时间旅行;
- 是否允许硬删除用户数据;
- 编辑器 binding 是否依赖已删除结构。
不要简单地把 gc: false 当作“保留历史功能”。它会增加文档体积,仍不能替代业务级版本快照和审计系统。
11. 使用 ShareDB 实现 OT 协作
Context7 核对的 ShareDB 官方文档表明:
- ShareDB 是基于 OT 的实时 JSON 文档后端;
- Backend 默认可以使用内存 DB / PubSub,生产环境应配置持久化适配器;
- 客户端通过
Connection#get获得文档,再调用subscribe、create、submitOp; - 默认 JSON0 operation 可以表示对象、数组、数值和字符串变化;
- Backend middleware 可用于权限、查询频道和提交治理;
- Presence 需要在 Backend 配置中显式启用。
11.1 安装
npm install express sharedb ws @teamwork/websocket-json-stream
npm install reconnecting-websocket
npm install --save-dev @types/express @types/node @types/ws typescript
11.2 Node.js 服务端
import { createServer } from 'node:http';
import express from 'express';
import ShareDB from 'sharedb';
import WebSocketJSONStream from '@teamwork/websocket-json-stream';
import { WebSocketServer } from 'ws';
const app = express();
const httpServer = createServer(app);
const webSocketServer = new WebSocketServer({ server: httpServer });
const backend = new ShareDB({
presence: true,
maxSubmitRetries: 10,
errorHandler: (error: Error): void => {
console.error('ShareDB error', error);
},
});
webSocketServer.on('connection', (webSocket, request) => {
// 生产环境必须在 backend.listen 之前完成身份认证和 Origin 校验。
const stream = new WebSocketJSONStream(webSocket);
backend.listen(stream, request);
});
httpServer.listen(8080, () => {
console.log('ShareDB server listening on http://localhost:8080');
});
默认内存适配器只适合本地开发和测试。多实例生产部署还需要共享数据库和 PubSub,否则不同实例无法形成一致的提交与广播链路。
11.3 浏览器客户端
import ReconnectingWebSocket from 'reconnecting-websocket';
import ShareDB from 'sharedb/lib/client';
interface SharedNote {
title: string;
content: string;
updatedAt: number;
}
export class SharedbNoteSession {
readonly #socket: ReconnectingWebSocket;
readonly #connection: ShareDB.Connection;
readonly #document: ShareDB.Doc;
public constructor(documentId: string) {
this.#socket = new ReconnectingWebSocket('ws://localhost:8080', [], {
// ShareDB 自己处理断线恢复;Socket 层额外缓存消息会产生未定义行为。
maxEnqueuedMessages: 0,
});
this.#connection = new ShareDB.Connection(this.#socket);
this.#document = this.#connection.get('notes', documentId);
}
/** 订阅文档;不存在时创建默认值。 */
public subscribe(): Promise<void> {
return new Promise((resolve, reject) => {
this.#document.subscribe((subscribeError?: Error) => {
if (subscribeError) {
reject(subscribeError);
return;
}
if (this.#document.type) {
resolve();
return;
}
const initialValue: SharedNote = {
title: '',
content: '',
updatedAt: Date.now(),
};
this.#document.create(initialValue, (createError?: Error) => {
if (createError) {
reject(createError);
} else {
resolve();
}
});
});
});
}
/** 替换标题;source 用于在 UI binding 中识别本地回显。 */
public setTitle(title: string, source: object): void {
const operation = [{ p: ['title'], od: this.data.title, oi: title }];
this.#document.submitOp(operation, { source });
}
/** 订阅 operation,并返回取消函数。 */
public onOperation(
listener: (isLocalSource: boolean) => void,
): () => void {
const handler = (_operation: unknown, source: unknown): void => {
listener(Boolean(source));
};
this.#document.on('op', handler);
return () => this.#document.removeListener('op', handler);
}
public get data(): SharedNote {
return this.#document.data as SharedNote;
}
public destroy(): void {
this.#document.destroy();
this.#connection.close();
this.#socket.close();
}
}
ShareDB 不同版本和构建入口的 TypeScript 类型导出可能存在差异。落地时应以项目锁定版本的声明文件为准;不要用全局 any 绕过类型问题,可在项目内增加最小、可审计的模块声明适配层。
11.4 JSON0 operation 示例
const operations = {
replaceObjectField: [
{ p: ['title'], od: 'Old title', oi: 'New title' },
],
incrementNumber: [
{ p: ['viewCount'], na: 1 },
],
insertArrayItem: [
{ p: ['tags', 0], li: 'collaboration' },
],
insertText: [
{ p: ['content', 5], si: 'hello' },
],
deleteText: [
{ p: ['content', 5], sd: 'hello' },
],
} as const;
操作中的旧值不是装饰信息。服务端 transform、校验和逆操作可能依赖它,必须由绑定层根据当前文档状态正确生成。
11.5 权限中间件原则
服务端授权至少按以下维度判断:
interface OperationAuthorizationContext {
readonly userId: string;
readonly tenantId: string;
readonly collection: string;
readonly documentId: string;
readonly operationPaths: readonly (readonly (string | number)[])[];
}
不要只检查“用户能否连接 WebSocket”。还应限制:
- 是否可读该 collection / document;
- 是否可创建或删除文档;
- operation 是否越过允许字段;
- 单次 operation 数量和载荷大小;
- 是否能修改所有者、租户、审批状态等服务端字段;
- 是否超出速率限制;
- 是否来自当前有效会话。
12. 使用 Automerge Repo 实现 Local-first
Context7 核对的 Automerge Repo 能力包括:
Repo管理多个 Automerge 文档;repo.create创建文档并返回DocHandle;repo.find从本地存储或网络查找文档;DocHandle#change修改文档;- 存储和网络通过 Adapter 组合;
- 浏览器可组合 IndexedDB、BroadcastChannel 和 WebSocket;
repo.export/repo.import处理二进制文档;repo.delete只删除本地副本,不等价于删除其他 Peer 上的数据。
12.1 浏览器端示例
npm install @automerge/automerge-vanillajs
import {
BroadcastChannelNetworkAdapter,
IndexedDBStorageAdapter,
Repo,
WebSocketClientAdapter,
type AutomergeUrl,
type DocHandle,
} from '@automerge/automerge-vanillajs';
interface NoteDocument {
title: string;
paragraphs: string[];
tags: string[];
}
export class AutomergeNoteRepository {
readonly #repo: Repo;
public constructor(syncServerUrl: string) {
this.#repo = new Repo({
storage: new IndexedDBStorageAdapter('collaborative-notes'),
network: [
new BroadcastChannelNetworkAdapter({
channelName: 'collaborative-notes',
}),
new WebSocketClientAdapter(syncServerUrl),
],
saveDebounceRate: 200,
});
}
/** 创建一个 Local-first 文档。 */
public create(): DocHandle<NoteDocument> {
return this.#repo.create<NoteDocument>({
title: '',
paragraphs: [],
tags: [],
});
}
/** 通过持久化 URL 重新打开文档。 */
public find(url: AutomergeUrl): Promise<DocHandle<NoteDocument>> {
return this.#repo.find<NoteDocument>(url);
}
/** 导出可备份的二进制文档。 */
public async export(url: AutomergeUrl): Promise<Uint8Array> {
const binary = await this.#repo.export(url);
if (!binary) {
throw new Error(`Document is unavailable: ${url}`);
}
return binary;
}
/** 刷新待写数据并释放网络适配器。 */
public shutdown(): Promise<void> {
return this.#repo.shutdown();
}
}
使用:
const repository = new AutomergeNoteRepository('wss://sync.example.com');
const handle = repository.create();
handle.on('change', ({ doc }) => {
console.log(doc.title, doc.paragraphs.length);
});
handle.change((document) => {
document.title = 'OT 与 CRDT';
document.paragraphs.push('Local-first collaboration');
});
localStorage.setItem('noteUrl', handle.url);
12.2 Automerge 的权限与删除边界
- 文档 URL 是定位符,不应自动被视为完整授权凭证。
- Sync Server 仍需认证 Peer,并控制允许共享的 document ID。
sharePolicy应按租户、用户和文档 ACL 决定,不应无条件返回true。- 本地
repo.delete不会从其他 Peer 删除文档。 - GDPR / 隐私删除需要单独设计密钥销毁、服务端清理、备份过期和旧客户端拒绝同步策略。
13. 编辑器集成
13.1 为什么不能直接监听 input 后全量覆盖
以下做法会破坏协同语义:
editor.onChange((nextHtml) => {
sharedMap.set('html', nextHtml);
});
问题包括:
- 每次输入变成整个文档字段的并发覆盖;
- 光标和选区无法使用稳定相对位置;
- 两个用户修改不同段落仍可能冲突;
- HTML 序列化差异制造无意义更新;
innerHTML还引入 XSS 风险。
13.2 正确分层
Editor Transaction
<-> Binding Adapter
<-> OT Operation / CRDT Shared Type
<-> Provider
Binding 层负责:
- 把编辑器 transaction 转为共享操作;
- 把远端共享操作映射回编辑器 transaction;
- 防止本地更新回环;
- 映射光标和选区;
- 协调 IME composition;
- 维护每个用户独立的 undo manager;
- 对粘贴内容做 Schema 归一化和安全净化。
13.3 编辑器选型关注点
| 编辑器 | 关注点 |
|---|---|
| ProseMirror / Tiptap | 节点 Schema、transaction、Yjs binding、undo manager |
| CodeMirror / Monaco | 文本模型、selection、增量 edit、超大文件性能 |
| Slate / Lexical | 节点 key、normalize 规则和社区 binding 成熟度 |
| Canvas / Whiteboard | 元素 identity、z-order、批量拖动、临时预览与最终提交分离 |
富文本协作最危险的问题之一是:不同客户端运行不同 Schema 或 normalize 逻辑。协议升级必须考虑旧客户端是否会删除它不认识的节点。
14. 持久化、快照与压缩
14.1 推荐存储模型
CREATE TABLE collaboration_documents (
tenant_id VARCHAR(64) NOT NULL,
document_id VARCHAR(128) NOT NULL,
snapshot_version BIGINT NOT NULL DEFAULT 0,
snapshot_bytes BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, document_id)
);
CREATE TABLE collaboration_updates (
tenant_id VARCHAR(64) NOT NULL,
document_id VARCHAR(128) NOT NULL,
sequence BIGSERIAL PRIMARY KEY,
actor_id VARCHAR(128) NOT NULL,
session_id VARCHAR(128) NOT NULL,
update_bytes BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX collaboration_updates_document_sequence_idx
ON collaboration_updates (tenant_id, document_id, sequence);
数据库列类型和自增策略需要按 PostgreSQL、MySQL 或分布式数据库调整。关键不是照抄 DDL,而是明确:
- 文档身份必须包含租户边界;
- 快照和 update log 有明确压缩水位;
- 每条更新关联 actor / session,便于安全审计;
- 二进制内容有大小上限;
- 清理任务可恢复、可重试、可观测。
14.2 快照流程
1. 读取当前 snapshotVersion 后的 updates
2. 在隔离进程中恢复文档
3. 校验恢复结果和文档大小
4. 生成新 snapshot
5. 事务性写入 snapshot + compactedThrough
6. 延迟删除已覆盖 update log
7. 保留最近若干恢复点并验证备份
14.3 不要把压缩做成阻塞热路径
压缩通常是 CPU 和内存密集操作。不要在每次用户按键后生成完整快照,也不要让单个超大文档阻塞整个 Node.js event loop。
可选策略:
- 每 N 条更新;
- 增量日志超过 N MB;
- 文档空闲窗口;
- 后台 Worker / 独立服务;
- 按文档分片的任务队列;
- 快照失败只报警,不删除原日志。
14.4 历史版本不是简单保留所有 CRDT 元数据
产品层的“版本历史”通常需要:
- 命名版本;
- 作者和时间范围;
- 可读 diff;
- 恢复前预览;
- 恢复动作本身可审计;
- 保留策略和法务删除。
应建立业务快照,不要直接把内部 update ID 暴露给用户。
15. Presence、光标与选区
15.1 Presence 的典型数据
interface PresenceState {
readonly user: Readonly<{
id: string;
displayName: string;
color: string;
}>;
readonly cursor: Readonly<{
anchor: string;
head: string;
}> | null;
readonly viewport: Readonly<{
x: number;
y: number;
zoom: number;
}> | null;
readonly lastActiveAt: number;
}
实际选区不要长期保存绝对字符索引。并发插入后绝对位置会漂移,应使用库提供的 relative position、元素 ID 或编辑器 binding 的位置映射。
15.2 生命周期规则
- Presence 带 session / client identity,不只带 user ID;同一用户可能开多个标签页。
- 心跳超时后自动移除,不等待客户端优雅下线。
- 以最新状态覆盖旧状态,不把每次鼠标移动写入日志。
- 光标更新限频,例如 20–50 ms 合并一次;视产品体验和带宽调整。
- 页面隐藏时降低频率或清除临时状态。
- 不在 Presence 中放 access token、邮箱、内部角色详情等敏感信息。
16. 权限、安全与多租户
16.1 最小安全链路
TLS -> Origin 校验 -> 身份认证 -> 租户解析 -> 文档 ACL
-> 消息类型校验 -> 二进制 / JSON 大小限制 -> 更新频率限制
-> Schema / 字段权限 -> 持久化 -> 广播 -> 审计
16.2 不可信输入校验
即使 CRDT update 是二进制,也不能默认可信。服务端至少限制:
- 单帧字节数;
- 单会话每秒 update 数;
- 单文档累计大小;
- 单用户并发连接数;
- 解码和应用 update 的 CPU / 内存预算;
- 文档 ID、租户 ID 和房间 ID 格式;
- Presence JSON 深度和字符串长度;
- 不支持的协议版本。
16.3 字段级权限
如果普通编辑者只能改正文,不能改 ownerId,最安全的方式是:
ownerId根本不放进客户端可写协作文档;或- 服务端在隔离副本中应用 update,比较受保护字段是否变化,再决定是否接受;或
- 将不同权限域拆为独立子文档和独立访问票据。
只在 UI 上隐藏按钮不构成权限控制。
16.4 Token 与日志
- WebSocket access token 使用短有效期和单文档 scope。
- 不在公开 URL 中放长期 token。
- 日志只记录 token 指纹或会话 ID,不记录完整凭证。
- 更新正文可能包含隐私数据,错误日志不可直接 dump 二进制还原内容。
- 多租户缓存 key、数据库 key、PubSub channel 都必须包含 tenant ID。
17. 性能与容量规划
17.1 关键变量
总带宽 ≈ 在线文档数 × 每文档在线人数 × 人均更新速率 × 平均更新大小 × 广播放大
还需要考虑:
- 初次同步快照大小;
- 断线后的补同步峰值;
- Presence 高频广播;
- 压缩和解码 CPU;
- 单文档热点导致的实例倾斜;
- 数据库写放大和索引开销。
17.2 客户端优化
- 编辑器 transaction 合理批处理,但不要引入明显输入延迟。
- Presence 限频并丢弃过时消息。
- 大文档按业务块拆分,而不是永远维持一个无界文档。
- 非活动标签页暂停非必要计算。
- 对本地存储设置配额失败和清理策略。
- 不要在每个 update 上执行完整
toJSON()、全文 diff 或 React 全树更新。 - observer 回调只更新受影响视图。
17.3 服务端优化
- 按 document ID 做一致性路由或共享 PubSub。
- 对热点文档设置独立限额与隔离。
- 二进制 update 原样存储,避免无意义的 Base64 膨胀;跨 JSON 边界时才编码。
- 快照放后台 Worker。
- 监控 event loop lag、文档驻留内存和单次 update 应用耗时。
- 对慢客户端执行背压或断开,不无限缓存广播队列。
17.4 文档拆分
一个“工作空间”不一定等于一个 CRDT 文档。可拆为:
workspace metadata -> 服务端数据库
page index -> 小型共享文档
page content / canvas -> 每页独立共享文档
comments -> 独立协作文档或普通数据库
presence -> 临时通道
permissions / billing -> 领域数据库
拆分可以降低首屏加载和热点广播,但跨文档原子事务会更困难,需要产品层接受最终一致或通过服务端命令协调。
18. 测试与质量门禁
18.1 算法性质测试
对自定义操作层至少验证:
Convergence:同一更新集合在不同顺序下结果一致
Commutativity:适用模型下 merge(a, b) == merge(b, a)
Associativity:merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotence:重复应用 update 不改变结果
Causality:依赖缺失时缓存或请求补齐,而不是静默破坏状态
18.2 基于随机调度的测试
interface NetworkEnvelope<TUpdate> {
readonly targetReplica: number;
readonly update: TUpdate;
}
/** 使用确定性种子打乱、复制和延迟消息,验证副本最终收敛。 */
export function deliverWithFaults<TUpdate>(
envelopes: readonly NetworkEnvelope<TUpdate>[],
apply: (replica: number, update: TUpdate) => void,
schedule: readonly number[],
): void {
for (const envelopeIndex of schedule) {
const envelope = envelopes[envelopeIndex];
if (!envelope) {
throw new RangeError(`Invalid envelope index: ${envelopeIndex}`);
}
apply(envelope.targetReplica, envelope.update);
}
}
测试生成器应覆盖:
- 并发同位置插入;
- 交叉区间删除;
- 删除后引用;
- 重复 update;
- delete 先于 insert;
- 客户端离线产生大量更新;
- 快照与增量交界;
- 中途重启服务端;
- 不同协议版本客户端共存。
18.3 编辑器 E2E
至少运行两个真实浏览器上下文:
- 同时输入同一段;
- 中文输入法 composition;
- 一端选区删除,另一端插入;
- 撤销只撤销当前用户操作;
- 复制粘贴复杂富文本;
- 一端离线编辑后重连;
- 页面刷新从 IndexedDB 恢复;
- 权限在会话中途被撤销;
- 旧客户端打开新 Schema 文档;
- Presence 在异常断线后按 TTL 消失。
18.4 故障注入
- 丢包、延迟、乱序和重复帧;
- WebSocket 半开连接;
- PubSub 暂时不可用;
- 数据库写超时;
- 快照任务被杀死;
- 本地存储配额不足;
- 超大恶意 update;
- 同一 token 并发建立大量连接。
浏览器 DevTools 的普通网络限速不能覆盖服务端 PubSub、数据库和多实例路由故障,应使用代理、故障注入层或测试环境网络策略。
19. 可观测性与故障处理
19.1 核心指标
| 指标 | 目的 |
|---|---|
| active_connections | 容量与连接泄漏 |
| active_documents | 热点和内存驻留 |
| update_bytes / update_rate | 流量和滥用识别 |
| sync_latency | 用户可见同步延迟 |
| initial_sync_duration | 首次打开体验 |
| reconnect_count | 网络和部署稳定性 |
| pending_outbound_bytes | 慢客户端背压 |
| snapshot_duration / failures | 压缩链路健康度 |
| document_size / log_size | GC 和压缩策略 |
| rejected_updates | 权限、Schema 和攻击检测 |
| event_loop_lag | Node.js 服务过载 |
19.2 Trace 关联字段
interface CollaborationTraceContext {
readonly traceId: string;
readonly tenantId: string;
readonly documentIdHash: string;
readonly sessionId: string;
readonly actorIdHash: string;
readonly protocol: 'yjs' | 'sharedb' | 'automerge';
readonly updateBytes: number;
}
对 document ID、actor ID 是否哈希取决于内部合规要求。不要把正文、完整 update 或 access token 作为普通 trace attribute。
19.3 恢复策略
- 数据库写失败:不确认更新,客户端重试;
- 广播失败但已持久化:通过日志补拉,广播可重试;
- 快照损坏:回退前一快照并重放日志;
- 单文档无法解码:隔离文档,不拖垮整个进程;
- PubSub 分区:禁止两个实例在没有一致性保障时都假装权威;
- 权限撤销:关闭现有会话,并让后续 update 全部失败;
- 客户端协议过旧:明确返回升级错误,不静默丢字段。
20. 迁移策略
20.1 从普通表单迁移到协同文档
推荐渐进步骤:
- 保留原数据库记录作为权威读模型;
- 仅将正文或画布等高价值字段迁入协作文档;
- 建立
businessRecordId <-> collaborationDocumentId映射; - 双写阶段只允许一个方向权威,避免循环覆盖;
- 用影子读取比较渲染结果;
- 逐步开放多人编辑;
- 稳定后停止旧字段直接写入;
- 保留可回滚快照。
20.2 OT 与 CRDT 互迁
不要尝试逐条无损翻译两套内部 operation。通常更可靠的是:
冻结旧系统写入 -> 导出业务快照 -> 转换为新共享模型
-> 生成新系统初始文档 -> 校验 -> 切换连接入口
-> 旧日志只读归档
如果要求不停机迁移,需要设计双协议网关和单向权威桥接,复杂度远高于普通数据迁移,必须单独做形式化和故障测试。
20.3 Schema 升级
共享文档 Schema 需要版本化:
interface VersionedDocument<TData> {
readonly schemaVersion: number;
readonly data: TData;
}
迁移函数应满足:
- 幂等;
- 可在服务端隔离执行;
- 旧客户端不会把新字段 normalize 掉;
- 大文档迁移有超时和回滚;
- 迁移结果生成新快照;
- 迁移动作可审计。
21. 常见反模式
21.1 用 Last Write Wins 伪装协同编辑
整个文档字段按最后更新时间覆盖,只能保证得到一个结果,不能保留并发编辑。
21.2 把 Presence 持久化
鼠标移动和光标位置进入 update log,会制造巨大写放大、隐私问题和过期状态。
21.3 自研算法后只测两个示例
并发算法的错误往往只在三方并发、乱序、重复、删除重叠和长时间离线后出现。没有 property-based / model-based 测试就不能证明可用。
21.4 每次输入保存完整 JSON / HTML
这会放大网络和存储,并把原本可合并的局部修改退化成整个字段冲突。
21.5 CRDT 文档承载所有业务状态
权限、审批、账务和唯一性约束会变得难以撤销和审计。
21.6 认为最终一致就不需要服务端
实际生产仍需要认证、ACL、限流、持久化、备份、数据删除、滥用治理和可观测性。
21.7 无界文档和永久历史
长期不拆分、不快照、不压缩,最终会造成首开慢、内存高和恢复时间不可控。
21.8 客户端收到远端更新后再次提交
缺少 source / origin 过滤会形成回环广播。Yjs Provider 使用 transaction origin;ShareDB binding 使用 source 标识本地提交。
21.9 多实例只加 Redis 广播就宣布可扩展
还必须验证提交顺序、数据库原子性、重复投递、实例重启、热点文档路由和分区期间行为。
21.10 只验证英文键盘输入
中文、日文、韩文输入法的 composition 事件、emoji、组合字符和双向文本都可能暴露位置模型错误。
22. 上线检查清单
数据边界
- 明确哪些字段属于共享文档。
- Presence 与持久化数据分离。
- 账务、库存、审批和权限仍由领域服务权威管理。
- 文档、租户和用户身份不可由客户端任意声明。
同步正确性
- 重复、乱序、延迟和断线更新测试通过。
- 离线副本回归测试通过。
- 客户端升级和协议版本策略明确。
- 编辑器 undo / redo、IME、粘贴和选区测试通过。
存储与恢复
- update / operation log 已持久化。
- 快照和压缩任务可重试、可观测。
- 已执行从备份恢复演练。
- 单文档损坏可隔离。
- 数据保留和删除策略满足合规要求。
安全
- TLS、Origin、认证和文档 ACL 全部启用。
- 单帧、单秒、单文档和单用户均有限额。
- 受保护字段不能由协同 update 修改。
- access token 不进入公开日志。
- Presence 不泄露敏感信息。
性能
- 最大文档首次加载和断线补同步达标。
- 热点文档压测完成。
- 慢客户端背压策略生效。
- Presence 与正文更新通道不会互相拖垮。
- 快照不会阻塞服务主线程。
运维
- 核心指标、日志和 Trace 已接入。
- 数据库、PubSub 和 WebSocket 故障演练完成。
- 权限撤销能关闭既有连接。
- 灰度和回滚不破坏文档协议兼容性。
- 值班手册包含热点文档、损坏文档和同步延迟处置流程。
23. 权威资料
以下资料通过 Context7 查询并以官方文档 / 官方仓库为主进行核对:
Yjs
- Yjs 官方文档:https://docs.yjs.dev/
- Yjs Document Updates:https://docs.yjs.dev/api/document-updates
- Yjs GitHub:https://github.com/yjs/yjs
- y-websocket:https://github.com/yjs/y-websocket
- y-indexeddb:https://github.com/yjs/y-indexeddb
ShareDB
- ShareDB GitHub:https://github.com/share/sharedb
- ShareDB Getting Started:https://github.com/share/sharedb/blob/master/docs/getting-started.md
- ShareDB Backend API:https://share.github.io/sharedb/api/backend
- ShareDB JSON0:https://github.com/ottypes/json0
Automerge
- Automerge 官方文档:https://automerge.org/docs/
- Automerge GitHub:https://github.com/automerge/automerge
- Automerge Repo:https://github.com/automerge/automerge-repo
理论与 Local-first
- Local-first software:https://www.inkandswitch.com/essay/local-first/
- A comprehensive study of Convergent and Commutative Replicated Data Types:https://inria.hal.science/inria-00555588
核对说明
- Context7 Library ID:
/yjs/docs、/share/sharedb、/automerge/automerge-repo。 - 本文示例采用严格 TypeScript 风格,但第三方包的导出路径和类型声明可能随版本变化;落地时应以项目锁定版本的官方声明文件为准。
- 教学版 OT / CRDT 实现用于理解算法边界,不代表生产级完备实现。
- 快速变化的 API、Provider 配置和包入口最后核对于 2026 年 7 月 27 日。