基于 CRDT/OT 算法的实时文档协同编辑技术方案与 API 接口设计
React/Vue 3TypeScriptProseMirrorYjsWebSocket
Node.js/GoWebSocketRedisPostgreSQLMongoDB
DockerKubernetesNginxPrometheusGrafana
Yjs(CRDT)AutomergeOT.jsShareDB
| 需求类别 | 详细描述 | 优先级 |
|---|---|---|
| 文档编辑 | 支持富文本/Markdown 编辑,实时保存和同步 | P0 |
| 多人协作 | 支持 100+ 人同时在线编辑同一文档 | P0 |
| 实时通信 | 编辑操作延迟 < 100ms,光标同步延迟 < 200ms | P0 |
| 冲突处理 | 自动解决并发编辑冲突,无数据丢失 | P0 |
| 权限控制 | 支持查看、评论、编辑、管理等权限级别 | P1 |
| 版本管理 | 自动版本快照,支持历史版本对比和回滚 | P1 |
| 离线支持 | 断网可编辑,重连后自动同步 | P2 |
┌─────────────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Web App │ │ Mobile App │ │ Desktop App │ │
│ │ (React/Vue) │ │ (React Native)│ │ (Electron) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ WebSocket │
└────────────────────────────┼─────────────────────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────────────────────┐
│ Gateway Layer │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Load Balancer (Nginx) │ │
│ └────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼─────────────────────────────────────┐ │
│ │ WebSocket Gateway Cluster │ │
│ │ (Connection Management & Routing) │ │
│ └────────────────────────────┬─────────────────────────────────────┘ │
└────────────────────────────┼─────────────────────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Document │ │ User & │ │ Version │ │ Comment │ │
│ │ Service │ │ Auth │ │ Service │ │ Service │ │
│ │ Service │ │ Service │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────┬─────────────────────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────────────────────┐
│ Message Queue │
│ (Redis Pub/Sub / Kafka) │
└────────────────────────────┬─────────────────────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ MongoDB │ │ Redis │ │ OSS │ │
│ │ (Metadata) │ │ (Documents) │ │ (Cache/ │ │ (Attachments)│ │
│ │ │ │ (CRDT) │ │ Session) │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
| 特性 | CRDT | OT (Operational Transformation) |
|---|---|---|
| 一致性保证 | 强一致性 (数学证明) | 依赖中心服务器转换 |
| 网络要求 | 支持 P2P、离线操作 | 需要中心服务器 |
| 实现复杂度 | 中等 | 较高 |
| 内存占用 | 较高 (需存储元数据) | 较低 |
| 适用场景 | 分布式、离线优先应用 | 中心化实时协作 |
// Yjs 核心数据类型 import { Y } from 'yjs' // 文档类型定义 interface DocumentContent { content: Y.Text // 富文本内容 cursors: Y.Map<Cursor> // 用户光标位置 awareness: Y.Map<Awareness> // 用户状态感知 metadata: Y.Map<any> // 文档元数据 } // 光标信息结构 interface Cursor { userId: string userName: string color: string position: number selection: { start: number, end: number } | null timestamp: number }
// 场景:两个用户同时在位置 5 插入不同文本 // 用户 A 插入 "Hello",用户 B 插入 "World" // Yjs 会自动为每个操作分配唯一的 Lamport 时间戳和客户端 ID // 根据 ID 和时间戳确定操作顺序,保证所有客户端结果一致 const ydoc = new Y.Doc() const ytext = ydoc.getText('content') // 用户 A 的操作 ytext.insert(5, 'Hello', clientA_ID) // 用户 B 的操作 (同时发生) ytext.insert(5, 'World', clientB_ID) // CRDT 会根据客户端 ID 字典序决定顺序 // 假设 clientA_ID < clientB_ID,则结果为 "...HelloWorld..." // 所有客户端都会得到相同的结果
// 使用 Yjs Awareness 实现光标和状态同步 import { Awareness } from 'y-protocols/awareness' const awareness = new Awareness(ydoc) // 设置本地用户状态 awareness.setLocalStateField('user', { name: '张三', color: '#FF6B6B', cursorPosition: 125, selection: { start: 120, end: 130 } }) // 监听其他用户状态变化 awareness.on('change', ({ added, updated, removed }) => { added.forEach(id => { const state = awareness.getState(id) showRemoteCursor(id, state.user) }) updated.forEach(id => { const state = awareness.getState(id) updateRemoteCursor(id, state.user) }) removed.forEach(id => { removeRemoteCursor(id) }) })
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Users │ │ Documents │ │ Doc_Versions │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ username │◄──────│ owner_id (FK) │──────►│ doc_id (FK) │
│ email │ │ title │ │ version_number │
│ password_hash │ │ content (Yjs) │ │ content_snapshot│
│ avatar_url │ │ created_at │ │ created_by (FK) │
│ created_at │ │ updated_at │ │ created_at │
└─────────────────┘ │ is_deleted │ │ change_summary │
│ └─────────────────┘ └─────────────────┘
│ │
│ ┌────────▼────────┐ ┌─────────────────┐
│ │ Permissions │ │ Operation_Log │
│ ├─────────────────┤ ├─────────────────┤
│ │ id (PK) │ │ id (PK) │
└───────────────►│ doc_id (FK) │ │ doc_id (FK) │
│ user_id (FK) │ │ user_id (FK) │
│ role │ │ operation_type │
│ granted_at │ │ operation_data │
│ granted_by (FK) │ │ timestamp │
└─────────────────┘ │ vector_clock │
└─────────────────┘
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | UUID | PRIMARY KEY | 用户唯一标识 |
| username | VARCHAR(50) | UNIQUE, NOT NULL | 用户名 |
| VARCHAR(255) | UNIQUE, NOT NULL | 邮箱地址 | |
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 (bcrypt) |
| avatar_url | VARCHAR(500) | NULL | 头像 URL |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | UUID | PRIMARY KEY | 文档唯一标识 |
| owner_id | UUID | FK → Users.id | 文档所有者 |
| title | VARCHAR(500) | NOT NULL | 文档标题 |
| content_state | BYTEA | NULL | Yjs 二进制状态 (最新) |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT NOW() | 最后更新时间 |
| is_deleted | BOOLEAN | DEFAULT FALSE | 软删除标记 |
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | UUID | PRIMARY KEY | 权限记录 ID |
| doc_id | UUID | FK → Documents.id | 关联文档 |
| user_id | UUID | FK → Users.id | 被授权用户 |
| role | ENUM | NOT NULL | 角色:viewer/commenter/editor/admin |
| granted_at | TIMESTAMP | DEFAULT NOW() | 授权时间 |
| granted_by | UUID | FK → Users.id | 授权人 |
{
"_id": ObjectId,
"doc_id": UUID,
"user_id": UUID,
"operation_type": "insert" | "delete" | "update",
"operation_data": {
"insert": { "position": Number, "content": String },
"delete": { "position": Number, "length": Number }
},
"timestamp": ISODate,
"vector_clock": { "client_id": Number },
"sequence_num": Number,
"yjs_update": BinData // Yjs 增量更新二进制
}
/api/v1// Request Body { "username": "zhangsan", "email": "zhangsan@example.com", "password": "SecurePass123!" } // Response 201 Created { "code": 201, "message": "注册成功", "data": { "userId": "550e8400-e29b-41d4-a716-446655440000", "username": "zhangsan", "email": "zhangsan@example.com" } }
// Request Body { "email": "zhangsan@example.com", "password": "SecurePass123!" } // Response 200 OK { "code": 200, "message": "登录成功", "data": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...", "expiresIn": 7200, "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "username": "zhangsan", "email": "zhangsan@example.com", "avatarUrl": "https://oss.example.com/avatars/xxx.png" } } }
// Request Headers Authorization: Bearer <refreshToken> // Response 200 OK { "code": 200, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiresIn": 7200 } }
// Request Headers Authorization: Bearer <accessToken> // Request Body { "title": "产品需求文档", "content": "", // 可选,初始内容 "type": "rich_text" // rich_text | markdown } // Response 201 Created { "code": 201, "message": "文档创建成功", "data": { "id": "doc-550e8400-e29b-41d4-a716-446655440001", "title": "产品需求文档", "ownerId": "550e8400-e29b-41d4-a716-446655440000", "createdAt": "2026-03-12T10:30:00.000Z", "updatedAt": "2026-03-12T10:30:00.000Z" } }
// Request Headers Authorization: Bearer <accessToken> // Response 200 OK { "code": 200, "data": { "id": "doc-550e8400-e29b-41d4-a716-446655440001", "title": "产品需求文档", "content": "<p>文档内容...</p>", "type": "rich_text", "owner": { "id": "550e8400-e29b-41d4-a716-446655440000", "username": "zhangsan", "avatarUrl": "https://oss.example.com/avatars/xxx.png" }, "permissions": { "canEdit": true, "canComment": true, "canShare": true, "canDelete": false }, "collaborators": [ { "userId": "user-001", "username": "lisi", "role": "editor", "isOnline": true } ], "createdAt": "2026-03-12T10:30:00.000Z", "updatedAt": "2026-03-12T15:45:00.000Z" } }
// Request Headers Authorization: Bearer <accessToken> // Request Body { "title": "产品需求文档 v2.0" } // Response 200 OK { "code": 200, "message": "更新成功", "data": { "id": "doc-550e8400-e29b-41d4-a716-446655440001", "title": "产品需求文档 v2.0", "updatedAt": "2026-03-12T16:00:00.000Z" } }
// Request Headers Authorization: Bearer <accessToken> // Response 200 OK { "code": 200, "message": "文档已删除" }
// Query Parameters page=1&limit=20&sortBy=updatedAt&order=desc&keyword=PRD // Response 200 OK { "code": 200, "data": { "list": [ { "id": "doc-001", "title": "产品需求文档", "owner": { "username": "zhangsan" }, "updatedAt": "2026-03-12T15:45:00.000Z", "myPermission": "editor" } ], "pagination": { "page": 1, "limit": 20, "total": 156, "totalPages": 8 } } }
// Request Headers Authorization: Bearer <accessToken> // Request Body { "userId": "user-002", "role": "editor", // viewer | commenter | editor | admin "expireAt": "2026-04-12T00:00:00.000Z" // 可选,过期时间 } // Response 201 Created { "code": 201, "message": "授权成功", "data": { "permissionId": "perm-001", "userId": "user-002", "role": "editor", "grantedAt": "2026-03-12T16:00:00.000Z" } }
// Response 200 OK { "code": 200, "data": [ { "permissionId": "perm-001", "user": { "id": "user-002", "username": "lisi", "avatarUrl": "https://oss.example.com/avatars/yyy.png" }, "role": "editor", "grantedBy": { "username": "zhangsan" }, "grantedAt": "2026-03-12T16:00:00.000Z" } ] }
// Response 200 OK { "code": 200, "message": "权限已撤销" }
// Query Parameters page=1&limit=50 // Response 200 OK { "code": 200, "data": { "list": [ { "versionId": "ver-050", "versionNumber": 50, "createdBy": { "id": "user-001", "username": "zhangsan" }, "createdAt": "2026-03-12T15:45:00.000Z", "changeSummary": "更新了第三章内容", "size": 15234 } ], "pagination": { "page": 1, "limit": 50, "total": 50 } } }
// Response 200 OK { "code": 200, "data": { "versionId": "ver-050", "versionNumber": 50, "content": "<p>历史版本内容...</p>", "createdAt": "2026-03-12T15:45:00.000Z", "createdBy": { "username": "zhangsan" } } }
// Request Headers Authorization: Bearer <accessToken> // Request Body (可选) { "comment": "回滚到稳定版本" } // Response 200 OK { "code": 200, "message": "回滚成功", "data": { "newVersionId": "ver-051", "versionNumber": 51, "createdAt": "2026-03-12T16:30:00.000Z" } }
// Request Headers Authorization: Bearer <accessToken> // Request Body { "content": "这段描述需要更详细一些", "range": { "start": 120, "end": 150 }, "parentId": "comment-001", // 可选,回复评论时使用 "mentions": ["user-002", "user-003"] // @提及的用户 } // Response 201 Created { "code": 201, "data": { "commentId": "comment-002", "content": "这段描述需要更详细一些", "author": { "id": "user-001", "username": "zhangsan" }, "createdAt": "2026-03-12T16:00:00.000Z", "replies": [] } }
// Response 200 OK { "code": 200, "data": [ { "commentId": "comment-001", "content": "整体结构不错", "author": { "id": "user-002", "username": "lisi" }, "range": { "start": 0, "end": 50 }, "createdAt": "2026-03-12T14:00:00.000Z", "resolved": false, "resolvedBy": null, "replies": [ { "commentId": "comment-002", "content": "谢谢反馈!", "author": { "username": "zhangsan" }, "createdAt": "2026-03-12T14:30:00.000Z" } ] } ] }
// Response 200 OK { "code": 200, "message": "评论已解决", "data": { "commentId": "comment-001", "resolved": true, "resolvedBy": { "username": "zhangsan" }, "resolvedAt": "2026-03-12T16:00:00.000Z" } }
// 连接参数 token: JWT Access Token (必需) documentId: 文档 ID (必需) protocol: y-websocket (协议版本) // 成功连接响应 { "type": "connected", "clientId": "client-abc123", "documentId": "doc-001", "stateVector": Uint8Array, // 当前文档状态向量 "collaborators": [ { "userId": "user-001", "userName": "zhangsan", "color": "#FF6B6B", "cursorPosition": 125 } ] } // 错误响应 { "type": "error", "code": "AUTH_FAILED", "message": "Token 无效或已过期" }
| 消息类型 | 方向 | 说明 |
|---|---|---|
sync-step1 |
Client → Server | 客户端发起同步请求,携带状态向量 |
sync-step2 |
Server → Client | 服务器返回缺失的更新数据 |
update |
双向 | Yjs 增量更新 (二进制) |
awareness |
双向 | 光标位置和用户状态更新 |
cursor-broadcast |
Server → Client | 广播其他用户的光标变化 |
user-join |
Server → Client | 新用户加入通知 |
user-leave |
Server → Client | 用户离开通知 |
ping/pong |
双向 | 心跳保活 (30s 间隔) |
// 客户端发送编辑操作 { "type": "update", "data": Uint8Array, // Yjs 编码的二进制更新 "clientId": "client-abc123", "timestamp": 1710259200000 } // 服务器广播给其他客户端 { "type": "update", "data": Uint8Array, "originClientId": "client-abc123", "timestamp": 1710259200000 }
// 客户端上报状态 { "type": "awareness", "clientId": "client-abc123", "state": { "user": { "userId": "user-001", "userName": "zhangsan", "color": "#FF6B6B", "avatarUrl": "https://oss.example.com/avatars/xxx.png" }, "cursor": { "position": 125, "selection": { "start": 120, "end": 130 } }, "status": "editing" // editing | viewing | away } } // 服务器广播状态变化 { "type": "awareness", "changes": { "added": [], "updated": ["client-abc123"], "removed": [] }, "states": { "client-abc123": { ... } // 完整状态 } }
// 用户加入 { "type": "user-join", "user": { "userId": "user-002", "userName": "lisi", "color": "#4ECDC4", "avatarUrl": "https://oss.example.com/avatars/yyy.png" } } // 用户离开 { "type": "user-leave", "userId": "user-002", "reason": "disconnect" // disconnect | timeout | kicked }
// 客户端每 30 秒发送 ping { "type": "ping", "timestamp": 1710259200000 } // 服务器响应 pong { "type": "pong", "timestamp": 1710259200000, "serverTime": 1710259200050 } // 90 秒无活动自动断开连接
{
"algorithm": "RS256",
"accessTokenExpiry": "2h",
"refreshTokenExpiry": "7d",
"issuer": "collab-system",
"audience": "collab-client",
"claims": {
"userId": "user-001",
"email": "user@example.com",
"roles": ["user"],
"permissions": {
"doc-001": "editor",
"doc-002": "viewer"
}
}
}
| 威胁类型 | 防护措施 |
|---|---|
| XSS 攻击 | 输入过滤、输出编码、CSP 策略 |
| CSRF 攻击 | CSRF Token、SameSite Cookie |
| SQL 注入 | 参数化查询、ORM 框架 |
| DDoS 攻击 | 限流、CDN、WAF |
| 暴力破解 | 登录失败次数限制、验证码 |
| 会话劫持 | Token 绑定 IP、定期轮换 |
{
"logId": "log-001",
"timestamp": "2026-03-12T16:00:00.000Z",
"userId": "user-001",
"action": "document.update",
"resource": "doc-001",
"ipAddress": "192.168.1.100",
"userAgent": "Mozilla/5.0...",
"requestId": "req-abc123",
"status": "success",
"metadata": {
"changeCount": 5,
"duration": 120
}
}
// 使用 gzip 压缩 Yjs Update 消息 import { gzip, ungzip } from 'pako' // 发送前压缩 const compressed = gzip(updateData) ws.send(compressed) // 接收后解压 ws.onmessage = async (event) => { const data = await event.arrayBuffer() const decompressed = ungzip(data) applyUpdate(decompressed) } // 压缩率:通常可减少 60-80% 的数据量
// 合并短时间内的多个操作 const BATCH_INTERVAL = 50 // ms let batchQueue = [] let batchTimer = null function enqueueOperation(op) { batchQueue.push(op) if (!batchTimer) { batchTimer = setTimeout(flushBatch, BATCH_INTERVAL) } } function flushBatch() { const mergedUpdate = mergeUpdates(batchQueue) broadcast(mergedUpdate) batchQueue = [] batchTimer = null }
// 光标更新使用节流 (100ms) const throttledSendCursor = throttle(sendCursor, 100) // 自动保存使用防抖 (2s) const debouncedSave = debounce(saveToStorage, 2000) editor.on('cursorChange', throttledSendCursor) editor.on('contentChange', debouncedSave)
| 指标名称 | 目标值 | 监控方式 |
|---|---|---|
| 编辑操作延迟 (P95) | < 100ms | Prometheus + Grafana |
| WebSocket 连接成功率 | > 99.5% | 自定义埋点 |
| 消息丢失率 | < 0.01% | 序列号校验 |
| 服务端 CPU 使用率 | < 70% | Node.js Metrics |
| 内存使用率 | < 80% | heapdump 分析 |
| 数据库查询延迟 | < 50ms | Slow Query Log |
# 多阶段构建 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build FROM node:18-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package.json ./ ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "dist/server.js"]
version: '3.8' services: app: build: . ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=postgresql://user:pass@db:5432/collab - REDIS_URL=redis://redis:6379 depends_on: - db - redis deploy: replicas: 3 db: image: postgres:15 volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_DB=collab - POSTGRES_USER=user - POSTGRES_PASSWORD=pass redis: image: redis:7-alpine volumes: - redisdata:/data nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf depends_on: - app volumes: pgdata: redisdata:
apiVersion: apps/v1 kind: Deployment metadata: name: collab-app spec: replicas: 5 selector: matchLabels: app: collab template: metadata: labels: app: collab spec: containers: - name: app image: collab-app:latest ports: - containerPort: 3000 env: - name: DATABASE_URL valueFrom: secretKeyRef: name: db-secret key: url resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "1Gi" cpu: "500m" livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 3000 initialDelaySeconds: 5 periodSeconds: 5
| 故障类型 | 应对策略 | RTO/RPO |
|---|---|---|
| 单节点故障 | K8s 自动重启 + 健康检查 | RTO < 30s |
| 机房故障 | 多可用区部署 + DNS 切换 | RTO < 5min |
| 数据库故障 | 主从切换 + 数据复制 | RPO < 1min |
| 数据误删 | 每日备份 + 版本回滚 | RPO < 24h |