设计类似 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 节的规模估计开始。那里的每个数字都决定了后续的一个架构选择。

system-designspring-bootkafkarediscase-study