文件存储与同步服务从外面看很平凡:把文件丢进一个文件夹,它就出现在每台设备上。底下,它是更丰富的系统设计问题之一,因为需求互相拉扯。你必须持久存储 PB 级(丢失用户文件不可原谅)、在数秒内跨多设备同步变更、避免在一字节变化时重新上传 2 GB 视频、跨数百万用户去重相同内容而不彼此泄露数据、在两台离线设备编辑同一文件时解决冲突,并在全球快速服务下载。关键架构动作是把文件切成块(chunk)、内容寻址去重,以及把元数据平面与块存储平面分开——几乎每个有趣的决策都从这三个流出。
- 把文件切成固定大小块(如 4 MB)——支持可恢复上传、并行传输、部分同步(只有改变的块移动)和去重。
- 内容寻址存储——一个块的 ID 是其字节的哈希,所以相同块只存一次(块级去重),节省大量存储和带宽。
- 把元数据与块分开——元数据服务(文件树、版本、块列表)小、事务性、对一致性敏感;块服务(S3 风格 blob 存储)巨大、仅追加。
- 同步 = diff + 增量上传 + 通知——客户端哈希本地块、只上传新的、向元数据提交一个新版本,通知服务告诉其他设备拉取。
- 绝不静默丢失一次编辑——并发离线编辑由版本化和"冲突副本"文件解决,而非最后写入胜。
- 持久性是首要非功能需求——跨 AZ/区域复制块(冷数据纠删码)以达 ~11 个 9,像 S3。
- 元数据是扩展瓶颈——按用户/命名空间分片文件树和共享图;blob 存储几乎线性扩展。
把文件切成 ~4 MB 块,并按内容哈希寻址每个块,使相同块只存一次。保持两个平面:一个元数据服务(文件树、版本、块列表——需事务和较强一致性)和一个 S3 支撑的块服务(便宜、持久、仅追加)。通过哈希本地块、只上传新的、提交新版本来同步;通知服务向其他设备推"现在拉取"。用版本化和冲突副本解决并发编辑。复制块以持久、经 CDN 服务下载。
┌──────────────────┐
┌───────────┐ 元数据 │ 元数据服务 │ ┌───────────────┐
│ 客户端 │◀───────▶│ (树、版本、 │──▶│ 元数据 DB │
│ watcher │ │ 块列表) │ │ (分片 SQL) │
│ + 同步 │ └──────────────────┘ └───────────────┘
│ 引擎 │ ┌──────────────────┐ ┌───────────────┐
└────┬──────┘ 块 │ 块服务 │──▶│ blob 存储 │──▶ CDN
│───────────────▶│ (上传/取回) │ │ (S3, 块) │
│ 订阅 └──────────────────┘ └───────────────┘
│ ┌──────────────────┐
└───────────────▶│ 通知服务 │ long-poll / websocket:
│ │ "你的命名空间变了"
└──────────────────┘
第 1 步 — 澄清需求
画框前钉死范围。功能需求:上传和下载文件;跨用户设备自动同步一个本地文件夹;与其他用户共享文件/文件夹;保留版本历史;离线工作、重连时同步。非功能:持久性至上(绝不丢数据——目标 ~11 个 9)、高可用、扩展到数亿用户和 EB 级数据、高效用带宽(不重发未变数据)、低同步延迟(变更数秒内传播)。值得显式把文档内实时协作编辑划出范围(那是 Google Docs / 操作转换问题);这里一个文件是一个不透明 blob,一次一个写者改它。
第 2 步 — 容量估算
大致数字证明架构合理。假设 5 亿注册用户、1 亿日活、每用户平均存 10 GB。
- 存储:500M × 10 GB = 5 EB 原始——去重前、复制前。去重可砍 30–50%;复制(3×)又乘回去。blob 存储必须扩展到许多 EB。
- 写吞吐:若每 DAU 每天上传 ~10 个文件、平均 1 MB,那是 ~1B 文件操作/天 ≈ ~12K 写/秒平均,峰值数倍更高。
- 读写比:下载和同步主导;预期读多。大多数读是便宜的元数据轮询加从 CDN 服务的块取回。
- 元数据 QPS:客户端频繁轮询变更;元数据服务见到的 QPS 远多于块服务,但每个请求很小。
头条要点:块大且易扩展;元数据小但高 QPS 且对一致性敏感。那种不对称正是我们分开它们的原因。
第 3 步 — API 设计
API 是块感知而非整文件的。客户端先协商服务器已有哪些块、只上传缺失的字节,然后提交一个引用块哈希的新文件版本。
POST /files/upload-urls {chunk_hashes:[...]} # 哪些缺失?
→ {missing:[hash→presigned PUT url]} # 去重发生在这里
PUT <presigned-url> <chunk bytes> # 客户端 → blob 存储直传
POST /files/commit {path, chunk_hashes:[...], base_version}
→ {version} # 新文件版本
GET /files/changes?cursor=X # 自上次同步的增量
→ {changes:[...], next_cursor}
GET /files/download?path&version # → 块列表 + CDN url
POST /shares {path, user, role} # 共享一个文件夹
注意上传流程:客户端问"这些块哈希里你已经有哪些?"并只上传缺失的,经预签名 URL 直接到 blob 存储(让大量字节远离应用服务器)。commit 调用是唯一变更元数据的。
第 4 步 — 分块:为什么把文件切成块
不把文件当一个不透明 blob,客户端把它切成固定大小块(chunk)(Dropbox 用 4 MB)。这一个决策解锁系统大部分效率:
- 部分同步。改一个 2 GB 文件里的一段,只有受影响的 4 MB 块需要重新上传——而非整个文件。
- 可恢复上传。断开的连接在块边界恢复;你重发一个块,而非数 GB。
- 并行传输。块并发上传/下载,饱和带宽。
- 去重。块是去重的单位(下一步)。
固定大小分块简单但有个弱点:在文件开头插入一字节会移动后续每个块边界,破坏去重。内容定义(变长)分块用滚动哈希基于内容设边界,所以插入只影响附近块——更好去重、更高 CPU 成本。固定大小是常见面试默认;提一下滚动哈希改进。
第 5 步 — 块存储与去重
每个块存在一个内容寻址的 blob 存储:它的标识符是其字节的密码学哈希(如 SHA-256)。两个后果立即落出。第一,同样内容总映射到同样 ID,所以若一个块已存在你只增加引用计数而非再存它——块级去重。第二,你能通过读时重哈希验证完整性。去重在实践中巨大:共享文档、常见应用文件和重存版本意味很大比例的块是重复的。
跨用户(全局)去重泄露信息。若去重跨所有用户,攻击者能探测一个已知文件是否已存在(他们的上传瞬间返回 = 别人有它),揭示另一用户存储了那个确切内容。许多服务因此把去重限于每用户或每命名空间,或加一个秘密 salt,用一些存储节省换隐私。总要提出这个取舍。
| 去重范围 | 存储节省 | 风险 |
|---|---|---|
| 全局(所有用户) | 最大 | 跨用户的存在性/确认攻击 |
| 每命名空间 | 共享文件夹内高 | 低——限于协作者 |
| 每用户 | 中等(版本、重复) | 无跨用户 |
第 6 步 — 元数据服务与数据模型
元数据平面是大脑:它记录文件夹树、每个文件的版本历史,以及重组每个版本的有序块哈希列表。它每条记录小,但必须事务性(一次 commit 原子更新几行)且相当强一致(客户端不能看到半写的文件)。
namespaces (ns_id, owner_id, type) # 用户的根或一个共享文件夹
files (file_id, ns_id, path, is_dir, latest_version, deleted)
versions (file_id, version, chunk_hashes[], size, modified_by, ts)
chunks (chunk_hash PK, blob_url, size, refcount) # 内容寻址
devices (device_id, user_id, last_cursor) # 每台设备读到哪
shares (ns_id, user_id, role) # viewer / editor / owner
一个文件版本只是一个有序块哈希列表——重建文件意味取那些块并拼接。命名空间(namespace)是分片和共享的单位:用户的私有根是一个命名空间,每个共享文件夹是它自己的、能附到多个用户的命名空间。按 ns_id 分片元数据让用户(或共享文件夹)的数据共置,所以列目录或算增量是单分片操作。关系存储(带分片)是好选择,因为事务 commit;NoSQL 存储也行,若你谨慎处理多行更新。
第 7 步 — 核心架构:两个平面
把第 4–6 步放一起,系统干净地分成两个独立扩展的数据平面:
- 元数据平面——元数据服务 + 分片元数据 DB。高 QPS、小载荷、事务性、对一致性敏感。大部分工程努力和大部分扩展痛苦住在这里。
- 块平面——块服务 + blob 存储(S3)+ CDN。EB 级、大载荷、仅追加且不可变(块从不变——它的哈希定义它)、平凡可缓存。客户端经预签名 URL 直接与 blob 存储传大量字节,所以应用服务器从不代理数 GB。
因为块不可变且内容寻址,块平面无需复杂一致性——它是一个巨大、持久的哈希 map。所有排序、版本化和"这个文件夹现在含什么"逻辑住在元数据平面。
第 8 步 — 同步算法
客户端的 watcher 观察本地文件夹。文件变化时,同步引擎大致跑这个循环:
on local_file_change(file):
chunks = split(file, 4MB)
hashes = [sha256(c) for c in chunks]
missing = api.upload_urls(hashes) # 服务器:哪些是新的?
for h in missing:
put(missing[h], chunk_for(h)) # 只上传新块 → blob 存储
api.commit(file.path, hashes, base_version=file.known_version)
下载路径是镜像:客户端调 changes?cursor=、得知哪些文件有新版本、取它本地缺失的块哈希、从 CDN 下载那些块、重组文件。关键地,客户端维护一个 cursor(每命名空间变更日志里的位置),所以它只拉增量,从不拉整棵树。
第 9 步 — 通知其他设备
第二台设备如何得知文件变了?每几秒轮询元数据服务能工作且简单,但浪费请求并加延迟。更好的设计是一个专用通知服务,每台在线设备订阅它;命名空间变化时,它推一个轻量的"你有变更,现在拉取"信号,设备随后做一个正常的 changes?cursor= 拉取。
| 机制 | 延迟 | 成本 / 注 |
|---|---|---|
| 周期轮询 | 秒–分钟 | 简单;空闲时浪费;不扩展到即时同步 |
| 长轮询 | ~1–2 s | 好的中间地带;连接持到变更或超时 |
| WebSocket / push | 亚秒 | 最低延迟;许多空闲持久连接要管理 |
一个常见混合:只为"ping"保持一个轻量长轮询/WebSocket 通道(无载荷),让现有元数据 API 服务实际的增量。这让通知层便宜(它推小信号)同时复用久经考验的变更拉取路径来取数据。
第 10 步 — 冲突解决
两台设备离线;都编辑 report.docx;都重连并试图提交。元数据 commit 包含 base_version——客户端起始的版本。服务器只在 base_version 等于当前最新版本时接受 commit(乐观 compare-and-set)。第二个提交者的 base_version 现在陈旧,所以服务器拒绝快路径,声明一个冲突。
device A: commit(base_version = 5) → 接受, latest = 6
device B: commit(base_version = 5) → 拒绝 (latest 现在是 6)
解决:不要覆盖。把 B 的内容存为一个新文件:
"report (conflicted copy — Bob's MacBook 2023-04-08).docx"
两个版本都被保留 → 无静默数据丢失;用户决定。
因为我们把文件当不透明 blob(无文档内合并),安全、广为理解的解决是保留两者:获胜版本留下,失败者被写为一个同步给所有人的冲突副本(conflicted copy)。这正是 Dropbox 的行为,且是正确的面试答案——绝不静默丢弃用户的编辑。(文档内自动合并是 CRDT / 操作转换的领域,是 Google Docs 问题,不是文件同步问题。)
第 11 步 — 共享与权限
共享用命名空间建模:一个共享文件夹是一个挂载进多个用户树的命名空间,shares 里一行授予每个协作者一个角色(viewer/editor/owner)。当任何成员对那个命名空间提交一个变更,通知服务扇出到所有成员的设备。权限检查在元数据服务里对每次读/写发生。扩展关切是共享/权限查找:深嵌套共享文件夹和大协作者列表使"这个用户能访问这个路径吗?"非平凡,所以权限通常被缓存并在命名空间级别(而非每文件)求值。
第 12 步 — 下载、缓存与 CDN
下载是不可变块的批量读——CDN 的完美契合。因为块内容寻址且从不变,它无限可缓存:URL(按块哈希为键)能在全球边缘位置缓存而无失效逻辑。客户端从块服务得到(预签名、会过期的)CDN URL 并从最近边缘拉块。热块(热门共享文件、应用安装器)几乎完全从缓存服务,省下源 blob 存储。这就是为什么分离不可变块与可变元数据再次回报:只有元数据需要失效。
第 13 步 — 扩展、持久性与容错
持久性是头条需求,所以块存储跨多个可用区(常跨区域)复制每个块;较冷数据移到纠删码(erasure-coded)存储,以远低于 3× 复制的开销达到同样持久性。托管对象存储(S3)已给 ~11 个 9,这就是为什么大多数设计倚靠一个而非自己造。元数据平面:按 ns_id 分片并复制每个分片(leader + follower)以求可用;每命名空间变更日志给基于 cursor 同步一个干净排序。应用和通知层无状态、在负载均衡器后横向扩展。因为块不可变且由哈希验证,后台 scrubbing 能通过从一个好副本重新复制来检测和修复静默损坏(bit rot)。
第 14 步 — 关键取舍
- 块大小。更小块 → 更好去重和更细粒度部分同步,但更多元数据(每文件更多哈希)和更多请求开销。~4 MB 是常见平衡。
- 固定 vs 内容定义分块。固定简单但插入毁去重;滚动哈希边界修这个、更高 CPU 成本。
- 去重范围。全局最大化节省但启用跨用户存在性攻击;每命名空间/每用户更安全。一个真实的隐私/成本取舍。
- 元数据一致性。强一致使同步正确性容易但花延迟、限制规模;每命名空间分片让命名空间内强一致可负担,而系统整体横向扩展。
- 通知机制。WebSocket 给亚秒同步但花许多空闲连接;长轮询是更便宜的近实时折中。
整个设计建在三个动作上:把文件切成块、按内容哈希寻址块(免费给去重和平凡 CDN 缓存),并把小但一致性关键的元数据平面与巨大但不可变的块平面分开。把这些做对,同步、去重、可恢复性和持久性都自然随之而来;剩下的难部分是冲突处理(绝不丢一次编辑)和扩展元数据/共享层。
为什么分块?部分同步(只有改变的块移动)、可恢复 + 并行传输、去重——若文件是一个不透明 blob 全都不可能。
为什么内容寻址存储?块的 ID 是其字节的哈希,所以相同块只存一次(去重),且下载无限可 CDN 缓存因为块不可变。
为什么把元数据与块分开?元数据小、高 QPS、事务性、对一致性敏感;块巨大、不可变、仅追加。不同存储、不同扩展,只有元数据需要失效。
怎么处理两次离线编辑?对 base_version 乐观 compare-and-set;陈旧提交者的文件被存为"冲突副本"——绝不静默覆盖。
全局去重的风险是什么?存在性/确认攻击:瞬间上传揭示别人存储了那个确切内容。把去重限于每命名空间/用户以缓解。