Penyimpanan objek vs blob database: simpan metadata file di Postgres, simpan byte di object storage, dan jaga unduhan cepat dengan biaya yang dapat diprediksi.

Unggahan pengguna terdengar sederhana: terima file, simpan, tampilkan nanti. Itu bekerja dengan beberapa pengguna dan file kecil. Lalu volume bertambah, file semakin besar, dan rasa sakit muncul di tempat yang tidak ada hubungannya dengan tombol unggah.
Unduhan melambat karena server aplikasi atau database melakukan pekerjaan berat. Backup menjadi besar dan lambat, sehingga restore memakan waktu lebih lama tepat saat Anda membutuhkannya. Tagihan penyimpanan dan bandwidth (egress) bisa melonjak karena file disajikan secara tidak efisien, terduplikasi, atau tidak pernah dibersihkan.
Yang biasanya Anda inginkan adalah hal yang membosankan namun andal: transfer cepat di bawah beban, aturan akses yang jelas, operasi sederhana (backup, restore, pembersihan), dan biaya yang tetap dapat diprediksi seiring pertumbuhan penggunaan.
Untuk sampai di sana, pisahkan dua hal yang sering tercampur:
Metadata adalah informasi kecil tentang file: siapa pemiliknya, bagaimana namanya, ukuran, tipe, kapan diunggah, dan di mana ia berada. Ini milik database Anda (seperti Postgres) karena Anda perlu melakukan query, filter, dan join ke pengguna, proyek, dan permission.
Byte file adalah isi aktual file (foto, PDF, video). Menyimpan byte di dalam blob database bisa bekerja, tetapi membuat database lebih berat, backup lebih besar, dan performa lebih sulit diprediksi. Menaruh byte di object storage menjaga database tetap fokus pada hal yang dilakukannya dengan baik, sementara file disajikan cepat dan murah oleh sistem yang dibuat untuk itu.
Saat orang bilang "simpan unggahan di database," mereka biasanya maksudnya blob database: entah kolom BYTEA (byte mentah dalam baris) atau Postgres "large objects" (fitur yang menyimpan nilai besar secara terpisah). Keduanya bisa bekerja, tapi keduanya membuat database Anda bertanggung jawab untuk menyajikan byte file.
Object storage adalah ide berbeda: file tinggal di bucket sebagai object, diakses dengan key (mis. uploads/2026/01/file.pdf). Ini dibuat untuk file besar, penyimpanan murah, dan unduhan streaming. Sistem ini juga menangani banyak pembacaan konkuren dengan baik, tanpa mengikat koneksi database Anda.
Postgres bersinar pada query, constraint, dan transaksi. Ia cocok untuk metadata seperti siapa pemilik file, apa isi file, kapan diunggah, dan apakah file bisa diunduh. Metadata itu kecil, mudah diindeks, dan mudah dijaga konsistensinya.
Aturan praktis singkat:
Pemeriksaan akal sehat cepat: jika backup, replica, dan migrasi akan menjadi menyusahkan jika byte file disertakan, jangan simpan byte di Postgres.
Setup yang biasanya dipakai tim cukup sederhana: simpan byte di object storage, dan simpan record file (siapa pemiliknya, apa itu, di mana lokasinya) di Postgres. API Anda mengoordinasikan dan mengotorisasi, tapi tidak menjadi proxy untuk unggahan dan unduhan besar.
Itu membagi tiga tanggung jawab dengan jelas:
file_id yang stabil, pemilik, ukuran, content type, dan pointer ke object.file_id yang stabil itu menjadi primary key untuk segalanya: komentar yang merujuk lampiran, invoice yang menunjuk PDF, log audit, dan alat dukungan. Pengguna bisa mengganti nama file, Anda bisa memindahkannya antar bucket, dan file_id tetap sama.
Jika memungkinkan, anggap object yang disimpan bersifat immutable. Jika pengguna mengganti dokumen, buat object baru (dan biasanya baris baru atau baris versi baru) daripada menimpa byte di tempat. Ini menyederhanakan caching, menghindari kejutan "tautan lama menampilkan file baru", dan memberi alur rollback yang bersih.
Putuskan privasi sejak dini: privat secara default, publik hanya jika perlu. Aturan yang baik adalah: database adalah sumber kebenaran untuk siapa yang dapat mengakses file; object storage menegakkan izin berumur pendek yang dikeluarkan API Anda.
Dengan pemisahan bersih, Postgres menyimpan fakta tentang file, dan object storage menyimpan byte. Itu menjaga database lebih kecil, backup lebih cepat, dan query sederhana.
Tabel uploads praktis hanya membutuhkan beberapa field untuk menjawab pertanyaan nyata seperti "siapa pemilik ini?", "di mana disimpan?", dan "aman untuk diunduh?"
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);
Beberapa keputusan yang menghemat rasa sakit nanti:
bucket + object_key sebagai pointer storage. Jaga agar tidak berubah setelah diunggah.pending. Ganti ke uploaded hanya setelah sistem Anda mengonfirmasi object ada dan ukurannya (dan idealnya checksum) cocok.original_filename hanya untuk ditampilkan. Jangan mengandalkannya untuk keputusan tipe atau keamanan.Jika Anda mendukung penggantian (mis. pengguna mengunggah ulang invoice), tambahkan tabel upload_versions terpisah dengan upload_id, version, object_key, dan created_at. Dengan begitu Anda bisa menyimpan riwayat, rollback kesalahan, dan menghindari merusak referensi lama.
Jaga unggahan tetap cepat dengan membuat API menangani koordinasi, bukan byte file. Database Anda tetap responsif, sementara object storage menanggung beban bandwidth.
Mulailah dengan membuat record unggahan sebelum file dikirim. API Anda mengembalikan upload_id, di mana file akan disimpan (object_key), dan izin unggah berumur pendek.
Alur umum:
pending, plus ukuran yang diharapkan dan content type yang dimaksudkan.upload_id dan field respons storage (mis. ETag). Server Anda memverifikasi ukuran, checksum (jika dipakai), dan content type, lalu menandai baris uploaded.failed dan opsional hapus object.Retry dan duplikat itu normal. Buat panggilan finalize idempoten: jika upload_id yang sama difinalisasi dua kali, kembalikan sukses tanpa mengubah apa pun.
Untuk mengurangi duplikat akibat retry dan re-upload, simpan checksum dan anggap "pemilik sama + checksum sama + ukuran sama" sebagai file yang sama.
Alur unduh yang baik dimulai dengan satu URL stabil di aplikasi Anda, meski byte berada di tempat lain. Pikirkan: /files/{file_id}. API Anda menggunakan file_id untuk mencari metadata di Postgres, memeriksa izin, lalu memutuskan cara mengirim file.
file_id.uploaded.Redirect sederhana dan cepat untuk file publik atau semi-publik. Untuk file privat, presigned GET URL menjaga storage tetap privat sekaligus memungkinkan browser mengunduh langsung.
Untuk video dan unduhan besar, pastikan object storage (dan lapisan proxy apa pun) mendukung range request (Range headers). Ini memungkinkan seeking dan unduhan yang dapat dilanjutkan. Jika Anda memproses byte lewat API, dukungan range sering rusak atau menjadi mahal.
Caching adalah sumber kecepatan. Endpoint stabil /files/{file_id} biasanya tidak bisa di-cache (ini gerbang otentikasi), sementara respons object storage sering bisa di-cache berdasarkan konten. Jika file immutable (unggahan baru = key baru), Anda bisa mengatur lifetime cache panjang. Jika menimpa file, jaga waktu cache pendek atau gunakan key yang versioned.
CDN membantu saat Anda punya banyak pengguna global atau file besar. Jika audiens kecil atau kebanyakan di satu region, object storage saja sering cukup dan lebih murah untuk memulai.
Tagihan kejutan biasanya datang dari unduhan dan churn, bukan byte mentah yang ada di disk.
Hitung empat penggerak yang mengubah angka: berapa banyak yang Anda simpan, seberapa sering Anda baca dan tulis (request), berapa banyak data keluar dari provider Anda (egress), dan apakah Anda memakai CDN untuk mengurangi unduhan ulang dari origin. File kecil yang diunduh 10.000 kali bisa lebih mahal daripada file besar yang tidak pernah disentuh.
Kontrol yang menjaga pengeluaran stabil:
Aturan lifecycle sering kali kemenangan termudah. Contoh: simpan foto asli "hot" selama 30 hari, lalu pindahkan ke kelas storage yang lebih murah; simpan invoice selama 7 tahun, tapi hapus bagian upload yang gagal setelah 7 hari. Kebijakan retensi dasar saja menghentikan pertumbuhan penyimpanan yang tak terkendali.
Deduplikasi bisa sederhana: simpan hash konten (mis. SHA-256) di tabel metadata file dan tegakkan unik per pemilik. Saat pengguna mengunggah PDF yang sama dua kali, Anda bisa memakai object yang ada dan hanya membuat baris metadata baru.
Terakhir, lacak penggunaan di tempat Anda sudah melakukan akuntansi pengguna: Postgres. Simpan bytes_uploaded, bytes_downloaded, object_count, dan last_activity_at per pengguna atau workspace. Itu memudahkan menampilkan batas di UI dan memicu peringatan sebelum tagihan datang.
Keamanan unggahan berfokus pada dua hal: siapa yang dapat mengakses file, dan apa yang bisa Anda buktikan nanti jika terjadi masalah.
Mulai dengan model akses yang jelas dan enkode di metadata Postgres, bukan aturan satu-off yang berserak di layanan.
Model sederhana yang menutup kebanyakan aplikasi:
Untuk file privat, hindari mengekspos object key mentah. Keluarkan URL unggah dan unduh berumur pendek yang dibatasi scope, dan rotasi secara sering.
Verifikasi enkripsi baik saat transit maupun saat disimpan. In transit berarti HTTPS end-to-end, termasuk unggahan langsung ke storage. At rest berarti enkripsi sisi server di penyedia storage Anda, dan bahwa backup serta replica juga terenkripsi.
Tambahkan titik pemeriksaan untuk keselamatan dan kualitas data: validasi content type dan ukuran sebelum mengeluarkan upload URL, lalu validasi lagi setelah unggah (berdasarkan byte yang tersimpan, bukan hanya nama file). Jika profil risiko Anda membutuhkannya, jalankan scanning malware secara asinkron dan karantina file sampai lulus.
Simpan field audit agar Anda bisa menyelidiki insiden dan memenuhi kebutuhan kepatuhan dasar: uploaded_by, ip, user_agent, dan last_accessed_at adalah baseline yang praktis.
Jika Anda punya persyaratan residensi data, pilih region storage dengan sengaja dan jaga konsistensinya dengan tempat compute berjalan.
Kebanyakan masalah unggahan bukan soal kecepatan mentah. Mereka datang dari pilihan desain yang terasa nyaman di awal, lalu menyakitkan ketika Anda punya trafik nyata, data nyata, dan tiket dukungan nyata.
Contoh konkret: jika pengguna mengganti foto profil tiga kali, Anda bisa berakhir membayar untuk tiga object lama selamanya kecuali Anda menjadwalkan pembersihan. Pola aman adalah soft delete di Postgres, lalu job background yang menghapus object dan merekam hasilnya.
Sebagian besar masalah muncul ketika file besar pertama tiba, pengguna me-refresh di tengah unggah, atau seseorang menghapus akun dan byte tetap ada.
Pastikan tabel Postgres Anda merekam ukuran file, checksum (agar Anda bisa memverifikasi integritas), dan jalur state yang jelas (mis. pending, uploaded, failed, deleted).
Checklist jarak-akhir:
Satu tes konkret: unggah file 2 GB, refresh halaman pada 30%, lalu lanjutkan. Lalu unduh pada koneksi lambat dan lakukan seek ke tengah. Jika salah satu alur goyah, perbaiki sekarang, bukan setelah peluncuran.
Aplikasi SaaS sederhana sering punya dua tipe unggahan yang sangat berbeda: foto profil (sering, kecil, aman di-cache) dan PDF invoice (sensitif, harus privat). Di sinilah pemisahan antara metadata di Postgres dan byte di object storage sangat menguntungkan.
Berikut contoh bagaimana metadata bisa terlihat di satu tabel files, dengan beberapa field yang mempengaruhi perilaku:
| kolom | contoh foto profil | contoh invoice PDF |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (disajikan 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 |
Saat pengguna mengganti foto, perlakukan itu sebagai file baru, bukan overwrite. Buat baris baru dan object_key baru, lalu perbarui profil pengguna untuk menunjuk ke file_id baru. Tandai baris lama sebagai replaced_by=<new_id> (atau deleted_at), dan hapus object lama nanti dengan job background. Ini menjaga riwayat, memudahkan rollback, dan menghindari kondisi balapan.
Dukungan dan debugging jadi lebih mudah karena metadata menceritakan kisah. Saat seseorang bilang "unggahan saya gagal," dukungan bisa memeriksa status, last_error yang bisa dibaca manusia, storage_request_id atau etag (untuk menelusuri log storage), timestamps (apakah terjadi stall?), dan owner_id serta kind (apakah kebijakan akses benar?).
Mulai kecil dan buat jalur bahagia yang membosankan: file terunggah, metadata tersimpan, unduhan cepat, dan tidak ada yang hilang.
Tonggak pertama yang baik adalah tabel metadata Postgres minimal plus satu alur unggah dan satu alur unduh yang bisa Anda jelaskan di papan tulis. Setelah itu bekerja end-to-end, tambahkan versi, kuota, dan aturan lifecycle.
Pilih satu kebijakan storage jelas per tipe file dan tuliskan. Misalnya, foto profil bisa di-cache, sedangkan invoice harus privat dan hanya dapat diakses lewat URL unduh berumur pendek. Mencampur kebijakan dalam satu prefix bucket tanpa rencana adalah penyebab kebocoran eksposur tidak sengaja.
Tambahkan instrumentasi sejak awal. Angka yang Anda inginkan sejak hari pertama adalah tingkat kegagalan finalize unggah, tingkat orphan (objek tanpa baris DB yang cocok, dan sebaliknya), volume egress berdasarkan tipe file, P95 latensi unduh, dan ukuran objek rata-rata.
Jika Anda ingin cara cepat untuk mem-prototipe pola ini, Koder.ai (koder.ai) dibangun untuk menghasilkan aplikasi penuh dari chat, dan ia cocok dengan stack umum yang dipakai di sini (React, Go, Postgres). Itu bisa menjadi cara yang membantu untuk iterasi skema, endpoint, dan job pembersihan background tanpa menulis ulang scaffolding berulang-ulang.
Setelah itu, tambahkan hanya apa yang bisa Anda jelaskan dalam satu kalimat: "kami menyimpan versi lama selama 30 hari" atau "setiap workspace mendapat 10 GB." Jaga sederhana sampai penggunaan nyata memaksa Anda berevolusi.
Gunakan Postgres untuk metadata yang perlu Anda query dan amankan (owner, permissions, state, checksum, pointer). Simpan byte di object storage sehingga unduhan dan transfer besar tidak menghabiskan koneksi database atau memperbesar backup.
Ini membuat database Anda berperan ganda sebagai file server. Ukuran tabel bertambah, backup dan restore melambat, replikasi terbebani, dan performa menjadi kurang dapat diprediksi ketika banyak pengguna mengunduh sekaligus.
Iya. Simpan satu file_id stabil di aplikasi Anda, simpan metadata di Postgres, dan simpan byte di object storage yang diakses lewat bucket dan object_key. API Anda sebaiknya mengotorisasi akses dan mengeluarkan izin unggah/unduh berumur pendek alih-alih memproxy byte.
Buat row pending dulu, hasilkan object_key unik, lalu biarkan klien mengunggah langsung ke storage menggunakan izin berumur pendek. Setelah unggah, minta klien memanggil endpoint finalize agar server dapat memverifikasi ukuran dan checksum (jika dipakai) sebelum menandai row sebagai uploaded.
Karena unggahan nyata gagal dan retry. Field state memungkinkan Anda membedakan file yang diharapkan tapi belum ada (pending), selesai (uploaded), rusak (failed), dan dihapus (deleted) sehingga UI, job pembersihan, dan alat dukungan berperilaku benar.
Anggap original_filename hanya untuk ditampilkan. Hasilkan key storage unik (seringkali path berbasis UUID) untuk menghindari tabrakan, karakter aneh, dan masalah keamanan. Anda tetap bisa menampilkan nama asli di UI sambil menjaga path storage bersih dan dapat diprediksi.
Gunakan URL aplikasi stabil seperti /files/{file_id} sebagai gerbang izin. Setelah memeriksa akses di Postgres, kembalikan redirect atau izin unduh signed berumur pendek sehingga klien mengunduh langsung dari object storage, menjaga API Anda tetap di luar hot path.
Egress dan unduhan berulang biasanya dominan, bukan hanya penyimpanan mentah. Batasi ukuran file dan tetapkan kuota, gunakan aturan retensi/lifecycle, deduplikasi berdasarkan checksum bila masuk akal, dan simpan counter penggunaan sehingga Anda bisa memberi peringatan sebelum tagihan melonjak.
Simpan permissions dan visibility di Postgres sebagai sumber kebenaran, dan jadikan storage privat secara default. Validasi tipe dan ukuran sebelum dan setelah unggah, gunakan HTTPS end-to-end, enkripsi di rest, dan tambahkan field audit sehingga Anda bisa menyelidiki masalah nanti.
Mulai dengan satu tabel metadata, satu alur unggah langsung-ke-storage, dan satu endpoint gate unduh, lalu tambahkan job pembersihan untuk objek orphan dan row soft-deleted. Jika ingin prototipe cepat di stack React/Go/Postgres, Koder.ai dapat menghasilkan endpoint, skema, dan task background dari chat sehingga Anda bisa iterasi tanpa menulis ulang boilerplate.