设计类似 Google Drive 的文件存储和共享系统
1. 概述
本文设计一个类似 Google Drive 的文件存储和共享系统。内容按序进行:确定需求、构建数据模型、定义 API、描述上传和同步流程。之后重点阐述三个难点:写入冲突、一致性和流量突增。
读完本文,你将获得一份可用于系统设计面试的完整设计方案。此外,还包含 Spring Boot 代码片段来阐述核心部分。
2. 需求
2.1. 功能需求
- 上传和下载文件,最大 10 GB。
- 按目录树组织文件,支持重命名和移动。
- 与他人共享文件或目录,权限为
VIEWER或EDITOR。支持公开链接。 - 保存版本历史并允许恢复旧版本。
- 同步同一用户多个设备之间的更改。
2.2. 规模和约束
假设用于计算:
用户:5000 万,DAU:1000 万
平均上传:2 个文件/用户/天,约 500 KB/文件
=> 约 2000 万文件/天,约 230 个文件/秒(峰值 x5 ≈ 1200/秒)
=> 约 10 TB 新数据/天,约 3.6 PB/年(未计副本)
读写比约 5:1
从这些数字可以得出三个约束:
- 不能丢失数据。 持久性需达到“11 个 9”的级别。因此文件内容必须存储在 S3 等对象存储上,而不是应用服务器的本地磁盘。
- 元数据需要强一致性。 一个文件不能有两个“最新版本”。这部分使用关系型数据库。
- 应用服务器不能成为带宽瓶颈。 文件字节应直接从客户端到存储。
3. 整体架构
Client ──► API Gateway (认证、速率限制) ──► Metadata Service ──► PostgreSQL (按 owner_id 分片)
│ │
│ 预签名 URL ├──► Redis (缓存权限、速率限制)
▼ └──► Outbox ──► Kafka ──► 通知 / 搜索 / 缩略图
Object Storage (S3) ◄── CDN (共享文件下载)
主要思想是将元数据与内容分离。文件被分割成 4 MB 的块。每个块通过其自身内容的 SHA-256 标识(内容寻址)。这种方法带来三个好处:
- 免费的去重。 相同的两个块只需存储一次。
- 恢复上传。 网络断开时,客户端只需重新发送缺失的块。
- 增量同步。 修改 1 GB 文件中的一小段,只需上传几个块。
4. 数据模型
CREATE TABLE files (
id UUID PRIMARY KEY,
owner_id BIGINT NOT NULL,
parent_id UUID REFERENCES files(id),
name VARCHAR(255) NOT NULL,
is_folder BOOLEAN NOT NULL,
head_version INT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
deleted_at TIMESTAMPTZ,
UNIQUE (parent_id, name)
);
CREATE TABLE file_versions (
file_id UUID NOT NULL REFERENCES files(id),
version INT NOT NULL,
block_list TEXT[] NOT NULL, -- 哈希列表(按顺序)
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, version)
);
CREATE TABLE blocks (
hash CHAR(64) PRIMARY KEY, -- SHA-256 十六进制
size_bytes INT NOT NULL,
ref_count BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE shares (
file_id UUID NOT NULL REFERENCES files(id),
grantee_id BIGINT NOT NULL,
role VARCHAR(10) NOT NULL, -- VIEWER | EDITOR
PRIMARY KEY (file_id, grantee_id)
);
CREATE TABLE change_journal (
seq BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
file_id UUID NOT NULL,
action VARCHAR(20) NOT NULL
);
Schema 中各部分的作用:
head_version是每个文件的乐观锁列(见第 6.1 节)。file_versions.block_list将每个版本表示为哈希列表。因此,恢复旧版本只需创建一个指向旧block_list的新版本,无需复制任何字节。blocks.ref_count帮助垃圾收集器识别哪些块无人使用,从而从 S3 中删除。change_journal是按用户的变更日志。设备通过从一个seq(游标)开始读取来同步。UNIQUE (parent_id, name)阻止同一目录中出现同名文件,在数据库层面实现。
注意: 按目录树检查权限,比如“共享父目录则可见子文件”,是递归查询且成本高昂。应在 Redis 中缓存权限结果,键为
perm:{userId}:{fileId},TTL 较短。共享改变时删除缓存。
5. API 和上传流程
上传分为三步进行:
POST /api/files/{fileId}/uploads {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT <presigned-url> (客户端直接将块上传到 S3)
POST /api/files/{fileId}/commit {baseVersion, blocks:[hash...]} -> {version}
GET /api/changes?cursor=1234 -> {changes:[...], nextCursor}
所需的 Maven 依赖:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.28.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.1. 确定缺失的块
去重逻辑应用纯 Java 编写,便于测试:
public class ChunkPlanner {
public List<String> missingBlocks(List<String> requested, Set<String> existing) {
return requested.stream()
.distinct()
.filter(hash -> !existing.contains(hash))
.toList();
}
}
客户端按顺序发送哈希列表。服务器查询 blocks 表获取 existing 集合。只有尚不存在的哈希才需要上传。distinct() 处理文件包含多个相同块的情况,例如全 0 字节区域。
5.2. 颁发预签名 URL
@RestController
@RequestMapping("/api/files")
public class UploadController {
private final S3Presigner presigner;
private final BlockRepository blockRepository;
private final ChunkPlanner planner = new ChunkPlanner();
public UploadController(S3Presigner presigner, BlockRepository blockRepository) {
this.presigner = presigner;
this.blockRepository = blockRepository;
}
@PostMapping("/{fileId}/uploads")
public UploadPlan initiate(@PathVariable UUID fileId, @RequestBody UploadRequest req) {
Set<String> existing = blockRepository.findExisting(req.blocks());
List<BlockUrl> urls = planner.missingBlocks(req.blocks(), existing).stream()
.map(hash -> new BlockUrl(hash, presign(hash)))
.toList();
return new UploadPlan(urls);
}
private String presign(String hash) {
return presigner.presignPutObject(r -> r
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(o -> o.bucket("drive-blocks").key("blocks/" + hash)))
.url().toString();
}
}
record UploadRequest(int baseVersion, List<String> blocks) {}
record BlockUrl(String hash, String url) {}
record UploadPlan(List<BlockUrl> missing) {}
对于每个缺失的块,服务器签署一个有效期为 15 分钟的 PUT URL。客户端直接将字节上传到 S3,因此应用服务器只需处理几 KB 的 JSON,而不是数 MB 的数据。示例响应:
{"missing":[{"hash":"9f86d0...","url":"https://drive-blocks.s3.amazonaws.com/blocks/9f86d0...?X-Amz-Signature=..."}]}
注意: 由于键是哈希值,客户端可能发送与声明的哈希不匹配的内容。在提交时,服务器必须重新验证,例如使用 S3 支持的
x-amz-checksum-sha256校验和。如果跳过此步骤,用户可能会“污染”他人共享的块。
5.3. 去重逻辑测试
class ChunkPlannerTest {
private final ChunkPlanner planner = new ChunkPlanner();
@Test
void shouldReturnOnlyBlocksNotYetStored() {
List<String> requested = List.of("a1", "b2", "a1", "c3");
Set<String> existing = Set.of("b2");
assertThat(planner.missingBlocks(requested, existing))
.containsExactly("a1", "c3");
}
@Test
void shouldReturnEmptyWhenEverythingIsDeduplicated() {
assertThat(planner.missingBlocks(List.of("a1"), Set.of("a1"))).isEmpty();
}
}
第二个测试模拟两个人上传相同的安装程序文件的情况。第二个人无需发送任何字节,上传立即完成。
6. 难点及解决方案
6.1. 冲突:两个设备同时编辑一个文件
笔记本和手机同时从版本 7 开始编辑。如果采用“后来者覆盖”的方式,一方的更改将丢失。解决方案是使用乐观锁:客户端附加 baseVersion,只有当 head_version 仍等于该值时提交才成功。
@Service
public class CommitService {
private final JdbcTemplate jdbc;
public CommitService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public int commit(UUID fileId, long userId, int baseVersion, List<String> blocks, long size) {
int updated = jdbc.update("""
UPDATE files SET head_version = head_version + 1, size_bytes = ?
WHERE id = ? AND head_version = ? AND deleted_at IS NULL
""", size, fileId, baseVersion);
if (updated == 0) {
throw new VersionConflictException(fileId, baseVersion);
}
int newVersion = baseVersion + 1;
jdbc.update("INSERT INTO file_versions(file_id, version, block_list, created_by) VALUES (?,?,?,?)",
fileId, newVersion, blocks.toArray(String[]::new), userId);
jdbc.update("UPDATE blocks SET ref_count = ref_count + 1 WHERE hash = ANY(?)",
(Object) blocks.toArray(String[]::new));
jdbc.update("INSERT INTO change_journal(user_id, file_id, action) VALUES (?,?,'MODIFIED')",
userId, fileId);
return newVersion;
}
}
UPDATE ... WHERE head_version = ? 语句在单个原子操作中同时检查和增加版本。后面提交的一方收到 updated == 0。此时 API 返回 409 Conflict。客户端不覆盖,而是将其版本保存为新文件,例如 report (Lan's Laptop 的冲突版本).docx。Dropbox 和 Google Drive 也以这种方式处理二进制文件。
不应使用悲观锁(SELECT ... FOR UPDATE 在整个上传过程中保持)。上传可能持续数分钟,长时间持有锁会导致数据库争用。如 Google Docs 那样的实时协作编辑是另一个问题,需要 OT 或 CRDT,超出本文范围。
6.2. DB、存储和其他服务之间的一致性
有两个问题需要回答。
首先:块已上传到 S3 但提交失败怎么办? 没关系。元数据只在提交成功后才指向块。ref_count = 0 的“孤立”块将在 24 小时后被 GC 任务删除。顺序总是先写字节后写元数据,所以元数据永远不会指向不存在的数据。
其次:如何通知其他设备、搜索索引和缩略图服务? 如果在事务内直接调用 kafkaTemplate.send(),就会遇到双写问题:数据库回滚但消息已发出,或反之。解决方案是事务性 Outbox。在与提交相同的事务中将事件写入 outbox 表,然后中继将事件推送到 Kafka:
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, owner_id, payload FROM outbox
ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED
""");
for (Map<String, Object> row : rows) {
kafka.send("file-events", row.get("owner_id").toString(), (String) row.get("payload")).join();
jdbc.update("DELETE FROM outbox WHERE id = ?", row.get("id"));
}
}
}
关键点说明:
FOR UPDATE SKIP LOCKED允许运行多个中继实例而不重复发送同一批次。- Kafka 键是
owner_id,因此同一用户的所有事件进入同一分区并保持正确的顺序。 - 中继仍可能重复发送,例如在
send之后但DELETE之前崩溃。因此消费者必须是幂等的:记录已处理的(file_id, version)并跳过重复项。
设备无需通过 Kafka 接收内容。通知服务只通过 WebSocket 推送“有更改”的信号。设备随后调用 GET /api/changes?cursor=... 来读取 change_journal。如果设备离线三天,它只需从旧游标继续读取,不会丢失任何事件。
6.3. 流量突增
有三个常见场景。
一个公开共享的文件传播开来,例如考卷被分享给数十万人。下载通过 CDN 使用签名 URL。由于块是不可变的且键是哈希值,可以永久缓存(Cache-Control: immutable)。结果是源几乎不承受任何负载。
早上开始时,数百万设备同时同步。 客户端使用带抖动的指数退避。服务器通过 Redis 按用户进行速率限制:
@Component
public class RateLimiter {
private final StringRedisTemplate redis;
public RateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long userId, int limitPerMinute) {
String key = "rl:%d:%d".formatted(userId, Instant.now().getEpochSecond() / 60);
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(70));
}
return count != null && count <= limitPerMinute;
}
}
每个用户对每分钟有一个计数器。INCR 是 Redis 上的原子操作,因此多个应用服务器共享计数器仍能正确计数。超过限制时,API 返回 429 Too Many Requests 并带有 Retry-After 头。固定窗口允许在两分钟边界处的流量突增翻倍。如需更精确,请使用滑动窗口或用 Lua 脚本编写的令牌桶。
一个账户有数百万个文件。 按 owner_id 对 PostgreSQL 进行分片,使一个用户的整个目录树位于同一分片。这样,重命名或移动目录只是本地事务。移动只改变一行的 parent_id,不涉及子文件。
注意: 在账户间共享的目录位于所有者的分片上。被授权者通过 Metadata Service 跨分片访问,而不是直接在数据库间进行 join。
6.4. 不应该做的事
- 在
BYTEA/BLOB列中存储文件。 备份膨胀,复制缓慢,数据库成为瓶颈。 - 让文件通过应用服务器。 服务器的带宽和内存与文件大小线性增长。
- 删除文件时立即删除块。 块可能被其他文件或版本使用。始终让 GC 基于
ref_count,并使用deleted_at进行软删除以支持回收站。 - 对需要良好增量同步的所有文件类型使用固定大小的块。 在文件开头插入一个字节会导致后面的所有块偏移。内容定义的分块(滚动哈希)可以解决这个问题,但更复杂。只有当实际数据显示明显收益时才应用。
7. 结论
设计类似 Google Drive 的系统围绕一个核心决策:将元数据(关系型数据库、强一致性)与内容(对象存储、通过哈希标识的不可变块)分离。由此,去重、恢复上传、版本历史和 CDN 缓存几乎是内生的。
难点有相当标准的解决方案:
- 写入冲突: 使用
head_version的乐观锁并创建冲突版本而不是覆盖。 - 一致性: 先写字节后提交元数据,结合事务性 outbox 和幂等消费者。
- 流量突增: 预签名 URL、CDN、基于 Redis 的速率限制和按所有者分片。
当将本文用作面试或实际项目的框架时,从第 2 节的规模估计开始。那里的每个数字都决定了后续的一个架构选择。