Goodnotes 后端面试题:两台设备如何同步文档
用户在 iPad 上离线写了三页笔记,同时又在 Mac 上改了标题、移动了页面。两台设备重新联网后,服务端应该接收什么、客户端应该拉什么,数据库又该存哪些字段,才能既不丢数据又不把整份文档反复上传?
这是 Goodnotes 类产品很典型的一道后端系统设计题。真正的难点不在 WebSocket,而在三个地方:同步粒度、增量日志和冲突语义。本文按面试推导顺序,把表结构、Push/Pull 协议、离线恢复、幂等和冲突解决串成一套可落地的设计。
本文脉络: |
一、先问清楚题目:到底要同步什么
面试官说“设计两台设备的文档同步”,别急着画 Kafka。先把需求边界问清楚,因为不同答案会直接决定冲突算法。
我会先确认下面几件事:
| 问题 | 本文采用的假设 |
|---|---|
| 是个人文档还是多人协作? | 同一用户的两台设备,先不做多人实时协作 |
| 文档包含什么? | 标题、页面顺序、文本块、手写笔画、图片和附件 |
| 是否支持离线编辑? | 支持,可能离线数天 |
| 同一文档能否在两台设备同时改? | 可以,不能简单拒绝后写入者 |
| 一致性要求是什么? | 最终一致;本地编辑必须立即可用 |
| 数据规模有多大? | 文档可能几百 MB,单次只改几 KB |
| 历史记录保留多久? | 增量日志有限期保留,过期设备走全量同步 |
这道题最重要的非功能要求是:
不丢编辑 |
“所有设备任何时刻都看到完全相同的数据”不是目标。设备离线时做不到强一致,硬追强一致只会让离线编辑不可用。
二、核心判断:同步操作,不同步整篇文档
最朴素的方案是给 documents 表加一个 version:
设备 A 下载 document(version=10) |
问题来了:B 的修改怎么办?
如果直接覆盖,A 的内容丢失;如果拒绝 B,用户离线写了半小时的内容可能只能手工复制;如果每次都上传完整文档,几百 MB 的笔记改一笔也要全量传输。
根因是同步粒度太粗。
Goodnotes 类文档应该拆成多个可独立合并的实体:
Document |
客户端上传的也不是“最新文档”,而是一组操作:
{ |
一句话概括整套方案:
客户端用本地 Outbox 记录操作,服务端用 Change Log 排列已接受的变化;Push 负责上传未确认操作,Pull 负责按游标补齐服务端变化。
三、系统架构与数据分层
文档同步不适合把所有内容都塞进关系数据库。数据库擅长事务、索引和元数据,大块二进制内容交给对象存储。
1. 服务端组件
| 组件 | 职责 |
|---|---|
| Sync API | 接收 Push、提供增量 Pull、校验设备与权限 |
| Metadata DB | 文档、页面、块、设备、版本与 Change Log |
| Object Storage | 图片、PDF、笔画二进制块、缩略图 |
| Message Queue | 异步生成缩略图、OCR、搜索索引,不参与同步正确性 |
| Push Notification | 通知另一台设备“有新版本”,不是数据源 |
Push Notification 或 WebSocket 只能减少拉取延迟,不能代替 Change Log。通知可能丢、可能重复,设备离线时更收不到。收到通知后,客户端仍然要拿自己的游标调用 Pull。
2. 客户端本地数据
客户端至少要有三部分:
| 本地表 | 作用 |
|---|---|
| Materialized State | 当前可展示的文档、页面和内容 |
| Outbox | 尚未被服务端确认的本地操作 |
| Sync State | 当前设备已应用到哪个服务端游标 |
用户落笔时先写本地状态和 Outbox,两者放在同一个本地事务中。界面不等网络返回,这就是 Offline-first。
BEGIN; |
如果 App 在写完笔画后立刻崩溃,Outbox 仍然在。下次启动继续上传,不需要猜“刚才那一笔到底传没传”。
四、数据库字段怎么设计
下面用接近 MySQL / PostgreSQL 的字段表示。具体数据库不是重点,重点是每个字段为什么存在。
1. 设备表 devices
CREATE TABLE devices ( |
device_id 不能用临时 Session ID。它要稳定标识一次设备安装,用来做操作幂等、冲突平局裁决和设备撤销。
2. 文档表 documents
CREATE TABLE documents ( |
这里有两个容易混淆的版本字段:
current_revision:服务端成功接受一次文档相关变更就递增,适合缓存失效和快速判断“文档有没有变化”;title_hlc:只负责标题字段的冲突裁决。
不要指望一个 version 解决所有字段的冲突。标题、页面顺序和笔画的合并语义完全不同。
3. 页面表 document_pages
CREATE TABLE document_pages ( |
position_key 不建议直接用连续整数 1, 2, 3。在两页之间插入一页时,连续整数会导致后面所有页面重排。
可以使用 Fractional Index / LexoRank 一类可分割排序键:
page-A position = a0 |
两台设备生成相同位置键时,再用 page_id 作为稳定的第二排序字段,保证最终顺序一致。
4. 内容块表 document_entities
笔画、文本块、图片可以统一抽象成文档实体:
CREATE TABLE document_entities ( |
小 payload 可以直接存 JSON;图片、PDF、大段笔画数据只存 object_key 和 content_hash。
上传大文件时采用两阶段流程:
1. 客户端向 Sync API 申请预签名上传地址 |
否则所有二进制数据经过应用服务器,带宽和内存都会很难看。
5. 操作幂等表 sync_operations
CREATE TABLE sync_operations ( |
op_id 由客户端生成,全局唯一。客户端超时重试同一个操作时必须复用原 op_id,服务端查到唯一键后直接返回第一次的处理结果。
这张表解决的是“请求是否处理过”,不是给设备做增量拉取。拉取应该使用单独的 Change Log。
6. 增量日志表 sync_changes
CREATE TABLE sync_changes ( |
设备拉增量时:
SELECT * |
change_id 就是游标。生产接口不要把它裸露成业务承诺,可以编码成 opaque cursor,后续分库分表时更容易迁移。
7. 设备同步状态 device_sync_states
CREATE TABLE device_sync_states ( |
服务端记录 ACK 的用途主要是观测、日志清理和判断设备是否过期。正确性不能只依赖服务端 ACK;客户端本地也必须持久化自己的 cursor。
五、正常在线时的 Push / Pull 协议
同步接口可以设计成两个核心端点:
POST /v1/sync/push |
1. Push:上传本地操作
客户端可以批量提交 Outbox:
{ |
服务端处理单个操作时,在一个事务里完成:
1. 校验 user、device 和 document 权限 |
这里 sync_changes 与实体更新必须同事务提交。否则可能出现“文档已经改了,但另一台设备永远拉不到这条变化”。
Push 响应不要只返回成功或失败,而应逐操作返回:
{ |
可能的状态包括:
| 状态 | 含义 |
|---|---|
APPLIED |
操作直接生效 |
MERGED |
与服务端状态合并后生效 |
DUPLICATE |
op_id 已处理,返回原结果 |
CONFLICT_COPY |
无法自动合并,已生成冲突副本 |
REJECTED |
权限、格式或依赖不合法 |
2. Pull:按游标拉取变化
{ |
客户端必须遵守一个细节:
只有一批 changes 已经在本地事务中全部应用成功,才能推进 cursor。
如果先保存 cursor,写本地数据库时 App 崩溃,这批变化下次不会再拉,数据就永久缺一段。
BEGIN; |
六、设备离线后重新上线,完整流程是什么
这是面试官最想听清楚的一段。
设备 B 离线期间:
设备 A:服务端 change_id 从 100 增长到 135 |
B 上线后的推荐流程是:
1. 恢复设备身份
客户端携带用户 Token 和稳定的 device_id 建立同步会话。服务端检查设备是否被撤销、App 版本是否还受支持。
2. Push 本地 Outbox
先上传离线期间产生的操作。服务端会依据 base_version 和 HLC 判断是否存在并发修改,并返回每个操作的处理结果。
为什么不先 Pull 再 Push?
因为先 Pull 可能让客户端把远端状态应用到本地后,再错误地把本地未上传操作当成基于新版本的修改。把 Outbox 作为不可变操作先推上去,服务端能看到它真正的 base_version,冲突关系更准确。
这不是唯一可行顺序。成熟实现也可以并发 Push/Pull,但需要更复杂的操作变换和本地 Rebase。面试里先给出串行、正确、容易解释的版本更稳。
3. 从旧 cursor 开始 Pull
B 仍从 cursor=100 拉取。返回内容既包括 A 产生的变化,也可能包括 B 刚刚 Push 后由服务端生成的合并结果。
不要过滤 source_device_id == current_device。本地操作经过服务端合并后,最终形态可能和原操作不同。设备应该把服务端 Change Log 当成收敛依据。
4. 分页应用直到追平
pull(cursor=100) -> changes 101...120, has_more=true |
如果中间断网,下次从最后一次成功提交的 cursor 继续,不会重头开始。
5. ACK 与订阅实时通知
追平后向服务端 ACK 137,再建立 WebSocket 或 Push Notification 订阅。以后收到“用户有新 change”通知,就继续 Pull。
状态机可以概括为:
OFFLINE |
任何一步失败都回到可重试状态,Outbox 和 cursor 保证重试安全。
七、两台设备同时修改,冲突怎么解决
冲突解决没有一种算法包打天下。关键不是选 LWW 还是 CRDT,而是按数据类型定义语义。
1. 先判断是不是并发修改
如果 B 的 base_version 等于服务端当前版本,它是在最新状态上修改,没有冲突。
如果版本落后,也不代表一定冲突。A 改标题、B 加笔画,两者作用于不同实体,可以直接合并。
真正的冲突是:
两个操作基于同一个旧状态 |
生产系统可以使用 version vector 精确判断因果关系,但每个实体保存完整设备向量成本较高。个人两设备场景可以组合使用:
base_version:检测客户端是否落后;- HLC(Hybrid Logical Clock):提供稳定的近似时间顺序;
device_id:HLC 完全相同时做确定性平局裁决;- 操作类型:决定是否需要比较顺序。
2. 不同数据类型的推荐策略
| 数据 | 推荐策略 | 原因 |
|---|---|---|
| 文档标题 | 字段级 LWW(HLC + device_id) | 标量无法有意义地自动拼接 |
| 标签集合 | OR-Set / Add-Wins Set | 两端新增标签可合并,删除有明确语义 |
| 手写笔画 | Add/Remove 操作集合 | 笔画天然有稳定 ID,新增通常可交换 |
| 文本块 | CRDT,例如 RGA / Yjs 类结构 | 同位置并发输入需要字符级收敛 |
| 页面顺序 | Sequence CRDT 或 Fractional Index | 支持并发插入、移动 |
| 图片属性 | 字段级 LWW | 坐标、旋转角度分别裁决 |
| PDF/附件二进制 | 冲突副本 | 无法安全自动合并 |
| 删除文档 | Tombstone + 明确优先级 | 防止旧设备把已删除文档“复活” |
3. 手写笔画为什么适合操作集合
一条笔画创建时生成唯一 stroke_id:
A 添加 stroke-A |
两边操作可以交换:
ADD(stroke-A) + ADD(stroke-B) |
最终结果就是两条笔画都存在,不需要比较谁最后写入。
擦除不能只上传“擦除坐标”,因为不同设备上的几何计算可能不完全一致。更稳的做法是客户端计算被删除的笔画 ID,上传:
{ |
如果需要部分擦除,则把原笔画 Tombstone,再创建裁剪后的新笔画片段。操作仍然是确定的 Add/Remove。
4. 标题用 LWW,但不能用客户端物理时间
直接比较 updated_at 很危险。用户设备时间可能快五分钟,也可能被手动改过。
可以使用 HLC:
physical_ms-logical_counter-device_id |
HLC 结合物理时间和逻辑计数。收到远端 HLC 后,本地时钟向前推进,不会因为同一毫秒发生多个事件而失去顺序。
字段比较规则:
HLC 大的胜出 |
平局规则不是为了证明哪个值“更正确”,而是确保所有设备得出同一个结果。
5. 文本为什么不能直接 LWW
A 把“同步设计”改成“离线同步设计”,B 同时在末尾增加“方案”。整块文本 LWW 会丢掉一边。
文本块应保存为字符/片段操作,由 CRDT 保证并发插入和删除最终收敛。工程上不建议面试现场手写一套 CRDT,讲清楚选型即可:
短文本、低冲突:字段级 LWW,简单优先 |
Goodnotes 类个人笔记的核心是笔画,不一定要把所有字段都 CRDT 化。CRDT 很强,也很贵:数据膨胀、调试困难、GC 复杂。只在真正需要并发合并的结构上使用。
6. 无法合并时保留冲突副本
如果两台设备都离线修改了同一个 PDF 文件,服务端没有可靠方法把两个二进制文件自动合成。
此时不要偷偷覆盖,创建:
原文档.pdf |
同步系统最重要的承诺是“不丢”。界面稍微麻烦一点,比静默丢掉用户几个小时的内容好得多。
八、删除、乱序、重复请求这些坑怎么处理
1. 删除必须是 Tombstone
不能收到删除操作就立刻物理删除行。
假设 A 删除 page-2,B 离线时还保留它。B 上线后上传旧页面,如果服务端已经没有任何删除记录,就可能把页面重新创建。
正确做法是保留:
deleted_at |
合并时:
比 delete_hlc 更旧的更新不能复活实体 |
Tombstone 的保留时间必须大于允许设备离线的最长时间。超过期限未上线的设备,不能继续用旧 cursor 增量同步,必须全量重建本地状态。
2. 重复请求靠 op_id
客户端 Push 成功后,响应在网络中丢失,于是重试。
没有 op_id 唯一键时,同一笔画可能创建两次;有唯一键后,服务端返回原处理结果:
SELECT status, result_revision |
不要用请求内容 Hash 代替 op_id。两个内容相同但确实发生了两次的操作,会被错误去重。
3. 乱序操作要检查依赖
UPDATE_ENTITY 可能比 CREATE_ENTITY 先到,页面操作也可能引用尚未同步的文档。
操作可以带依赖:
{ |
同一批 Push 内服务端做拓扑排序;依赖不存在时返回 MISSING_DEPENDENCY,客户端保留在 Outbox,等缺失操作补齐后重试。
4. Change Log 至少一次投递
Pull 允许重复返回,客户端应用必须幂等:
- 实体 Upsert 按
entity_version判断; - 旧版本直接忽略;
- 同版本相同内容重复应用无副作用;
- Tombstone 也有版本和 HLC。
不要追求“网络层严格只投一次”。跨网络的 Exactly-once 往往只是把去重逻辑藏到了别处。
九、全量同步、增量日志清理与扩展性
Change Log 不可能永久保留。设备可能一年没上线,也可能刚清空本地数据库。
1. Snapshot + Watermark
服务端为用户生成快照时,记录一个 watermark:
snapshot_id = snap-900 |
客户端流程:
1. 下载 snapshot,包含 change_id <= 500000 的一致状态 |
Watermark 防止一个经典漏洞:客户端下载快照期间,服务端仍在接收新修改。如果快照没有边界,客户端不知道哪些变化已经包含、哪些还需要拉。
2. 游标过期
Pull 可能返回:
{ |
客户端收到后走全量恢复,不要尝试从已经被清理的 Change Log 猜状态。
3. 分库分表
初期 change_id BIGINT AUTO_INCREMENT 足够简单可靠。规模上来后,可以按 user_id 分片,因为个人文档的同步边界天然是用户。
分片后游标可以变成:
{ |
编码成 opaque cursor 返回客户端。只要同一用户固定路由到一个分片,就不需要跨分片做全局排序。
4. 大文件和热点
- Blob 直传对象存储,数据库只保存引用;
- 内容按页或块切分,避免整篇文档热点;
- Push/Pull 都做批量与分页;
- 对同一文档的 revision 更新可通过行锁或原子自增完成;
- Change Log 按用户分区,并为
(user_id, change_id)建覆盖索引; - 缩略图、OCR、全文索引全部异步,不能阻塞同步提交。
5. 需要监控什么
| 指标 | 说明 |
|---|---|
| Sync Lag | 设备 cursor 距服务端最新 change_id 的差距 |
| Push Success Rate | 操作上传成功率 |
| Duplicate Rate | 网络重试和客户端行为是否异常 |
| Conflict Rate | 按实体类型统计冲突比例 |
| Conflict Copy Rate | 无法自动合并的比例 |
| Cursor Expired Rate | 日志保留期是否过短 |
| Outbox Age | 本地操作长时间未上传 |
| Convergence Time | 多设备最终追平需要多久 |
只监控 API 延迟不够。接口很快但 cursor 永远追不平,用户看到的仍然是“同步坏了”。
十、面试时如何把这套方案讲清楚
这道题信息很多,面试时不要一上来背十张表。推荐按下面顺序讲:
第一步:定边界
先做同一用户两台设备、支持离线、最终一致; |
第二步:给核心模型
文档拆成页和实体; |
第三步:走一遍离线重连
本地编辑先落 Outbox; |
第四步:讲冲突不是“一刀切”
标题 LWW; |
第五步:补可靠性
op_id 幂等; |
如果面试时间只剩一分钟,记住这句话:
Outbox 保住离线修改,Change Log 保住增量顺序,幂等键保住重试,按数据类型定义的冲突策略保住用户内容。
十一、常见问题
| 问题 | 回答要点 |
|---|---|
为什么不能直接用 updated_at 拉增量? |
多行可能拥有相同时间戳,分页容易漏数据;时间还会受精度和时钟影响。使用单调 change_id 更稳。 |
| Push 和 Pull 谁先执行? | 简化实现推荐先 Push 不可变 Outbox,再从旧 cursor Pull 服务端最终结果。复杂系统可以并发,但要处理本地 Rebase。 |
| WebSocket 能不能代替轮询? | 不能。它只负责提醒“有变化”,Change Log 和 cursor 才负责补齐丢失、重复和离线期间的数据。 |
base_version 不一致就一定冲突吗? |
不一定。不同实体或可交换操作可以直接合并;只有作用于同一不可交换字段时才需要裁决。 |
| 为什么不用一个 CRDT 解决所有问题? | 成本太高。标题用 LWW 足够,笔画适合操作集合,只有并发文本等场景值得用复杂 CRDT。 |
| 删除后旧设备重新上传怎么办? | 服务端保留 Tombstone,旧于 delete_hlc 的更新不能复活实体;过期设备强制全量同步。 |
| Change Log 清理后,老设备怎么恢复? | 返回 CURSOR_EXPIRED,客户端下载带 watermark 的最新快照,再从 watermark 之后拉增量。 |
| 如何保证同一个操作不会执行两次? | 客户端生成稳定 op_id,服务端对 (user_id, op_id) 建唯一键并返回首次处理结果。 |
| 大文件怎么同步? | 通过预签名 URL 直传对象存储,Sync API 只提交元数据、Hash 和 object key。 |
| 两台设备同时改同一个 PDF 怎么办? | 无法可靠自动合并,保留冲突副本并让用户选择,绝不能静默覆盖。 |