Objektspeicher vs. Datenbank‑Blobs: Dateimetadaten in Postgres modellieren, Bytes im Objektspeicher ablegen und Downloads schnell halten bei vorhersehbaren Kosten.

Nutzer‑Uploads klingen einfach: eine Datei annehmen, speichern, später zeigen. Das funktioniert mit wenigen Nutzer:innen und kleinen Dateien. Sobald das Volumen wächst und Dateien größer werden, zeigt sich der Schmerz an Stellen, die nichts mit dem Upload‑Button zu tun haben.
Downloads werden langsamer, weil dein App‑Server oder die Datenbank die schwere Arbeit übernimmt. Backups werden riesig und langsam, Wiederherstellungen dauern länger genau dann, wenn du sie brauchst. Speicher‑ und Bandbreiten‑(Egress)‑Rechnungen können explodieren, weil Dateien ineffizient ausgeliefert, dupliziert oder nie bereinigt werden.
Was du meistens willst, ist langweilig und zuverlässig: schnelle Übertragungen unter Last, klare Zugriffsregeln, einfache Operationen (Backup, Restore, Cleanup) und Kosten, die mit wachender Nutzung vorhersehbar bleiben.
Um das zu erreichen, trenne zwei Dinge, die häufig vermischt werden:
Metadaten sind kleine Informationen über eine Datei: wem sie gehört, wie sie heißt, Größe, Typ, wann sie hochgeladen wurde und wo sie liegt. Das gehört in deine Datenbank (z. B. Postgres), weil du es abfragen, filtern und mit Nutzern, Projekten und Berechtigungen verknüpfen musst.
Dateibits sind der eigentliche Inhalt der Datei (Foto, PDF, Video). Bytes in Datenbank‑Blobs zu speichern funktioniert, macht die Datenbank aber schwerer, Backups größer und Performance schwerer vorhersagbar. Bytes im Objektspeicher zu halten, lässt die Datenbank das tun, was sie am besten kann, während Dateien schnell und günstig von Systemen geliefert werden, die dafür gemacht sind.
Wenn Leute sagen „speichere Uploads in der Datenbank“, meinen sie meist Datenbank‑Blobs: entweder eine BYTEA‑Spalte (roh als Bytes in einer Zeile) oder Postgres „large objects“ (eine Funktion, die große Werte separat speichert). Beides kann funktionieren, macht aber die Datenbank dafür verantwortlich, Dateibytes auszuliefern.
Objektspeicher ist ein anderes Konzept: die Datei liegt in einem Bucket als Objekt, erreichbar über einen Key (z. B. uploads/2026/01/file.pdf). Er ist für große Dateien, günstigen Speicher und Streaming‑Downloads gebaut. Er kann viele parallele Lesezugriffe gut handhaben, ohne deine Datenbankverbindungen zu blockieren.
Postgres glänzt bei Abfragen, Constraints und Transaktionen. Es ist ideal für Metadaten wie Besitzer, Typ, Upload‑Zeit und Zugriffsrechte. Diese Metadaten sind klein, leicht zu indexieren und konsistent zu halten.
Eine praktische Faustregel:
Ein kurzer Realitätscheck: Wenn Backups, Replikate und Migrationen mit eingebetteten Bytes schmerzhaft würden, halte die Bytes aus Postgres heraus.
Die Einrichtung, auf die die meisten Teams hinauslaufen, ist unkompliziert: Bytes im Objektspeicher, der Dateieintrag (wer besitzt ihn, was es ist, wo er liegt) in Postgres. Deine API koordiniert und autorisiert, sie proxyet aber keine großen Uploads oder Downloads.
Das gibt dir drei klare Verantwortlichkeiten:
file_id, Besitzer, Größe, Content‑Type und den Objektreferenz.Diese stabile file_id wird zum Primärschlüssel für alles: Kommentare, die ein Attachment referenzieren, Rechnungen mit einem PDF, Audit‑Logs und Support‑Tools. Nutzer:innen können eine Datei umbenennen oder in einen anderen Bucket verschieben; die file_id bleibt gleich.
Behandle gespeicherte Objekte wenn möglich als unveränderlich. Ersetzt ein Nutzer ein Dokument, lege ein neues Objekt an (und meist eine neue Zeile oder Version), anstatt Bytes in‑place zu überschreiben. Das vereinfacht Caching, vermeidet Überraschungen wie „alter Link zeigt neue Datei“ und gibt eine saubere Rollback‑Option.
Entscheide früh über Privatsphäre: standardmäßig privat, öffentlich nur ausnahmsweise. Gute Regel: die Datenbank ist die Quelle der Wahrheit dafür, wer eine Datei sehen darf; der Objektspeicher setzt die kurzlebigen Berechtigungen durch, die deine API ausgibt.
Mit der klaren Trennung speichert Postgres Fakten über die Datei, der Objektspeicher die Bytes. Das hält die Datenbank klein, Backups schneller und Abfragen einfach.
Eine praktische uploads‑Tabelle braucht nur ein paar Felder, um echte Fragen zu beantworten wie „wem gehört das?“, „wo liegt es?“ und „kann man es herunterladen?":
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);
Einige Entscheidungen, die später Schmerz sparen:
bucket + object_key als Speicherzeiger. Halte ihn nach dem Upload unveränderlich.pending‑Zeile ein. Schalte erst auf uploaded, nachdem dein System bestätigt, dass das Objekt existiert und die Größe (und idealerweise die Prüfsumme) übereinstimmt.original_filename nur zur Anzeige. Vertraue ihm nicht für Typ‑ oder Sicherheitsentscheidungen.Unterstützt du Ersetzungen (z. B. ein Nutzer lädt eine Rechnung neu hoch), füge eine separate upload_versions‑Tabelle mit upload_id, version, object_key und created_at hinzu. So behältst du Historie, kannst Fehler zurückrollen und alte Referenzen bleiben intakt.
Halte Uploads schnell, indem deine API Koordination macht, nicht die Dateibits. Deine Datenbank bleibt reaktionsschnell, während der Objektspeicher die Bandbreite trägt.
Beginne damit, einen Upload‑Eintrag zu erstellen, bevor etwas gesendet wird. Deine API gibt eine upload_id zurück, wo die Datei liegen wird (object_key) und eine kurzlebige Upload‑Berechtigung.
Ein üblicher Ablauf:
pending an, plus erwartete Größe und beabsichtigten Content‑Type.upload_id und ggf. Storage‑Antwortfeldern (z. B. ETag) auf. Dein Server verifiziert Größe, Prüfsumme (falls genutzt) und Content‑Type und markiert die Zeile als uploaded.failed und lösche optional das Objekt.Retries und Duplikate sind normal. Mach den Finalize‑Call idempotent: wird dieselbe upload_id zweimal finalisiert, gib Erfolg zurück, ohne etwas zu ändern.
Um Duplikate bei Retries und Reuploads zu reduzieren, speichere eine Prüfsumme und behandle „gleicher Besitzer + gleiche Prüfsumme + gleiche Größe“ als dieselbe Datei.
Ein guter Download‑Flow beginnt mit einer stabilen URL in deiner App, auch wenn die Bytes woanders liegen. Denk an: /files/{file_id}. Deine API schaut mit file_id die Metadaten in Postgres nach, prüft die Berechtigung und entscheidet dann, wie die Datei geliefert wird.
file_id an.uploaded ist.Weiterleitungen sind einfach und schnell für öffentliche oder halböffentliche Dateien. Für private Dateien sorgen presigned GET‑URLs dafür, dass Storage privat bleibt und der Browser trotzdem direkt herunterladen kann.
Bei Videos und großen Downloads achte darauf, dass dein Objektspeicher (und jede Proxy‑Schicht) Range‑Requests (Range‑Header) unterstützt. Das erlaubt Seeking und resumable Downloads. Wenn du Bytes durch deine API schleifst, fällt Range‑Support oft aus oder wird teuer.
Caching ist der Hebel für Geschwindigkeit. Dein stabiles /files/{file_id}‑Endpoint sollte meist nicht cachebar sein (es ist ein Auth‑Gate), während die Antwort des Objektspeichers oft anhand des Inhalts gecached werden kann. Sind Dateien unveränderlich (neuer Upload = neuer Key), setze lange Cache‑Lifetimes. Überschreibst du Dateien, halte Caches kurz oder nutze versionierte Keys.
Ein CDN hilft bei vielen globalen Nutzern oder großen Dateien. Bei kleinem Publikum oder regionaler Nutzung reicht oft der Objektspeicher und ist günstiger zum Start.
Überraschungsrechnungen entstehen meist durch Downloads und Churn, nicht durch die reinen Bytes auf der Platte.
Preis die vier Treiber, die den Unterschied machen: wie viel du speicherst, wie oft du liest und schreibst (Requests), wie viel Daten den Provider verlassen (Egress) und ob du ein CDN einsetzt, um wiederholte Origin‑Downloads zu reduzieren. Eine kleine Datei, die 10.000‑mal heruntergeladen wird, kann teurer sein als eine große Datei, die niemand anfasst.
Kontrollen, die Ausgaben stabil halten:
Lifecycle‑Regeln sind oft der einfachste Hebel. Beispiel: Originalfotos 30 Tage „hot“ halten, dann in eine günstigere Klasse verschieben; Rechnungen 7 Jahre aufbewahren, aber fehlgeschlagene Upload‑Teile nach 7 Tagen löschen. Solche Retention‑Policies stoppen Storage‑Creep.
Deduplizierung kann simpel sein: speichere einen Inhalts‑Hash (z. B. SHA‑256) in der Metadatentabelle und erzwinge Einzigartigkeit pro Besitzer. Lädt ein Nutzer dasselbe PDF zweimal hoch, kannst du das existierende Objekt wiederverwenden und nur eine neue Metadatentabellezeile anlegen.
Schließlich tracke Nutzung dort, wo du ohnehin Users‑Accounting machst: in Postgres. Speichere bytes_uploaded, bytes_downloaded, object_count und last_activity_at pro Nutzer oder Workspace. So zeigst du Limits in der UI und triggst Alerts, bevor die Rechnung kommt.
Sicherheit bei Uploads reduziert sich auf zwei Dinge: wer darf auf eine Datei zugreifen und was kannst du später als Nachweis vorlegen, wenn etwas schiefgeht.
Beginne mit einem klaren Zugriffmodell und kodifiziere es in Postgres‑Metadaten, nicht in verstreuten Einzelfällen über Services.
Ein einfaches Modell, das die meisten Apps abdeckt:
Für private Dateien vermeide das Offenlegen roher Object‑Keys. Gib zeitlich begrenzte, scope‑limitierte presigned Upload‑ und Download‑URLs aus und rotiere diese oft.
Verifiziere Verschlüsselung in Transit und im Ruhezustand. In Transit bedeutet HTTPS durchgängig, auch für Direkt‑Uploads. At‑Rest heißt serverseitige Verschlüsselung beim Storage‑Provider und dass Backups/Replikate ebenfalls verschlüsselt sind.
Füge Prüfungen für Sicherheit und Datenqualität hinzu: validiere Content‑Type und Größe bevor du eine Upload‑URL ausstellst, und validiere danach erneut basierend auf den tatsächlich gespeicherten Bytes, nicht nur dem Dateinamen. Braucht dein Risiko‑Profil es, führe asynchron Malware‑Scans durch und quarantäne die Datei bis zum Bestehen.
Speichere Audit‑Felder, damit du Vorfälle untersuchen kannst: uploaded_by, ip, user_agent und last_accessed_at sind ein praktisches Minimum.
Hast du Datenresidenz‑Anforderungen, wähle die Storage‑Region bewusst und stimm sie mit deiner Compute‑Region ab.
Die meisten Upload‑Probleme sind keine Frage roher Geschwindigkeit. Sie entstehen durch Design‑Entscheidungen, die anfangs bequem wirken und später schmerzhaft werden.
Ein konkretes Beispiel: ersetzt ein Nutzer sein Profilbild dreimal, zahlst du eventuell für drei alte Objekte für immer, wenn kein Cleanup geplant ist. Ein sicheres Muster ist ein Soft‑Delete in Postgres und ein Hintergrundjob, der Objekt und Ergebnis später löscht und protokolliert.
Die meisten Probleme treten auf, wenn die erste große Datei kommt, ein Nutzer die Seite während des Uploads neu lädt oder ein Account gelöscht wird und die Bytes bleiben.
Stell sicher, dass deine Postgres‑Tabelle Größe, Prüfsumme (zur Integritätsprüfung) und einen klaren Zustandsweg (pending, uploaded, failed, deleted) aufzeichnet.
Eine Checkliste fürs letzte Stück:
Ein konkreter Test: lade eine 2‑GB‑Datei hoch, lade die Seite bei 30 % neu und setze den Upload fort. Lade sie dann über eine langsame Verbindung herunter und springe in die Mitte. Wenn einer der Flows schwach ist, behebe es jetzt, nicht nach dem Launch.
Eine einfache SaaS‑App hat oft zwei sehr unterschiedliche Upload‑Typen: Profilfotos (häufig, klein, gut cachebar) und PDF‑Rechnungen (sensibel, privat). Genau hier zahlt sich die Trennung zwischen Metadaten in Postgres und Bytes im Objektspeicher aus.
So könnten Metadaten in einer files‑Tabelle aussehen, mit ein paar Feldern, die das Verhalten steuern:
| Feld | Profilfoto‑Beispiel | Rechnung‑PDF‑Beispiel |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (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 |
Ersetzt ein Nutzer ein Foto, behandle es als neue Datei, nicht als Überschreibung. Erstelle eine neue Zeile und einen neuen object_key, aktualisiere das Benutzerprofil auf die neue file_id. Markiere die alte Zeile als replaced_by=<new_id> (oder deleted_at) und lösche das alte Objekt später per Hintergrundjob. So bleibt Historie erhalten, Rollbacks sind einfacher und Race‑Conditions werden vermieden.
Support und Debugging werden einfacher, weil die Metadaten eine Geschichte erzählen. Meldet jemand „mein Upload ist fehlgeschlagen“, kann Support status, ein lesbares last_error, storage_request_id oder etag (zum Nachverfolgen in Storage‑Logs), Zeitstempel (hängt es?) und owner_id und kind prüfen (stimmt die Zugriffspolitik?).
Fange klein an und mache den Happy‑Path langweilig: Dateien laden hoch, Metadaten speichern, Downloads sind schnell und nichts geht verloren.
Ein guter erster Meilenstein ist eine minimale Postgres‑Tabelle für Dateimetadaten plus ein einzelner Upload‑Flow und ein einzelner Download‑Flow, den du auf einem Whiteboard erklären kannst. Funktioniert das Ende‑zu‑Ende, füge Versionen, Quoten und Lifecycle‑Regeln hinzu.
Lege pro Dateityp eine klare Speicherpolitik fest und schreibe sie auf. Beispiel: Profilfotos sind cachebar, Rechnungen sind privat und nur via kurzlebiger Download‑URLs erreichbar. Mischst du Richtlinien innerhalb eines Bucket‑Prefixes ohne Plan, entsteht versehentliche Exposition.
Füge früh Instrumentierung hinzu. Die Zahlen, die du ab Tag eins willst: Finalize‑Fehlerrate bei Uploads, Orphan‑Rate (Objekte ohne DB‑Zeile und umgekehrt), Egress‑Volumen nach Dateityp, P95‑Download‑Latenz und durchschnittliche Objektgröße.
Wenn du schneller prototypen willst, ist Koder.ai (koder.ai) um das Generieren kompletter Apps aus Chat gebaut und passt gut zum hier beschriebenen Stack (React, Go, Postgres). Es kann beim Iterieren von Schema, Endpunkten und Hintergrundaufgaben helfen, ohne dass du immer wieder dieselbe Boilerplate schreiben musst.
Danach füge nur hinzu, was du in einem Satz erklären kannst: „Wir behalten alte Versionen 30 Tage“ oder „Jeder Workspace bekommt 10 GB.“ Halte es einfach, bis reale Nutzung neue Anforderungen stellt.
Verwende Postgres für Metadaten, die du abfragen und sichern musst (Owner, Berechtigungen, Zustand, Prüfsumme, Zeiger). Lege die Bytes im Objektspeicher ab, damit Downloads und große Transfers keine Datenbankverbindungen beanspruchen oder Backups aufblähen.
Die Datenbank muss dann auch als Dateiserver arbeiten. Das erhöht Tabellengrößen, verlangsamt Backups und Wiederherstellungen, belastet die Replikation und macht Performance weniger vorhersehbar, wenn viele Nutzer gleichzeitig herunterladen.
Ja. Behalte eine stabile file_id in deiner App, speichere Metadaten in Postgres und die Bytes im Objektspeicher, adressiert durch bucket und object_key. Deine API autorisiert Zugriffe und gibt kurzlebige Upload-/Download‑Berechtigungen aus, statt die Bytes zu proxyen.
Erstelle zuerst eine pending‑Zeile, generiere einen eindeutigen object_key und lasse den Client direkt in den Storage mit einer kurzlebigen Berechtigung hochladen. Nach dem Upload ruft der Client einen Finalize‑Endpunkt auf, damit der Server Größe und Prüfsumme verifiziert, bevor die Zeile auf uploaded gesetzt wird.
Weil echte Uploads fehlschlagen und wiederholt werden. Ein Zustandsfeld erlaubt, zwischen erwarteten, fehlenden, erfolgreichen und entfernten Dateien zu unterscheiden (pending, uploaded, failed, deleted), sodass UI, Aufräumjobs und Support richtig reagieren.
Behandle original_filename nur zur Anzeige. Generiere einen eindeutigen Speicher‑Key (häufig ein UUID‑basierter Pfad), um Kollisionen, seltsame Zeichen und Sicherheitsprobleme zu vermeiden. Den Originalnamen kannst du weiterhin in der UI zeigen.
Nutze eine stabile App‑URL wie /files/{file_id} als Berechtigungs‑Gate. Nach der Zugriffskontrolle in Postgres gibst du entweder eine Weiterleitung oder eine kurzlebige signierte Download‑Berechtigung zurück, sodass der Client direkt aus dem Objektspeicher herunterlädt und deine API nicht im Hot‑Path liegt.
Egress und wiederholte Downloads dominieren meist die Kosten, nicht der reine Speicherplatz. Setze Größenlimits und Quoten, nutze Aufbewahrungs‑/Lifecycle‑Regeln, dedupliziere per Prüfsumme wo sinnvoll und tracke Nutzungsdaten, damit du vor Kostensprüngen warnen kannst.
Speichere Berechtigungen und Sichtbarkeit in Postgres als Single Source of Truth, halte Storage standardmäßig privat. Validiere Typ und Größe vor und nach dem Upload, nutze HTTPS durchgängig, verschlüssele Daten im Ruhezustand und ergänze Audit‑Felder, damit Vorfälle später untersucht werden können.
Starte mit einer Metadaten‑Tabelle, einem Direkt‑zu‑Storage Upload‑Flow und einem Download‑Gate‑Endpoint, dann ergänze Aufräumjobs für verwaiste Objekte und Soft‑Deletes. Wenn du schnell prototypen willst auf einem React/Go/Postgres‑Stack, kann Koder.ai (koder.ai) Endpunkte, Schema und Background‑Tasks aus Chat generieren und dir wiederholte Boilerplate ersparen.