文件存储与同步服务从外面看很平凡:把文件丢进一个文件夹,它就出现在每台设备上。底下,它是更丰富的系统设计问题之一,因为需求互相拉扯。你必须持久存储 PB 级(丢失用户文件不可原谅)、在数秒内跨多设备同步变更、避免在一字节变化时重新上传 2 GB 视频、跨数百万用户去重相同内容而不彼此泄露数据、在两台离线设备编辑同一文件时解决冲突,并在全球快速服务下载。关键架构动作是把文件切成块(chunk)内容寻址去重,以及把元数据平面与块存储平面分开——几乎每个有趣的决策都从这三个流出。

⚡ 速览要点
  • 把文件切成固定大小块(如 4 MB)——支持可恢复上传、并行传输、部分同步(只有改变的块移动)和去重。
  • 内容寻址存储——一个块的 ID 是其字节的哈希,所以相同块只存一次(块级去重),节省大量存储和带宽。
  • 把元数据与块分开——元数据服务(文件树、版本、块列表)小、事务性、对一致性敏感;块服务(S3 风格 blob 存储)巨大、仅追加。
  • 同步 = diff + 增量上传 + 通知——客户端哈希本地块、只上传新的、向元数据提交一个新版本,通知服务告诉其他设备拉取。
  • 绝不静默丢失一次编辑——并发离线编辑由版本化和"冲突副本"文件解决,而非最后写入胜。
  • 持久性是首要非功能需求——跨 AZ/区域复制块(冷数据纠删码)以达 ~11 个 9,像 S3。
  • 元数据是扩展瓶颈——按用户/命名空间分片文件树和共享图;blob 存储几乎线性扩展。
tldr

把文件切成 ~4 MB 块,并按内容哈希寻址每个块,使相同块只存一次。保持两个平面:一个元数据服务(文件树、版本、块列表——需事务和较强一致性)和一个 S3 支撑的块服务(便宜、持久、仅追加)。通过哈希本地块、只上传新的、提交新版本来同步;通知服务向其他设备推"现在拉取"。用版本化和冲突副本解决并发编辑。复制块以持久、经 CDN 服务下载。

high-level architecture
                         ┌──────────────────┐
   ┌───────────┐  元数据  │  元数据服务      │   ┌───────────────┐
   │  客户端   │◀───────▶│ (树、版本、      │──▶│  元数据 DB    │
   │  watcher  │         │  块列表)         │   │ (分片 SQL)    │
   │  + 同步   │         └──────────────────┘   └───────────────┘
   │  引擎     │         ┌──────────────────┐   ┌───────────────┐
   └────┬──────┘  块     │  块服务          │──▶│  blob 存储    │──▶ CDN
        │───────────────▶│ (上传/取回)      │   │ (S3, 块)      │
        │ 订阅           └──────────────────┘   └───────────────┘
        │                ┌──────────────────┐
        └───────────────▶│   通知服务       │  long-poll / websocket:
                         │                  │  "你的命名空间变了"
                         └──────────────────┘

第 1 步 — 澄清需求

画框前钉死范围。功能需求:上传和下载文件;跨用户设备自动同步一个本地文件夹;与其他用户共享文件/文件夹;保留版本历史;离线工作、重连时同步。非功能:持久性至上(绝不丢数据——目标 ~11 个 9)、高可用、扩展到数亿用户和 EB 级数据、高效用带宽(不重发未变数据)、低同步延迟(变更数秒内传播)。值得显式把文档内实时协作编辑划出范围(那是 Google Docs / 操作转换问题);这里一个文件是一个不透明 blob,一次一个写者改它。

第 2 步 — 容量估算

大致数字证明架构合理。假设 5 亿注册用户、1 亿日活、每用户平均存 10 GB。

头条要点:块大且易扩展;元数据小但高 QPS 且对一致性敏感。那种不对称正是我们分开它们的原因。

第 3 步 — API 设计

API 是块感知而非整文件的。客户端先协商服务器已有哪些块、只上传缺失的字节,然后提交一个引用块哈希的新文件版本。

core API (auth token implied)
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)。这一个决策解锁系统大部分效率:

固定大小分块简单但有个弱点:在文件开头插入一字节会移动后续每个块边界,破坏去重。内容定义(变长)分块用滚动哈希基于内容设边界,所以插入只影响附近块——更好去重、更高 CPU 成本。固定大小是常见面试默认;提一下滚动哈希改进。

第 5 步 — 块存储与去重

每个块存在一个内容寻址的 blob 存储:它的标识符是其字节的密码学哈希(如 SHA-256)。两个后果立即落出。第一,同样内容总映射到同样 ID,所以若一个块已存在你只增加引用计数而非再存它——块级去重。第二,你能通过读时重哈希验证完整性。去重在实践中巨大:共享文档、常见应用文件和重存版本意味很大比例的块是重复的。

安全坑

跨用户(全局)去重泄露信息。若去重跨所有用户,攻击者能探测一个已知文件是否已存在(他们的上传瞬间返回 = 别人有它),揭示另一用户存储了那个确切内容。许多服务因此把去重限于每用户或每命名空间,或加一个秘密 salt,用一些存储节省换隐私。总要提出这个取舍。

去重范围存储节省风险
全局(所有用户)最大跨用户的存在性/确认攻击
每命名空间共享文件夹内高低——限于协作者
每用户中等(版本、重复)无跨用户

第 6 步 — 元数据服务与数据模型

元数据平面是大脑:它记录文件夹树、每个文件的版本历史,以及重组每个版本的有序块哈希列表。它每条记录小,但必须事务性(一次 commit 原子更新几行)且相当强一致(客户端不能看到半写的文件)。

metadata schema (sharded by namespace_id)
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 步放一起,系统干净地分成两个独立扩展的数据平面:

因为块不可变且内容寻址,块平面无需复杂一致性——它是一个巨大、持久的哈希 map。所有排序、版本化和"这个文件夹现在含什么"逻辑住在元数据平面。

第 8 步 — 同步算法

客户端的 watcher 观察本地文件夹。文件变化时,同步引擎大致跑这个循环:

client sync engine (upload path)
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 现在陈旧,所以服务器拒绝快路径,声明一个冲突。

conflict via version compare-and-set
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 步 — 关键取舍

总结

整个设计建在三个动作上:把文件切成块、按内容哈希寻址块(免费给去重和平凡 CDN 缓存),并把小但一致性关键的元数据平面与巨大但不可变的块平面分开。把这些做对,同步、去重、可恢复性和持久性都自然随之而来;剩下的难部分是冲突处理(绝不丢一次编辑)和扩展元数据/共享层。

🎯 面试速答

为什么分块?部分同步(只有改变的块移动)、可恢复 + 并行传输、去重——若文件是一个不透明 blob 全都不可能。
为什么内容寻址存储?块的 ID 是其字节的哈希,所以相同块只存一次(去重),且下载无限可 CDN 缓存因为块不可变。
为什么把元数据与块分开?元数据小、高 QPS、事务性、对一致性敏感;块巨大、不可变、仅追加。不同存储、不同扩展,只有元数据需要失效。
怎么处理两次离线编辑?base_version 乐观 compare-and-set;陈旧提交者的文件被存为"冲突副本"——绝不静默覆盖。
全局去重的风险是什么?存在性/确认攻击:瞬间上传揭示别人存储了那个确切内容。把去重限于每命名空间/用户以缓解。

← 上一篇
设计酒店预订应用