两台电脑都从版本 7 开始编辑同一个文件。A 先上传并提交版本 8,B 随后上传自己的内容。如果服务端只采用最后到达的请求,A 的修改就被静默覆盖;如果只拒绝 B,B 又可能丢掉已经完成的工作。文件同步需要保存字节,也需要保存每次修改相对于哪个版本发生。

Dropbox 在这里表示多端文件同步题型。候选方案支持普通二进制文件、分块上传、离线编辑与冲突副本,不承诺理解任意文档格式并自动合并。正文沿上传恢复和版本提交两条路径展开。

文件身份不等于文件路径

文件拥有稳定 file_id,名称与父目录只是可变元数据。否则重命名会被误识别为删除旧文件再创建新文件,其他设备的未完成上传也会失去归属。基础数据有 files(file_id, parent_id, name, current_version, deleted)、versions(file_id, version, parent_version, manifest_hash, device_id),以及按有序块哈希组成的 manifest。

POST /uploads 携带文件 ID、基础版本、文件长度和块清单,服务端返回缺少的块;PUT /uploads/{upload_id}/chunks/{hash} 校验实际内容哈希后存储;POST /files/{id}/commit 带 base_version 和 manifest。提交事务检查当前版本是否仍等于基础版本,成立才推进,否则返回 409 并保存可查询的冲突版本。

下载接口返回已提交 manifest 和对应块。客户端先写临时文件、逐块检查,再核对完整文件哈希,最后原子替换本地可见文件。下载中断不应覆盖旧的完整本地版本。文件路径需要规范化并约束在同步目录,服务端对象键不直接使用用户提交的 ../ 路径。

版本日志是元数据事实,块仓库存字节。相同块可以在不同文件版本间复用,但去重必须限定授权范围,不能让未授权用户通过“该哈希已存在”的查询推测别人的文件。跨租户去重会引入隐私与加密边界,本方案先只在同一账户内复用。

分块大小改变重传成本

教学假设每天 10 万名活跃用户,每人修改 20 个文件,每个文件平均 8 MiB,总逻辑修改量约 100000 × 20 × 8 MiB = 16.78 TB/日。若基线分块为 4 MiB、平均只变动四分之一字节,理想新增块约 4.19 TB/日;这个四分之一是假设,不能从“只改一个字符”直接推出只传一个小块。

固定分块在文件头插入字节可能移动后续所有块边界,使去重效果急剧降低。内容定义分块可以改善这种输入,但需要滚动边界算法、最小与最大块长约束,并增加计算成本。对不可压缩视频的大范围修改,两种分块都可能需要传大量新数据,因此上线阈值应根据真实文件类型分布决定。

按 4 MiB 块,每个 8 MiB 文件只有两个块;每条块引用含哈希、长度和位置按 64 B 计,200 万次修改的引用约 256 MB/日,不含索引与版本表。把块缩小为 256 KiB,引用数量变十六倍,上传每块的请求开销也增加。小块可以减少局部编辑重传,却可能让元数据和请求费先成为瓶颈。

若保留 30 天历史、新增块 4.19 TB/日且两份副本,则约 251.66 TB;实际去重率、压缩率和用户清理会改变它。峰值按平均的六倍,新增字节流量约 291 MB/s。恢复下载和首次全量同步是另一组负载,不能只按日增量估算新设备入网时的出口。

先上传块,再原子发布清单

flowchart LR
    A[设备A] --> S[会话与缺块查询]
    B[设备B] --> S
    S --> C[(账户内块仓库)]
    A --> V[版本提交 CAS]
    B --> V
    V --> M[(文件与版本元数据)]
    V --> X[冲突版本]
    M --> L[变更日志与同步游标]
    L --> A
    L --> B

块先到、版本后到是刻意选择。只有全部块存在且校验通过,manifest 才能成为已提交版本。上传会话保存已接收块,断点恢复以服务端确认的集合为准,不以客户端“发送过”记录为准。传输中断后,同一块重复上传到同一内容键应得到同一结果;实际字节与声明哈希不同则拒绝。

版本竞争可用一条带条件更新表达:UPDATE files SET current_version = current_version + 1 WHERE file_id = ? AND current_version = ?。受影响行数为零,表示基础版本已过时,需要进入冲突处理。版本号比较、版本记录插入和变更日志写入应在同一事务内;不能先更新指针,再异步补 manifest 引用。

sequenceDiagram
    participant A as 设备A
    participant B as 设备B
    participant D as 版本库
    A->>D: 读取版本7
    B->>D: 读取版本7
    A->>D: 提交基于7的内容A
    D-->>A: 成功 版本8
    B->>D: 提交基于7的内容B
    D-->>B: 冲突 当前版本8
    B->>D: 保存内容B为冲突版本
    D-->>B: 返回冲突副本标识

冲突副本不必永久显示为一个新路径,也可以作为文件的待合并分支,但用户必须能够发现并取回它。对于普通二进制文件,保留双方版本比自动猜测合并安全。对于文本,可以提供基于共同祖先的三方合并;遇到重叠修改仍保留冲突标记,不能把算法输出自动解释为业务正确。

删除、重命名与回收如何相遇

删除写墓碑而非立即移除全部版本,离线设备重连后才知道这个 ID 已被删除。若设备在旧基础上编辑已删除文件,产品可以保留为恢复副本并提示冲突;不能让一个迟到上传无声取消删除。重命名与内容编辑是否允许合并,取决于元数据版本是否按字段拆分,初版采用整文件版本冲突更容易解释。

垃圾回收从已提交版本、未过期上传会话和保留的冲突版本出发标记可达块,只有超过宽限期且不可达的块才可删除。单纯靠客户端引用计数很脆弱:响应丢失、事务回滚和离线设备都可能造成计数偏差。标记扫描代价较大,但初版可以离线执行并与增量引用计数交叉核对。

同步日志使用单调游标,客户端持久化“已经完整应用”的位置。日志保留期过后,设备不能继续从过旧游标增量拉取,应获得明确的重建指令并进行全量清单对账。这样可以控制服务端保留成本,又避免假装永久离线设备永远都能只拉几个事件恢复。

八块文件的恢复与冲突实验

examples/system-design/labs/15/sync.py 创建 8192 B 文件,分成八个不同的 1024 B 块。第一次只上传首块,然后重新查询目录并补齐其余七块,按 manifest 顺序拼回文件,核对完整 SHA-256 与原始字节一致。实验故意破坏一个块,确认校验失败后修复,避免只证明存在性而没有证明完整性。

真实 SQLite 表随后执行两个基于版本 1 的提交:A 条件更新成功到版本 2,B 条件更新影响零行并记录冲突。断言当前内容仍是 A,冲突表保留 B。这个顺序调度等价于两个客户端从同一旧版本出发的确定性竞争,不是对所有数据库并发交错的穷举。

1
2
python3 examples/system-design/labs/15/sync.py
python3 examples/system-design/labs/15/sync.py --unsafe

默认退出 0;负例识别“最后写覆盖”会丢弃 A 后退出 2。原始哈希、补传数量和版本结果见 examples/system-design/evidence/15/。未测试跨机网络、掉电、文件系统同步刷盘、真实内容定义分块或多目录原子操作;本地文件 rename 的正确调用也不自动等于掉电持久性。

45 分钟面试先区分文件身份、块身份和版本身份,再算带宽与元数据,画出块上传和 CAS 提交,最后推演双端编辑、删除后迟到和恢复同步。追问“用了哈希是否就没有冲突”,答案是哈希只标识字节内容,编辑冲突来自两个版本都基于旧状态;追问“块都齐了能否直接显示”,答案仍是需要一次原子版本发布。

参考资料