Object storage vs blob nel database: conserva i metadati file in Postgres, i byte in object storage e mantieni i download veloci con costi prevedibili.

Gli upload degli utenti sembrano semplici: accetta un file, salvalo, mostrane il contenuto dopo. Funziona con pochi utenti e file piccoli. Poi il volume cresce, i file diventano più grandi e il dolore emerge in posti che non hanno nulla a che fare con il pulsante di upload.
I download rallentano perché il tuo server applicativo o il database stanno facendo il lavoro pesante. I backup diventano enormi e lenti, quindi i restore richiedono più tempo proprio quando ne hai più bisogno. Le bollette di storage e banda (egress) possono salire perché i file vengono serviti in modo inefficiente, duplicati o mai puliti.
Quello che in genere vuoi è noioso e affidabile: trasferimenti veloci sotto carico, regole d'accesso chiare, operazioni semplici (backup, restore, cleanup) e costi che restano prevedibili con la crescita dell'uso.
Per arrivarci, separa due cose che spesso vengono confuse:
I metadati sono piccole informazioni su un file: chi ne è il proprietario, come si chiama, dimensione, tipo, quando è stato caricato e dove si trova. Questo appartiene al database (come Postgres) perché devi poterlo interrogare, filtrare e joinare con utenti, progetti e permessi.
I byte del file sono il contenuto effettivo del file (la foto, il PDF, il video). Conservare i byte dentro i blob del database può funzionare, ma appesantisce il database, rende i backup più grandi e le prestazioni più difficili da prevedere. Mettere i byte in object storage mantiene il database concentrato su quello che sa fare meglio, mentre i file vengono serviti in modo rapido ed economico da sistemi pensati per questo lavoro.
Quando si dice "memorizzare gli upload nel database", di solito si intende i blob del database: o una colonna BYTEA (byte raw in una riga) o i "large objects" di Postgres (una feature che conserva valori grandi separatamente). Entrambe le soluzioni possono funzionare, ma fanno ricadere sul database la responsabilità di servire i byte dei file.
L'object storage è un'idea diversa: il file vive in un bucket come oggetto, indirizzato da una chiave (tipo uploads/2026/01/file.pdf). È progettato per file grandi, storage economico e download in streaming. Gestisce anche molte letture concorrenti senza occupare le connessioni del database.
Postgres eccelle nelle query, nei vincoli e nelle transazioni. È ideale per i metadati come chi possiede il file, cosa è, quando è stato caricato e se può essere scaricato. Questi metadati sono piccoli, facili da indicizzare e facili da mantenere consistenti.
Una regola pratica:
Un rapido controllo di buon senso: se backup, repliche e migrazioni diventerebbero dolorose con i byte inclusi, tieni i byte fuori da Postgres.
La configurazione con cui la maggior parte dei team finisce è semplice: conserva i byte in object storage e il record del file (chi lo possiede, cosa è, dove si trova) in Postgres. La tua API coordina e autorizza, ma non fa da proxy per grandi upload e download.
Questo ti dà tre responsabilità chiare:
file_id stabile, proprietario, dimensione, content type e il puntatore all'oggetto.Quel file_id stabile diventa la chiave primaria per tutto: commenti che fanno riferimento a un attachment, fatture che puntano a un PDF, log di audit e strumenti di supporto. Gli utenti possono rinominare un file, puoi spostarlo tra bucket e il file_id resta lo stesso.
Quando possibile, tratta gli oggetti memorizzati come immutabili. Se un utente sostituisce un documento, crea un nuovo oggetto (e di solito una nuova riga o una riga di versione) invece di sovrascrivere i byte in loco. Semplifica la cache, evita sorprese del tipo "il link vecchio mostra il file nuovo" e ti dà una storia di rollback pulita.
Decidi la privacy in anticipo: privata per default, pubblica solo per eccezione. Una buona regola è: il database è la fonte di verità su chi può accedere a un file; l'object storage applica i permessi a breve durata che la tua API rilascia.
Con la separazione pulita, Postgres conserva i fatti sul file e l'object storage i byte. Questo mantiene il database più piccolo, i backup più veloci e le query semplici.
Una tabella uploads pratica ha bisogno di pochi campi per rispondere a domande reali come "chi possiede questo?", "dove è memorizzato?" e "è sicuro scaricarlo?"
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);
Alcune decisioni che evitano problemi in seguito:
bucket + object_key come puntatore di storage. Lascialo immutabile dopo l'upload.pending. Passa a uploaded solo dopo che il sistema conferma che l'oggetto esiste e la dimensione (e idealmente il checksum) corrispondono.original_filename solo per la visualizzazione. Non fidarti di esso per decisioni di tipo o sicurezza.Se supporti le sostituzioni (come un utente che ricarica una fattura), aggiungi una tabella upload_versions separata con upload_id, version, object_key e created_at. In questo modo puoi mantenere la cronologia, fare rollback degli errori ed evitare di rompere riferimenti vecchi.
Mantieni gli upload veloci facendo gestire alla tua API la sola coordinazione, non i byte del file. Il tuo database resta reattivo, mentre l'object storage prende il colpo di banda.
Inizia creando il record di upload prima che qualcosa venga inviato. La tua API restituisce un upload_id, dove risiederà il file (un object_key) e un permesso di upload a breve durata.
Un flusso comune:
pending, più la dimensione prevista e il content type intenzionato.upload_id e eventuali campi di risposta dello storage (come ETag). Il server verifica dimensione, checksum (se lo usi) e content type, poi marca la riga uploaded.failed ed eventualmente elimina l'oggetto.Retry e duplicati sono normali. Rendi la chiamata di finalize idempotente: se lo stesso upload_id viene finalizzato due volte, restituisci successo senza cambiare nulla.
Per ridurre duplicati tra retry e re-upload, conserva un checksum e tratta "stesso proprietario + stesso checksum + stessa dimensione" come lo stesso file.
Un buon flusso di download inizia con un URL stabile nell'app, anche se i byte vivono altrove. Pensa: /files/{file_id}. La tua API usa file_id per cercare i metadati in Postgres, verifica il permesso e poi decide come consegnare il file.
file_id.uploaded.I redirect sono semplici e veloci per file pubblici o semi-pubblici. Per file privati, gli URL GET presigned mantengono lo storage privato permettendo comunque il download diretto dal browser.
Per video e download grandi, assicurati che l'object storage (e qualsiasi layer proxy) supporti le richieste range (Range headers). Questo permette seeking e download riprendibili. Se fai passare i byte attraverso la tua API, il supporto per range spesso si rompe o diventa costoso.
La cache è dove arriva la velocità. Il tuo endpoint stabile /files/{file_id} dovrebbe di solito non essere cacheabile (è una porta d'accesso con auth), mentre la risposta dell'object storage può spesso essere cacheata in base al contenuto. Se i file sono immutabili (nuovo upload = nuova key), puoi impostare una lunga durata di cache. Se sovrascrivi file, tieni i tempi di cache brevi o usa chiavi versionate.
Un CDN aiuta quando hai molti utenti globali o file grandi. Se il tuo pubblico è piccolo o per lo più in una regione, l'object storage da solo spesso è sufficiente e più economico per iniziare.
Le bollette a sorpresa di solito arrivano da download e churn, non dai byte grezzi sul disco.
Valuta i quattro driver che muovono la spesa: quanto memorizzi, quanto leggi e scrivi (requests), quanta dati escono dal provider (egress) e se usi un CDN per ridurre i download ripetuti dall'origin. Un file piccolo scaricato 10.000 volte può costare più di un file grande che nessuno tocca.
Controlli che mantengono la spesa sotto controllo:
Le regole di lifecycle sono spesso la vittoria più semplice. Per esempio: conserva le foto originali "hot" per 30 giorni, poi spostale in una classe di storage più economica; conserva le fatture per 7 anni, ma elimina parti di upload falliti dopo 7 giorni. Anche politiche di retention di base fermano la crescita incontrollata dello storage.
La deduplica può essere semplice: memorizza un hash di contenuto (tipo SHA-256) nella tabella dei metadati e applica unicità per proprietario. Quando un utente carica lo stesso PDF due volte, puoi riutilizzare l'oggetto esistente e creare solo una nuova riga di metadati.
Infine, traccia l'uso dove già fai contabilità utente: Postgres. Memorizza bytes_uploaded, bytes_downloaded, object_count e last_activity_at per utente o workspace. Questo rende semplice mostrare limiti nell'UI e triggerare avvisi prima di ricevere la fattura.
La sicurezza per gli upload si riduce a due cose: chi può accedere a un file e cosa puoi dimostrare dopo se qualcosa va storto.
Inizia con un modello d'accesso chiaro e codificalo nei metadati di Postgres, non in regole una tantum sparse tra i servizi.
Un modello semplice che copre la maggior parte delle app:
Per file privati, evita di esporre le object key raw. Emissiona URL di upload e download firmati a tempo limitato e con ambito limitato, e ruotali spesso.
Verifica la cifratura in transito e a riposo. In transito significa HTTPS end-to-end, inclusi gli upload diretti allo storage. A riposo significa cifratura lato server nel provider di storage e che backup e repliche siano anch'essi cifrati.
Aggiungi checkpoint per sicurezza e qualità dei dati: valida content type e dimensione prima di emettere un upload URL, poi valida di nuovo dopo l'upload (basandoti sui byte effettivamente memorizzati, non solo sul nome del file). Se il tuo profilo di rischio lo richiede, esegui scansione malware in modo asincrono e metti il file in quarantena fino al superamento.
Conserva campi di audit così puoi indagare incidenti e soddisfare esigenze di compliance di base: uploaded_by, ip, user_agent e last_accessed_at sono una baseline pratica.
Se hai requisiti di residenza dei dati, scegli consapevolmente la regione di storage e mantienila coerente con dove esegui il compute.
La maggior parte dei problemi di upload non riguarda la velocità pura. Nascono da scelte di design che sembrano comode all'inizio e poi diventano dolorose con traffico reale, dati reali e ticket di supporto reali.
Un esempio concreto: se un utente sostituisce una foto profilo tre volte, puoi finire per pagare tre oggetti vecchi per sempre a meno che tu non pianifichi la pulizia. Un pattern sicuro è soft delete in Postgres, poi un job in background che rimuove l'oggetto e registra il risultato.
La maggior parte dei problemi appare quando arriva il primo file grande, un utente ricarica la pagina a metà upload o qualcuno cancella un account e i byte restano indietro.
Assicurati che la tabella Postgres registri la dimensione del file, il checksum (per verificare l'integrità) e un percorso di stato chiaro (per esempio: pending, uploaded, failed, deleted).
Una checklist finale:
Un test concreto: carica un file da 2 GB, aggiorna la pagina al 30%, poi riprendi. Poi scaricalo con una connessione lenta e vai a metà file. Se uno dei due flussi è instabile, risolvilo ora, non dopo il lancio.
Una SaaS semplice spesso ha due tipi di upload molto diversi: foto profilo (frequenti, piccole, sicure da cacheare) e PDF fatture (sensibili, devono restare privati). Qui la separazione tra metadati in Postgres e byte in object storage paga davvero.
Ecco come i metadati possono apparire in una tabella files, con un paio di campi che influenzano il comportamento:
| field | esempio foto profilo | esempio PDF fattura |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (servito via signed URL) | 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 un utente sostituisce una foto, trattala come un file nuovo, non come una sovrascrittura. Crea una nuova riga e un nuovo object_key, poi aggiorna il profilo utente per puntare al nuovo file_id. Marca la riga vecchia come replaced_by=<new_id> (o con deleted_at) e cancella l'oggetto vecchio successivamente con un job in background. Questo mantiene la cronologia, facilita i rollback ed evita condizioni di race.
Supporto e debug diventano più facili perché i metadati raccontano una storia. Quando qualcuno dice "il mio upload è fallito", il supporto può controllare status, un last_error leggibile, un storage_request_id o etag (per tracciare i log dello storage), timestamp (si è bloccato?) e owner_id e kind (la policy d'accesso è corretta?).
Inizia in piccolo e rendi il percorso felice noioso: i file si caricano, i metadati si salvano, i download sono veloci e niente si perde.
Un buon primo traguardo è una tabella minima in Postgres per i metadati file più un singolo flusso di upload diretto allo storage e un singolo flusso di download che puoi spiegare su una lavagna. Quando quello funziona end-to-end, aggiungi versioning, quote e regole di lifecycle.
Scegli una policy di storage chiara per tipo di file e mettila per iscritto. Per esempio, le foto profilo possono essere cacheable, mentre le fatture devono essere private e accessibili solo tramite URL di download a breve durata. Mescolare policy all'interno di un prefisso di bucket senza un piano è come causare esposizione accidentale.
Aggiungi instrumentazione presto. I numeri che vuoi dal giorno uno sono: tasso di fallimento finalize upload, tasso di orfani (oggetti senza riga DB corrispondente e viceversa), volume di egress per tipo di file, latenza download P95 e dimensione media oggetto.
Se vuoi un modo più veloce per prototipare questo pattern, Koder.ai (koder.ai) è costruito attorno alla generazione di app complete da chat e rispecchia lo stack comune usato qui (React, Go, Postgres). Può essere un modo utile per iterare sullo schema, gli endpoint e i job di cleanup senza riscrivere sempre gli stessi scaffolding.
Usa Postgres per i metadati che devi interrogare e proteggere (proprietario, permessi, stato, checksum, puntatore). Metti i byte in object storage così i download e i trasferimenti grandi non consumano connessioni al database né gonfiano i backup.
Fa fare al database anche il lavoro del file server. Aumenta la dimensione delle tabelle, rallenta backup e restore, aumenta il carico di replica e può rendere le prestazioni meno prevedibili quando molti utenti scaricano contemporaneamente.
Sì. Mantieni un file_id stabile nella tua app, conserva i metadati in Postgres e i byte in object storage indirizzati tramite bucket e object_key. La tua API dovrebbe autorizzare l'accesso e fornire permessi brevi per upload/download invece di fare da proxy per i byte.
Crea prima una riga pending, genera un object_key unico, poi lascia che il client carichi direttamente nello storage usando un permesso a breve durata. Dopo l'upload, fai chiamare il client a un endpoint di finalize così il server può verificare dimensione e checksum (se lo usi) prima di segnare la riga come uploaded.
Perché gli upload reali falliscono e vengono ritentati. Un campo di stato ti permette di distinguere file previsti ma non presenti (pending), completati (uploaded), corrotti (failed) e rimossi (deleted) così la UI, i job di cleanup e gli strumenti di supporto si comportano correttamente.
Tratta original_filename solo per la visualizzazione. Genera una chiave di storage unica (spesso un percorso basato su UUID) per evitare collisioni, caratteri strani e problemi di sicurezza. Puoi comunque mostrare il nome originale nell'interfaccia mantenendo i percorsi di storage puliti e prevedibili.
Usa un URL stabile dell'app come /files/{file_id} come porta d'accesso per le autorizzazioni. Dopo aver verificato l'accesso in Postgres, restituisci un reindirizzamento oppure un URL di download firmato a breve scadenza così il client scarica direttamente dall'object storage, tenendo la tua API fuori dal percorso critico.
L'egress e i download ripetuti di solito dominano i costi, non lo storage grezzo. Imposta limiti di dimensione e quote, usa regole di retention/lifecycle, deduplica tramite checksum dove ha senso e registra i contatori d'uso così puoi avvisare prima che le bollette crescano.
Conserva permessi e visibilità in Postgres come fonte di verità, e tieni lo storage privato per impostazione predefinita. Valida tipo e dimensione prima e dopo l'upload, usa HTTPS end-to-end, cripta i dati a riposo e aggiungi campi di audit così puoi indagare problemi successivamente.
Inizia con una tabella di metadati, un flusso di upload diretto allo storage e un endpoint di download che fa da gate; poi aggiungi job di cleanup per oggetti orfani e righe soft-deleted. Se vuoi prototipare rapidamente su uno stack React/Go/Postgres, Koder.ai (koder.ai) può generare endpoint, schema e task di background da una chat e lasciarti iterare senza riscrivere boilerplate.