Armazenamento de objetos vs blobs no banco para uploads rápidos e baratos
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.

O problema real com uploads de usuários
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.
Armazenamento de objetos vs blobs no banco, em termos simples
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:
- Use Postgres para metadados de arquivo, permissões e relacionamentos.
- Use object storage para os bytes quando arquivos passarem de alguns MB, ou quando os downloads forem frequentes.
- Considere blobs no banco apenas para assets minúsculos que precisem estar transacionados com um registro (como um ícone pequeno) e você tiver certeza de que o crescimento do banco vai permanecer modesto.
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.
Uma arquitetura simples que permanece gerenciável
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:
- Postgres guarda uma linha pequena por arquivo: um
file_idestável, owner, size, content type e o ponteiro para o objeto. - Object storage guarda os bytes reais, otimizado para arquivos grandes e armazenamento barato.
- Sua API cria e autoriza registros de arquivos e emite permissões de curta duração para o storage.
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.
Como modelar metadados de arquivo no Postgres
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:
- Use
bucket + object_keycomo ponteiro de armazenamento. Mantenha imutável depois do upload. - Registre estado. Quando um usuário inicia um upload, insira uma linha
pending. Troque parauploadedsomente depois que seu sistema confirmar que o objeto existe e o tamanho (e idealmente checksum) bate. - Armazene
original_filenamesó 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.
Fluxo de upload passo a passo (sem bloquear sua API)
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:
- Cliente solicita upload: sua API cria uma linha com
pending, mais o tamanho esperado e o content type pretendido. - API retorna uma URL pré-assinada: para arquivos grandes, gere uma URL de upload pré-assinada. Para arquivos pequenos (como avatares), você ainda pode fazer proxy pelo backend se quiser código cliente mais simples.
- Cliente faz upload diretamente ao object storage: o navegador ou app móvel envia bytes ao storage, não pela sua API.
- Finalizar: o cliente chama sua API com
upload_ide quaisquer campos de resposta do storage (como ETag). Seu servidor verifica tamanho, checksum (se usar) e content type, então marca a linha comouploaded. - Falhar com segurança: se a verificação falhar, marque
failede 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.
Fluxo de download passo a passo (rápido e amigável ao cache)
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.
- Cliente solicita sua URL estável com
file_id. - API verifica que o usuário pode acessar e que o arquivo está
uploaded. - API retorna ou um redirect para o object storage (muitas vezes o melhor), ou uma URL GET assinada de curta duração para arquivos privados.
- Cliente faz o download direto do object storage, mantendo sua API e servidores fora do caminho quente.
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.
Manter custos previsíveis ao longo do tempo
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:
- Limite o tamanho por upload e defina quotas por usuário conforme o plano.
- Aplique rate limits em uploads e downloads para evitar abuso e loops acidentais.
- Use regras de lifecycle para que arquivos antigos movam para uma camada mais barata ou expirem quando não forem mais necessários.
- Deduplique por checksum para que retries ou re-uploads não criem cópias extras.
- Armazene contadores de uso no Postgres para que cobrança e alertas se baseiem em fatos, não em suposições.
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 e compliance básicas para uploads
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.
Controle de acesso que bate com o uso real
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:
- Owner-only: apenas o uploader (e admins) podem acessar.
- Shared: acessível a usuários específicos ou a um time/workspace.
- Public: acessível sem login (use com parcimônia e ainda assim rastreie).
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.
Checagens de compliance que salvam você depois
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.
Erros comuns que causam lentidão e incidentes
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.
- Armazenar bytes dentro do Postgres: funciona para apps pequenas, depois backups incham, restores demoram e manutenção rotineira vira arriscada. Uma única tabela grande pode atrasar vacuum, replicação e até consultas simples.
- Usar o nome do arquivo fornecido pelo usuário como object key: colisões acontecem (dois usuários enviam "invoice.pdf") e caracteres estranhos criam casos de borda. Mantenha nomes apenas para exibição e gere uma chave única (como UUID) para o storage.
- Pular a validação na finalização: mesmo que valide no cliente, ainda precisa de checagens no servidor para tamanho, content type e propriedade quando marcar um upload como completo.
- Tornar objetos públicos por acidente e nunca rotacionar acesso: uma política de bucket "temporária" pública ou URLs de longa duração muitas vezes viram permanentes. Prefira links de baixada de curta duração e tenha uma maneira de revogar acesso rápido.
- Deletar apenas um lado (metadados ou bytes): deletar a linha do Postgres e deixar o objeto cria vazamentos de custo silenciosos. Deletar o objeto e manter metadados cria downloads quebrados e carga no 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.
Checklist rápido antes do lançamento
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:
- Confirme que retries são seguros: tentativas repetidas não devem criar objetos extras ou linhas
uploadedcom bytes faltando. - Faça uploads retomáveis ou ao menos reiniciáveis sem tickets de suporte (timeouts e redes móveis vão acontecer).
- Verifique se downloads aceitam range requests para que arquivos grandes iniciem rápido e retomem após pausa.
- Defina exclusão de ponta a ponta: marcar metadados, deletar bytes do objeto e lidar com limpeza atrasada se um job falhar.
- Adicione monitoramento básico: taxa de erro em upload/download, crescimento de armazenamento e picos de egresso repentinos.
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.
Cenário de exemplo: fotos e faturas numa mesma app
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?).
Próximos passos para implementar sem overbuild
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.
Perguntas frequentes
Devo armazenar arquivos enviados no Postgres ou no armazenamento de objetos?
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.
Qual a principal desvantagem de armazenar bytes de arquivo em blobs do Postgres?
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.
Qual é a arquitetura de upload mais simples que escala sem complicar?
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.
Como deve funcionar o fluxo de upload se eu não quiser que minha API manipule bytes de arquivo?
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.
Por que preciso de um “estado” de upload como pending/uploaded/failed/deleted?
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.
Posso usar o nome do arquivo do usuário como a chave do objeto no storage?
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.
Qual é o padrão de download recomendado para arquivos privados?
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.
O que geralmente causa custos surpresa com uploads de usuários?
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.
Quais verificações de segurança valem a pena fazer no dia um?
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.
Como posso implementar isso rapidamente sem superconstruir?
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.