协同文档丢失?Yjs状态漂移与三层防线

本次故障的核心表现是:用户刷新页面后,协同文档内容全部丢失,编辑器显示空白。根本原因在于后端 onLoadDocument 解码 Yjs 二进制状态时抛出 Unexpected end of array 异常,且原逻辑没有任何兜底机制,导致文档无法恢复。

通过代码审查和日志分析,我们定位了四个层面的根因:

  1. MongoDB 中存储了损坏的二进制数据
  2. 服务端对数据格式假设过强
  3. 协同状态下 REST 与 Yjs 双通道互相污染
  4. 前端连接状态语义不精确。

本次修复建立了"输入归一化→执行容错→恢复自愈"三层防线,同时收敛了数据写入路径、明确了协同状态机语义,从根本上解决了问题。


第一部分:问题现象与故障定义

问题复现

页面刷新后,原有文档数据全部丢失,重新加载后编辑器显示空白,无任何内容。

故障日志:

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 协同通道 协同编辑的核心状态 后端 onStoreDocumentyjsState(二进制)
REST 通道 传统 CRUD 操作 documentController.updateDocumentcontent 字段

关键结论 :协同模式下,真正决定编辑器恢复和内容渲染的是 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 回调仍可能更新已卸载组件的状态
  • 新旧连接状态互相干扰

修复措施

  1. 后端检测到坏数据时执行 $unset 清理(hocuspocus.ts:94
  2. 前端使用 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 防抖缓冲
文档切换 连接残留 + 脏数据循环 完整清理 + 自动清理坏数据

后续优化建议

  1. 告警增强 :在 onStoreDocument 打印写入字节长度和 docId(debug 级)
  2. 主动发现 :增加后台巡检脚本,扫描 yjsState 解码失败的文档并标记/修复
  3. 用户提示:当检测到坏数据被清理时,通过 WebSocket 向前端发送事件,显示 toast 提示"上次保存的数据异常,已重置"
  4. 离线支持 :考虑集成 y-indexeddb,在断网场景下保留本地编辑状态

小结

本次故障修复不是简单的 try-catch 包裹,而是建立了一套完整的防御性数据读取链路状态语义收敛机制 。从 toUint8Array 的 6 种格式兼容,到 Y.applyUpdate 的异常捕获与自愈清理,再到前端 onSynced 的真实就绪判定,每一层都在回答同一个问题:当数据不完美时,系统如何体面地降级和恢复?

这次复盘也验证了一个核心架构原则:协同编辑系统中,Yjs state 必须是唯一的真相源,任何 REST 通道的并行写入都会引入不可预期的状态漂移。后续所有涉及文档内容的写入操作,都应当遵循这一原则。

相关推荐
Waoooo19992 小时前
谷歌云配置嵌套虚拟化
前端·chrome
风花雪月_2 小时前
保姆级 | 实现大文件切片上传、断点续传与秒传(Vue3+React+Node全覆盖)
前端
用户游民2 小时前
Android xml设置fitsSystemWindows与ImmersionBar设置fitsSystemWindows的区别及影响
前端
空中海2 小时前
第三章: Vue 3组合式 API(Composition API)
前端·javascript·vue.js
ai产品老杨2 小时前
架构实战:基于 GB28181/RTSP 多协议兼容的 AI 视频中台——支持源码交付与边缘异构部署
人工智能·架构·音视频
Wect2 小时前
HTML5 原生拖拽 API 基础原理与核心机制
前端·面试·html
甜鲸鱼2 小时前
JWT过滤器:从单体应用到微服务架构
微服务·架构·gateway·springcloud
Ruihong2 小时前
Vue 转 React:揭秘样式语言是如何被 VuReact 编译的?
vue.js·react.js·面试
用户游民2 小时前
Android 的 FragmentTransaction 中,hide() 和 add() 方法的执行顺序
前端