大文档系统设计
约 3525 字大约 12 分钟
2025-12-31
数据模型
documents:文档“元信息”结构
// documents
{
_id: docId, // 建议 ULID/UUID
workspaceId, // 知识库/空间
title,
slug, // URL 友好
status: "draft" | "normal" | "archived",
createdAt,
createdBy, // 文档创建者 userId
updatedAt,
updatedBy,
// 版本指针
head: 128, // 当前最新 docVer(编辑态)
publishedHead: 100, // 已发布版本
// 也可以做 branches:draftHead/publishedHead
// 根节点(可选,但我强烈建议有)
rootBlockId, // 整个文档的虚拟根容器(结构树的根)
// 统计/派生字段(可选)
blockCount,
wordCount,
// 权限、协作(可选)
aclId,
visibility: "private"|"workspace"|"public",
// 扩展字段(封面、icon、目录归属等)
coverAssetId,
icon,
folderId,
tags: []
}关键点:
documents不存全文内容(否则 16MB 和并发更新会折磨你)- 文档“内容”通过
rootBlockId和blocks/versions体系拼出来
blocks:Block 的身份(不存版本内容)
// blocks
{
_id: blockId,
docId,
type: "paragraph" | "heading" | "listItem" | "code" | "image" | "table" | "quote" | "custom:xxx",
createdAt,
createdBy,
latestVer: 7, // 这个 block 当前最新版本号(只增)
latestAt,
latestBy,
// 软删除(身份保留,历史可追溯)
isDeleted: false,
deletedAt,
deletedBy
}为什么要有 blocks 表?
它给你:
- block 的稳定索引(docId + type + 状态)
- 快速拿“当前版本号”(latestVer)
- 自定义类型可扩展(type 字符串命名空间)
block_versions:Block 的每个版本(这里是重头戏)
当拖动导致顺序也要版本化,解决方案是:把结构位置信息也当成 block version 的一部分。
也就是说:一个 block 的 version 里,不止存内容,还存它在树里的位置:
parentId:父 block 是谁(根节点下就是 rootBlockId)sortKey:在同级下的顺序(支持拖动最关键)
推荐用“可插入排序键”(fractional index / LexoRank 类似思想)
拖动时只改被移动的 block 的 parentId/sortKey,而不是修改整个父节点 children 数组(避免大数组、避免 16MB 风险)。
// block_versions
{
_id: `${blockId}@${ver}`,
blockId,
docId,
ver, // 从 1 开始递增
createdAt,
createdBy, // 这个版本的作者(谁编辑/移动的)
// 结构位置信息 —— 用它解决“拖动/顺序版本”
parentId, // 父 blockId(一般 rootBlockId 为顶级父)
sortKey, // 同级排序键(字符串/可比较)
// 可选:缩进层级、折叠状态等也属于结构态
indent: 0,
collapsed: false,
// 版本内容(payload)
payload: {
// 通用属性
attrs: {},
// 文本类 block(Markdown+)
richText: {
format: "md+", // 你说的 markdown+
source: "## 标题\n这是一段...", // canonical 存 markdown+ 源
// 可选:缓存解析后的 AST/PM JSON(渲染/编辑更快)
ast: {...} // 非必须,可按需缓存
},
// 资源类 block
assetId, // image/file 等
// 表格类 block
table: {...}, // 结构化数据
// 自定义 block
custom: {...} // 任意 JSON(由 registry 校验)
},
// 加速与审计
hash: "sha256(payload+parentId+sortKey+...)", // 用于判断是否真的变化
plainText: "标题 这是一段", // 用于搜索、diff、摘要
refs: [
{ kind: "asset", id: "assetId1" },
{ kind: "doc", id: "docId2" }
],
// 可选:字段级变更标签(方便 UI)
changeHint: {
moved: true,
contentChanged: false
}
}这一招直接解决你提到的所有痛点:
- “block 有的只有一个版本”:因为它从来没被编辑/移动过,它永远停留在 ver=1
- “block 有好多版本”:每次编辑/拖动/缩进都会产生新 ver
- “拖动导致顺序也有版本”:排序信息就在版本里
- “最终把 block 串起来渲染”:按 docVer 得到每个 block 的版本 → 用 parentId+sortKey 组装树 → 渲染
这比“父节点 children 数组版本化”更适合超大文档。
doc_revisions:文档版本(一次保存 = 一个 docVer)
文档版本不是保存全文,而是记录“这次保存影响了哪些 block,从哪个版本到哪个版本”。
// doc_revisions
{
_id: `${docId}@${docVer}`,
docId,
docVer,
createdAt,
createdBy,
message: "自动保存" | "手动保存" | "发布" | "导入",
// 这次保存,哪些 block 的版本发生变化
patches: [
{ blockId: "b1", from: 3, to: 4 },
{ blockId: "b9", from: 1, to: 2 }
],
// 方便定位根
rootBlockId,
// 可选:保存原因/操作来源(拖动、输入、粘贴)
source: "editor" | "api",
opSummary: { moved: 2, edited: 5, inserted: 1, deleted: 0 },
// 可选:分支/发布体系
branch: "draft" | "published"
}doc_snapshots:快照(用来加速恢复“任意 docVer 的 block 版本映射”)
要渲染某个 docVer,你需要一个东西:
blockVersionMap:在 docVer=X 时,每个 blockId 对应哪个 blockVer
你不能每个 docVer 都存全量 map(会爆),所以要:
- revisions 存 patch(增量)
- snapshots 每隔 N 次存一次“基线 map”
最简单版快照(适合中大型文档)
// doc_snapshots
{
_id: `${docId}@snap@${docVer}`,
docId,
docVer,
createdAt,
rootBlockId,
// 关键:在这个 docVer 时刻,所有 block 的版本号
blockVersionMap: {
"root": 12,
"b1": 4,
"b2": 1,
"b3": 7
}
}超大文档注意:Mongo 单文档 16MB 限制
如果你预期一个 doc 可能 50k/100k blocks,blockVersionMap 可能会逼近/超过 16MB。 那就用“分片快照”:
// doc_snapshots
{
_id: snapId,
docId,
docVer,
createdAt,
rootBlockId,
chunkCount: 12
}
// doc_snapshot_chunks
{
_id: `${snapId}@${idx}`,
snapId,
idx,
entries: [
{ blockId: "b1", ver: 4 },
{ blockId: "b2", ver: 1 }
]
}assets:附件(图片/文件)
// assets
{
_id: assetId,
workspaceId,
createdAt,
createdBy,
kind: "image" | "file" | "video",
filename,
mime,
size,
// 存储位置
storage: {
provider: "s3" | "oss" | "local",
key: "path/to/object",
url: "..." // 可选(如果是私有,通常不直接存 url)
},
// 可选:图片宽高、hash、缩略图
meta: { width, height, sha256 }
}把各种 block 串起来渲染为文档”的标准流程
渲染需要的输入只有两个:
- 在 docVer=X 时的 blockVersionMap(每个 block 用哪个版本)
- 这些 block_versions 的 parentId/sortKey + payload
渲染步骤(docVer=X):
- 找最近的 snapshot(≤X),拿到 baseMap
- 把 snapshot.docVer+1..X 的 doc_revisions 的 patches 依次 apply 到 map
- 得到最终
blockVersionMap - 批量读取这些 block_versions(至少读取你要渲染范围内的)
- 用
parentId把 blocks 分组,用sortKey排序,构建树 - 从
rootBlockId开始递归渲染(不同 type 对应不同 renderer)
这一步不需要任何“特殊处理复用”。map 指到哪个版本就是哪个版本,没改的自然还是旧 ver。
拖动 block / 缩进 / 移动到别的父级”怎么写入?
举例:把 block b9 从 root 下移动到 b3(某个容器/列表)下,并插到中间:
你只需要:
- 新增一个
b9@ver+1:更新parentId=b3,sortKey=介于前后两个之间 - 写一条
doc_revisionpatches:{blockId:b9, from:oldVer, to:newVer}
只有 b9 变了,不是整个文档结构变了。
这就是这个模型对“大文档拖动”特别友好的原因。
Block 对比(diff)怎么做才不爆炸?
你要分三层做,才会“又快又准确”:
Layer A:先判断版本号是否相同
- 同一个 docVer 对比另一个 docVer
- 直接看
blockVersionMap[blockId]:- 相同 → 无变化
- 不同 → 才进入下一层
Layer B:区分“移动变化”还是“内容变化”
比较两个版本的:
parentId / sortKey / indent→ 结构变化(移动/拖动/缩进)payload.hash或hash→ 内容变化
这样你可以在 UI 上非常清晰地显示:
- “这一段被移动到了某处”
- “这一段内容被修改了”
Layer C:内容 diff(按类型)
- 文本块:对
plainText做 diff(diff-match-patch / Myers)用于展示 - 代码块:按行 diff 更友好
- 表格块:按单元格 diff
- 自定义块:由 block 类型自己提供
diff(payloadA, payloadB)(这就是 registry 的价值)
存储层不建议存 patch(除非你极端追求空间),展示层算 diff 就行。
Markdown+ & 自定义 Block 类型:怎么设计才可持续?
要提前定一个“协议”,让未来新增 block 类型不至于改数据库结构。
建议:所有 block_versions.payload 遵循统一外壳
payload: {
schema: { type: "paragraph", ver: 1 }, // 类型 & schema 版本
attrs: {...}, // 通用属性
body: {...} // 类型私有内容
}比如:
paragraph:
body: { richText: { format:"md+", source:"..." } }image:
body: { assetId:"...", caption:{...} }custom:chart:
body: { spec:{...}, dataRef:"..." }未来你改 schema,就 bump schema.ver,读的时候做 migration。
作者/创建者到底记录在哪里才“可追溯”?
“文档作者、小 block 作者、block 版本作者”——这套模型天然支持:
- 文档作者:
documents.createdBy - block 创建者:
blocks.createdBy - block 每个版本作者:
block_versions.createdBy - doc 每个版本作者:
doc_revisions.createdBy
你甚至可以很容易做:
- “贡献者列表”:统计 doc_revisions.createdBy / block_versions.createdBy
- “某段是谁改的”:看这个 block 当前版本是谁写的,追溯版本链即可
建议的索引
blocks
{ docId: 1 }{ docId: 1, type: 1 }{ docId: 1, isDeleted: 1 }
block_versions
- 唯一:
{ blockId: 1, ver: 1 } - 查询当前 doc 下某批 block 的某些 ver:
{ docId: 1, blockId: 1, ver: 1 } - 组树用:
{ docId: 1, parentId: 1, sortKey: 1, ver: 1 }(按你的查询方式调整)
doc_revisions
- 唯一:
{ docId: 1, docVer: 1 } { docId: 1, createdAt: -1 }
doc_snapshots
{ docId: 1, docVer: -1 }
结构解读
大文档本质不是“一个长字符串”,而是一棵树:
- 文档的根节点叫
rootBlock - root 下面挂一堆内容块:段落、标题、列表项、图片……
- 有些块还能作为容器,再挂子块(比如列表、引用、折叠块、表格)
Document = 元信息 + 指向 rootBlock 的指针
Block = 树中的一个节点
documents 表只存:这篇文档是谁、叫什么、现在最新版本是多少、根块是谁
它不存全文内容。
最小字段可以这么设计:
// documents
{
_id: "doc_1", // 文档 ID
title: "我的第一篇文档",
createdAt: 1700000000,
createdBy: "u_1",
updatedAt: 1700001000,
updatedBy: "u_2",
head: 12, // 文档级别版本号(docVer)
rootBlockId: "root_doc_1" // 这篇文档的根块
}你把它理解成:documents 是“文件夹里的文件信息”,rootBlockId 是“文件内容从哪里开始”。
blocks 表存的是 Block 的“身份信息”:它属于哪个文档、是什么类型、谁创建的、当前最新版本是多少。
它也不存具体内容(内容在 block_versions 里)。
最小 blocks 长这样:
// blocks
{
_id: "b_1",
docId: "doc_1", // 关联到 documents._id
type: "paragraph", // block 的类型
createdAt: 1700000001,
createdBy: "u_1",
latestVer: 3, // 这个 block 最新版本号
latestAt: 1700001000,
latestBy: "u_2",
isDeleted: false
}你把它理解成:blocks 是“文档里每个块的目录索引”,告诉你文档里有哪些 block 以及每个 block 是啥类型。
documents 与 blocks 的关联方式:靠 docId + rootBlockId
关联 1:docId(最重要的外键)
blocks.docId = documents._id
这就是“这个 block 属于哪篇文档”。
关联 2:rootBlockId(定义文档的结构树根)
- documents.rootBlockId 是 root block 的 blockId
- 这个 root block 本身也会存在 blocks 表里:
// blocks 中的一条记录
{
_id: "root_doc_1",
docId: "doc_1",
type: "root",
...
}但是仅靠 documents + blocks,还不知道文档内容顺序啊?
blocks 表只是列了有哪些 block,但没告诉你它们的顺序/层级关系。
所以你必须有一种办法表示结构:
结构(顺序/父子关系)不放在 blocks 里,而放在 block_versions 里。(因为结构会变化,需要版本)
文档的结构关系在 block_versions 的 parentId 和 sortKey 字段里
每一个 block_version(每个版本)都会存:
- parentId:它属于哪个父 block
- sortKey:它在兄弟中的顺序
这就是文档的结构本体。
比如:
// block_versions
{
blockId: "b_1",
ver: 1,
parentId: "root_doc_1",
sortKey: "M",
payload: { ...段落内容... }
}表示:b_1 是 root_doc_1 的一个子节点,它在这个父节点下排序是 "M"
完整极简例子
假设你有一个文档 doc_1:
标题:Hello
段落:第一段
段落:第二段documents 表
{
_id: "doc_1",
title: "Demo 文档",
rootBlockId: "root_doc_1",
head: 1
}blocks 表(有 3 个 block + 1 个 root)
[
{ _id: "root_doc_1", docId: "doc_1", type: "root", latestVer: 1 },
{ _id: "b_title", docId: "doc_1", type: "heading", latestVer: 1 },
{ _id: "b_p1", docId: "doc_1", type: "paragraph", latestVer: 1 },
{ _id: "b_p2", docId: "doc_1", type: "paragraph", latestVer: 1 }
]block_versions 表(这里决定了结构 + 内容)
[
// root 自己的位置(root 的 parentId 设成自己即可)
{ blockId: "root_doc_1", ver: 1, parentId: "root_doc_1", sortKey: "U", payload: {} },
// 标题
{
blockId: "b_title", ver: 1,
parentId: "root_doc_1", sortKey: "M",
payload: { body: { richText: { source: "Hello" } } }
},
// 第一段
{
blockId: "b_p1", ver: 1,
parentId: "root_doc_1", sortKey: "T",
payload: { body: { richText: { source: "第一段" } } }
},
// 第二段
{
blockId: "b_p2", ver: 1,
parentId: "root_doc_1", sortKey: "Z",
payload: { body: { richText: { source: "第二段" } } }
}
]文档内容是:
- 从 documents.rootBlockId 得到 root blockId
- 找到所有 block_versions(当前 docVer 下对应的版本)
- 筛选 parentId=rootBlockId 的那些 block
- 按 sortKey 排序
- 渲染这些 block 的 payload
可以把 doc_revisions 理解成:文档的提交记录表(commit log)每一次保存(自动保存/手动保存/发布/粘贴/拖动)都会新增一条 doc_revision。
它不是“完整文档内容”,它是“这次变化的摘要”。
doc_revisions 的最小结构
// doc_revisions
{
_id: "doc_1@5", // docId@docVer
docId: "doc_1",
docVer: 5, // 文档版本号(递增)
createdAt: ...,
createdBy: "u_2",
message: "拖动段落",
patches: [
{ blockId: "b_9", from: 1, to: 2 }, // b_9 的 blockVer 从 1 变 2
{ blockId: "b_11", from: 3, to: 4 }, // b_11 内容也改了
],
rootBlockId: "root_doc_1"
}核心字段只有一个:patches。
关键点:
文档版本 docVer 并不是把所有 block 的版本都存一遍
而是只存这个 docVer 相比上一个 docVer 改了哪些 block
也就是说:
- docVer=5 只知道:这次变了 b_9, b_11
- 没写进 patches 的 block,默认沿用 docVer=4 的版本
patches 的语义就是:“把 docVer-1 的状态,apply 这些 patch,就得到 docVer 的状态”
这跟 Git commit 很像:
- commit 不存全量文件树
- commit 存 diff/变更
- 想恢复某个 commit 的文件树,需要从某个基线+apply
非常直观的“顺序版本化”例子
假设一开始顺序是:
A
B
CdocVer=1 状态:
A@1 sortKey="M"
B@1 sortKey="T"
C@1 sortKey="Z"
doc_revisions(1):
patches: [
{ A: 0->1 }, { B: 0->1 }, { C: 0->1 }
]现在拖动 B 到最上面(变成 B A C)
你会产生一个新版本:
B@2 sortKey="G" (比 M 更小)
doc_revisions(2):
patches: [
{ blockId:"B", from:1, to:2 }
]docVer=2 的顺序怎么恢复?
你拿到 docVer=2 下:
A -> 1
B -> 2
C -> 1
加载它们对应的 block_versions:
- B@2 sortKey="G"
- A@1 sortKey="M"
- C@1 sortKey="Z"
按 sortKey 排序,就得到:
B A C
最小实现
你最终要实现的三个核心概念:
- Document(文档元信息):
documents - Block + BlockVersion(块身份 + 块版本内容/位置):
blocks+block_versions - DocRevision + Snapshot(文档级版本历史):
doc_revisions+doc_snapshots
然后实现这些关键 API:
文档类:
createDocumentgetDocumentupdateDocumentMeta
块类:
createBlockupdateBlockContentmoveBlockdeleteBlockgetRenderedTree
版本类:
commit(内部)getDocState(docId, docVer?)listDocVersionscreateSnapshotdiffDocVersions(基础版)diffBlockVersions(基础版)
Document(documents)
type Document = {
id: string;
title: string;
createdAt: number;
createdBy: string;
updatedAt: number;
updatedBy: string;
head: number; // docVer
rootBlockId: string; // root block
};BlockIdentity(blocks)
type Block = {
id: string;
docId: string;
type: string;
createdAt: number;
createdBy: string;
latestVer: number;
latestAt: number;
latestBy: string;
isDeleted: boolean;
};BlockVersion(block_versions)
type BlockVersion = {
id: string; // blockId@ver
docId: string;
blockId: string;
ver: number;
createdAt: number;
createdBy: string;
parentId: string; // structure
sortKey: string; // order
indent: number;
collapsed: boolean;
payload: any; // content
plainText: string;
hash: string;
};Snapshot(doc_snapshots)
type Snapshot = {
id: string;
docId: string;
docVer: number;
createdAt: number;
blockVersionMap: Record<string, number>;
};