跳到主要内容

设计在线协同编辑系统

问题

如何设计一个类似 Google Docs / 飞书文档 / Notion 的在线协同编辑系统?多用户同时编辑同一份文档时,如何保证数据一致性、实时性和冲突解决?请从核心算法(OT/CRDT)、操作模型、通信协议到离线编辑等方面详细说明。

答案

在线协同编辑是前端系统设计中最具挑战性的课题之一。其核心难题在于:多个用户并发修改同一份文档时,如何让所有客户端最终收敛到一致的状态。这不是简单的"最后写入者获胜",而是需要 保留每个人的编辑意图 并以确定性方式合并冲突。

核心设计原则

协同编辑系统的本质是:在分布式环境中,实现多副本数据的最终一致性(Eventual Consistency),同时保证用户的实时编辑体验


一、需求分析

功能需求

模块功能点
实时协同多人同时编辑文档,所有人的改动实时可见
冲突解决并发编辑同一段文字时自动解决冲突,不丢失任何人的输入
光标同步显示其他协作者的光标位置和选区,支持用户颜色标识
版本历史查看文档修改历史,支持按版本/按时间回滚
评论批注对文档内容添加评论,评论跟随文档位置移动
离线编辑断网后可继续编辑,恢复网络后自动同步
权限控制查看、编辑、评论等细粒度权限管理
富文本支持标题、列表、表格、图片、代码块等富文本格式

非功能需求

指标目标
实时性操作延迟 < 100ms(同地域),用户无感知
一致性所有客户端最终收敛到相同文档状态(最终一致性)
可用性单点故障不影响编辑,支持优雅降级
并发能力单文档支持 50+ 用户同时编辑
数据安全编辑内容不丢失,支持自动保存和版本快照
可扩展支持表格、画板、演示文稿等不同文档类型
核心挑战

协同编辑面临的三大核心挑战:

  1. 并发冲突:多个用户同时修改同一位置的内容
  2. 一致性保证:不同顺序收到操作的客户端必须最终一致
  3. 实时性要求:用户输入必须立即生效,不能等待服务器确认

二、整体架构

架构分层说明

层级职责关键技术
客户端编辑器渲染、本地操作生成、冲突解决Yjs / Slate.js / ProseMirror
接入层WebSocket 长连接管理、负载均衡WebSocket、Nginx、Sticky Session
协同核心层操作转换/合并、版本管理、光标同步OT 或 CRDT 算法
存储层文档持久化、操作日志、快照PostgreSQL、Redis、Kafka

三、核心技术 —— 协同算法

协同编辑的核心问题是:两个用户同时编辑时,如何合并彼此的操作?目前业界有两种主流方案:OT(Operation Transformation)CRDT(Conflict-free Replicated Data Type)

3.1 操作模型

无论 OT 还是 CRDT,首先需要将文档编辑抽象为 操作(Operation)

types/operation.ts
/** 插入操作 */
interface InsertOp {
type: 'insert';
position: number; // 插入位置
content: string; // 插入内容
}

/** 删除操作 */
interface DeleteOp {
type: 'delete';
position: number; // 删除起始位置
length: number; // 删除长度
}

/** 保留操作(跳过指定长度的内容不修改) */
interface RetainOp {
type: 'retain';
length: number; // 保留长度
attributes?: Record<string, unknown>; // 格式属性(加粗、斜体等)
}

type Operation = InsertOp | DeleteOp | RetainOp;

/** 一次编辑动作,包含多个原子操作 */
interface Change {
ops: Operation[];
clientId: string;
revision: number; // 基于哪个版本生成的操作
timestamp: number;
}
操作模型的设计哲学

Quill 和 OT.js 等库采用 Retain-Insert-Delete 三操作模型——用 Retain 跳过不变的部分,Insert 插入新内容,Delete 删除旧内容。这种模型可以表达任意文档变更,包括格式化操作(通过 Retain + attributes)。

3.2 OT(Operation Transformation)算法

OT 是 Google Docs 采用的方案。核心思想是:当两个并发操作基于同一文档版本产生时,通过 transform 函数调整其中一个操作的参数,使两个操作无论以哪种顺序执行,最终结果都一致

transform 函数实现

ot/transform.ts
/**
* OT 核心:transform 函数
* 给定两个并发操作 opA 和 opB(基于同一版本),
* 返回 [opA', opB'],使得:
* apply(apply(doc, opA), opB') === apply(apply(doc, opB), opA')
*/
function transform(
opA: InsertOp | DeleteOp,
opB: InsertOp | DeleteOp
): [InsertOp | DeleteOp, InsertOp | DeleteOp] {

// Case 1: 两个都是 Insert
if (opA.type === 'insert' && opB.type === 'insert') {
if (opA.position < opB.position ||
(opA.position === opB.position && opA.clientId < opB.clientId)) {
// A 在前面插入,B 的位置需要后移
return [
opA,
{ ...opB, position: opB.position + opA.content.length }
];
} else {
// B 在前面插入,A 的位置需要后移
return [
{ ...opA, position: opA.position + opB.content.length },
opB
];
}
}

// Case 2: A 是 Insert,B 是 Delete
if (opA.type === 'insert' && opB.type === 'delete') {
if (opA.position <= opB.position) {
// 插入在删除前面,删除位置后移
return [
opA,
{ ...opB, position: opB.position + opA.content.length }
];
} else if (opA.position >= opB.position + opB.length) {
// 插入在删除后面,插入位置前移
return [
{ ...opA, position: opA.position - opB.length },
opB
];
} else {
// 插入在删除范围内,插入位置调整到删除起始点
return [
{ ...opA, position: opB.position },
// 删除需要拆分:删除插入点前面的 + 删除插入点后面的
{ ...opB, length: opB.length + opA.content.length }
];
}
}

// Case 3: A 是 Delete,B 是 Insert(对称处理)
if (opA.type === 'delete' && opB.type === 'insert') {
const [newB, newA] = transform(opB, opA);
return [newA, newB];
}

// Case 4: 两个都是 Delete
if (opA.type === 'delete' && opB.type === 'delete') {
if (opA.position >= opB.position + opB.length) {
// A 在 B 后面,A 的位置前移 B 的长度
return [
{ ...opA, position: opA.position - opB.length },
opB
];
} else if (opB.position >= opA.position + opA.length) {
// B 在 A 后面,B 的位置前移 A 的长度
return [
opA,
{ ...opB, position: opB.position - opA.length }
];
} else {
// 删除区间有重叠,需要处理交集
const start = Math.max(opA.position, opB.position);
const end = Math.min(
opA.position + opA.length,
opB.position + opB.length
);
const overlap = end - start;

return [
{ ...opA, position: Math.min(opA.position, opB.position), length: opA.length - overlap },
{ ...opB, position: Math.min(opA.position, opB.position), length: opB.length - overlap }
];
}
}

return [opA, opB];
}

OT 服务端控制流程

OT 通常采用 中心化服务器 模式(CS 架构):服务器维护一个操作版本号,客户端基于版本号提交操作,服务器负责 transform 和广播。

ot/server.ts
class OTServer {
private document: string = '';
private revision: number = 0;
private history: Change[] = []; // 操作历史

/**
* 接收客户端操作
* 客户端提交时携带 revision,表示"我基于第 N 个版本做的修改"
*/
receiveOperation(change: Change): Change {
// 1. 将客户端操作与它"错过"的操作逐一 transform
let transformedOps = change.ops;
for (let i = change.revision; i < this.revision; i++) {
const serverOp = this.history[i];
[transformedOps] = transformOperations(transformedOps, serverOp.ops);
}

// 2. 应用转换后的操作到服务端文档
this.document = applyOperations(this.document, transformedOps);

// 3. 记录到历史并推进版本号
const serverChange: Change = {
ops: transformedOps,
clientId: change.clientId,
revision: this.revision,
timestamp: Date.now(),
};
this.history.push(serverChange);
this.revision++;

// 4. 广播给其他客户端
this.broadcast(serverChange, change.clientId);

return serverChange;
}

private broadcast(change: Change, excludeClient: string): void {
// 向除操作发起者外的所有客户端推送
}
}

3.3 CRDT(Conflict-free Replicated Data Type)

CRDT 是一种去中心化的方案,每个字符都有全局唯一 ID,操作天然可交换(Commutative),不需要中心服务器做 transform。Yjs、Automerge 等库采用此方案。

CRDT 的核心思想

CRDT 为每个字符分配一个 全局唯一、可比较 的 ID。插入和删除都基于 ID 操作而非位置,因此操作顺序不影响最终结果——这就是"Conflict-free"的含义。

CRDT 字符标识

crdt/types.ts
/**
* CRDT 中每个字符的唯一标识
* 由 (clientId, clock) 组成 Lamport 时间戳
*/
interface CharId {
clientId: number; // 客户端唯一标识
clock: number; // 逻辑时钟(每次操作递增)
}

/**
* CRDT 文档中的字符节点
*/
interface CRDTChar {
id: CharId;
content: string;
left: CharId | null; // 左邻居(插入时的上下文)
right: CharId | null; // 右邻居(插入时的上下文)
deleted: boolean; // 墓碑标记(软删除)
attributes?: Record<string, unknown>; // 格式属性
}

简化的 CRDT 实现(基于 Yjs 思想)

crdt/yata.ts
/**
* 简化的 YATA(Yet Another Transformation Approach)实现
* 这是 Yjs 所采用的 CRDT 算法
*/
class YATADocument {
private chars: CRDTChar[] = [];
private clientId: number;
private clock: number = 0;

constructor(clientId: number) {
this.clientId = clientId;
}

/**
* 插入字符
* 关键:通过 left/right 上下文确定插入位置
*/
insert(index: number, content: string): CRDTChar {
const id: CharId = {
clientId: this.clientId,
clock: this.clock++,
};

// 找到插入位置的左右邻居
const visibleChars = this.chars.filter(c => !c.deleted);
const left = index > 0 ? visibleChars[index - 1].id : null;
const right = index < visibleChars.length ? visibleChars[index].id : null;

const char: CRDTChar = {
id,
content,
left,
right,
deleted: false,
};

// 找到正确的物理插入位置
const physicalIndex = this.findInsertPosition(char);
this.chars.splice(physicalIndex, 0, char);

return char;
}

/**
* 核心:解决并发插入时的位置冲突
* 当两个字符有相同的 left/right 时,按 clientId 排序
*/
private findInsertPosition(newChar: CRDTChar): number {
const leftIndex = newChar.left
? this.chars.findIndex(c =>
c.id.clientId === newChar.left!.clientId &&
c.id.clock === newChar.left!.clock
)
: -1;

const rightIndex = newChar.right
? this.chars.findIndex(c =>
c.id.clientId === newChar.right!.clientId &&
c.id.clock === newChar.right!.clock
)
: this.chars.length;

// 在 left 和 right 之间找到正确位置
// 如果有多个并发插入在同一位置,按 clientId 降序排列
let i = leftIndex + 1;
while (i < rightIndex) {
const existing = this.chars[i];
if (this.compareIds(existing.id, newChar.id) < 0) {
break;
}
i++;
}

return i;
}

/**
* 删除字符(墓碑标记,不物理删除)
*/
delete(index: number): void {
const visibleChars = this.chars.filter(c => !c.deleted);
if (index >= 0 && index < visibleChars.length) {
visibleChars[index].deleted = true; // 软删除
}
}

/** 比较两个字符 ID 的顺序 */
private compareIds(a: CharId, b: CharId): number {
if (a.clock !== b.clock) return a.clock - b.clock;
return a.clientId - b.clientId;
}

/** 获取可见文本 */
getText(): string {
return this.chars
.filter(c => !c.deleted)
.map(c => c.content)
.join('');
}
}
墓碑(Tombstone)与 GC

CRDT 删除字符时只做"软删除"(标记 deleted: true),不会物理移除。这是因为远程操作可能引用已删除的字符作为上下文。墓碑会持续占用内存,需要通过 垃圾回收(GC) 机制定期清理——但 GC 必须在所有客户端都确认不再需要该墓碑后才能执行。

3.4 OT vs CRDT 对比

维度OTCRDT
架构模式中心化(需要服务器协调)去中心化(P2P 友好)
一致性保证依赖 transform 函数正确性数学证明收敛(Commutative)
算法复杂度transform 函数编写困难,组合爆炸数据结构设计复杂,但操作更简单
服务端压力服务端需要做 transform 计算服务端仅做转发和持久化
内存占用较低(仅存操作日志)较高(墓碑、字符 ID)
离线支持困难(需要服务器参与 transform)天然支持(本地操作可离线合并)
实时性优秀(操作小巧)优秀(操作可直接应用)
生态Google Docs、ShareDBYjs、Automerge、Liveblocks
适用场景已有中心化服务、文档类型单一P2P、离线优先、多种文档类型
  • 操作体积小,网络传输高效
  • 不需要为每个字符维护元数据,内存友好
  • 操作历史天然有序,方便做版本回滚
  • Google Docs 验证了大规模可用性
面试建议

面试中推荐重点讲 CRDT + Yjs 方案。原因:(1) Yjs 是目前最流行的开源协同编辑库;(2) CRDT 更容易解释核心原理;(3) 现代产品(Figma、Notion)趋向于使用 CRDT 或类 CRDT 方案。


四、Yjs 实战

Yjs 是目前最流行的 CRDT 协同编辑框架,提供了完整的文档模型、同步协议和编辑器绑定。

4.1 核心概念

概念说明
Y.DocCRDT 文档实例,所有共享数据的根容器
Y.Text共享文本类型,支持富文本格式
Y.Array共享数组,支持列表操作
Y.Map共享键值对,支持嵌套数据
Y.XmlFragment共享 XML 结构,适配 ProseMirror/Tiptap
Provider同步层,负责在客户端之间同步文档更新
Awareness感知协议,同步光标、选区、在线状态等非持久化数据

4.2 基础使用

collaboration/setup.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';

// 1. 创建 CRDT 文档
const ydoc = new Y.Doc();

// 2. 获取共享文本类型
const ytext = ydoc.getText('document');

// 3. 建立 WebSocket 同步
const provider = new WebsocketProvider(
'wss://your-server.com',
'room-document-123', // 房间 ID = 文档 ID
ydoc,
{
connect: true,
// 断线重连配置
maxBackoffTime: 10000,
}
);

// 4. 监听连接状态
provider.on('status', (event: { status: string }) => {
console.log('Connection status:', event.status); // connected | disconnected
});

// 5. 绑定到 Quill 编辑器
const quill = new Quill('#editor', {
theme: 'snow',
modules: { toolbar: true },
});

const binding = new QuillBinding(ytext, quill, provider.awareness);

// 6. 监听文档变更
ytext.observe((event: Y.YTextEvent) => {
console.log('Document changed:', event.changes);
});

4.3 Awareness(光标与选区同步)

Awareness 是 Yjs 提供的 短暂状态同步 机制,用于同步光标位置、用户名、在线状态等不需要持久化的数据。

collaboration/awareness.ts
import { Awareness } from 'y-protocols/awareness';

// 用户信息类型
interface UserAwareness {
user: {
name: string;
color: string;
avatar?: string;
};
cursor?: {
anchor: number; // 选区锚点
head: number; // 选区焦点
} | null;
}

// 设置当前用户的 Awareness 状态
function setUserAwareness(
awareness: Awareness,
userInfo: UserAwareness
): void {
awareness.setLocalStateField('user', userInfo.user);
}

// 监听光标变化并同步
function syncCursor(
awareness: Awareness,
editor: { getSelection: () => { index: number; length: number } | null }
): void {
// 编辑器选区变化时更新 Awareness
const selection = editor.getSelection();
if (selection) {
awareness.setLocalStateField('cursor', {
anchor: selection.index,
head: selection.index + selection.length,
});
} else {
awareness.setLocalStateField('cursor', null);
}
}

// 渲染其他用户的光标
function renderRemoteCursors(awareness: Awareness): void {
awareness.on('change', () => {
const states = awareness.getStates();

states.forEach((state, clientId) => {
if (clientId === awareness.doc.clientID) return; // 跳过自己

const { user, cursor } = state as UserAwareness;
if (cursor) {
renderCursor(clientId, user.name, user.color, cursor);
}
});
});
}

function renderCursor(
clientId: number,
name: string,
color: string,
cursor: { anchor: number; head: number }
): void {
// 在编辑器中绘制远程用户的光标和选区
// - 光标:竖线 + 用户名标签
// - 选区:半透明背景色
console.log(`Render cursor for ${name} at position ${cursor.anchor}`);
}

4.4 版本控制(Version Vector)

Version Vector(版本向量)用于追踪每个客户端的操作进度,判断哪些操作已经被同步、哪些是新的。

collaboration/version.ts
/**
* 版本向量:记录每个客户端的最新时钟值
* 例如 { 1: 5, 2: 3 } 表示已收到 Client1 的前 5 个操作和 Client2 的前 3 个操作
*/
type VersionVector = Map<number, number>;

/**
* 计算两个版本向量的差异
* 用于增量同步:只发送对方没有的操作
*/
function getMissingOps(
local: VersionVector,
remote: VersionVector,
allOps: Map<number, CRDTChar[]>
): CRDTChar[] {
const missing: CRDTChar[] = [];

local.forEach((localClock, clientId) => {
const remoteClock = remote.get(clientId) ?? 0;
if (localClock > remoteClock) {
// 远端缺少 clientId 从 remoteClock+1 到 localClock 的操作
const clientOps = allOps.get(clientId) ?? [];
const missingOps = clientOps.filter(
op => op.id.clock > remoteClock && op.id.clock <= localClock
);
missing.push(...missingOps);
}
});

return missing;
}

/**
* Yjs 的快照机制
* 创建文档在某个时间点的快照,用于版本历史和回滚
*/
function createSnapshot(ydoc: Y.Doc): Uint8Array {
return Y.encodeStateAsUpdate(ydoc);
}

function restoreFromSnapshot(snapshot: Uint8Array): Y.Doc {
const doc = new Y.Doc();
Y.applyUpdate(doc, snapshot);
return doc;
}

/**
* 增量编码:只编码两个状态之间的差异
* 大幅减少网络传输量
*/
function encodeStateDiff(
ydoc: Y.Doc,
remoteStateVector: Uint8Array
): Uint8Array {
return Y.encodeStateAsUpdate(ydoc, remoteStateVector);
}

五、通信层设计

5.1 WebSocket 协议设计

collaboration/protocol.ts
/** 消息类型枚举 */
enum MessageType {
// 同步协议
SyncStep1 = 0, // 发送本地 StateVector,请求差异更新
SyncStep2 = 1, // 发送差异更新(响应 Step1)
Update = 2, // 增量更新(实时操作同步)

// Awareness 协议
AwarenessUpdate = 3, // 光标、在线状态同步
AwarenessQuery = 4, // 查询其他用户状态

// 业务协议
Auth = 10, // 认证
Permission = 11, // 权限变更
Comment = 12, // 评论操作
Snapshot = 13, // 快照请求/响应
}

interface ProtocolMessage {
type: MessageType;
payload: Uint8Array;
roomId: string; // 文档房间 ID
timestamp: number;
}

5.2 WebSocket 服务端

server/ws-server.ts
import { WebSocketServer, WebSocket } from 'ws';
import * as Y from 'yjs';

interface Room {
doc: Y.Doc;
clients: Map<WebSocket, { clientId: number; userId: string }>;
awareness: Map<number, unknown>;
}

class CollaborationServer {
private rooms: Map<string, Room> = new Map();
private wss: WebSocketServer;

constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.wss.on('connection', this.handleConnection.bind(this));
}

private handleConnection(ws: WebSocket, req: Request): void {
const roomId = this.extractRoomId(req);
const room = this.getOrCreateRoom(roomId);

ws.on('message', (data: Buffer) => {
this.handleMessage(ws, room, roomId, data);
});

ws.on('close', () => {
room.clients.delete(ws);
// 通知其他用户该用户离线
this.broadcastAwareness(room, ws);

// 房间为空时,持久化文档并清理
if (room.clients.size === 0) {
this.persistDocument(roomId, room.doc);
this.rooms.delete(roomId);
}
});

// 新用户加入:发送当前文档状态
this.sendSyncStep1(ws, room);
room.clients.set(ws, {
clientId: Date.now(),
userId: 'user-xxx',
});
}

private handleMessage(
ws: WebSocket,
room: Room,
roomId: string,
data: Buffer
): void {
const msgType = data[0] as MessageType;

switch (msgType) {
case MessageType.Update: {
// 收到客户端增量更新,应用到服务端文档并广播
const update = data.slice(1);
Y.applyUpdate(room.doc, update);
this.broadcastUpdate(room, ws, update);
break;
}

case MessageType.SyncStep1: {
// 收到客户端 StateVector,发送差异更新
const stateVector = data.slice(1);
const diff = Y.encodeStateAsUpdate(room.doc, stateVector);
this.send(ws, MessageType.SyncStep2, diff);
break;
}

case MessageType.AwarenessUpdate: {
// 光标状态广播给其他用户
const awarenessData = data.slice(1);
this.broadcastExcept(room, ws, data);
break;
}
}
}

private broadcastUpdate(
room: Room,
sender: WebSocket,
update: Uint8Array
): void {
const msg = new Uint8Array(1 + update.length);
msg[0] = MessageType.Update;
msg.set(update, 1);

room.clients.forEach((_, client) => {
if (client !== sender && client.readyState === WebSocket.OPEN) {
client.send(msg);
}
});
}

// ...其他辅助方法
}

5.3 断线重连与操作压缩

collaboration/reconnect.ts
class ReconnectableProvider {
private ws: WebSocket | null = null;
private ydoc: Y.Doc;
private roomId: string;
private url: string;
private pendingUpdates: Uint8Array[] = []; // 离线期间积攒的更新
private retryCount: number = 0;
private maxRetries: number = 10;

constructor(url: string, roomId: string, ydoc: Y.Doc) {
this.url = url;
this.roomId = roomId;
this.ydoc = ydoc;

// 监听文档变更,如果断线则缓存更新
this.ydoc.on('update', (update: Uint8Array) => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendUpdate(update);
} else {
this.pendingUpdates.push(update); // 离线缓存
}
});

this.connect();
}

private connect(): void {
this.ws = new WebSocket(`${this.url}/${this.roomId}`);

this.ws.onopen = () => {
this.retryCount = 0;
// 重连后:发送 StateVector 请求差异同步
const stateVector = Y.encodeStateVector(this.ydoc);
this.send(MessageType.SyncStep1, stateVector);

// 发送离线期间积攒的更新(合并后发送)
if (this.pendingUpdates.length > 0) {
const mergedUpdate = Y.mergeUpdates(this.pendingUpdates);
this.sendUpdate(mergedUpdate);
this.pendingUpdates = [];
}
};

this.ws.onclose = () => {
this.scheduleReconnect();
};
}

/**
* 指数退避重连
*/
private scheduleReconnect(): void {
if (this.retryCount >= this.maxRetries) {
console.error('Max reconnection attempts reached');
return;
}

const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
const jitter = Math.random() * 1000;

setTimeout(() => {
this.retryCount++;
this.connect();
}, delay + jitter);
}

private sendUpdate(update: Uint8Array): void {
this.send(MessageType.Update, update);
}

private send(type: MessageType, payload: Uint8Array): void {
if (this.ws?.readyState === WebSocket.OPEN) {
const msg = new Uint8Array(1 + payload.length);
msg[0] = type;
msg.set(payload, 1);
this.ws.send(msg);
}
}
}
操作压缩

Yjs 的 Y.mergeUpdates() 方法可以将多个增量更新合并为一个——合并后的更新在语义上等价于依次应用所有原始更新,但体积通常小很多。这在以下场景非常有用:

  • 断线重连:将离线期间的多次更新合并为一次发送
  • 持久化:定期将操作日志合并为快照,减少存储占用
  • 新用户加入:发送合并后的完整状态而非完整操作历史

六、权限控制

collaboration/permission.ts
/** 权限级别 */
enum Permission {
None = 0,
View = 1, // 只读
Comment = 2, // 可评论
Edit = 3, // 可编辑
Admin = 4, // 管理员(可管理权限)
}

interface DocumentPermission {
documentId: string;
userId: string;
permission: Permission;
expiresAt?: Date; // 权限过期时间
shareLink?: string; // 分享链接
}

/**
* 权限中间件:在 WebSocket 连接和消息处理时校验权限
*/
class PermissionGuard {
async checkConnection(
userId: string,
documentId: string
): Promise<Permission> {
const perm = await this.getPermission(userId, documentId);
if (perm === Permission.None) {
throw new Error('No access to this document');
}
return perm;
}

/**
* 过滤操作:只读用户不能发送编辑操作
*/
filterMessage(
permission: Permission,
msgType: MessageType
): boolean {
switch (msgType) {
case MessageType.Update:
return permission >= Permission.Edit;
case MessageType.Comment:
return permission >= Permission.Comment;
case MessageType.AwarenessUpdate:
return permission >= Permission.View;
default:
return true;
}
}

private async getPermission(
userId: string,
documentId: string
): Promise<Permission> {
// 从数据库查询权限,可加 Redis 缓存
return Permission.Edit;
}
}

七、离线编辑与同步

离线编辑是 CRDT 的天然优势。结合 IndexedDB 持久化,可以实现完整的离线体验。

collaboration/offline.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

/**
* 离线优先的协同编辑方案
* 核心思路:IndexedDB 作为本地持久化层,WebSocket 作为同步层
*/
class OfflineFirstEditor {
private ydoc: Y.Doc;
private localPersistence: IndexeddbPersistence;
private wsProvider: ReconnectableProvider | null = null;

constructor(documentId: string) {
this.ydoc = new Y.Doc();

// 1. 首先从 IndexedDB 恢复本地状态
this.localPersistence = new IndexeddbPersistence(
documentId,
this.ydoc
);

this.localPersistence.on('synced', () => {
console.log('Local data loaded from IndexedDB');
// 2. 本地数据加载完成后,建立 WebSocket 连接同步远程更新
this.connectRemote(documentId);
});
}

private connectRemote(documentId: string): void {
// WebSocket 连接(如果在线)
// 离线时 ReconnectableProvider 会缓存更新,恢复后自动同步
this.wsProvider = new ReconnectableProvider(
'wss://collab.example.com',
documentId,
this.ydoc
);
}

/**
* 获取当前网络状态
*/
getConnectionStatus(): 'online' | 'offline' | 'connecting' {
if (!this.wsProvider) return 'offline';
// 根据 WebSocket 状态返回
return 'online';
}
}

八、评论批注系统

评论需要 锚定到文档中的某个位置或区间,并且当文档内容变化时,评论锚点要跟着移动。

collaboration/comment.ts
import * as Y from 'yjs';

/**
* 评论锚点:使用 Yjs 的 RelativePosition 实现
* RelativePosition 不是绝对位置,而是相对于 CRDT 字符 ID 的位置
* 因此即使文档内容发生变化,锚点也能正确跟随
*/
interface Comment {
id: string;
authorId: string;
content: string;
anchorStart: Y.RelativePosition; // 评论区间起始
anchorEnd: Y.RelativePosition; // 评论区间结束
createdAt: number;
resolved: boolean;
replies: Reply[];
}

interface Reply {
id: string;
authorId: string;
content: string;
createdAt: number;
}

class CommentManager {
private ydoc: Y.Doc;
private ytext: Y.Text;
private comments: Y.Map<Comment>;

constructor(ydoc: Y.Doc) {
this.ydoc = ydoc;
this.ytext = ydoc.getText('document');
this.comments = ydoc.getMap('comments');
}

/**
* 创建评论:将绝对位置转为相对位置
*/
addComment(
startIndex: number,
endIndex: number,
content: string,
authorId: string
): string {
const commentId = crypto.randomUUID();

// 将绝对索引转为 RelativePosition
// RelativePosition 基于 CRDT 字符 ID,不会因文档变化而失效
const anchorStart = Y.createRelativePositionFromTypeIndex(
this.ytext,
startIndex
);
const anchorEnd = Y.createRelativePositionFromTypeIndex(
this.ytext,
endIndex
);

const comment: Comment = {
id: commentId,
authorId,
content,
anchorStart,
anchorEnd,
createdAt: Date.now(),
resolved: false,
replies: [],
};

this.comments.set(commentId, comment);
return commentId;
}

/**
* 获取评论的当前绝对位置
* 即使文档被大量编辑,RelativePosition 也能正确转回绝对位置
*/
getCommentPosition(commentId: string): { start: number; end: number } | null {
const comment = this.comments.get(commentId);
if (!comment) return null;

const start = Y.createAbsolutePositionFromRelativePosition(
comment.anchorStart,
this.ydoc
);
const end = Y.createAbsolutePositionFromRelativePosition(
comment.anchorEnd,
this.ydoc
);

if (!start || !end) return null;
return { start: start.index, end: end.index };
}
}

九、性能优化

9.1 操作合并与压缩

optimization/merge.ts
import * as Y from 'yjs';

/**
* 定时合并操作日志,减少存储和传输开销
*/
class UpdateCompactor {
private pendingUpdates: Uint8Array[] = [];
private compactTimer: ReturnType<typeof setTimeout> | null = null;
private ydoc: Y.Doc;

constructor(ydoc: Y.Doc) {
this.ydoc = ydoc;

// 收集增量更新
this.ydoc.on('update', (update: Uint8Array) => {
this.pendingUpdates.push(update);
this.scheduleCompact();
});
}

/**
* 节流合并:每 500ms 合并一次待发送的更新
*/
private scheduleCompact(): void {
if (this.compactTimer) return;

this.compactTimer = setTimeout(() => {
this.compactTimer = null;
if (this.pendingUpdates.length > 1) {
const merged = Y.mergeUpdates(this.pendingUpdates);
this.pendingUpdates = [merged];
this.onCompacted(merged);
} else if (this.pendingUpdates.length === 1) {
this.onCompacted(this.pendingUpdates[0]);
this.pendingUpdates = [];
}
}, 500);
}

private onCompacted(update: Uint8Array): void {
// 发送合并后的更新
}
}

9.2 快照与垃圾回收

optimization/snapshot-gc.ts
import * as Y from 'yjs';

/**
* 文档快照管理
* 定期创建快照用于:
* 1. 新用户加入时快速同步(发送快照而非全部操作历史)
* 2. 版本历史记录
* 3. 减少操作日志体积
*/
class SnapshotManager {
private snapshotInterval: number;
private lastSnapshotRevision: number = 0;

constructor(intervalMs: number = 5 * 60 * 1000) {
this.snapshotInterval = intervalMs;
}

/**
* 创建文档快照
*/
async createSnapshot(
ydoc: Y.Doc,
documentId: string
): Promise<void> {
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
const stateVector = Y.encodeStateVector(ydoc);

await this.saveSnapshot({
documentId,
stateUpdate, // 完整文档状态
stateVector, // 版本向量(用于增量同步)
timestamp: Date.now(),
size: stateUpdate.byteLength,
});
}

/**
* 从快照恢复 + 增量更新 = 完整文档
* 新用户加入时使用此方式,避免回放完整操作历史
*/
async loadDocument(documentId: string): Promise<Y.Doc> {
const doc = new Y.Doc();

// 1. 加载最新快照
const snapshot = await this.loadLatestSnapshot(documentId);
if (snapshot) {
Y.applyUpdate(doc, snapshot.stateUpdate);
}

// 2. 加载快照之后的增量更新
const incrementalUpdates = await this.loadUpdatesSinceSnapshot(
documentId,
snapshot?.stateVector
);
incrementalUpdates.forEach(update => {
Y.applyUpdate(doc, update);
});

return doc;
}

private async saveSnapshot(data: {
documentId: string;
stateUpdate: Uint8Array;
stateVector: Uint8Array;
timestamp: number;
size: number;
}): Promise<void> {
// 保存到数据库/对象存储
}

private async loadLatestSnapshot(
documentId: string
): Promise<{ stateUpdate: Uint8Array; stateVector: Uint8Array } | null> {
return null;
}

private async loadUpdatesSinceSnapshot(
documentId: string,
stateVector?: Uint8Array
): Promise<Uint8Array[]> {
return [];
}
}

9.3 性能优化全景

优化方向具体措施效果
网络传输二进制协议(非 JSON)、操作合并、增量同步减少 80%+ 传输量
内存定期 GC 清理墓碑、文档分片加载降低 50%+ 内存占用
渲染虚拟滚动、增量渲染、requestAnimationFrame 批量更新大文档流畅编辑
存储快照 + 增量更新、操作日志定期压缩减少 70%+ 存储空间
首屏快照优先加载、懒加载非首屏内容打开速度 < 1s
Worker将 CRDT 计算放入 Web Worker不阻塞主线程渲染

十、扩展设计

10.1 表格协同

extensions/table.ts
import * as Y from 'yjs';

/**
* 表格的 CRDT 数据模型
* 使用 Y.Array 嵌套 Y.Map 实现
*/
function createCollaborativeTable(ydoc: Y.Doc): void {
const table = ydoc.getArray('table');

// 添加一行
const row = new Y.Map();
row.set('id', 'row-1');
row.set('cells', new Y.Array());

// 每个单元格是一个 Y.Map,包含 Y.Text
const cell = new Y.Map();
cell.set('id', 'cell-1-1');
cell.set('content', new Y.Text());
(row.get('cells') as Y.Array<Y.Map<unknown>>).push([cell]);

table.push([row]);
}

10.2 撤销/重做(Undo/Redo)

extensions/undo.ts
import * as Y from 'yjs';

/**
* Yjs 内置的 UndoManager
* 只撤销当前用户的操作,不影响其他协作者
*/
const ydoc = new Y.Doc();
const ytext = ydoc.getText('document');

const undoManager = new Y.UndoManager(ytext, {
// 操作合并时间窗口:500ms 内的连续操作合并为一次撤销
captureTimeout: 500,
// 可以跟踪多个共享类型
// trackedOrigins: new Set([...])
});

// 撤销
function undo(): void {
undoManager.undo();
}

// 重做
function redo(): void {
undoManager.redo();
}

// 监听撤销栈变化(更新 UI 按钮状态)
undoManager.on('stack-item-added', (event: { type: string }) => {
console.log(`Stack updated: ${event.type}`);
// 更新 Undo/Redo 按钮的 disabled 状态
});

常见面试问题

Q1: OT 和 CRDT 的核心区别是什么?各适合什么场景?

答案

OT 和 CRDT 都是解决协同编辑冲突的算法,但设计哲学截然不同:

OT(Operation Transformation)

  • 思路是 操作转换——当两个并发操作冲突时,通过 transform 函数调整操作的参数,使结果一致
  • 依赖 中心服务器 确定操作的全局顺序
  • 操作基于 位置索引(Position-based)

CRDT(Conflict-free Replicated Data Type)

  • 思路是 数据结构设计——设计一种特殊的数据结构,使操作天然可交换(Commutative),不需要 transform
  • 去中心化,不需要中心服务器协调
  • 操作基于 唯一标识(ID-based)
对比示例
// OT:基于位置的操作
// "Hello" -> Client A: Insert(5, " World") -> Client B: Insert(5, "!")
// 需要 transform 调整位置

// CRDT:基于 ID 的操作
// 每个字符有唯一 ID:H(1,0) e(1,1) l(1,2) l(1,3) o(1,4)
// Client A: Insert(" World", after: (1,4)) -- 不用关心位置
// Client B: Insert("!", after: (1,4)) -- 按 clientId 排序解决冲突
场景推荐方案原因
已有中心化服务OTOT 的中心化架构与现有后端契合
需要离线编辑CRDTCRDT 天然支持离线合并
P2P 协作CRDT不需要中心服务器
内存敏感OTOT 不需要为每个字符存元数据
快速迭代CRDT (Yjs)Yjs 生态成熟,开箱即用

Q2: 如何保证多个客户端最终一致性?如果操作到达顺序不同怎么办?

答案

最终一致性(Eventual Consistency)意味着:所有客户端在收到相同的操作集合后,最终会收敛到相同的文档状态,无论操作到达的顺序如何

OT 方案的一致性保证

OT 依靠中心服务器确定 全局操作顺序。服务器为每个操作分配递增的 revision 号,客户端必须按 revision 顺序应用操作。当客户端提交的操作基于旧版本时,服务器通过 transform 将其调整为基于最新版本的等价操作。

OT 一致性流程
// 客户端提交操作时携带 revision
interface ClientOp {
ops: Operation[];
revision: number; // "我基于第 N 个版本做的修改"
}

// 服务端处理:
// 1. 客户端 A 基于 v3 提交 opA -> 服务器当前版本 v5
// 2. 服务器将 opA 与 v3->v4、v4->v5 的操作逐一 transform
// 3. 得到 opA'(基于 v5 的等价操作),应用并分配 v6
// 4. 广播 opA' 给所有客户端

// 客户端收到服务端确认的操作后,按顺序应用
// 所有客户端最终都执行了相同的有序操作序列 -> 一致性保证

CRDT 方案的一致性保证

CRDT 的一致性由 数学性质 保证——操作满足交换律(Commutative)和幂等性(Idempotent):

CRDT 一致性原理
// 交换律:操作以任意顺序应用,结果相同
// apply(apply(doc, opA), opB) === apply(apply(doc, opB), opA)

// 幂等性:同一操作应用多次,结果与应用一次相同
// apply(apply(doc, opA), opA) === apply(doc, opA)

// 这意味着:
// 1. 不需要关心操作到达顺序
// 2. 不需要关心操作是否重复
// 3. 只要所有客户端最终收到相同的操作集合,状态就一致
关键区别

OT 的一致性依赖 正确的 transform 函数实现(实践中很容易出 Bug),而 CRDT 的一致性由 数学证明 保证(只要数据结构设计正确)。这也是近年来 CRDT 越来越流行的原因之一。


Q3: 如何实现离线编辑?断网后恢复时如何处理冲突?

答案

离线编辑的核心挑战是:用户在断网期间做了修改,同时其他用户也在修改文档,恢复连接后如何合并

CRDT 方案(推荐)

CRDT 天然支持离线编辑,因为操作不依赖全局顺序:

offline/crdt-offline.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

class OfflineCollaboration {
private ydoc: Y.Doc;

constructor(docId: string) {
this.ydoc = new Y.Doc();

// 关键步骤 1:IndexedDB 持久化
// 所有文档更新自动保存到 IndexedDB
const persistence = new IndexeddbPersistence(docId, this.ydoc);

// 关键步骤 2:WebSocket 同步
const wsProvider = new WebsocketProvider(
'wss://server.com',
docId,
this.ydoc
);

// 离线时的编辑全部由 Yjs 自动保存到 IndexedDB
// 恢复连接后的同步流程:
// 1. wsProvider 自动发送本地 StateVector
// 2. 服务端返回差异更新
// 3. 本地应用远程更新(CRDT 自动合并,无冲突)
// 4. 发送本地在离线期间的更新
}
}

同步流程

OT 方案的离线处理(较复杂):

OT 的离线处理困难得多,因为需要中心服务器做 transform。通常的做法是:

  1. 离线期间的操作缓存到本地队列
  2. 恢复连接后,将操作逐个提交给服务器
  3. 服务器对每个操作做 transform 后再广播
  4. 如果离线时间太长导致 transform 链过长,可能需要 文档重置(拉取最新文档,丢弃离线修改)
OT 的离线限制

OT 不是为离线场景设计的。如果产品需要强大的离线能力,CRDT 是更合适的选择。Google Docs 处理"离线"的方式是让用户离线编辑一个副本,恢复后尝试合并,冲突严重时提示用户手动解决。


Q4: 在大文档场景下,协同编辑有哪些性能瓶颈?如何优化?

答案

大文档(数万字以上)场景下,协同编辑面临以下性能瓶颈:

1. CRDT 内存膨胀

CRDT 为每个字符维护 ID、左右邻居、删除标记等元数据,且删除的字符作为墓碑(Tombstone)保留在内存中。

optimization/memory.ts
// 问题:10 万字文档可能有 50 万个 CRDT 字符节点(含墓碑)
// 每个节点约 50-100 字节 -> 内存占用可达 25-50 MB

// 优化 1:Yjs 的 GC 机制
// Yjs 在所有客户端都确认不再需要某些墓碑后,自动清理
const ydoc = new Y.Doc({ gc: true }); // 启用垃圾回收(默认开启)

// 优化 2:文档分片
// 将大文档拆分为多个 Y.Doc,按需加载
interface DocumentShard {
shardId: string;
ydoc: Y.Doc;
range: { startPage: number; endPage: number };
loaded: boolean;
}

2. 首次加载慢

新用户加入时需要同步完整文档状态。

optimization/initial-load.ts
// 优化:快照 + 增量更新
async function fastInitialLoad(docId: string): Promise<Y.Doc> {
const doc = new Y.Doc();

// 1. 先加载最近的快照(通常很快,几十 KB 的二进制数据)
const snapshot = await fetchSnapshot(docId);
Y.applyUpdate(doc, snapshot);

// 2. 再拉取快照之后的增量更新
const stateVector = Y.encodeStateVector(doc);
const diff = await fetchDiff(docId, stateVector);
Y.applyUpdate(doc, diff);

return doc;
}

3. 高频操作的网络压力

快速打字时每秒可能产生几十个操作。

优化手段实现方式效果
操作节流合并 50-100ms 内的操作为一个 batch减少 90% 消息量
二进制编码Yjs 使用自定义二进制格式而非 JSON减少 60%+ 传输量
增量同步只同步 StateVector 差异避免全量传输
Web WorkerCRDT 计算移到 Worker 线程不阻塞编辑器渲染

4. 渲染性能

大文档中大量远程操作导致频繁重渲染。

optimization/render.ts
// 优化:批量应用远程更新,使用 requestAnimationFrame 合并渲染
class RenderScheduler {
private pendingUpdates: Array<() => void> = [];
private rafId: number | null = null;

scheduleUpdate(applyFn: () => void): void {
this.pendingUpdates.push(applyFn);

if (!this.rafId) {
this.rafId = requestAnimationFrame(() => {
// 在一个动画帧内批量应用所有更新
this.pendingUpdates.forEach(fn => fn());
this.pendingUpdates = [];
this.rafId = null;
});
}
}
}

相关链接