Przechowywanie obiektowe kontra bloby w bazie: przechowuj metadane plików w Postgresie, bajty w storage obiektowym i utrzymuj szybkie pobrania przy przewidywalnych kosztach.

Uploady użytkowników brzmią prosto: przyjmij plik, zapisz go, pokaż później. To działa przy kilku użytkownikach i małych plikach. Potem rośnie wolumen, pliki stają się większe i ból pojawia się w miejscach, które nie mają nic wspólnego z przyciskiem "wyślij".
Pobieranie zwalnia, bo serwer aplikacji lub baza wykonuje ciężką pracę. Backupy stają się ogromne i wolne, więc przywrócenie trwa dłużej wtedy, gdy najbardziej tego potrzebujesz. Rachunki za przechowywanie i transfer (egress) mogą skoczyć, bo pliki są serwowane nieefektywnie, duplikowane lub nigdy nie sprzątnięte.
Zwykle chcesz czegoś nudnego i niezawodnego: szybkich transferów pod obciążeniem, jasnych zasad dostępu, prostych operacji (backup, restore, sprzątanie) i kosztów, które pozostają przewidywalne wraz ze wzrostem użycia.
Aby to osiągnąć, rozdziel dwie rzeczy, które często się mieszają:
Metadane to małe informacje o pliku: kto jest właścicielem, jak się nazywa, rozmiar, typ, kiedy został przesłany i gdzie się znajduje. To należy trzymać w bazie (np. Postgres), bo potrzebujesz móc tego szukać, filtrować i łączyć z użytkownikami, projektami i uprawnieniami.
Bajty pliku to rzeczywista zawartość (zdjęcie, PDF, wideo). Przechowywanie bajtów w blobach bazy może działać, ale powoduje, że baza staje się cięższa, backupy większe, a wydajność trudniejsza do przewidzenia. Umieszczenie bajtów w storage obiektowym pozwala bazie robić to, co robi najlepiej, a pliki są serwowane szybko i tanio przez systemy do tego stworzone.
Gdy ludzie mówią "przechowuj uploady w bazie", zwykle mają na myśli bloby: albo kolumnę BYTEA (surowe bajty w wierszu), albo Postgresowe "large objects" (funkcja przechowująca duże wartości osobno). Oba rozwiązania działają, ale oba obciążają bazę odpowiedzialnością za serwowanie bajtów.
Storage obiektowy to inny pomysł: plik żyje w bucketcie jako obiekt, adresowany kluczem (np. uploads/2026/01/file.pdf). Jest stworzony do dużych plików, taniego przechowywania i strumieniowego pobierania. Dobrze radzi sobie z wieloma równoczesnymi odczytami, bez wiązania połączeń bazy.
Postgres błyszczy w zapytaniach, ograniczeniach i transakcjach. Świetnie nadaje się do metadanych: kto jest właścicielem pliku, co to jest, kiedy to przesłano i czy można to pobrać. Te metadane są małe, łatwe do indeksowania i proste do utrzymania spójności.
Praktyczna zasada:
Szybki test zdrowego rozsądku: jeśli backupy, repliki i migracje stałyby się problemem z powodu bajtów w bazie, trzymaj bajty poza Postgresem.
Ustawienie, do którego większość zespołów dochodzi, jest proste: bajty w storage obiektowym, rekord pliku (kto, co, gdzie) w Postgresie. Twoje API koordynuje i autoryzuje, ale nie proxy'uje dużych uploadów i downloadów.
To daje trzy jasne odpowiedzialności:
file_id, właściciel, rozmiar, content type i wskaźnik do obiektu.To stabilne file_id staje się kluczem głównym dla wszystkiego: komentarzy odnoszących się do załącznika, faktur wskazujących PDF, logów audytu i narzędzi wsparcia. Użytkownik może zmienić nazwę pliku, możesz przenieść go między bucketami — file_id pozostaje ten sam.
Gdy to możliwe, traktuj obiekty jako niemutowalne. Jeśli użytkownik zastępuje dokument, utwórz nowy obiekt (i zwykle nowy wiersz lub wersję) zamiast nadpisywać bajty w miejscu. Upraszcza to cache'owanie, unika niespodzianek typu "stary link zwraca nowy plik" i daje prostą ścieżkę rollbacku.
Zdecyduj o prywatności wcześnie: domyślnie prywatne, publiczne tylko wyjątkowo. Dobra zasada: baza jest źródłem prawdy, kto może dostać plik; storage egzekwuje krótkotrwałe uprawnienia wydane przez API.
Przy czystym rozdziale Postgres przechowuje fakty o pliku, a storage bajty. To trzyma bazę mniejszą, backupy szybsze i zapytania proste.
Praktyczna tabela uploads potrzebuje kilku pól, by odpowiedzieć na pytania typu "kto jest właścicielem?", "gdzie się to znajduje?" i "czy da się to pobrać?"
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);
Kilka decyzji, które oszczędzają kłopotów później:
bucket + object_key jako wskaźnika do przechowywania. Trzymaj to niemutowalne po przesłaniu.state. Gdy użytkownik zaczyna upload, wstaw wiersz pending. Przełącz na uploaded dopiero po potwierdzeniu, że obiekt istnieje i rozmiar (a idealnie suma kontrolna) się zgadza.original_filename tylko do wyświetlania. Nie ufaj mu w kwestiach typu czy bezpieczeństwa.Jeśli obsługujesz zastępowanie (np. użytkownik ponownie przesyła fakturę), dodaj osobną tabelę upload_versions z upload_id, version, object_key i created_at. Dzięki temu zachowasz historię, cofniesz zmiany i unikniesz psucia starych odwołań.
Utrzymaj uploady szybkie, sprawiając, że API koordynuje, a nie przesyła bajtów. Baza pozostaje responsywna, a storage bierze na siebie ruch sieciowy.
Zacznij od stworzenia rekordu uploadu zanim cokolwiek zostanie wysłane. API zwraca upload_id, gdzie plik będzie przechowywany (object_key) i krótkotrwałe uprawnienie do uploadu.
Typowy przepływ:
pending, wraz z oczekiwanym rozmiarem i zamierzonym content type.upload_id i polami odpowiedzi storage (np. ETag). Serwer weryfikuje rozmiar, sumę kontrolną (jeśli używasz) i content type, potem oznacza wiersz jako uploaded.failed i opcjonalnie usuń obiekt.Retry i duplikaty są normalne. Spraw, by wywołanie finalizacji było idempotentne: jeśli to samo upload_id zostanie sfinalizowane dwukrotnie, zwróć sukces bez zmiany stanu.
Aby zmniejszyć duplikaty przy retryach i ponownych uploadach, przechowuj sumę kontrolną i traktuj "ten sam właściciel + ta sama suma + ten sam rozmiar" jako ten sam plik.
Dobry przepływ pobierania zaczyna się od jednego stabilnego URL w aplikacji, nawet jeśli bajty są gdzie indziej. Pomyśl: /files/{file_id}. API używa file_id, żeby znaleźć metadane w Postgresie, sprawdza uprawnienia, a potem decyduje, jak dostarczyć plik.
file_id.uploaded.Przekierowania są proste i szybkie dla publicznych lub półpublicznych plików. Dla prywatnych plików presigned GET URL zachowują storage prywatnym, a jednocześnie pozwalają przeglądarce pobrać plik bez udziału API.
Dla wideo i dużych pobrań upewnij się, że storage (i każda warstwa proxy) obsługuje zapytania zakresowe (Range headers). To umożliwia przewijanie i wznawianie. Jeśli funnelujesz bajty przez API, obsługa zakresów często przestaje działać lub staje się kosztowna.
Cache to źródło prędkości. Twój stabilny endpoint /files/{file_id} zwykle nie powinien być cache'owalny (to bramka autoryzacyjna), podczas gdy odpowiedź storage często można cache'ować na podstawie zawartości. Jeśli pliki są niemutowalne (nowy upload = nowy klucz), ustaw długie TTL. Jeśli nadpisujesz pliki, trzymaj krótkie czasy cache lub używaj wersjonowanych kluczy.
CDN pomaga, gdy masz dużo globalnych użytkowników lub duże pliki. Jeśli twoja publiczność jest mała lub głównie w jednym regionie, sam storage często wystarczy i jest tańszy na start.
Zaskakujące rachunki zwykle wynikają z pobrań i churnu, nie z surowych bajtów na dysku.
Oszacuj cztery czynniki, które poruszają dźwignię kosztów: ile przechowujesz, jak często czytasz i zapisujesz (requesty), ile danych wychodzi od providera (egress) i czy używasz CDN, by zmniejszyć powtarzające się pobrania. Mały plik pobrany 10 000 razy może kosztować więcej niż duży plik, którego nikt nie dotyka.
Kontrole, które utrzymają wydatki w ryzach:
Reguły lifecycle to często najprostszy zysk. Na przykład: trzymaj oryginalne zdjęcia "hot" przez 30 dni, potem przenieś do tańszej klasy; faktury trzymaj 7 lat; części nieudanych uploadów usuwaj po 7 dniach. Podstawowe polityki retencji powstrzymują przyrost storage.
Deduplikacja może być prosta: zapisz hash treści (np. SHA-256) w tabeli metadanych i wymuś unikalność per właściciel. Gdy użytkownik ponownie wrzuca ten sam PDF, możesz użyć istniejącego obiektu i stworzyć nowy wiersz metadanych.
Na koniec, śledź użycie tam, gdzie już robisz rozliczenia: w Postgresie. Przechowuj bytes_uploaded, bytes_downloaded, object_count i last_activity_at per użytkownik lub workspace. Dzięki temu łatwo pokazywać limity w UI i wyzwalać alerty zanim przyjdzie rachunek.
Bezpieczeństwo uploadów sprowadza się do dwóch rzeczy: kto może dostać plik i co potrafisz udowodnić później, jeśli coś pójdzie nie tak.
Zacznij od jasnego modelu dostępu i zakoduj go w metadanych Postgresa, nie w jednorazowych regułach rozsianych po serwisach.
Prosty model, który wystarcza większości aplikacji:
Dla prywatnych plików unikaj ujawniania surowych kluczy obiektów. Wydawaj krótkotrwałe, ograniczone zakresem presigned URL dla uploadu i pobrania i rotuj je często.
Weryfikuj szyfrowanie w tranzycie i w spoczynku. W tranzycie oznacza HTTPS end-to-end, także przy uploadach bezpośrednio do storage. W spoczynku oznacza szyfrowanie po stronie providera storage i szyfrowanie backupów i replik.
Dodaj punkty kontrolne dla bezpieczeństwa i jakości danych: waliduj content type i rozmiar przed wydaniem URL do uploadu, potem waliduj znów po uploadzie (na podstawie rzeczywiście zapisanych bajtów, nie tylko nazwy pliku). Jeśli masz wyższe ryzyko, uruchamiaj asynchroniczne skanowanie antywirusowe i kwarantannuj plik do czasu czystego wyniku.
Przechowuj pola audytu, żeby móc badać incydenty i spełniać podstawowe wymagania zgodności: uploaded_by, ip, user_agent i last_accessed_at to praktyczne minimum.
Jeśli masz wymagania co do lokalizacji danych, wybierz region storage świadomie i trzymaj go spójnym z miejscem uruchamiania compute.
Większość problemów z uploadami nie dotyczy surowej szybkości. Wynikają z decyzji projektowych, które wydają się wygodne na początku, a potem bolesne przy realnym ruchu, danych i zgłoszeniach do supportu.
Konkretny przykład: jeśli użytkownik trzykrotnie zmienia zdjęcie profilowe, możesz płacić za trzy stare obiekty na zawsze, jeśli nie zaplanujesz sprzątania. Bezpieczny wzorzec to miękkie usunięcie w Postgresie, a potem zadanie w tle, które usuwa obiekt i zapisuje wynik.
Większość problemów ujawnia się, gdy przychodzi pierwszy duży plik, użytkownik odświeża stronę w trakcie uploadu lub ktoś usuwa konto, a bajty zostają.
Upewnij się, że tabela Postgresa zapisuje rozmiar pliku, sumę kontrolną (by móc weryfikować integralność) i jasną ścieżkę stanów (np. pending, uploaded, failed, deleted).
Ostatnia lista kontroli:
Jeden konkretny test: wyślij plik 2 GB, odśwież stronę na 30%, potem wznow upload. Potem pobierz na wolnym łączu i przeskocz do środka. Jeśli któraś z tych ścieżek jest niestabilna, napraw to teraz, nie po starcie.
Prosta aplikacja SaaS często ma dwa różne typy uploadów: zdjęcia profilowe (częste, małe, bezpieczne do cache'owania) oraz PDF-y faktur (wrażliwe, prywatne). To tu rozdzielenie metadanych w Postgresie i bajtów w storage daje największe korzyści.
Tak mogą wyglądać metadane w jednej tabeli files, z kilkoma polami wpływającymi na zachowanie:
| field | przykład avataru | przykład PDF faktury |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (serwowany przez 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 |
Gdy użytkownik zamienia zdjęcie, traktuj to jako nowy plik, a nie overwrite. Stwórz nowy wiersz i nowy object_key, potem zaktualizuj profil użytkownika, żeby wskazywał na nowe file_id. Oznacz stary wiersz jako replaced_by=<new_id> (lub ustaw deleted_at) i usuń stary obiekt później w zadaniu w tle. To zachowuje historię, ułatwia rollback i unika wyścigów.
Wsparcie i debugowanie stają się prostsze, bo metadane opowiadają historię. Gdy ktoś mówi "mój upload się nie powiódł", support może sprawdzić status, czytelne last_error, storage_request_id lub etag (do śledzenia logów storage), znaczniki czasu (czy zacięło się?) oraz owner_id i kind (czy polityka dostępu jest poprawna?).
Zacznij prosto i spraw, by happy path był nudny: pliki się uploadują, metadane zapisują, pobierania są szybkie i nic nie ginie.
Dobry pierwszy kamień milowy to minimalna tabela metadanych w Postgresie plus pojedynczy przepływ uploadu i pojedynczy przepływ pobierania, które potrafisz opisać na tablicy. Gdy to zadziała end-to-end, dodaj wersje, kwoty i reguły lifecycle.
Wybierz jedną jasną politykę storage per typ pliku i zapisz ją. Na przykład: avatary są cache'owalne, faktury prywatne i dostępne tylko przez krótkotrwałe URL. Mieszanie polityk w jednym prefixie bez planu to sposób na przypadkowe wycieki.
Dodaj metryki wcześniej. Liczby, które chcesz od dnia 1: współczynnik błędów finalizacji uploadu, współczynnik sierot (obiekty bez odpowiadającego wiersza DB i odwrotnie), wolumen egressu wg typu pliku, P95 latency pobierania i średni rozmiar obiektu.
Jeśli chcesz szybciej prototypować ten wzorzec, Koder.ai (koder.ai) jest zbudowany wokół generowania pełnych aplikacji z czatu i pasuje do wspólnego stacku tu opisanego (React, Go, Postgres). Może to być wygodny sposób iteracji nad schematem, endpointami i zadaniami w tle bez przepisywania szablonów.
Po tym dodawaj tylko to, co potrafisz wyjaśnić jednym zdaniem: "trzymamy stare wersje 30 dni" albo "każdy workspace ma 10 GB". Trzymaj prostotę, aż rzeczywiste użycie nie zmusi cię do zmian.
Użyj Postgresa do metadanych, które chcesz zapytować i zabezpieczyć (właściciel, uprawnienia, stan, suma kontrolna, wskaźnik). Umieść bajty w storage obiektowym, żeby pobieranie i duże transfery nie obciążały połączeń z bazą ani nie powiększały kopii zapasowych.
Sprawia, że baza robi też za serwer plików. Rosną rozmiary tabel, backupy i przywracania się wydłużają, rośnie obciążenie replikacji i wydajność staje się mniej przewidywalna przy wielu równoczesnych pobraniach.
Tak. Trzymaj w aplikacji stabilne file_id, przechowuj metadane w Postgresie, a bajty w storage obiektowym wskazywane przez bucket i object_key. Twoje API autoryzuje dostęp i wydaje krótkotrwałe uprawnienia zamiast proxy'ować bajty.
Utwórz najpierw wiersz pending, wygeneruj unikalny object_key, pozwól klientowi przesłać plik bezpośrednio do storage używając krótkotrwałego uprawnienia. Po zakończeniu klient wywołuje endpoint finalizacji, by serwer mógł zweryfikować rozmiar i sumę kontrolną (jeśli używasz) przed oznaczeniem wiersza jako uploaded.
Ponieważ prawdziwe przesyłania zawodzą i są powtarzane. Pole stanu pozwala odróżnić pliki oczekiwane, których jeszcze nie ma (pending), ukończone (uploaded), uszkodzone (failed) i usunięte (deleted), dzięki czemu UI, zadania porządkowe i narzędzia wsparcia zachowują się poprawnie.
Traktuj original_filename tylko do wyświetlania. Generuj unikalny klucz przechowywania (często oparty na UUID), żeby uniknąć kolizji, dziwnych znaków i problemów bezpieczeństwa. Nadal możesz pokazywać oryginalną nazwę w UI, utrzymując ścieżki storage czyste i przewidywalne.
Użyj stabilnego adresu w aplikacji np. /files/{file_id} jako bramki autoryzacyjnej. Po sprawdzeniu dostępu w Postgresie zwróć przekierowanie lub krótkotrwały podpisany URL do pobrania, żeby klient pobierał bezpośrednio ze storage i trzymał API poza gorącą ścieżką.
Zwykle dominują koszty egressu i powtarzających się pobrań, nie sam dysk. Ustal limity rozmiaru pliku i kwoty, stosuj reguły retencji/lifecycle, deduplikuj po sumie kontrolnej tam, gdzie ma to sens, i śledź liczniki użycia, żeby ostrzec przed rosnącym rachunkiem.
Przechowuj uprawnienia i widoczność w Postgresie jako źródło prawdy, trzymaj storage prywatny domyślnie. Waliduj typ i rozmiar przed i po uploadzie, używaj HTTPS end-to-end, szyfruj dane w spoczynku i dodaj pola audytu, aby móc później badać incydenty.
Zacznij od jednej tabeli metadanych, jednego przepływu bezpośrednio do storage i jednego endpointu bramkującego pobieranie, potem dodaj zadania porządkujące dla sierot i miękkich usunięć. Jeśli chcesz szybciej prototypować na stacku React/Go/Postgres, Koder.ai (koder.ai) potrafi wygenerować endpointy, schemat i zadania w tle z czatu, co przyspiesza iterację bez przepisywania boilerplate'u.