Systementwurf für ein Google-Drive-ähnliches Dateispeicher- und Freigabesystem

1. Überblick

Dieser Artikel beschreibt den Entwurf eines Dateispeicher- und Freigabesystems ähnlich Google Drive. Der Inhalt folgt dieser Reihenfolge: Anforderungen festlegen, Datenmodell entwickeln, APIs definieren, Upload- und Synchronisierungsabläufe beschreiben. Anschließend konzentriert sich der Artikel auf drei Herausforderungen: Schreibkonflikte, Datenkonsistenz und Lastspitzen.

Nach dem Lesen haben Sie ein funktionierendes Design für ein System-Design-Interview. Außerdem erhalten Sie Spring-Boot-Code-Beispiele für die Kernkomponenten.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Upload und Download von Dateien mit maximaler Größe von 10 GB.
  • Dateien in einer Verzeichnisstruktur organisieren, Umbenennung und Verschiebung unterstützen.
  • Dateien oder Ordner mit anderen mit den Berechtigungen VIEWER oder EDITOR teilen. Öffentliche Links unterstützen.
  • Versionsverlauf speichern und Wiederherstellung älterer Versionen ermöglichen.
  • Änderungen zwischen mehreren Geräten desselben Benutzers synchronisieren.

2.2. Skalierung und Einschränkungen

Annahmen für Berechnungen:

Benutzer: 50 Millionen, DAU: 10 Millionen
Durchschnittlicher Upload: 2 Dateien/Person/Tag, ~ 500 KB/Datei
=> ~ 20 Millionen Dateien/Tag, ~ 230 Dateien/Sekunde (Peak x5 ≈ 1.200/Sekunde)
=> ~ 10 TB neue Daten/Tag, ~ 3,6 PB/Jahr (ohne Replikation)
Lese-Schreib-Verhältnis ≈ 5:1

Aus diesen Zahlen ergeben sich drei Einschränkungen:

  • Keine Datenverluste. Die Dauerhaftigkeit muss “11 Neunen” erreichen. Daher müssen Dateiinhalte in einem Object Storage wie S3 gespeichert werden, nicht auf der Festplatte des App-Servers.
  • Metadaten erfordern starke Konsistenz. Eine Datei darf nicht zwei “neueste Versionen” haben. Dafür wird eine relationale Datenbank verwendet.
  • Der App-Server darf kein Bandbreitenen-Engpass sein. Datei-Bytes sollten direkt vom Client zum Storage gehen.

3. Gesamtarchitektur

Client ──► API Gateway (Authentifizierung, Rate Limiting) ──► Metadata Service ──► PostgreSQL (nach owner_id sharded)
   │                                              │
   │  presigned URL                               ├──► Redis (Berechtigungen cachen, Rate Limiting)
   ▼                                              └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (Download geteilter Dateien)

Die Hauptidee ist die Trennung von Metadaten und Inhalten. Dateien werden in 4-MB-Blöcke aufgeteilt. Jeder Block wird durch den SHA-256-Hash seines Inhalts identifiziert (content-addressed). Dies bietet drei Vorteile:

  • Kostenlose Deduplizierung. Zwei identische Blöcke werden nur einmal gespeichert.
  • Wiederaufnahme von Uploads. Wenn das Netzwerk abbricht, muss der Client nur die fehlenden Blöcke erneut senden.
  • Delta-Synchronisierung. Das Bearbeiten eines kleinen Teils einer 1-GB-Datei erfordert nur den Upload weniger Blöcke.

4. Datenmodell

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,          -- Liste von Hashes in Reihenfolge
    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
);

Rollen der einzelnen Teile im Schema:

  • head_version ist die Spalte für optimistisches Locking für jede Datei (siehe Abschnitt 6.1).
  • file_versions.block_list macht jede Version zu einer Hashliste. Dadurch ist die Wiederherstellung einer älteren Version nur das Erstellen einer neuen Version, die auf die alte block_list verweist, ohne Bytes zu kopieren.
  • blocks.ref_count hilft dem Garbage Collector, zu wissen, welche Blöcke nicht mehr verwendet werden und aus S3 gelöscht werden können.
  • change_journal ist ein Änderungsprotokoll pro Benutzer. Geräte werden synchronisiert, indem sie von einem seq (Cursor) an lesen.
  • UNIQUE (parent_id, name) verhindert zwei Dateien mit demselben Namen in einem Verzeichnis auf Datenbankebene.

Hinweis: Berechtigungsprüfungen in der Verzeichnisstruktur, wie „wenn der übergeordnete Ordner geteilt ist, können Sie die untergeordneten Dateien sehen”, sind rekursive Abfragen und teuer. Cachen Sie die Berechtigungsergebnisse in Redis unter dem Schlüssel perm:{userId}:{fileId} mit kurzer TTL. Löschen Sie den Cache, wenn sich die Freigabe ändert.

5. APIs und Upload-Ablauf

Der Upload läuft in drei Schritten ab:

POST /api/files/{fileId}/uploads      {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT  <presigned-url>                  (Client sendet Block direkt zu S3)
POST /api/files/{fileId}/commit       {baseVersion, blocks:[hash...]} -> {version}
GET  /api/changes?cursor=1234         -> {changes:[...], nextCursor}

Dependency Maven, die verwendet wird:

<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. Fehlende Blöcke identifizieren

Die Dedup-Logik sollte in reinem Java geschrieben werden, um sie leicht zu testen:

public class ChunkPlanner {

    public List<String> missingBlocks(List<String> requested, Set<String> existing) {
        return requested.stream()
                .distinct()
                .filter(hash -> !existing.contains(hash))
                .toList();
    }
}

Der Client sendet eine Liste von Hashes in Reihenfolge. Der Server fragt die Tabelle blocks ab, um die Menge existing zu erhalten. Nur Hashes, die noch nicht vorhanden sind, müssen hochgeladen werden. distinct() behandelt den Fall, in dem eine Datei mehrere identische Blöcke enthält, z.B. ein Bereich mit lauter Nullbytes.

5.2. Presignierte URLs bereitstellen

@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) {}

Für jeden fehlenden Block signiert der Server eine PUT-URL, die 15 Minuten lang gültig ist. Der Client sendet die Bytes direkt zu S3, sodass der App-Server nur wenige KB JSON verarbeitet statt Megabyte an Daten. Beispielantwort:

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

Hinweis: Da der Schlüssel ein Hash ist, könnte der Client Inhalte senden, die nicht mit dem deklarierten Hash übereinstimmen. Bei der Übermittlung muss der Server dies überprüfen, z.B. durch die Checksum x-amz-checksum-sha256, die S3 unterstützt. Wenn Sie diesen Schritt auslassen, kann ein Benutzer die gemeinsam genutzten Blöcke anderer Benutzer „vergiften”.

5.3. Tests für die Dedup-Logik

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

Der zweite Test simuliert den Fall, dass zwei Personen dieselbe Installationsdatei hochladen. Die zweite Person muss keine Bytes senden und der Upload ist “sofort” abgeschlossen.

6. Schwierigkeiten und Lösungen

6.1. Konflikte: Zwei Geräte bearbeiten dieselbe Datei

Laptop und Smartphone bearbeiten beide ab Version 7. Wenn Sie “Last-Write-Wins” anwenden würden, würden die Änderungen einer Seite verloren gehen. Die Lösung ist optimistisches Locking: Der Client sendet baseVersion mit, und Commit gelingt nur, wenn head_version immer noch denselben Wert hat.

@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;
    }
}

Die UPDATE ... WHERE head_version = ?-Anweisung prüft und erhöht die Version in einer einzigen atomaren Operation. Der zweite Commit erhält updated == 0. Daraufhin gibt die API 409 Conflict zurück. Der Client überschreibt nicht, sondern speichert seine Kopie als neue Datei, z.B. report (Lan's Laptop - Konflikt-Kopie).docx. Auch Dropbox und Google Drive behandeln Binärdateien auf diese Weise.

Sie sollten kein pessimistisches Locking verwenden (SELECT ... FOR UPDATE während des gesamten Uploads). Der Upload kann mehrere Minuten dauern, und das lange Halten eines Locks würde die DB blockieren. Die Echtzeit-Zusammenarbeit für Änderungen wie in Google Docs ist ein anderes Problem, das OT oder CRDT erfordert und außerhalb des Umfangs dieses Artikels liegt.

6.2. Konsistenz zwischen DB, Storage und anderen Diensten

Es gibt zwei Fragen zu beantworten.

Erstens: Was passiert, wenn der Block auf S3 hochgeladen wurde, aber der Commit fehlschlägt? Das ist kein Problem. Metadaten verweisen auf den Block nur nach erfolgreichem Commit. “Verwaiste” Blöcke mit ref_count = 0 werden nach 24 Stunden von einem GC-Job gelöscht. Die Reihenfolge ist immer: Bytes schreiben zuerst, Metadaten schreiben danach, also gibt es nie Metadaten, die auf nicht existierende Daten verweisen.

Zweitens: Wie benachrichtigen Sie andere Geräte, den Search-Index und den Thumbnail-Dienst? Wenn Sie kafkaTemplate.send() direkt in einer Transaktion aufrufen, stoßen Sie auf das Dual-Write-Problem: DB-Rollback, aber die Nachricht ist bereits gesendet, oder umgekehrt. Die Lösung ist transactional Outbox. Schreiben Sie das Event in die Tabelle outbox in derselben Transaktion wie der Commit, und lassen Sie dann ein Relay das Event zu Kafka pushen:

@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"));
        }
    }
}

Erläuterung der Hauptpunkte:

  • FOR UPDATE SKIP LOCKED ermöglicht mehrere Relay-Instanzen, ohne denselben Batch doppelt zu senden.
  • Der Kafka-Schlüssel ist owner_id, sodass alle Ereignisse eines Benutzers in dieselbe Partition gehen und die richtige Reihenfolge beibehalten wird.
  • Das Relay könnte immer noch doppelte Nachrichten senden, z.B. wenn es nach send aber vor DELETE abstürzt. Daher muss der Consumer idempotent sein: speichern Sie (file_id, version) bereits verarbeiteter Ereignisse und überspringen Sie Duplikate.

Geräte müssen nicht den gesamten Inhalt über Kafka erhalten. Der Notification-Dienst pusht nur ein “es gibt Änderungen”-Signal über WebSocket. Das Gerät ruft dann GET /api/changes?cursor=... auf, um change_journal zu lesen. Wenn ein Gerät drei Tage lang offline ist, kann es einfach vom alten Cursor weiterlesen, ohne ein Ereignis zu verlieren.

6.3. Lastspitzen

Es gibt drei häufige Szenarien.

Eine öffentlich geteilte Datei wird viral, z.B. eine Prüfung wird mit Hunderttausenden von Menschen geteilt. Downloads laufen über CDN mit signierter URL. Blöcke sind unveränderlich, da der Schlüssel ein Hash ist, sodass sie unbegrenzt zwischengespeichert werden können (Cache-Control: immutable). Das Ergebnis ist, dass die Origin kaum belastet wird.

Am frühen Morgen synchronisieren sich Millionen von Geräten gleichzeitig. Clients verwenden exponentielles Backoff mit Jitter. Der Server rate-limited pro Benutzer mit 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;
    }
}

Jeder Benutzer hat einen Zähler pro Minute. INCR ist eine atomare Operation auf Redis, sodass mehrere App-Server den Zähler korrekt gemeinsam nutzen können. Wenn das Limit überschritten wird, gibt die API 429 Too Many Requests mit dem Header Retry-After zurück. Das Fixed-Window-Modell erlaubt eine Burst-Verdopplung an der Grenze zwischen zwei Minuten. Wenn Sie mehr Genauigkeit benötigen, verwenden Sie Sliding Window oder Token Bucket in einem Lua-Skript.

Ein Konto mit Millionen von Dateien. Sharden Sie PostgreSQL nach owner_id, sodass der gesamte Dateibaum einer Person auf demselben Shard liegt. Dadurch ist das Umbenennen oder Verschieben von Ordnern nur eine lokale Transaktion. Der Umzug wechselt nur die parent_id einer Zeile, nicht die untergeordneten Dateien.

Hinweis: Ordner, die zwischen Konten geteilt sind, liegen auf dem Shard des Eigentümers. Benutzer, denen der Zugriff gewährt wurde, greifen über den Metadata-Dienst zwischen Shards zu, anstatt direkt zwischen Datenbanken zu verbinden.

6.4. Antipattern

  • Speichern Sie Dateien nicht in Spalten des Typs BYTEA/BLOB. Backups werden groß, Replikation ist langsam, und die DB wird zum Engpass.
  • Leiten Sie Dateien nicht über den App-Server um. Bandbreite und Speicher des Servers skalieren linear mit der Dateigröße.
  • Löschen Sie Blöcke nicht sofort, wenn Sie Dateien löschen. Der Block könnte von anderen Dateien oder Versionen verwendet werden. Lassen Sie GC basierend auf ref_count laufen und verwenden Sie Soft Delete mit deleted_at, um den Papierkorb zu unterstützen.
  • Verwenden Sie keine Chunks mit fester Größe für alle Dateitypen, wenn Sie gute Delta-Sync benötigen. Das Einfügen eines Bytes am Anfang einer Datei versetzt alle folgenden Blöcke. Content-defined Chunking (Rolling Hash) löst dies, ist aber komplexer. Wenden Sie es nur an, wenn echte Daten klare Vorteile zeigen.

7. Fazit

Das Design eines Google-Drive-ähnlichen Systems dreht sich um eine zentrale Entscheidung: Trennung von Metadaten (relationale Datenbank, starke Konsistenz) von Inhalten (Object Storage, unveränderliche Blöcke mit Hash-Identifizierung). Daraus folgen fast automatisch: Deduplizierung, Upload-Wiederaufnahme, Versionsverlauf und CDN-Caching.

Die schwierigen Punkte haben standardisierte Lösungen:

  • Schreibkonflikte: Optimistisches Locking mit head_version und Erstellen von Konflikt-Kopien statt Überschreiben.
  • Konsistenz: Bytes zuerst schreiben, Metadaten danach committen, transactional Outbox mit idempotenten Consumern kombinieren.
  • Lastspitzen: Presignierte URLs, CDN, Rate Limiting mit Redis und Sharding nach Besitzer.

Wenn Sie diesen Artikel als Grundlage für ein Interview oder ein echtes Projekt verwenden, beginnen Sie mit der Skalierungsschätzung in Abschnitt 2. Jede Zahl dort bestimmt eine Architekturentscheidung danach.

system-designspring-bootkafkarediscase-study