序
本次故障的核心表现是:用户刷新页面后,协同文档内容全部丢失,编辑器显示空白。根本原因在于后端 onLoadDocument 解码 Yjs 二进制状态时抛出 Unexpected end of array 异常,且原逻辑没有任何兜底机制,导致文档无法恢复。
通过代码审查和日志分析,我们定位了四个层面的根因:
- MongoDB 中存储了损坏的二进制数据
- 服务端对数据格式假设过强
- 协同状态下 REST 与 Yjs 双通道互相污染
- 前端连接状态语义不精确。
本次修复建立了"输入归一化→执行容错→恢复自愈"三层防线,同时收敛了数据写入路径、明确了协同状态机语义,从根本上解决了问题。
第一部分:问题现象与故障定义
问题复现
页面刷新后,原有文档数据全部丢失,重新加载后编辑器显示空白,无任何内容。
故障日志:
bash
[API] [onLoadDocument] Unexpected end of array
[API] [hocuspocus-wiki] New connection to "[DOC_ID]"
[API] [hocuspocus-wiki] Loaded document "[DOC_ID]"
[API] [onLoadDocument] Unexpected end of array # 刷新后再次出现
协同文档载入时 Yjs State 解码失败 ,导致刷新后系统无法获取有效的文档状态,编辑器直接空白。日志中的 Unexpected end of array 本质是 Yjs 在解析 update 二进制时发现数据不完整或格式不合法。
这是一个协同文档持久化读取失败 + 双通道写入带来状态漂移风险的组合问题。
第二部分:系统架构与数据流
两条数据通道
当前协同文档存在两条独立的数据通道:
| 通道 | 用途 | 持久化方式 |
|---|---|---|
| Yjs 协同通道 | 协同编辑的核心状态 | 后端 onStoreDocument 存 yjsState(二进制) |
| REST 通道 | 传统 CRUD 操作 | documentController.updateDocument 写 content 字段 |
关键结论 :协同模式下,真正决定编辑器恢复和内容渲染的是 Yjs state ,而非普通的
content字段。
故障时间线
| 步骤 | 事件 | 说明 |
|---|---|---|
| 1 | 新建/进入文档 | WebSocket 连接成功 |
| 2 | 编辑文档内容 | 触发 Document changed + REST PUT 请求 |
| 3 | 会话关闭 | 执行 Store "docId",持久化写入 Yjs 状态 |
| 4 | 刷新重载 | onLoadDocument 报错,解码失败,编辑器空白 |
第三部分:根因分析
问题一:为什么刷新后空白?
答案:onLoadDocument 解码失败,原逻辑无兜底。
修复前的 onLoadDocument 直接执行 Y.applyUpdate(ydoc, update),一旦抛出异常,整个请求链路崩溃,返回空文档。没有 try-catch,没有降级方案,没有任何恢复机制。
修复后 (hocuspocus.ts:90-94):
typescript
try {
Y.applyUpdate(ydoc, update);
return ydoc;
} catch (error) {
console.error(`Failed to decode yjsState:`, error);
// 核心自愈:丢弃损坏状态
await Document.findByIdAndUpdate(documentName, {
$unset: { yjsState: 1, yjsStateUpdatedAt: 1 },
});
return null;
}
问题二:为什么有 unexpected end of array?
答案:MongoDB 中存了损坏/不完整的二进制数据。
损坏可能来自:
- 历史数据迁移时的格式变化
- 写入过程中断(服务重启、网络波动)
- 序列化路径不一致(Buffer vs base64 vs 数组)
修复前的代码假设 stored.yjsState 永远是干净的 Uint8Array,直接 Uint8Array.from() 硬转。修复后增加了 6 种格式的兼容归一化 (hocuspocus.ts:10-45):
typescript
const toUint8Array = (value: unknown): Uint8Array | null => {
if (Buffer.isBuffer(value)) return new Uint8Array(value);
if (value instanceof Uint8Array) return value;
if (Array.isArray(value)) return Uint8Array.from(value);
if (typeof value === "string") {
const buf = Buffer.from(value, "base64");
return buf.length > 0 ? new Uint8Array(buf) : null;
}
if (typeof value === "object" && raw.type === "Buffer" && Array.isArray(raw.data)) {
return Uint8Array.from(raw.data);
}
return null;
};
问题三:为什么切换文档后协同失效?
答案:脏数据循环加载 + 旧连接未完全清理。
脏数据循环:数据库中留存了损坏的 Yjs 状态,每次切换回来都读取同一份坏数据,反复触发解码失败。
旧连接未清理:切换文档时,旧的 Provider 和 Y.Doc 没有被正确销毁,导致:
- 内存泄漏
- WebSocket 回调仍可能更新已卸载组件的状态
- 新旧连接状态互相干扰
修复措施:
- 后端检测到坏数据时执行
$unset清理(hocuspocus.ts:94) - 前端使用
cancelled标志位 + 完整清理(useCollaboration.ts):
typescript
return () => {
cancelled = true;
clearDisconnectTimer();
provider.destroy();
ydoc.destroy();
setBundle(null);
};
问题四:为什么 WebSocket 连上了还显示 connecting?
答案:等 onSynced 才切 connected------物理连接 ≠ 数据就绪。
修复前的逻辑是:WebSocket 状态变为 Connected 就直接显示"已连接"。但 WebSocket 连上只代表网络管道通了,Yjs 可能还没从服务端拉取到完整的状态数据。
修复后 (useCollaboration.ts:144-155):
typescript
onStatus: ({ status: wsStatus }) => {
if (wsStatus === WebSocketStatus.Connected) {
// 二次确认:必须等 Yjs 同步完成
setConnStatus(provider.synced ? "connected" : "connecting");
}
},
onSynced: ({ state }) => {
if (state) setConnStatus("connected"); // 真正就绪
}
同时增加了 480ms 断线防抖,避免网络瞬断时状态频繁闪烁。
问题五:为什么协同模式下不存 content?
答案:避免双通道状态漂移,Yjs 是唯一真相源。
修复前的逻辑是:无论是否协同模式,REST 接口都会写入 content 字段。这导致两个问题:
- UI 看着像保存了(content 变了)
- 但协同恢复看 yjsState(另一个世界)
- 刷新后出现"内容不一致/丢失"
修复后 (App.tsx:162-177):
typescript
const payload = isCollaborationEnabled
? { title: note.title } // 协同:只存 title
: { title: note.title, content: note.content }; // 非协同:全量存
updateDocument(currentDocId, payload);
协同模式下,文档的权威状态完全由 Yjs 管理,REST 通道不再并行覆盖同一语义内容。
第四部分:修复方案总览
三层防御体系
| 防线 | 位置 | 策略 |
|---|---|---|
| 输入防线 | toUint8Array |
6 种格式归一化,入口保证数据合规 |
| 执行防线 | Y.applyUpdate try-catch |
异常可控,不因单条错误导致整链崩溃 |
| 恢复防线 | $unset 清理 |
损坏状态自动清除,允许后续写入正常数据 |
状态机语义收紧
修复后的协同状态定义:
typescript
type CollaborationStatus =
| "disabled" // 无 docId,纯本地模式
| "connecting" // WebSocket 连接中 或 Yjs 同步中
| "connected" // WebSocket 已连 + Yjs 同步完成
| "disconnected"; // 连接断开或认证失败
架构原则
协同模式单一真相源:协同文档的唯一权威数据源是 Yjs state,REST 通道不能并行覆盖同一语义的文档内容。
第五部分:修复成果与后续建议
修复成果对比
| 模块 | 旧表现 | 新机制 |
|---|---|---|
| 状态机 | Socket 连上即 Ready | onSynced 数据同步后才 Ready |
| 异常处理 | 报错卡死文档 | 异常捕获 + 自动清空坏账 |
| 写入策略 | 双通道覆盖写入 | 协同模式下 REST 仅更新 title |
| 网络抖动 | 状态频繁闪烁 | 480ms 防抖缓冲 |
| 文档切换 | 连接残留 + 脏数据循环 | 完整清理 + 自动清理坏数据 |
后续优化建议
- 告警增强 :在
onStoreDocument打印写入字节长度和 docId(debug 级) - 主动发现 :增加后台巡检脚本,扫描
yjsState解码失败的文档并标记/修复 - 用户提示:当检测到坏数据被清理时,通过 WebSocket 向前端发送事件,显示 toast 提示"上次保存的数据异常,已重置"
- 离线支持 :考虑集成
y-indexeddb,在断网场景下保留本地编辑状态
小结
本次故障修复不是简单的 try-catch 包裹,而是建立了一套完整的防御性数据读取链路 和状态语义收敛机制 。从 toUint8Array 的 6 种格式兼容,到 Y.applyUpdate 的异常捕获与自愈清理,再到前端 onSynced 的真实就绪判定,每一层都在回答同一个问题:当数据不完美时,系统如何体面地降级和恢复?
这次复盘也验证了一个核心架构原则:协同编辑系统中,Yjs state 必须是唯一的真相源,任何 REST 通道的并行写入都会引入不可预期的状态漂移。后续所有涉及文档内容的写入操作,都应当遵循这一原则。