Diseño del sistema de almacenamiento y compartición de archivos tipo Google Drive

1. Descripción general

Este artículo diseña un sistema de almacenamiento y compartición de archivos similar a Google Drive. El contenido sigue una secuencia: fijar requisitos, construir el modelo de datos, definir las APIs, describir el flujo de carga y sincronización. Luego, el artículo se enfoca en tres puntos difíciles: conflictos de escritura, consistencia y picos de carga.

Después de leer, tendrás un diseño funcional para entrevistas de system design. También tendrás fragmentos de código Spring Boot que ilustran las partes principales.

2. Requisitos

2.1. Requisitos funcionales

  • Carga y descarga de archivos, tamaño máximo 10 GB.
  • Organizar archivos en una estructura de árbol de carpetas, permitir renombrado y movimiento.
  • Compartir archivos o carpetas con otros usuarios con permisos VIEWER o EDITOR. Admitir también enlaces públicos.
  • Mantener historial de versiones y permitir la restauración de versiones anteriores.
  • Sincronizar cambios entre múltiples dispositivos del mismo usuario.

2.2. Escala y restricciones

Suposiciones para el cálculo:

Usuarios: 50 millones, DAU: 10 millones
Carga promedio: 2 archivos/usuario/día, ~500 KB/archivo
=> ~20 millones de archivos/día, ~230 archivos/segundo (pico x5 ≈ 1.200/segundo)
=> ~10 TB de datos nuevos/día, ~3,6 PB/año (sin contar replicación)
Relación lectura:escritura ≈ 5:1

De estas cifras se pueden extraer tres restricciones:

  • No debe perderse datos. La durabilidad debe alcanzar “once nueves”. Por lo tanto, el contenido del archivo debe estar en object storage como S3, no en el disco del servidor de aplicaciones.
  • Los metadatos requieren consistencia fuerte. Un archivo no puede tener dos “versiones más recientes”. Para esto, usamos una base de datos relacional.
  • El servidor de aplicaciones no debe ser un cuello de botella de ancho de banda. Los bytes del archivo deben pasar directamente del cliente al almacenamiento.

3. Arquitectura general

Client ──► API Gateway (auth, rate limit) ──► Metadata Service ──► PostgreSQL (dividida por owner_id)
   │                                              │
   │  presigned URL                               ├──► Redis (caché de permisos, rate limit)
   ▼                                              └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (descarga de archivos compartidos frecuentemente)

La idea principal es separar metadatos del contenido. Los archivos se dividen en bloques de 4 MB. Cada bloque se identifica por el SHA-256 de su propio contenido (direccionamiento por contenido). Este enfoque proporciona tres ventajas:

  • Deduplicación gratuita. Dos bloques idénticos se almacenan una sola vez.
  • Reanudar carga. Si la red se cae, el cliente solo necesita enviar nuevamente los bloques faltantes.
  • Sincronización delta. Editar una pequeña sección en un archivo de 1 GB solo requiere cargar algunos bloques.

4. Modelo de datos

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,          -- lista de hashes en orden
    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
);

El propósito de cada parte del esquema:

  • head_version es la columna de bloqueo optimista para cada archivo (ver sección 6.1).
  • file_versions.block_list convierte cada versión en una lista de hashes. Por lo tanto, restaurar una versión anterior es simplemente crear una nueva versión que apunte a la block_list anterior, sin copiar bytes.
  • blocks.ref_count ayuda al recolector de basura a saber qué bloques no están en uso para eliminarlos de S3.
  • change_journal es el registro de cambios por usuario. Los dispositivos se sincronizan leyendo desde un seq (cursor) en adelante.
  • UNIQUE (parent_id, name) impide que dos archivos tengan el mismo nombre en una carpeta a nivel de base de datos.

Nota: Verificar permisos a través del árbol de carpetas, como “si comparten la carpeta padre, ves los archivos hijo”, es una consulta recursiva y costosa. Almacena en caché los resultados de permisos en Redis con la clave perm:{userId}:{fileId} con un TTL corto. Cuando los permisos cambien, borra el caché.

5. APIs y flujo de carga

La carga ocurre en tres pasos:

POST /api/files/{fileId}/uploads      {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT  <presigned-url>                  (cliente carga el bloque directamente a S3)
POST /api/files/{fileId}/commit       {baseVersion, blocks:[hash...]} -> {version}
GET  /api/changes?cursor=1234         -> {changes:[...], nextCursor}

Dependencias Maven que se necesitan:

<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. Identificar bloques faltantes

La lógica de deduplicación debe escribirse en Java puro para facilitar las pruebas:

public class ChunkPlanner {

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

El cliente envía una lista de hashes en orden. El servidor consulta la tabla blocks para obtener el conjunto existing. Solo los hashes que no existen necesitan cargarse. distinct() maneja el caso en que un archivo contiene múltiples bloques idénticos, por ejemplo, una región de bytes cero.

5.2. Emitir URLs presignadas

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

Para cada bloque faltante, el servidor firma una URL PUT válida por 15 minutos. El cliente envía los bytes directamente a S3, por lo que el servidor de aplicaciones solo procesa unos pocos KB de JSON en lugar de cientos de MB de datos. Ejemplo de respuesta:

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

Nota: Dado que la clave es el hash, el cliente puede enviar contenido que no coincida con el hash declarado. Al confirmar, el servidor debe verificar nuevamente, por ejemplo, usando el checksum x-amz-checksum-sha256 que S3 admite. Si omite este paso, un usuario podría “envenenar” un bloque compartido de otro usuario.

5.3. Pruebas para la lógica de deduplicación

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

La segunda prueba simula el caso en que dos personas cargan el mismo archivo de instalación. La segunda persona no necesita enviar bytes y la carga es “instantánea”.

6. Puntos difíciles y soluciones

6.1. Conflictos: dos dispositivos editando el mismo archivo

La laptop y el teléfono editan desde la versión 7. Si aplicas “el último escribe gana”, los cambios de un lado se pierden. La solución es usar bloqueo optimista: el cliente envía baseVersion, y el commit solo tiene éxito si head_version sigue siendo ese valor.

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

La declaración UPDATE ... WHERE head_version = ? verifica e incrementa la versión en una operación atómica. El commit posterior recibe updated == 0. En ese momento, la API devuelve 409 Conflict. El cliente no sobrescribe sino que guarda su versión como un archivo nuevo, por ejemplo report (conflicting copy from Lan's Laptop).docx. Dropbox y Google Drive también manejan archivos binarios de esta manera.

No uses bloqueo pesimista (SELECT ... FOR UPDATE durante la carga). La carga puede durar varios minutos, y mantener un bloqueo tanto tiempo estrangulará la base de datos. La edición colaborativa en tiempo real, como Google Docs, es un problema diferente que requiere OT o CRDT, y está fuera del alcance de este artículo.

6.2. Consistencia entre BD, almacenamiento y otros servicios

Hay dos preguntas que responder.

Primero: ¿qué pasa si el bloque se carga en S3 pero el commit falla? No es un problema. Los metadatos solo apuntan al bloque después de que el commit tiene éxito. Los bloques “huérfanos” con ref_count = 0 serán eliminados por un trabajo de recolección de basura después de 24 horas. El orden siempre es escribir bytes primero, metadatos después, por lo que nunca hay metadatos que apunten a datos inexistentes.

Segundo: ¿cómo notificar a otros dispositivos, índice de búsqueda y servicio de miniaturas? Si llamas a kafkaTemplate.send() dentro de la transacción, enfrentas el problema de la escritura dual: la BD se revierte pero el mensaje ya se envió, o viceversa. La solución es outbox transaccional. Escribe eventos en la tabla outbox en la misma transacción que el commit, luego un relé impulsa eventos a 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"));
        }
    }
}

Explicación de los puntos clave:

  • FOR UPDATE SKIP LOCKED permite ejecutar múltiples instancias de relé sin enviar el mismo lote dos veces.
  • La clave de Kafka es owner_id, por lo que todos los eventos de un usuario entran en la misma partición y mantienen el orden correcto.
  • El relé aún puede enviar duplicados, por ejemplo, si falla después de send pero antes de DELETE. Por lo tanto, el consumidor debe ser idempotente: registre (file_id, version) procesados e ignore duplicados.

Los dispositivos no necesitan recibir contenido a través de Kafka. El servicio de notificación solo envía una señal “hay cambios” a través de WebSocket. El dispositivo luego llama a GET /api/changes?cursor=... para leer change_journal. Si un dispositivo está sin conexión durante tres días, solo necesita leer desde el cursor anterior, sin perder ningún evento.

6.3. Manejo de picos de carga

Hay tres escenarios comunes.

Un archivo compartido públicamente se vuelve viral, por ejemplo, un examen se comparte con cientos de miles de personas. La descarga va a través de CDN con URL firmada. Los bloques son inmutables porque la clave es el hash, por lo que pueden almacenarse en caché permanentemente (Cache-Control: immutable). El resultado es que la origin casi no soporta carga.

A primera hora de la mañana, millones de máquinas se sincronizan al mismo tiempo. El cliente usa backoff exponencial con jitter. El servidor limita la velocidad por usuario usando 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;
    }
}

Cada usuario tiene un contador por minuto. INCR es una operación atómica en Redis, por lo que múltiples servidores de aplicaciones que comparten el contador aún cuentan correctamente. Cuando se excede el límite, la API devuelve 429 Too Many Requests con el encabezado Retry-After. La ventana fija permite ráfagas dobles en el límite de dos minutos. Si necesitas más precisión, usa ventana deslizante o token bucket escrito en script Lua.

Una cuenta con millones de archivos. Particiona PostgreSQL por owner_id para que todo el árbol de carpetas de una persona esté en la misma partición. Esto permite que renombrar o mover carpetas sea una transacción local.

Nota: Una carpeta compartida entre cuentas residirá en la partición del propietario. La persona que recibe el acceso compartido accede entre particiones a través del Metadata Service, sin unir directamente las bases de datos.

6.4. Lo que no debes hacer

  • Almacenar archivos en columnas BYTEA/BLOB. Las copias de seguridad se vuelven enormes, la replicación es lenta y la BD se convierte en un cuello de botella.
  • Permitir que los archivos pasen por el servidor de aplicaciones. El ancho de banda y la memoria del servidor crecen linealmente con el tamaño del archivo.
  • Eliminar bloques inmediatamente al eliminar un archivo. Los bloques podrían ser utilizados por otro archivo o versión. Siempre usa GC basado en ref_count y soft delete con deleted_at para admitir papelera de reciclaje.
  • Usar tamaños de chunk fijos para todos los tipos de archivos cuando se necesita una buena sincronización delta. Insertar un byte al principio de un archivo desplazará todos los bloques posteriores. El fragmentado definido por contenido (rolling hash) lo soluciona, pero es más complejo. Solo aplica cuando los datos reales muestren un beneficio claro.

7. Conclusión

Diseñar un sistema tipo Google Drive gira en torno a una decisión central: separar metadatos (base de datos relacional, consistencia fuerte) del contenido (almacenamiento de objetos, bloques inmutables identificados por hash). De esto, la deduplicación, reanudar cargas, historial de versiones y caché CDN surgen casi automáticamente.

Los puntos difíciles tienen soluciones bastante estándar:

  • Conflictos de escritura: bloqueo optimista con head_version e crear copia de conflicto en lugar de sobrescribir.
  • Consistencia: escribir bytes primero, commit de metadatos después, combinando outbox transaccional y consumidor idempotente.
  • Picos de carga: URL presignadas, CDN, limitación de velocidad con Redis y particionamiento por propietario.

Cuando uses este artículo como marco para una entrevista o proyecto real, comienza con la estimación de escala en la sección 2. Cada número allí determina una elección de arquitectura detrás de él.

system-designspring-bootkafkarediscase-study