跳到主要内容

OT与CRDT协同编辑工程实践

面向需要设计、实现和上线多人实时协作系统的前端与全栈工程师。

文档状态:独立根目录工程手册,不自动同步到 agent-docs-site

官方资料核对日期:2026 年 7 月 27 日。

目录


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 最重要的工程判断

  1. OT / CRDT 只解决共享状态的一致性,不自动解决权限、审核、业务约束和数据删除。
  2. Presence 不应写进持久化文档。 在线状态、临时光标、正在输入提示应走可过期的独立通道。
  3. 协同数据结构不能替代领域 API。 “账户余额减 100”应该是服务端命令,而不是任意客户端合并字段。
  4. 富文本不能只按纯字符串处理。 格式标记、节点树、选区锚点和撤销栈都需要编辑器绑定层参与。
  5. 不要自研生产级 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 的含义

设并发操作为 AB,需要计算:

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 对比

维度OTCRDT
核心机制对并发 operation 做转换通过稳定身份、因果信息和合并规则收敛
常见拓扑中心化 Client / ServerC/S、P2P、多 Provider、Local-first
离线时长可缓存,但重连与 rebase 复杂通常是主要能力之一
服务端权威可强可弱,取决于接入层和权限模型
审计方式revision + operation log 直观update / change log + snapshot,需要额外业务审计层
数据开销操作通常较小可能包含较多身份和历史元数据
算法难点Transform 正确性与客户端状态机数据结构、因果同步、GC 与权限撤销
乱序 / 重复由协议和 revision 处理通常天然考虑幂等、乱序和缺失更新
P2P不自然较自然
典型实现ShareDBYjs、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.TextY.MapY.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>;
}

写入流程至少要保证:

  1. 鉴权和文档 ACL 已完成;
  2. 单条 update 大小受限;
  3. update 被成功持久化后再向其他节点确认;
  4. 广播可重复,接收端按 Yjs 更新语义幂等应用;
  5. 定期生成快照并记录压缩边界;
  6. 快照写入成功后,才能清理已覆盖的旧日志;
  7. 需要单独记录“谁在何时通过哪个会话提交”,因为二进制 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 获得文档,再调用 subscribecreatesubmitOp
  • 默认 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,最安全的方式是:

  1. ownerId 根本不放进客户端可写协作文档;或
  2. 服务端在隔离副本中应用 update,比较受保护字段是否变化,再决定是否接受;或
  3. 将不同权限域拆为独立子文档和独立访问票据。

只在 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

至少运行两个真实浏览器上下文:

  1. 同时输入同一段;
  2. 中文输入法 composition;
  3. 一端选区删除,另一端插入;
  4. 撤销只撤销当前用户操作;
  5. 复制粘贴复杂富文本;
  6. 一端离线编辑后重连;
  7. 页面刷新从 IndexedDB 恢复;
  8. 权限在会话中途被撤销;
  9. 旧客户端打开新 Schema 文档;
  10. 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_sizeGC 和压缩策略
rejected_updates权限、Schema 和攻击检测
event_loop_lagNode.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 从普通表单迁移到协同文档

推荐渐进步骤:

  1. 保留原数据库记录作为权威读模型;
  2. 仅将正文或画布等高价值字段迁入协作文档;
  3. 建立 businessRecordId &lt;-> collaborationDocumentId 映射;
  4. 双写阶段只允许一个方向权威,避免循环覆盖;
  5. 用影子读取比较渲染结果;
  6. 逐步开放多人编辑;
  7. 稳定后停止旧字段直接写入;
  8. 保留可回滚快照。

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

ShareDB

Automerge

理论与 Local-first

核对说明

  • Context7 Library ID:/yjs/docs/share/sharedb/automerge/automerge-repo
  • 本文示例采用严格 TypeScript 风格,但第三方包的导出路径和类型声明可能随版本变化;落地时应以项目锁定版本的官方声明文件为准。
  • 教学版 OT / CRDT 实现用于理解算法边界,不代表生产级完备实现。
  • 快速变化的 API、Provider 配置和包入口最后核对于 2026 年 7 月 27 日。