Design de sistema de armazenamento e compartilhamento tipo Google Drive
1. Visão Geral
Este artigo projeta um sistema de armazenamento e compartilhamento de arquivos similar ao Google Drive. O conteúdo segue uma sequência: definir requisitos, construir o modelo de dados, definir APIs, descrever o fluxo de upload e sincronização. Depois o artigo se concentra em três desafios: conflitos de escrita, consistência e picos de carga.
Após a leitura, você terá um design utilizável para entrevistas de design de sistemas. Você também terá exemplos de código Spring Boot ilustrando as partes essenciais.
2. Requisitos
2.1. Requisitos Funcionais
- Upload e download de arquivos, tamanho máximo de 10 GB.
- Organizar arquivos em árvore de diretórios, suporte a renomeação e movimentação.
- Compartilhar arquivo ou pasta com outras pessoas com permissões
VIEWERouEDITOR. Suporte a links públicos. - Manter histórico de versões e permitir recuperação de versões antigas.
- Sincronizar alterações entre múltiplos dispositivos do mesmo usuário.
2.2. Escala e Restrições
Pressupostos para cálculo:
Usuários: 50 milhões, DAU: 10 milhões
Upload médio: 2 arquivos/pessoa/dia, ~ 500 KB/arquivo
=> ~ 20 milhões arquivos/dia, ~ 230 arquivos/segundo (pico x5 ≈ 1.200/segundo)
=> ~ 10 TB dados novos/dia, ~ 3,6 PB/ano (sem contar réplicas)
Taxa leitura:escrita ≈ 5:1
A partir desses números, podemos extrair três restrições:
- Não perder dados. A durabilidade deve alcançar “11 noves”. Por isso o conteúdo do arquivo deve estar em object storage como S3, não em disco do servidor de aplicação.
- Metadata deve ser fortemente consistente. Um arquivo não pode ter duas “versões mais recentes”. Para isso, usamos banco de dados relacional.
- Servidor de aplicação não deve ser gargalo de banda. Os bytes do arquivo devem ir direto do cliente para o storage.
3. Arquitetura Geral
Client ──► API Gateway (auth, rate limit) ──► Metadata Service ──► PostgreSQL (sharded segundo owner_id)
│ │
│ presigned URL ├──► Redis (cache de permissões, rate limit)
▼ └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (download de arquivos compartilhados)
A ideia central é separar metadata do conteúdo. O arquivo é dividido em blocos de 4 MB. Cada bloco é identificado pelo SHA-256 do seu próprio conteúdo (content-addressed). Essa abordagem traz três vantagens:
- Dedup gratuito. Dois blocos idênticos são armazenados apenas uma vez.
- Retomar upload. Se a rede cair, o cliente só precisa enviar os blocos faltantes novamente.
- Delta sync. Editar um pequeno pedaço em um arquivo de 1 GB precisa apenas fazer upload de alguns blocos.
4. Modelo de Dados
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 hash em ordem
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
);
O papel de cada parte do schema:
head_versioné a coluna de lock otimista para cada arquivo (veja seção 6.1).file_versions.block_listtorna cada versão uma lista de hash. Assim, recuperar uma versão antiga é apenas criar uma nova versão apontando para ablock_listantiga, sem copiar bytes.blocks.ref_countajuda o coletor de lixo a saber qual bloco não está mais em uso para deletar do S3.change_journalé um log de alterações por usuário. Os dispositivos sincronizam lendo a partir de umseq(cursor).UNIQUE (parent_id, name)previne dois arquivos com mesmo nome no mesmo diretório direto no banco de dados.
Nota: Verificar permissões pela árvore de diretórios, tipo “se compartilhar a pasta pai, ver os arquivos filhos”, é uma consulta recursiva e cara. Cachear o resultado das permissões no Redis com chave
perm:{userId}:{fileId}com TTL curto. Quando o compartilhamento mudar, limpar o cache.
5. API e Fluxo de Upload
O upload ocorre em três etapas:
POST /api/files/{fileId}/uploads {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT <presigned-url> (cliente envia bloco direto para S3)
POST /api/files/{fileId}/commit {baseVersion, blocks:[hash...]} -> {version}
GET /api/changes?cursor=1234 -> {changes:[...], nextCursor}
Dependências Maven necessárias:
<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 Blocos Faltantes
A lógica de dedup deve ser escrita em Java puro para facilitar testes:
public class ChunkPlanner {
public List<String> missingBlocks(List<String> requested, Set<String> existing) {
return requested.stream()
.distinct()
.filter(hash -> !existing.contains(hash))
.toList();
}
}
O cliente envia uma lista de hash em ordem. O servidor consulta a tabela blocks para obter o conjunto existing. Apenas os hash não existentes precisam ser enviados. distinct() lida com o caso de um arquivo contendo múltiplos blocos idênticos, como uma região toda com bytes 0.
5.2. Provisionar 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) {}
Para cada bloco faltante, o servidor assina uma URL PUT válida por 15 minutos. O cliente envia os bytes direto para S3, então o servidor de aplicação processa apenas alguns KB de JSON em vez de megabytes de dados. Exemplo de resposta:
{"missing":[{"hash":"9f86d0...","url":"https://drive-blocks.s3.amazonaws.com/blocks/9f86d0...?X-Amz-Signature=..."}]}
Nota: Como a chave é um hash, o cliente pode enviar conteúdo que não corresponde ao hash declarado. No commit, o servidor precisa revalidar, por exemplo usando checksum
x-amz-checksum-sha256que S3 suporta. Se pularmos essa etapa, um usuário pode corromper blocos compartilhados de outro usuário.
5.3. Teste para a Lógica de Dedup
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();
}
}
O segundo teste simula o caso de duas pessoas fazendo upload do mesmo arquivo de instalação. A segunda pessoa não precisa enviar bytes algum, e o upload é “instantâneo”.
6. Os Desafios e Soluções
6.1. Conflitos: Dois Dispositivos Editam o Mesmo Arquivo ao Mesmo Tempo
Laptop e telefone editam a partir da versão 7. Se aplicarmos “last-write-wins”, as alterações de um lado serão perdidas. A solução é usar optimistic locking: o cliente envia junto baseVersion, e o commit só é bem-sucedido quando head_version ainda tem esse 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;
}
}
O comando UPDATE ... WHERE head_version = ? simultaneamente verifica e incrementa a versão em uma operação atômica. O commit posterior recebe updated == 0. Nesse caso a API retorna 409 Conflict. O cliente não sobrescreve, mas salva sua versão como um novo arquivo, por exemplo relatório (cópia conflitante do Laptop da Lan).docx. Dropbox e Google Drive também lidam com arquivos binários dessa forma.
Não use lock pessimista (SELECT ... FOR UPDATE mantendo durante todo o upload). O upload pode levar vários minutos, e manter lock tanto tempo vai engargalar o DB. Edição colaborativa em tempo real como Google Docs é outro problema, precisa OT ou CRDT, e está fora do escopo deste artigo.
6.2. Consistência Entre DB, Storage e Outros Serviços
Há duas perguntas a responder.
Primeira: bloco subiu para S3 mas commit falhou, e agora? Sem problemas. A metadata só aponta para blocos após commit bem-sucedido. Blocos “órfãos” com ref_count = 0 serão deletados por um job GC após 24 horas. A ordem é sempre: escrever bytes primeiro, escrever metadata depois, então nunca há metadata apontando para dados inexistentes.
Segunda: como notificar outro dispositivo, search index e serviço de thumbnail? Se chamar kafkaTemplate.send() direto na transaction, você enfrenta o problema dual-write: DB rollback mas message já saiu, ou vice-versa. A solução é transactional outbox. Escrever o evento na tabela outbox na mesma transaction do commit, depois um relay empurra o evento para 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"));
}
}
}
Explicação dos pontos principais:
FOR UPDATE SKIP LOCKEDpermite executar múltiplas instâncias de relay sem enviar duplicatas do mesmo batch.- Chave Kafka é
owner_id, então todo evento de um usuário vai para a mesma partition e mantém a ordem. - O relay ainda pode enviar duplicatas, por exemplo ao crashear após
sendmas antes deDELETE. Por isso o consumer deve ser idempotente: armazenar(file_id, version)já processado e ignorar duplicatas.
O dispositivo não precisa receber conteúdo via Kafka. O serviço de notificação só empurra um sinal “há alteração” via WebSocket. O dispositivo depois chama GET /api/changes?cursor=... para ler change_journal. Se o dispositivo fica offline três dias, lê a partir do cursor antigo, sem perder evento algum.
6.3. Picos de Carga
Há três cenários comuns.
Um arquivo chia sẻ publicamente vira viral, por exemplo prova sendo compartilhada com centenas de milhares de pessoas. Download passa por CDN usando signed URL. Blocos são imutáveis pois a chave é um hash, então pode cachear indefinidamente (Cache-Control: immutable). Resultado é que origin recebe quase nenhuma carga.
Início da manhã, milhões de máquinas sincronizam ao mesmo tempo. Cliente usa exponential backoff com jitter. Servidor limita taxa por usuário 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 usuário tem um contador para cada minuto. INCR é uma operação atômica no Redis, então múltiplos servidores de aplicação compartilhando o contador ainda contam corretamente. Quando ultrapassa o limite, a API retorna 429 Too Many Requests com header Retry-After. Fixed window permite burst no dobro na fronteira de dois minutos. Se precisar de precisão maior, use sliding window ou token bucket escrito com script Lua.
Uma conta com milhões de arquivos. Shard PostgreSQL segundo owner_id para que toda a árvore de um usuário fique no mesmo shard. Assim, renomear ou mover pasta é apenas uma transaction local. Mover só muda parent_id de uma linha, não toca arquivo filho nenhum.
Nota: Pasta compartilhada entre contas fica no shard do proprietário. Pessoa com compartilhamento acessa cross-shard via Metadata Service, não faz join direto entre DBs.
6.4. O Que Não Fazer
- Armazenar arquivo em coluna
BYTEA/BLOB. Backup fica gigante, replicação fica lenta, e DB vira gargalo. - Deixar arquivo passar pelo servidor de aplicação. Largura de banda e memória do servidor crescem linearmente com tamanho do arquivo.
- Deletar bloco ao deletar arquivo. Bloco pode estar em uso por outro arquivo ou outra versão. Sempre deixe GC baseado em
ref_counte soft delete comdeleted_atpara suportar lixeira. - Usar tamanho de chunk fixo para todos os tipos de arquivo quando precisa bom delta sync. Inserir um byte no início do arquivo vai deslocar todo bloco depois. Content-defined chunking (rolling hash) resolve, mas é mais complexo. Só aplique quando dados reais mostrarem benefício claro.
7. Conclusão
Projetar um sistema tipo Google Drive gira em torno de uma decisão central: separar metadata (banco relacional, fortemente consistente) do conteúdo (object storage, blocos imutáveis identificados por hash). A partir disso, dedup, retomar upload, histórico de versão e cache CDN vêm quase de graça.
Os desafios têm soluções bastante padronizadas:
- Conflitos de escrita: optimistic locking com
head_versione criar cópia conflitante em vez de sobrescrever. - Consistência: escrever bytes primeiro, commit metadata depois, combinado com transactional outbox e consumer idempotente.
- Picos de carga: presigned URL, CDN, rate limit via Redis e shard segundo proprietário.
Quando usar este artigo como framework para entrevista ou projeto real, comece pela estimativa de escala na seção 2. Cada número lá decide uma escolha de arquitetura depois.