Goodnotes 后端面试题:两台设备如何同步文档

用户在 iPad 上离线写了三页笔记,同时又在 Mac 上改了标题、移动了页面。两台设备重新联网后,服务端应该接收什么、客户端应该拉什么,数据库又该存哪些字段,才能既不丢数据又不把整份文档反复上传?

这是 Goodnotes 类产品很典型的一道后端系统设计题。真正的难点不在 WebSocket,而在三个地方:同步粒度、增量日志和冲突语义。本文按面试推导顺序,把表结构、Push/Pull 协议、离线恢复、幂等和冲突解决串成一套可落地的设计。

Goodnotes 文档同步系统架构

本文脉络:
一 先问清楚题目:到底要同步什么
二 核心判断:同步操作,不同步整篇文档
三 系统架构与数据分层
四 数据库字段怎么设计
五 正常在线时的 Push / Pull 协议
六 设备离线后重新上线,完整流程是什么
七 两台设备同时修改,冲突怎么解决
八 删除、乱序、重复请求这些坑怎么处理
九 全量同步、增量日志清理与扩展性
十 面试时如何把这套方案讲清楚
十一 常见问题

一、先问清楚题目:到底要同步什么

面试官说“设计两台设备的文档同步”,别急着画 Kafka。先把需求边界问清楚,因为不同答案会直接决定冲突算法。

我会先确认下面几件事:

问题 本文采用的假设
是个人文档还是多人协作? 同一用户的两台设备,先不做多人实时协作
文档包含什么? 标题、页面顺序、文本块、手写笔画、图片和附件
是否支持离线编辑? 支持,可能离线数天
同一文档能否在两台设备同时改? 可以,不能简单拒绝后写入者
一致性要求是什么? 最终一致;本地编辑必须立即可用
数据规模有多大? 文档可能几百 MB,单次只改几 KB
历史记录保留多久? 增量日志有限期保留,过期设备走全量同步

这道题最重要的非功能要求是:

不丢编辑
允许重复请求
支持断点续传
增量同步
同一个输入最终收敛到相同结果

“所有设备任何时刻都看到完全相同的数据”不是目标。设备离线时做不到强一致,硬追强一致只会让离线编辑不可用。

二、核心判断:同步操作,不同步整篇文档

最朴素的方案是给 documents 表加一个 version

设备 A 下载 document(version=10)
设备 B 下载 document(version=10)
A 修改后上传,服务端变成 version=11
B 修改后上传,发现 base_version=10,不匹配

问题来了:B 的修改怎么办?

如果直接覆盖,A 的内容丢失;如果拒绝 B,用户离线写了半小时的内容可能只能手工复制;如果每次都上传完整文档,几百 MB 的笔记改一笔也要全量传输。

根因是同步粒度太粗。

Goodnotes 类文档应该拆成多个可独立合并的实体:

Document
├── Metadata
│ ├── title
│ ├── cover
│ └── updated_at
├── Page sequence
│ ├── page-1
│ ├── page-2
│ └── page-3
└── Page content
├── text blocks
├── strokes
├── images
└── attachments

客户端上传的也不是“最新文档”,而是一组操作:

{
"op_id": "01JZ...R8",
"device_id": "ipad-01",
"document_id": "doc-1001",
"entity_type": "stroke",
"entity_id": "stroke-7788",
"op_type": "ADD",
"base_version": 18,
"hlc": "1785120000123-0004-ipad-01",
"payload": {
"page_id": "page-3",
"blob_key": "strokes/doc-1001/stroke-7788.bin",
"content_hash": "sha256:..."
}
}

一句话概括整套方案:

客户端用本地 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;

INSERT INTO local_strokes (...);
INSERT INTO local_outbox (op_id, document_id, op_type, payload, status)
VALUES (?, ?, 'ADD_STROKE', ?, 'PENDING');

COMMIT;

如果 App 在写完笔画后立刻崩溃,Outbox 仍然在。下次启动继续上传,不需要猜“刚才那一笔到底传没传”。

四、数据库字段怎么设计

下面用接近 MySQL / PostgreSQL 的字段表示。具体数据库不是重点,重点是每个字段为什么存在。

1. 设备表 devices

CREATE TABLE devices (
id VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
platform VARCHAR(32) NOT NULL,
app_version VARCHAR(32) NOT NULL,
last_seen_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,

INDEX idx_devices_user (user_id)
);

device_id 不能用临时 Session ID。它要稳定标识一次设备安装,用来做操作幂等、冲突平局裁决和设备撤销。

2. 文档表 documents

CREATE TABLE documents (
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(512) NOT NULL,
title_hlc VARCHAR(64) NOT NULL,
title_device_id VARCHAR(64) NOT NULL,
current_revision BIGINT NOT NULL DEFAULT 0,
cover_object_key VARCHAR(1024) NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL,
delete_hlc VARCHAR(64) NULL,
deleted_by_device VARCHAR(64) NULL,

INDEX idx_documents_user_update (user_id, updated_at)
);

这里有两个容易混淆的版本字段:

  • current_revision:服务端成功接受一次文档相关变更就递增,适合缓存失效和快速判断“文档有没有变化”;
  • title_hlc:只负责标题字段的冲突裁决。

不要指望一个 version 解决所有字段的冲突。标题、页面顺序和笔画的合并语义完全不同。

3. 页面表 document_pages

CREATE TABLE document_pages (
id VARCHAR(36) PRIMARY KEY,
document_id VARCHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
position_key VARCHAR(128) NOT NULL,
background_key VARCHAR(1024) NULL,
content_version BIGINT NOT NULL DEFAULT 0,
created_hlc VARCHAR(64) NOT NULL,
updated_hlc VARCHAR(64) NOT NULL,
updated_by_device VARCHAR(64) NOT NULL,
deleted_at TIMESTAMP NULL,
delete_hlc VARCHAR(64) NULL,

INDEX idx_pages_document_position (document_id, position_key),
INDEX idx_pages_user (user_id)
);

position_key 不建议直接用连续整数 1, 2, 3。在两页之间插入一页时,连续整数会导致后面所有页面重排。

可以使用 Fractional Index / LexoRank 一类可分割排序键:

page-A position = a0
page-B position = a2
中间插入 page-C position = a1

两台设备生成相同位置键时,再用 page_id 作为稳定的第二排序字段,保证最终顺序一致。

4. 内容块表 document_entities

笔画、文本块、图片可以统一抽象成文档实体:

CREATE TABLE document_entities (
id VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
document_id VARCHAR(36) NOT NULL,
page_id VARCHAR(36) NOT NULL,
entity_type VARCHAR(32) NOT NULL,
version BIGINT NOT NULL,
object_key VARCHAR(1024) NULL,
inline_payload JSON NULL,
content_hash VARCHAR(128) NULL,
created_hlc VARCHAR(64) NOT NULL,
updated_hlc VARCHAR(64) NOT NULL,
updated_by_device VARCHAR(64) NOT NULL,
deleted_at TIMESTAMP NULL,
delete_hlc VARCHAR(64) NULL,

INDEX idx_entities_page (page_id, entity_type),
INDEX idx_entities_document (document_id)
);

小 payload 可以直接存 JSON;图片、PDF、大段笔画数据只存 object_keycontent_hash

上传大文件时采用两阶段流程:

1. 客户端向 Sync API 申请预签名上传地址
2. 客户端把 Blob 直传 Object Storage
3. 上传成功后再 Push 元数据操作
4. 服务端确认 object_key 存在后提交数据库事务

否则所有二进制数据经过应用服务器,带宽和内存都会很难看。

5. 操作幂等表 sync_operations

CREATE TABLE sync_operations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
op_id VARCHAR(36) NOT NULL,
device_id VARCHAR(64) NOT NULL,
document_id VARCHAR(36) NOT NULL,
entity_type VARCHAR(32) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
op_type VARCHAR(32) NOT NULL,
base_version BIGINT NULL,
client_hlc VARCHAR(64) NOT NULL,
payload JSON NULL,
payload_object_key VARCHAR(1024) NULL,
result_revision BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,

UNIQUE KEY uk_operation_user_op (user_id, op_id),
INDEX idx_operation_document (document_id, id)
);

op_id 由客户端生成,全局唯一。客户端超时重试同一个操作时必须复用原 op_id,服务端查到唯一键后直接返回第一次的处理结果。

这张表解决的是“请求是否处理过”,不是给设备做增量拉取。拉取应该使用单独的 Change Log。

6. 增量日志表 sync_changes

CREATE TABLE sync_changes (
change_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
document_id VARCHAR(36) NOT NULL,
entity_type VARCHAR(32) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
change_type VARCHAR(32) NOT NULL,
entity_version BIGINT NOT NULL,
source_device_id VARCHAR(64) NOT NULL,
source_op_id VARCHAR(36) NOT NULL,
payload JSON NULL,
object_key VARCHAR(1024) NULL,
server_hlc VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,

INDEX idx_changes_user_cursor (user_id, change_id),
INDEX idx_changes_document_cursor (document_id, change_id)
);

设备拉增量时:

SELECT *
FROM sync_changes
WHERE user_id = ?
AND change_id > ?
ORDER BY change_id
LIMIT 500;

change_id 就是游标。生产接口不要把它裸露成业务承诺,可以编码成 opaque cursor,后续分库分表时更容易迁移。

7. 设备同步状态 device_sync_states

CREATE TABLE device_sync_states (
user_id BIGINT NOT NULL,
device_id VARCHAR(64) NOT NULL,
last_acked_change BIGINT NOT NULL DEFAULT 0,
last_snapshot_id VARCHAR(64) NULL,
last_sync_at TIMESTAMP NULL,
PRIMARY KEY (user_id, device_id)
);

服务端记录 ACK 的用途主要是观测、日志清理和判断设备是否过期。正确性不能只依赖服务端 ACK;客户端本地也必须持久化自己的 cursor。

五、正常在线时的 Push / Pull 协议

同步接口可以设计成两个核心端点:

POST /v1/sync/push
GET /v1/sync/pull?cursor=...&limit=500
POST /v1/sync/ack

1. Push:上传本地操作

客户端可以批量提交 Outbox:

{
"device_id": "ipad-01",
"operations": [
{
"op_id": "op-100",
"document_id": "doc-1",
"entity_type": "document",
"entity_id": "doc-1",
"op_type": "UPDATE_TITLE",
"base_version": 18,
"hlc": "1785120000123-0001-ipad-01",
"payload": { "title": "系统设计笔记" }
}
]
}

服务端处理单个操作时,在一个事务里完成:

1. 校验 user、device 和 document 权限
2. 用 (user_id, op_id) 检查幂等
3. 读取目标实体当前版本
4. 按该实体的冲突策略决定接受、合并或生成冲突副本
5. 更新 materialized state
6. 写 sync_operations
7. 写 sync_changes
8. 递增 documents.current_revision
9. 提交事务

这里 sync_changes 与实体更新必须同事务提交。否则可能出现“文档已经改了,但另一台设备永远拉不到这条变化”。

Push 响应不要只返回成功或失败,而应逐操作返回:

{
"results": [
{
"op_id": "op-100",
"status": "APPLIED",
"entity_version": 19,
"document_revision": 42,
"change_cursor": "eyJpZCI6ODg4OH0"
}
]
}

可能的状态包括:

状态 含义
APPLIED 操作直接生效
MERGED 与服务端状态合并后生效
DUPLICATE op_id 已处理,返回原结果
CONFLICT_COPY 无法自动合并,已生成冲突副本
REJECTED 权限、格式或依赖不合法

2. Pull:按游标拉取变化

{
"changes": [
{
"change_id": 8888,
"document_id": "doc-1",
"entity_type": "stroke",
"entity_id": "stroke-7788",
"change_type": "UPSERT",
"entity_version": 1,
"object_key": "strokes/doc-1/stroke-7788.bin",
"server_hlc": "1785120001000-0000-sync-3"
}
],
"next_cursor": "eyJpZCI6ODg4OH0",
"has_more": false
}

客户端必须遵守一个细节:

只有一批 changes 已经在本地事务中全部应用成功,才能推进 cursor。

如果先保存 cursor,写本地数据库时 App 崩溃,这批变化下次不会再拉,数据就永久缺一段。

BEGIN;

-- 应用本批远端变化
UPSERT INTO local_entities (...);

-- 和数据在同一事务中推进游标
UPDATE local_sync_state
SET cursor = ?
WHERE user_id = ?;

COMMIT;

六、设备离线后重新上线,完整流程是什么

这是面试官最想听清楚的一段。

设备离线重连后的同步时序

设备 B 离线期间:

设备 A:服务端 change_id 从 100 增长到 135
设备 B:本地 cursor 仍是 100,并产生 op-B1、op-B2

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=120

pull(cursor=120) -> changes 121...137, has_more=false
应用成功,保存 cursor=137

如果中间断网,下次从最后一次成功提交的 cursor 继续,不会重头开始。

5. ACK 与订阅实时通知

追平后向服务端 ACK 137,再建立 WebSocket 或 Push Notification 订阅。以后收到“用户有新 change”通知,就继续 Pull。

状态机可以概括为:

OFFLINE
→ AUTHENTICATING
→ PUSHING_OUTBOX
→ PULLING_CHANGES
→ APPLYING
→ CAUGHT_UP
→ WATCHING

任何一步失败都回到可重试状态,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
B 添加 stroke-B

两边操作可以交换:

ADD(stroke-A) + ADD(stroke-B)

最终结果就是两条笔画都存在,不需要比较谁最后写入。

擦除不能只上传“擦除坐标”,因为不同设备上的几何计算可能不完全一致。更稳的做法是客户端计算被删除的笔画 ID,上传:

{
"op_type": "REMOVE_STROKES",
"stroke_ids": ["stroke-A", "stroke-C"]
}

如果需要部分擦除,则把原笔画 Tombstone,再创建裁剪后的新笔画片段。操作仍然是确定的 Add/Remove。

4. 标题用 LWW,但不能用客户端物理时间

直接比较 updated_at 很危险。用户设备时间可能快五分钟,也可能被手动改过。

可以使用 HLC:

physical_ms-logical_counter-device_id

1785120000123-0004-ipad-01

HLC 结合物理时间和逻辑计数。收到远端 HLC 后,本地时钟向前推进,不会因为同一毫秒发生多个事件而失去顺序。

字段比较规则:

HLC 大的胜出
HLC 相同则 device_id 字典序大的胜出

平局规则不是为了证明哪个值“更正确”,而是确保所有设备得出同一个结果。

5. 文本为什么不能直接 LWW

A 把“同步设计”改成“离线同步设计”,B 同时在末尾增加“方案”。整块文本 LWW 会丢掉一边。

文本块应保存为字符/片段操作,由 CRDT 保证并发插入和删除最终收敛。工程上不建议面试现场手写一套 CRDT,讲清楚选型即可:

短文本、低冲突:字段级 LWW,简单优先
正文富文本、并发编辑:成熟 CRDT 库

Goodnotes 类个人笔记的核心是笔画,不一定要把所有字段都 CRDT 化。CRDT 很强,也很贵:数据膨胀、调试困难、GC 复杂。只在真正需要并发合并的结构上使用。

6. 无法合并时保留冲突副本

如果两台设备都离线修改了同一个 PDF 文件,服务端没有可靠方法把两个二进制文件自动合成。

此时不要偷偷覆盖,创建:

原文档.pdf
原文档(来自 Allen 的 iPad,冲突副本).pdf

同步系统最重要的承诺是“不丢”。界面稍微麻烦一点,比静默丢掉用户几个小时的内容好得多。

八、删除、乱序、重复请求这些坑怎么处理

1. 删除必须是 Tombstone

不能收到删除操作就立刻物理删除行。

假设 A 删除 page-2,B 离线时还保留它。B 上线后上传旧页面,如果服务端已经没有任何删除记录,就可能把页面重新创建。

正确做法是保留:

deleted_at
delete_hlc
deleted_by_device

合并时:

比 delete_hlc 更旧的更新不能复活实体
比 delete_hlc 更新的显式 RESTORE 才能恢复

Tombstone 的保留时间必须大于允许设备离线的最长时间。超过期限未上线的设备,不能继续用旧 cursor 增量同步,必须全量重建本地状态。

2. 重复请求靠 op_id

客户端 Push 成功后,响应在网络中丢失,于是重试。

没有 op_id 唯一键时,同一笔画可能创建两次;有唯一键后,服务端返回原处理结果:

SELECT status, result_revision
FROM sync_operations
WHERE user_id = ?
AND op_id = ?;

不要用请求内容 Hash 代替 op_id。两个内容相同但确实发生了两次的操作,会被错误去重。

3. 乱序操作要检查依赖

UPDATE_ENTITY 可能比 CREATE_ENTITY 先到,页面操作也可能引用尚未同步的文档。

操作可以带依赖:

{
"op_id": "op-update-2",
"depends_on": ["op-create-1"]
}

同一批 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
watermark_change_id = 500000

客户端流程:

1. 下载 snapshot,包含 change_id <= 500000 的一致状态
2. 在本地事务中导入 snapshot
3. cursor 设置为 500000
4. Pull change_id > 500000 的变化

Watermark 防止一个经典漏洞:客户端下载快照期间,服务端仍在接收新修改。如果快照没有边界,客户端不知道哪些变化已经包含、哪些还需要拉。

2. 游标过期

Pull 可能返回:

{
"error": "CURSOR_EXPIRED",
"snapshot_id": "snap-900"
}

客户端收到后走全量恢复,不要尝试从已经被清理的 Change Log 猜状态。

3. 分库分表

初期 change_id BIGINT AUTO_INCREMENT 足够简单可靠。规模上来后,可以按 user_id 分片,因为个人文档的同步边界天然是用户。

分片后游标可以变成:

{
"shard": 12,
"sequence": 8888
}

编码成 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 和 cursor;
服务端保存 materialized state、幂等操作表和 Change Log。

第三步:走一遍离线重连

本地编辑先落 Outbox;
上线先 Push 离线操作;
服务端幂等处理并写 Change Log;
客户端从旧 cursor 分页 Pull;
本地应用成功后才推进 cursor。

第四步:讲冲突不是“一刀切”

标题 LWW;
手写笔画 Add/Remove 合并;
文本用 CRDT;
页面顺序用 Fractional Index / Sequence CRDT;
二进制无法合并时保留冲突副本;
删除使用 Tombstone。

第五步:补可靠性

op_id 幂等;
操作与 Change Log 同事务;
cursor 断点续传;
Snapshot + Watermark 做全量恢复;
过期设备强制重新同步。

如果面试时间只剩一分钟,记住这句话:

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 怎么办? 无法可靠自动合并,保留冲突副本并让用户选择,绝不能静默覆盖。