Google Drive風のファイル保存共有システムの設計

1. 概要

この記事はGoogle Driveに似たファイル保存共有システムを設計します。内容は以下の流れで進みます:要件の確定、データモデルの構築、APIの定義、uploadと同期の流れの説明。その後、記事は3つの難しい点、つまり書き込み競合、一貫性、負荷スパイク対策に焦点を当てます。

読み終わったら、システム設計面接に使える設計書ができています。また、重要な部分を説明するSpring Bootコード例も得られます。

2. 要件

2.1. 機能要件

  • ファイルのuploadとdownload、最大サイズ10 GB。
  • ファイルをディレクトリツリーで整理、名前変更と移動をサポート。
  • ファイルまたはディレクトリを他のユーザーと共有、VIEWERまたはEDITOR権限をサポート。公開リンクにも対応。
  • バージョン履歴を保存し、古いバージョンへの復元をサポート。
  • 同一ユーザーの複数デバイス間で変更を同期。

2.2. スケールと制約

計算用の仮定:

ユーザー:5000万、DAU:1000万
平均upload:2ファイル/ユーザー/日、~500 KB/ファイル
=> ~2000万ファイル/日、~230ファイル/秒(ピーク時5倍 ≈ 1,200/秒)
=> ~10 TB新規データ/日、~3.6 PB/年(レプリカ除く)
読み書き比率 ≈ 5:1

これらの数字から3つの制約が導き出せます:

  • データを失わない。 耐久性は「11個の9」のレベルに達する必要があります。そのため、ファイルの内容はS3のようなobject storageに置く必要があり、appサーバーのディスクに置いてはいけません。
  • メタデータは強い一貫性が必要。 あるファイルが「最新バージョン」を2つ持つことはできません。そのため、関係型データベースを使用します。
  • appサーバーが帯域幅のボトルネックになってはいけない。 ファイルのバイトはクライアントからstorageへ直接流れるべき。

3. 全体アーキテクチャ

Client ──► API Gateway (auth, rate limit) ──► Metadata Service ──► PostgreSQL (owner_idでシャード)
   │                                              │
   │  presigned URL                               ├──► Redis (権限cache、rate limit)
   ▼                                              └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (共有ファイルのdownload)

メインのアイデアはメタデータとコンテンツの分離です。ファイルを4 MBのブロックに分割します。各ブロックはその内容のSHA-256で識別されます(content-addressed)。このアプローチは3つの利点をもたらします:

  • 無料のdedup。 同じ内容のブロック2つは1回だけ保存。
  • Resumeできるupload。 ネットワークが切れたら、クライアントは欠けているブロックだけ再送。
  • デルタ同期。 1 GBのファイルの小さな部分を編集する場合、数ブロックだけをuploadすればよい。

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 hex
    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
);

スキーマの各部分の役割:

  • head_versionは各ファイルのoptimisticロック列です(セクション6.1参照)。
  • file_versions.block_listは各バージョンをハッシュリストに変換します。このおかげで、古いバージョンへの復元は古いblock_listを指す新しいバージョンを作成するだけで、バイトをコピーする必要がありません。
  • blocks.ref_countは、garbage collectorがどのブロックが使用されなくなったかを認識し、S3から削除するのに役立ちます。
  • change_journalは、ユーザーごとの変更ログです。デバイスはseq(カーソル)から読み込むことで同期します。
  • UNIQUE (parent_id, name)は、1つのディレクトリ内で同じ名前のファイルが2つ存在することをDB層で防ぎます。

注意: 「親ディレクトリを共有されたら子ファイルが見える」というようなツリーに基づく権限チェックは、再帰クエリで高コストです。Redisでキーperm:{userId}:{fileId}に短いTTLで権限結果をcacheしてください。共有が変更されたらcacheを削除します。

5. APIとuploadフロー

Uploadは3つのステップで行われます:

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. 欠けているブロックを特定する

dedup logicは純粋な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セットを取得します。まだないハッシュだけがuploadが必要です。distinct()は、例えばすべてbyte 0の領域など、ファイルが同じ内容の複数ブロックを含む場合を処理します。

5.2. presigned 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に直接送信するため、appサーバーはMBではなく数KB のJSONだけを処理します。レスポンス例:

{"missing":[{"hash":"9f86d0...","url":"https://drive-blocks.s3.amazonaws.com/blocks/9f86d0...?X-Amz-Signature=..."}]}

注意: キーがハッシュなので、クライアントは宣言されたハッシュと一致しないコンテンツを送信することができます。commitの際、サーバーはS3がサポートするx-amz-checksum-sha256などのチェックサムで再確認する必要があります。この手順をスキップすると、あるユーザーが共有ブロックを「汚染」する可能性があります。

5.3. dedup logic用のテスト

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();
    }
}

2番目のテストは、2人が同じセットアップファイルをuploadするケースをシミュレートします。2番目のユーザーはバイトを送信する必要がなく、uploadは「即座」です。

6. 難しい点と解決策

6.1. 競合:2つのデバイスが同じファイルを編集する

ノートパソコンと携帯電話がバージョン7から同時に編集します。「後から来たものが上書きする」アプローチを適用すると、一方の変更が失われます。解決策はoptimistic lockingを使うことです:クライアントはbaseVersionを送信し、head_versionがその値のままの場合のみcommitが成功します。

@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 = ?文は、チェックとversionの増加を1つのatomicな操作で行います。後からcommitする側はupdated == 0を受け取ります。その場合、APIは409 Conflictを返します。クライアントは上書きせず、report (Lan's Laptopからの競合バージョン).docxのような新しいファイルとして自分のバージョンを保存します。DropboxとGoogle Driveもバイナリファイルをこの方法で処理します。

悲観的ロック(uploadの間中SELECT ... FOR UPDATEを保持)を使用するべきではありません。uploadは数分かかる可能性があり、そのようにロングロックを保持するとDBを詰まらせます。Google Docsのようなリアルタイム協調編集は異なる問題で、OTまたはCRDTが必要であり、この記事の範囲外です。

6.2. DB、storage、その他のサービス間の一貫性

2つの質問に答える必要があります。

第1に:ブロックはS3にuploadされたがcommitが失敗した場合はどうしますか? 問題ありません。メタデータはcommitが成功した後にのみブロックを指します。ref_count = 0の「孤立した」ブロックは、24時間後にGCジョブによって削除されます。順序は常にバイトを書き込んでからメタデータを書き込むことなので、存在しないデータを指すメタデータは決して発生しません。

第2に:他のデバイス、検索インデックス、サムネイルサービスに通知するには? トランザクション内で直接kafkaTemplate.send()を呼び出すと、dual-writeの問題が発生します:DBはrollbackしてもmessageは送信されるか、またはその逆です。解決策はtransactional outboxです。commitと同じトランザクションでoutboxテーブルに事象を書き込み、その後relayが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により、複数のrelayインスタンスを実行して、同じbatchを重複送信しないことができます。
  • KafkaキーはTowner_idなので、1人のユーザーのすべてのイベントは同じpartitionに入り、順序を保ちます。
  • relayはまだ重複送信する可能性があります。例えばsend後だがDELETE前にクラッシュする場合。したがって、consumersは**idempotent**である必要があります:処理済みの(file_id, version)`を保存して、重複を無視します。

デバイスはKafka経由で内容を受け取る必要がありません。Notificationサービスはただ「変更がある」シグナルをWebSocket経由でプッシュするだけです。その後、デバイスはGET /api/changes?cursor=...を呼び出してchange_journalを読みます。デバイスが3日間オフラインの場合、古いカーソルから読み続けるだけで、イベントを失いません。

6.3. 負荷スパイク

3つの一般的なシナリオがあります。

公開共有ファイルが流行する、例えばテスト問題が数十万人と共有されます。downloadはsigned URLを使ってCDNを経由します。ブロックは不変です(キーがハッシュなので)。したがって、無期限でcacheできます(Cache-Control: immutable)。結果として、originはほぼ負荷を受けません。

朝の時間帯に、数百万マシンが同時に同期。 クライアントはjitter付きのexponential backoffを使用します。サーバーは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;
    }
}

各ユーザーは1分ごとにカウンターを持ちます。INCRはRedisのatomic操作なので、複数のappサーバーが共有カウンターを使用してもカウントは正確です。しきい値を超えると、APIはRetry-Afterヘッダー付きで429 Too Many Requestsを返します。fixed windowは2分の境界でバースト2倍を許可します。より正確が必要な場合は、Luaスクリプトで書いたsliding windowまたはtoken bucketを使用してください。

1つのアカウントに数百万ファイル。 owner_idでPostgreSQLをシャードして、1人の全ディレクトリツリーを同じシャード上に配置します。こうすることで、名前変更やディレクトリの移動は単なるlocal transactionになります。移動は1行のparent_idを変更するだけで、子ファイルに触れません。

注意: アカウント間で共有されるディレクトリは、所有者のシャード上に置かれます。共有ユーザーはMetadata Serviceを経由してクロスシャードアクセスし、DB間でdirectに結合しません。

6.4. すべきでないこと

  • ファイルをBYTEA/BLOBカラムに保存する。 backupが膨らみ、replicationが遅く、DBがボトルネックになります。
  • ファイルがappサーバーを通過させる。 サーバーの帯域幅とメモリはファイルサイズに線形に増加します。
  • ファイル削除時にすぐにブロックを削除する。 ブロックは別のファイルまたは別のバージョンで使用されている可能性があります。常にref_countベースのGCとtrash bin対応のdeleted_atによるsoft deleteを使用してください。
  • 良好なdelta syncが必要な場合、すべてのファイルタイプに固定サイズのchunkを使用する。 ファイルの先頭にバイトを挿入すると、後続のすべてのブロックがずれます。Content-defined chunking(rolling hash)で解決できますが、より複雑です。実際のデータが明確な利益を示すときだけ適用してください。

7. まとめ

Google Drive風システムの設計は、1つの中心的な決定を中心に展開します:メタデータ(関係型DB、強い一貫性)とコンテンツ(object storage、ハッシュで識別される不変ブロック)の分離。それにより、dedup、upload resume、バージョン履歴、CDN cacheはほぼ自動的に得られます。

難しい点はかなり標準的な解決策があります:

  • 書き込み競合: head_versionを使ったoptimistic lockingと上書きの代わりに競合バージョンを作成。
  • 一貫性: バイトを先に書き込み、メタデータのcommitを後に、transactional outboxとidempotent consumerを組み合わせ。
  • 負荷スパイク: presigned URL、CDN、Redisによるrate limit、所有者によるシャード。

この記事を面接やリアルプロジェクトのフレームワークとして使う場合、セクション2のスケール見積もりから始めてください。そこの各数字が、後続のアーキテクチャの決定を左右します。

system-designspring-bootkafkarediscase-study