Armazenamento de objetos vs blobs no banco: modele metadados de arquivo no Postgres, armazene bytes no object storage e mantenha downloads rápidos com custos previsíveis.

Uploads de usuários parecem simples: aceite um arquivo, salve-o, mostre depois. Isso funciona com poucos usuários e arquivos pequenos. Aí o volume cresce, os arquivos aumentam, e a dor aparece em lugares que nada têm a ver com o botão de upload.
Os downloads ficam lentos porque seu servidor de aplicação ou banco está fazendo o trabalho pesado. Backups viram monstros e demoram mais para restaurar, bem na hora em que você precisa. A conta de armazenamento e de banda (egresso) pode subir porque arquivos são servidos de forma ineficiente, duplicados ou nunca limpos.
O que normalmente você quer é chato e confiável: transferências rápidas sob carga, regras de acesso claras, operações simples (backup, restore, limpeza) e custos que permaneçam previsíveis conforme o uso cresce.
Para chegar lá, separe duas coisas que costumam se misturar:
Metadados são informações pequenas sobre um arquivo: quem é o dono, qual é o nome, tamanho, tipo, quando foi enviado e onde está. Isso pertence ao banco (como Postgres) porque você precisa consultar, filtrar e juntar com usuários, projetos e permissões.
Bytes do arquivo são o conteúdo real (a foto, PDF, vídeo). Guardar bytes dentro de blobs do banco pode funcionar, mas deixa o banco mais pesado, backups maiores e performance mais difícil de prever. Colocar bytes em object storage mantém o banco focado no que faz de melhor, enquanto arquivos são servidos rápida e barato por sistemas feitos para isso.
Quando as pessoas dizem "guardar uploads no banco" geralmente querem dizer blobs no banco: ou uma coluna BYTEA (bytes brutos em uma linha) ou os "large objects" do Postgres (uma feature que guarda valores grandes separadamente). Ambos funcionam, mas ambos fazem seu banco responsável por servir bytes de arquivo.
Object storage é uma ideia diferente: o arquivo vive em um bucket como um objeto, endereçado por uma chave (como uploads/2026/01/file.pdf). Foi projetado para arquivos grandes, armazenamento barato e downloads em streaming. Também lida bem com muitas leituras concorrentes, sem ocupar conexões do banco.
Postgres brilha em consultas, restrições e transações. É ótimo para metadados como quem possui o arquivo, o que é, quando foi enviado e se pode ser baixado. Esses metadados são pequenos, fáceis de indexar e fáceis de manter consistentes.
Uma regra prática:
Uma checagem rápida: se backups, réplicas e migrações ficariam penosos com bytes de arquivo incluídos, mantenha os bytes fora do Postgres.
A configuração que a maioria das equipes adota é direta: armazene bytes no object storage e registre o arquivo (quem é o dono, o que é, onde vive) no Postgres. Sua API coordena e autoriza, mas não proxya uploads e downloads grandes.
Isso te dá três responsabilidades claras:
file_id estável, owner, size, content type e o ponteiro para o objeto.Esse file_id estável vira a chave primária para tudo: comentários que referenciam um anexo, faturas que apontam para um PDF, logs de auditoria e ferramentas de suporte. Usuários podem renomear um arquivo, você pode movê-lo entre buckets, e o file_id continua o mesmo.
Quando possível, trate objetos armazenados como imutáveis. Se um usuário substituir um documento, crie um novo objeto (e normalmente uma nova linha ou uma nova linha de versão) em vez de sobrescrever bytes no lugar. Isso simplifica cache, evita surpresas de "link antigo mostra arquivo novo" e dá uma história de rollback limpa.
Decida privacidade cedo: privado por padrão, público só por exceção. Uma boa regra: o banco é a fonte da verdade sobre quem pode acessar um arquivo; o object storage aplica a permissão de curta duração que sua API der.
Com a divisão limpa, o Postgres armazena fatos sobre o arquivo, e o object storage armazena os bytes. Isso mantém seu banco menor, backups mais rápidos e consultas simples.
Uma tabela uploads prática precisa de poucos campos para responder perguntas reais como "quem é o dono?", "onde está armazenado?" e "é seguro baixar?"
CREATE TABLE uploads (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
bucket text NOT NULL,
object_key text NOT NULL,
size_bytes bigint NOT NULL,
content_type text,
original_filename text,
checksum text,
state text NOT NULL CHECK (state IN ('pending','uploaded','failed','deleted')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX uploads_owner_created_idx ON uploads (owner_id, created_at DESC);
CREATE INDEX uploads_checksum_idx ON uploads (checksum);
Algumas decisões que salvam dores depois:
bucket + object_key como ponteiro de armazenamento. Mantenha imutável depois do upload.pending. Troque para uploaded somente depois que seu sistema confirmar que o objeto existe e o tamanho (e idealmente checksum) bate.original_filename só para exibição. Não confie nele para decisões de tipo ou segurança.Se você suportar substituições (como um usuário re-enviando uma fatura), adicione uma tabela upload_versions separada com upload_id, version, object_key e created_at. Assim você mantém histórico, permite rollback de erros e evita quebrar referências antigas.
Mantenha uploads rápidos fazendo sua API cuidar da coordenação, não dos bytes. Seu banco fica responsivo enquanto o object storage pega o hit de banda.
Comece criando um registro de upload antes de qualquer coisa ser enviada. Sua API retorna um upload_id, onde o arquivo ficará (um object_key) e uma permissão de upload de curta duração.
Um fluxo comum:
pending, mais o tamanho esperado e o content type pretendido.upload_id e quaisquer campos de resposta do storage (como ETag). Seu servidor verifica tamanho, checksum (se usar) e content type, então marca a linha como uploaded.failed e opcionalmente delete o objeto.Retries e duplicações são normais. Faça a chamada de finalização idempotente: se o mesmo upload_id for finalizado duas vezes, retorne sucesso sem alterar nada.
Para reduzir duplicados entre retries e re-uploads, armazene um checksum e trate "mesmo owner + mesmo checksum + mesmo size" como o mesmo arquivo.
Um bom fluxo de download começa com uma URL estável na sua app, mesmo que os bytes morem em outro lugar. Pense: /files/{file_id}. Sua API usa file_id para buscar metadados no Postgres, checa permissão e decide como entregar o arquivo.
file_id.uploaded.Redirects são simples e rápidos para arquivos públicos ou semi-públicos. Para arquivos privados, URLs GET pré-assinadas mantêm o storage privado enquanto permitem que o navegador baixe diretamente.
Para vídeo e downloads grandes, verifique se o object storage (e qualquer camada proxy) suporta requisições por range (Range headers). Isso habilita seek e downloads retomáveis. Se você canalizar bytes pela API, o suporte a range costuma quebrar ou ficar caro.
Cache é onde vem a velocidade. Seu endpoint estável /files/{file_id} geralmente não deve ser cacheável (é um portão de autenticação), enquanto a resposta do object storage pode ser cacheada com base no conteúdo. Se arquivos são imutáveis (novo upload = nova chave), você pode definir um lifetime de cache longo. Se sobrescrever arquivos, mantenha tempos de cache curtos ou use chaves versionadas.
Um CDN ajuda quando você tem muitos usuários globais ou arquivos grandes. Se seu público é pequeno ou maioritariamente em uma região, o object storage sozinho costuma ser suficiente e mais barato para começar.
Contas-surpresa normalmente vêm de downloads e churn, não dos bytes guardados no disco.
Precifique os quatro fatores que movem a agulha: quanto você armazena, com que frequência lê e escreve (requests), quanto dado sai do provedor (egresso) e se você usa um CDN para reduzir downloads repetidos da origem. Um arquivo pequeno baixado 10.000 vezes pode custar mais que um arquivo grande que ninguém toca.
Controles que mantêm o gasto estável:
Regras de lifecycle são muitas vezes o ganho mais fácil. Por exemplo: mantenha fotos originais "hot" por 30 dias, depois mova para uma classe de armazenamento mais barata; mantenha faturas por 7 anos, mas delete partes de upload falhadas após 7 dias. Mesmo políticas básicas de retenção param o crescimento descontrolado.
Deduplicação pode ser simples: armazene um hash de conteúdo (como SHA-256) na tabela de metadados e imponha unicidade por owner. Quando um usuário envia o mesmo PDF duas vezes, você pode reaproveitar o objeto existente e apenas criar uma nova linha de metadados.
Por fim, monitore uso onde você já faz contabilidade de usuário: Postgres. Guarde bytes_uploaded, bytes_downloaded, object_count e last_activity_at por usuário ou workspace. Isso facilita mostrar limites na UI e disparar alertas antes de a conta crescer demais.
Segurança de uploads se resume a duas coisas: quem pode acessar um arquivo e o que você pode provar depois se algo der errado.
Comece com um modelo de acesso claro e o codifique em metadados no Postgres, não em regras pontuais espalhadas por serviços.
Um modelo simples que cobre a maioria das apps:
Para arquivos privados, evite expor chaves brutas de objeto. Emita URLs de upload e download com tempo limitado e escopo restrito, e rotacione com frequência.
Verifique criptografia em trânsito e em repouso. Em trânsito significa HTTPS fim a fim, incluindo uploads diretos para o storage. Em repouso significa criptografia do lado do servidor no provedor de storage, e que backups e réplicas também estejam criptografados.
Adicione pontos de verificação para segurança e qualidade de dados: valide content type e tamanho antes de emitir uma URL de upload, e valide novamente após o upload (com base nos bytes realmente armazenados, não só no nome do arquivo). Se seu perfil de risco exigir, rode checagem de malware assincronamente e coloque o arquivo em quarentena até passar.
Armazene campos de auditoria para investigar incidentes e atender necessidades básicas de compliance: uploaded_by, ip, user_agent e last_accessed_at são uma base prática.
Se você tem requisitos de residência de dados, escolha a região de storage deliberadamente e mantenha consistente com onde roda o compute.
A maioria dos problemas de upload não é sobre velocidade bruta. Vem de decisões de design que parecem convenientes no começo e ficam dolorosas quando há tráfego real, dados reais e tickets reais de suporte.
Um exemplo concreto: se um usuário substitui a foto do perfil três vezes, você pode acabar pagando por três objetos antigos para sempre a menos que agende limpeza. Um padrão seguro é soft delete no Postgres e depois um job em background que remove o objeto e registra o resultado.
A maioria dos problemas aparece quando chega o primeiro arquivo grande, um usuário atualiza a página no meio do upload ou alguém deleta uma conta e os bytes ficam para trás.
Certifique-se de que sua tabela no Postgres registra o tamanho do arquivo, checksum (para verificar integridade) e um caminho de estados claro (por exemplo: pending, uploaded, failed, deleted).
Um checklist final:
uploaded com bytes faltando.Um teste concreto: envie um arquivo de 2 GB, atualize a página em 30% e retome. Depois baixe em uma conexão lenta e vá para o meio do arquivo. Se qualquer fluxo estiver frágil, corrija agora, não depois do lançamento.
Uma SaaS simples costuma ter dois tipos de upload muito diferentes: fotos de perfil (frequentes, pequenas, seguras para cache) e PDFs de fatura (sensíveis, precisam ser privados). É aqui que a divisão entre metadados no Postgres e bytes no object storage compensa.
Veja como os metadados podem ficar em uma tabela files, com alguns campos que importam para o comportamento:
| field | exemplo foto de perfil | exemplo PDF de fatura |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (servido via URL assinada) | private |
cache_control | public, max-age=31536000, immutable | no-store |
object_key | users/42/avatars/2026-01-17T120102Z.webp | orgs/7/invoices/INV-1049.pdf |
status | uploaded | uploaded |
size_bytes | 184233 | 982341 |
Quando um usuário substitui uma foto, trate como um novo arquivo, não um overwrite. Crie uma nova linha e novo object_key, então atualize o perfil do usuário para apontar para o novo file ID. Marque a linha antiga como replaced_by=<new_id> (ou deleted_at) e delete o objeto antigo depois com um job em background. Isso mantém histórico, facilita rollback e evita condições de corrida.
Suporte e debug ficam mais fáceis porque os metadados contam uma história. Quando alguém diz "meu upload falhou", o suporte pode checar status, um last_error legível, um storage_request_id ou etag (para rastrear logs do storage), timestamps (travou?), e o owner_id e kind (a política de acesso está correta?).
Comece pequeno e torne o caminho feliz entediante: arquivos sobem, metadados salvam, downloads são rápidos e nada se perde.
Um bom primeiro marco é uma tabela mínima no Postgres para metadados de arquivo mais um fluxo de upload direto para storage e um fluxo de download que você consiga desenhar em um quadro branco. Quando isso funcionar de ponta a ponta, adicione versões, quotas e regras de lifecycle.
Escolha uma política clara de storage por tipo de arquivo e escreva. Por exemplo: fotos de perfil podem ser cacheáveis, enquanto faturas devem ser privadas e só acessíveis via URLs de curta duração. Misturar políticas dentro de um prefixo de bucket sem plano é como acontece exposição acidental.
Adicione instrumentação cedo. Os números que você quer desde o dia um são taxa de falha na finalização do upload, taxa de órfãos (objetos sem linha DB correspondente e vice-versa), volume de egresso por tipo de arquivo, latência P95 de download e tamanho médio de objeto.
Se quiser prototipar esse padrão mais rápido, Koder.ai (koder.ai) é construído para gerar apps inteiras a partir de chat e combina com a stack comum aqui (React, Go, Postgres). Pode ser uma forma prática de iterar no esquema, endpoints e jobs de limpeza sem reescrever sempre o mesmo scaffolding.
Depois disso, adicione só o que você consegue explicar em uma frase: "guardamos versões antigas por 30 dias" ou "cada workspace tem 10 GB." Mantenha simples até que o uso real force mudanças.
Use o Postgres para os metadados que você precisa consultar e proteger (owner, permissions, state, checksum, pointer). Coloque os bytes em object storage para que downloads e grandes transferências não consumam conexões do banco nem inflem backups.
Faz o banco assumir também o papel de servidor de arquivos. Isso aumenta o tamanho das tabelas, torna backups e restores mais lentos, aumenta a carga de replicação e pode tornar o desempenho imprevisível quando muitos usuários baixam ao mesmo tempo.
Sim. Mantenha um file_id estável na sua app, armazene metadados no Postgres e os bytes em object storage endereçados por bucket e object_key. Sua API deve autorizar acesso e entregar permissões de upload/download de curta duração em vez de proxyar os bytes.
Crie primeiro uma linha pending, gere um object_key único e permita que o cliente suba diretamente para o storage usando uma permissão de curta duração. Após o upload, o cliente chama um endpoint de finalização para que o servidor verifique tamanho e checksum (se usar) antes de marcar a linha como uploaded.
Porque uploads reais falham e são re-tentados. Um campo de estado permite distinguir arquivos esperados mas não presentes (pending), completos (uploaded), corrompidos (failed) e removidos (deleted) — assim a UI, os jobs de limpeza e as ferramentas de suporte se comportam corretamente.
Trate original_filename apenas como exibição. Gere uma chave de armazenamento única (geralmente baseada em UUID) para evitar colisões, caracteres estranhos e surpresas de segurança. Você ainda pode mostrar o nome original na UI mantendo caminhos de armazenamento limpos e previsíveis.
Use uma URL estável no app como /files/{file_id} como porta de autorização. Após verificar o acesso no Postgres, retorne um redirect ou uma URL de download assinada de curta duração para que o cliente baixe diretamente do object storage, mantendo sua API fora do caminho quente.
Normalmente os downloads repetidos e o egresso dominam os custos, não apenas os bytes armazenados. Defina limites de tamanho por upload e quotas, use regras de retenção/lifecycle, deduplicação por checksum quando fizer sentido e monitore contadores de uso para alertar antes da fatura explodir.
Armazene permissões e visibilidade no Postgres como fonte da verdade, e mantenha o storage privado por padrão. Valide tipo e tamanho antes e depois do upload, use HTTPS fim a fim, criptografe em repouso e adicione campos de auditoria para investigar problemas posteriormente.
Comece com uma tabela de metadados, um fluxo direto para storage e um endpoint de gate para download, depois adicione jobs de limpeza para objetos órfãos e soft-deletes. Se quiser prototipar rápido em uma stack React/Go/Postgres, Koder.ai (koder.ai) pode gerar endpoints, esquema e tarefas em background a partir do chat e permitir iterar sem reescrever boilerplate.