Conception d'un système de stockage et partage de fichiers type Google Drive

1. Aperçu général

Cet article conçoit un système de stockage et partage de fichiers similaire à Google Drive. Le contenu suit la séquence : valider les exigences, construire le modèle de données, définir les API, décrire les flux d’upload et de synchronisation. Ensuite, l’article se concentre sur trois points difficiles : les conflits d’écriture, la cohérence et les pics de charge.

Après la lecture, vous aurez une conception utilisable pour un entretien de conception de système. Vous aurez également des fragments de code Spring Boot illustrant les parties essentielles.

2. Exigences

2.1. Exigences fonctionnelles

  • Télécharger et télécharger des fichiers, taille maximale 10 GB.
  • Organiser les fichiers en arborescence, prendre en charge le renommage et le déplacement.
  • Partager un fichier ou un dossier avec d’autres avec les droits VIEWER ou EDITOR. Prendre en charge les liens publics.
  • Conserver l’historique des versions et permettre la restauration de versions anciennes.
  • Synchroniser les modifications entre plusieurs appareils du même utilisateur.

2.2. Échelle et contraintes

Hypothèses pour les calculs :

Utilisateurs : 50 millions, DAU : 10 millions
Upload moyen : 2 fichiers/personne/jour, ~ 500 KB/fichier
=> ~ 20 millions de fichiers/jour, ~ 230 fichiers/seconde (pic x5 ≈ 1 200/seconde)
=> ~ 10 TB de nouvelles données/jour, ~ 3,6 PB/an (sans les replicas)
Ratio lecture:écriture ≈ 5:1

De ces chiffres, on peut extraire trois contraintes :

  • Ne pas perdre de données. La durabilité doit atteindre le niveau « 11 neufs ». Par conséquent, le contenu des fichiers doit résider dans un stockage d’objets comme S3, pas sur le disque d’un serveur d’application.
  • Les métadonnées doivent être fortement cohérentes. Un fichier ne peut pas avoir deux « dernières versions ». Cette partie utilise une base de données relationnelle.
  • Le serveur d’application ne doit pas devenir un goulot d’étranglement de la bande passante. Les octets du fichier doivent aller directement du client au stockage.

3. Architecture générale

Client ──► API Gateway (auth, rate limit) ──► Metadata Service ──► PostgreSQL (shardé par owner_id)
   │                                              │
   │  presigned URL                               ├──► Redis (cache des permissions, rate limit)
   ▼                                              └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (télécharger les fichiers partagés)

L’idée principale est de séparer les métadonnées du contenu. Le fichier est divisé en blocs de 4 MB. Chaque bloc est identifié par le SHA-256 de son propre contenu (adressage par contenu). Cela apporte trois avantages :

  • Déduplication gratuite. Deux blocs identiques ne sont stockés qu’une fois.
  • Reprendre les uploads. Si le réseau tombe, le client n’a besoin que de renvoyer les blocs manquants.
  • Delta sync. Modifier une petite section d’un fichier de 1 GB ne nécessite que quelques blocs à uploader.

4. Modèle de données

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 de hashes dans l'ordre
    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
);

Le rôle de chaque partie du schéma :

  • head_version est la colonne clé d’optimistic locking pour chaque fichier (voir section 6.1).
  • file_versions.block_list transforme chaque version en une liste de hashes. Grâce à cela, la restauration d’une version ancienne crée simplement une nouvelle version pointant vers l’ancienne block_list, sans copier d’octets.
  • blocks.ref_count aide le garbage collector à savoir quel bloc n’est utilisé par personne pour le supprimer de S3.
  • change_journal est un journal des modifications par utilisateur. Les appareils se synchronisent en lisant à partir d’un seq (curseur) vers l’avant.
  • UNIQUE (parent_id, name) empêche deux fichiers portant le même nom dans un dossier au niveau de la DB.

Note : Vérifier les permissions selon l’arborescence des dossiers, comme « si vous avez accès au dossier parent alors vous voyez les fichiers enfants », est une requête récursive et coûteuse. Mettez en cache le résultat des permissions dans Redis avec la clé perm:{userId}:{fileId} avec un TTL court. Quand le partage change, supprimez le cache.

5. API et flux d’upload

L’upload se déroule en trois étapes :

POST /api/files/{fileId}/uploads      {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT  <presigned-url>                  (le client envoie directement le bloc à S3)
POST /api/files/{fileId}/commit       {baseVersion, blocks:[hash...]} -> {version}
GET  /api/changes?cursor=1234         -> {changes:[...], nextCursor}

Dépendances Maven à utiliser :

<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. Identifier les blocs manquants

La logique de déduplication doit être écrite en pur Java pour faciliter les tests :

public class ChunkPlanner {

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

Le client envoie la liste des hashes dans l’ordre. Le serveur interroge la table blocks pour obtenir l’ensemble existing. Seuls les hashes qui n’existent pas encore doivent être uploadés. distinct() gère le cas où un fichier contient plusieurs blocs identiques, par exemple une zone de tous les octets 0.

5.2. Générer l’URL présignée

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

Pour chaque bloc manquant, le serveur signe une URL PUT valide 15 minutes. Le client envoie directement les octets à S3, donc le serveur d’application traite seulement quelques KB de JSON au lieu de plusieurs MB de données. Par exemple, une réponse :

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

Note : Puisque la clé est un hash, le client pourrait envoyer un contenu qui ne correspond pas au hash déclaré. Lors du commit, le serveur doit vérifier à nouveau, par exemple avec la somme de contrôle x-amz-checksum-sha256 que S3 supporte. Si vous sautez cette étape, un utilisateur pourrait « empoisonner » un bloc partagé par d’autres.

5.3. Test pour la logique de déduplication

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

Le deuxième test simule le cas où deux personnes uploadent le même fichier d’installation. La deuxième personne n’a pas besoin d’envoyer d’octets, et l’upload est « instantané ».

6. Points difficiles et solutions

6.1. Conflit : deux appareils éditent le même fichier

L’ordinateur portable et le téléphone éditent tous les deux à partir de la version 7. Si on applique « le dernier gagne », les modifications de l’un seront perdues. La solution est d’utiliser optimistic locking : le client envoie baseVersion, et le commit ne réussit que si head_version est toujours égal à cette valeur.

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

L’instruction UPDATE ... WHERE head_version = ? vérifie et incrémente la version en une seule opération atomique. Le commit qui arrive après reçoit updated == 0. L’API retourne alors 409 Conflict. Le client ne remplace pas mais enregistre sa version dans un nouveau fichier, par exemple rapport (conflit du Laptop de Lan).docx. Dropbox et Google Drive gèrent également les fichiers binaires de cette manière.

Ne devrait pas utiliser de verrous pessimistes (SELECT ... FOR UPDATE qui maintient le verrou pendant tout le processus d’upload). L’upload peut prendre plusieurs minutes, et garder un verrou aussi longtemps va étouffer la BD. L’édition collaborative en temps réel comme Google Docs est un problème différent, nécessitant OT ou CRDT, et en dehors du champ d’application de cet article.

6.2. Cohérence entre DB, storage et les autres services

Il y a deux questions à répondre.

Premièrement : un bloc est monté à S3 mais le commit échoue ? Pas grave. Les métadonnées pointent vers le bloc uniquement après le commit réussi. Les blocs « orphelins » avec ref_count = 0 seront supprimés par un job GC après 24 heures. L’ordre est toujours : écrire les octets d’abord, écrire les métadonnées ensuite, donc il n’y a jamais de métadonnées pointant vers des données inexistantes.

Deuxièmement : comment informer les autres appareils, l’index de recherche et le service de miniatures ? Si vous appelez kafkaTemplate.send() directement dans la transaction, vous rencontrez le problème de double-écriture : la BD revient en arrière mais le message est déjà parti, ou l’inverse. La solution est la transactional outbox. Écrire l’événement dans la table outbox dans la même transaction que le commit, puis un relais pousse l’événement vers 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"));
        }
    }
}

Explication des points clés :

  • FOR UPDATE SKIP LOCKED permet à plusieurs instances du relais de fonctionner sans envoyer en double le même lot.
  • La clé Kafka est owner_id, donc tous les événements d’un utilisateur vont dans la même partition et conservent l’ordre.
  • Le relais peut toujours envoyer en double, par exemple lors d’un crash après send mais avant DELETE. Par conséquent, le consumer doit être idempotent : enregistrer (file_id, version) déjà traité et ignorer les doublons.

Les appareils n’ont pas besoin de recevoir le contenu via Kafka. Le service de notification pousse juste un signal « il y a une modification » via WebSocket. L’appareil appelle ensuite GET /api/changes?cursor=... pour lire change_journal. Si un appareil est hors ligne pendant trois jours, il lit juste à partir de son ancien curseur, sans perdre aucun événement.

6.3. Pics de charge

Il y a trois scénarios courants.

Un fichier partagé publiquement se propage, par exemple un examen partagé avec des centaines de milliers de personnes. Le téléchargement passe par CDN avec une URL signée. Les blocs sont immuables puisque la clé est un hash, donc ils peuvent être mis en cache indéfiniment (Cache-Control: immutable). En résultat, l’origin subit presque aucune charge.

Tôt le matin, des millions de machines se synchronisent en même temps. Le client utilise l’exponential backoff avec jitter. Le serveur limite la vitesse par utilisateur avec 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;
    }
}

Chaque utilisateur a un compteur pour chaque minute. INCR est une opération atomique sur Redis, donc plusieurs serveurs d’application partageant le compteur vont toujours compter correctement. Quand on dépasse le seuil, l’API retourne 429 Too Many Requests avec l’en-tête Retry-After. Fixed window permet des rafales double à la limite entre deux minutes. Pour plus de précision, utilisez sliding window ou token bucket écrit en Lua script.

Un compte a des millions de fichiers. Shardez PostgreSQL par owner_id pour que toute l’arborescence d’une personne soit sur le même shard. De cette manière, renommer ou déplacer un dossier n’est qu’une transaction locale. Déplacer ne change que le parent_id d’une ligne, sans toucher aux fichiers enfants.

Note : Un dossier partagé entre les comptes sera sur le shard du propriétaire. La personne qui a accès au dossier accède entre les shards via le Metadata Service, sans jointure directe entre les BD.

6.4. Ce qu’il ne faut pas faire

  • Stocker les fichiers dans une colonne BYTEA/BLOB. La sauvegarde gonfle, la réplication ralentit, et la BD devient un goulot d’étranglement.
  • Faire passer les fichiers par le serveur d’application. La bande passante et la mémoire du serveur augmentent linéairement avec la taille du fichier.
  • Supprimer les blocs immédiatement après la suppression du fichier. Le bloc peut être utilisé par un autre fichier ou une autre version. Laissez toujours le GC basé sur ref_count et la soft delete avec deleted_at pour supporter une corbeille.
  • Utiliser une taille de chunk fixe pour tous les types de fichiers quand on a besoin d’une bonne synchronisation delta. Insérer un octet au début d’un fichier va décaler tous les blocs après. Content-defined chunking (rolling hash) résout cela, mais c’est plus complexe. Utilisez-le uniquement quand les données réelles montrent un avantage clair.

7. Conclusion

Concevoir un système type Google Drive tourne autour d’une décision centrale : séparer les métadonnées (base de données relationnelle, cohérence forte) du contenu (object storage, blocs immuables avec identifiants par hash). À partir de là, la déduplication, la reprise d’upload, l’historique des versions et le cache CDN arrivent presque gratuitement.

Les points difficiles ont des solutions assez standard :

  • Conflits d’écriture : optimistic locking avec head_version et créer une version en conflit au lieu d’écraser.
  • Cohérence : écrire les octets d’abord, commiter les métadonnées ensuite, combiné avec transactional outbox et consumer idempotent.
  • Pics de charge : URL présignée, CDN, rate limit avec Redis et sharding par propriétaire.

Quand vous utilisez cet article comme cadre pour un entretien ou un vrai projet, commencez par l’estimation de l’échelle à la section 2. Chaque chiffre là-bas détermine un choix architectural derrière.

system-designspring-bootkafkarediscase-study