Lưu trữ đối tượng hay blob trong DB: lưu metadata tệp trong Postgres, bytes trong object storage, và giữ việc tải xuống nhanh với chi phí có thể dự đoán.

Tải lên từ người dùng nghe thì đơn giản: nhận tệp, lưu nó, hiển thị sau. Điều đó đúng khi ít người dùng và tệp nhỏ. Rồi khi lưu lượng tăng, tệp lớn hơn, các vấn đề xuất hiện ở chỗ không liên quan trực tiếp tới nút upload.
Tốc độ tải xuống chậm lại vì app server hoặc cơ sở dữ liệu phải gánh việc phục vụ bytes. Sao lưu trở nên lớn và chậm, nên khôi phục mất nhiều thời gian đúng lúc bạn cần. Hóa đơn lưu trữ và băng thông (egress) có thể tăng vọt vì tệp được phục vụ kém hiệu quả, bị nhân đôi, hoặc không bao giờ được dọn dẹp.
Những gì bạn thường muốn là nhàm chán nhưng đáng tin: truyền nhanh dưới tải, quy tắc truy cập rõ ràng, thao tác đơn giản (backup, restore, cleanup), và chi phí giữ ổn định khi usage tăng.
Để tới đó, tách hai thứ thường bị trộn lẫn:
Metadata là thông tin nhỏ về tệp: ai sở hữu, tên, kích thước, kiểu, khi nào tải lên, và nó nằm ở đâu. Đây thuộc về cơ sở dữ liệu (ví dụ Postgres) vì bạn cần truy vấn, lọc và join với người dùng, dự án và quyền.
Bytes của tệp là nội dung thực tế (ảnh, PDF, video). Lưu bytes trong blob của cơ sở dữ liệu có thể hoạt động, nhưng làm database nặng hơn, backup lớn hơn và hiệu năng khó dự đoán. Đưa bytes vào object storage giữ database chuyên về việc nó giỏi, trong khi hệ thống lưu trữ phục vụ tệp nhanh và rẻ hơn.
Khi người ta nói "lưu uploads trong DB", họ thường ám chỉ blob: hoặc cột BYTEA (bytes thô trong hàng) hoặc các "large objects" của Postgres (lưu giá trị lớn riêng). Cả hai đều hoạt động, nhưng đều khiến database phải phục vụ bytes tệp.
Object storage là ý tưởng khác: tệp sống trong một bucket dưới dạng object, được địa chỉ bằng một key (ví dụ uploads/2026/01/file.pdf). Nó được xây dựng cho tệp lớn, lưu rẻ và tải stream. Nó cũng xử lý nhiều lượt đọc đồng thời tốt mà không chiếm kết nối DB.
Postgres nổi bật ở truy vấn, ràng buộc và giao dịch. Nó phù hợp cho metadata như ai sở hữu tệp, đó là gì, khi nào tải lên và liệu có thể tải xuống không. Metadata nhỏ, dễ index và dễ giữ nhất quán.
Nguyên tắc thực tế:
Một kiểm tra nhanh: nếu backup, replica và migration sẽ trở nên phiền toái khi có bytes tệp, thì giữ bytes ra ngoài Postgres.
Thiết lập mà hầu hết đội chọn là rõ ràng: lưu bytes trong object storage, lưu record tệp (ai sở hữu, là gì, ở đâu) trong Postgres. API của bạn phối hợp và ủy quyền, nhưng không proxy các upload và download lớn.
Điều này cho bạn ba trách nhiệm rõ ràng:
file_id ổn định, owner, size, content type, và con trỏ tới object.file_id ổn định trở thành khoá chính cho mọi thứ: bình luận tham chiếu tệp đính kèm, hoá đơn trỏ tới PDF, log audit và công cụ hỗ trợ. Người dùng có thể đổi tên tệp, bạn có thể di chuyển giữa bucket, nhưng file_id vẫn giữ nguyên.
Khi có thể, coi object đã lưu là bất biến. Nếu người dùng thay thế tài liệu, tạo object mới (và thường tạo hàng mới hoặc hàng version mới) thay vì ghi đè bytes tại chỗ. Nó đơn giản hoá cache, tránh việc "link cũ trả về tệp mới" và cho bạn khả năng rollback rõ ràng.
Quyết định quyền riêng tư sớm: mặc định private, public chỉ khi cần. Quy tắc hay: database là nguồn sự thật cho ai có quyền truy cập; object storage thi hành quyền ngắn hạn API cấp.
Với tách biệt rõ ràng, Postgres lưu sự thật về tệp, object storage lưu bytes. Điều đó giữ database nhỏ hơn, backup nhanh hơn và truy vấn đơn giản.
Một bảng uploads thực tế chỉ cần vài trường để trả lời các câu hỏi như "ai sở hữu?", "nó nằm đâu?", và "có an toàn để tải xuống không?"
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);
Một vài quyết định giúp tránh rắc rối sau này:
bucket + object_key làm con trỏ lưu trữ. Giữ nó bất biến sau khi upload.state. Khi người dùng bắt đầu upload, insert hàng pending. Chuyển sang uploaded chỉ sau khi hệ thống xác nhận object tồn tại và kích thước (và lý tưởng là checksum) khớp.original_filename chỉ để hiển thị. Đừng tin nó cho quyết định kiểu hay bảo mật.Nếu hỗ trợ thay thế (ví dụ người dùng tải lại hoá đơn), thêm bảng upload_versions với upload_id, version, object_key và created_at. Như vậy bạn giữ lịch sử, rollback lỗi dễ và tránh làm hỏng tham chiếu cũ.
Giữ upload nhanh bằng cách để API lo phối hợp, không lo bytes. Database của bạn vẫn phản hồi tốt, trong khi object storage chịu phần băng thông.
Bắt đầu bằng cách tạo record upload trước khi gửi gì cả. API trả về upload_id, vị trí tệp sẽ sống (một object_key) và quyền upload ngắn hạn.
Luồng thường gặp:
pending, kèm kích thước dự kiến và content type mong muốn.upload_id và các trường phản hồi từ storage (ví dụ ETag). Server kiểm tra kích thước, checksum (nếu có), và content type, rồi đánh dấu uploaded.failed và có thể xóa object.Retry và duplicate là bình thường. Làm cho gọi finalize idempotent: nếu cùng upload_id được finalize hai lần, trả về thành công mà không thay đổi gì.
Để giảm duplicate khi retry và re-upload, lưu checksum và coi "cùng owner + cùng checksum + cùng kích thước" là cùng một tệp.
Luồng download tốt bắt đầu với một URL ổn định trong app, ngay cả khi bytes ở nơi khác. Nghĩ: /files/{file_id}. API dùng file_id tra metadata trong Postgres, kiểm tra quyền, rồi quyết định cách giao tệp.
file_id.uploaded.Redirect đơn giản và nhanh cho tệp public hoặc bán-công-khai. Với tệp private, presigned GET URLs giữ storage riêng tư trong khi vẫn cho browser tải trực tiếp.
Với video và tải lớn, đảm bảo object storage (và proxy nếu có) hỗ trợ range requests (Range headers). Điều này cho phép seek và resumable downloads. Nếu bạn đi qua API để funnel bytes, hỗ trợ range thường hỏng hoặc tốn kém.
Cache là nơi tốc độ đến. Endpoint /files/{file_id} nên thường không cache (nó là cửa kiểm quyền), trong khi phản hồi từ object storage có thể cache dựa trên nội dung. Nếu files bất biến (upload mới = key mới), bạn có thể đặt thời gian cache dài. Nếu ghi đè, giữ thời gian cache ngắn hoặc dùng key theo version.
CDN hữu ích khi bạn có nhiều người dùng toàn cầu hoặc tệp lớn. Nếu khán giả nhỏ hoặc chủ yếu ở một vùng, object storage một mình thường đủ và rẻ hơn để bắt đầu.
Hóa đơn bất ngờ thường đến từ lượt tải xuống và churn, không phải bytes nằm trên đĩa.
Định giá theo bốn yếu tố chính: bao nhiêu bạn lưu, tần suất đọc và ghi (requests), bao nhiêu dữ liệu rời nhà cung cấp (egress), và bạn có dùng CDN để giảm tải origin hay không. Một tệp nhỏ tải 10.000 lần có thể tốn hơn một tệp lớn không ai động tới.
Các biện pháp giữ chi phí ổn định:
Lifecycle rules thường là thắng lợi dễ nhất. Ví dụ: giữ ảnh gốc "hot" 30 ngày rồi chuyển lớp rẻ hơn; giữ hoá đơn 7 năm; xóa phần upload thất bại sau 7 ngày. Ngay cả chính sách lưu giữ cơ bản cũng ngăn lưu trữ tăng dần.
Dedupe đơn giản: lưu hash nội dung (ví dụ SHA-256) trong metadata và đảm bảo tính duy nhất theo owner. Khi người dùng tải cùng PDF lên hai lần, bạn có thể tái sử dụng object hiện có và chỉ tạo một hàng metadata mới.
Cuối cùng, theo dõi sử dụng nơi bạn đã làm kế toán người dùng: Postgres. Lưu bytes_uploaded, bytes_downloaded, object_count và last_activity_at theo người dùng hoặc workspace. Điều này giúp hiển thị giới hạn trong UI và kích hoạt cảnh báo trước khi hóa đơn tới.
Bảo mật cho uploads xoay quanh hai điều: ai có thể truy cập file, và những gì bạn có thể chứng minh sau này nếu có sự cố.
Bắt đầu với mô hình truy cập rõ ràng và mã hoá nó trong metadata Postgres, không rải rác các quy tắc rời rạc khắp dịch vụ.
Mô hình đơn giản đáp ứng phần lớn ứng dụng:
Với file riêng tư, tránh lộ raw object keys. Phát URL upload/download có giới hạn thời gian và scope, và xoay chúng thường xuyên.
Xác nhận mã hóa both in transit và at rest. In transit nghĩa là HTTPS end-to-end, bao gồm upload trực tiếp tới storage. At rest nghĩa là server-side encryption ở nhà cung cấp storage, và backup/replica cũng được mã hóa.
Thêm checkpoint cho an toàn và chất lượng dữ liệu: xác thực content type và size trước khi phát upload URL, rồi xác thực lại sau upload (dựa trên bytes thực lưu, không chỉ tên tệp). Nếu rủi ro cần, chạy quét malware bất đồng bộ và cách ly file cho tới khi an toàn.
Lưu trường audit để điều tra sự cố và đáp ứng tuân thủ: uploaded_by, ip, user_agent và last_accessed_at là chuẩn tối thiểu hữu dụng.
Nếu có yêu cầu về vùng dữ liệu (data residency), chọn region lưu trữ phù hợp và giữ nhất quán với nơi bạn chạy compute.
Hầu hết vấn đề upload không nằm ở tốc độ thô. Chúng đến từ các lựa chọn thiết kế tiện lúc đầu nhưng đau sau khi có traffic thật, dữ liệu thật và ticket hỗ trợ thật.
Ví dụ cụ thể: nếu người dùng thay avatar 3 lần, bạn có thể trả tiền cho ba object cũ mãi mãi trừ khi lập lịch dọn dẹp. Mẫu an toàn là soft delete ở Postgres, rồi job nền xóa object và ghi kết quả.
Phần lớn lỗi xuất hiện khi tệp lớn đầu tiên tới, người dùng refresh giữa chừng upload, hoặc ai đó xóa tài khoản nhưng bytes vẫn còn.
Đảm bảo bảng Postgres ghi kích thước tệp, checksum (để xác thực), và một luồng trạng thái rõ ràng (ví dụ: pending, uploaded, failed, deleted).
Checklist cuối cùng:
uploaded thiếu bytes.Một bài test cụ thể: upload file 2 GB, refresh trang ở 30%, rồi resume. Sau đó tải xuống trên kết nối chậm và seek tới giữa. Nếu một trong hai flow lỏng lẻo, sửa ngay, đừng chờ tới sau launch.
Một SaaS đơn giản thường có hai loại upload khác biệt: ảnh profile (thường, nhỏ, an toàn cache) và PDF hoá đơn (nhạy cảm, phải private). Đây là nơi tách metadata trong Postgres và bytes trong object storage thực sự có ích.
Metadata trong một bảng files có thể bao gồm vài trường ảnh hưởng hành vi:
| field | ví dụ ảnh profile | ví dụ invoice PDF |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (phục vụ qua 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 |
Khi người dùng thay ảnh, coi đó là tệp mới, không ghi đè. Tạo hàng mới và object_key mới, rồi cập nhật profile để trỏ tới file ID mới. Đánh dấu hàng cũ replaced_by=<new_id> (hoặc deleted_at), và xóa object cũ sau bằng job nền. Cách này giữ lịch sử, dễ rollback và tránh race condition.
Hỗ trợ và debug dễ hơn vì metadata kể một câu chuyện. Khi ai đó báo "upload thất bại", support có thể kiểm tra status, last_error dễ đọc, storage_request_id hoặc etag (để truy vết logs storage), timestamp (có bị stall không?), và owner_id cùng kind (chính sách truy cập có đúng không?).
Bắt đầu nhỏ và làm con đường thuận lợi thật nhàm chán: tệp upload xong, metadata lưu, download nhanh và không mất dữ liệu.
Mốc đầu tốt là một bảng metadata tối thiểu trong Postgres cộng một luồng upload trực tiếp tới storage và một luồng download bạn có thể giải thích trên whiteboard. Khi cái đó chạy end-to-end, thêm version, quota và lifecycle rule.
Chọn một chính sách lưu trữ rõ ràng cho mỗi loại tệp và ghi nó ra. Ví dụ, ảnh profile cache được, còn hoá đơn là private và chỉ truy cập bằng URL tải xuống ngắn hạn. Trộn chính sách trong cùng một prefix bucket mà không có kế hoạch là cách dễ gây rò rỉ dữ liệu.
Thêm instrumentation sớm. Các số bạn cần từ ngày đầu: tỷ lệ finalize upload lỗi, tỷ lệ orphan (object không có row DB và ngược lại), egress theo loại tệp, P95 latency download và kích thước trung bình object.
Nếu muốn prototype nhanh pattern này, Koder.ai (koder.ai) được xây dựng để sinh app từ chat, khớp với stack thông dụng ở đây (React, Go, Postgres). Nó có thể là cách tiện để lặp schema, endpoints và job cleanup nền mà không phải viết lại phần scaffolding cơ bản.
Sau đó, chỉ thêm những gì bạn có thể giải thích trong một câu: "chúng tôi giữ phiên bản cũ 30 ngày" hoặc "mỗi workspace có 10 GB." Giữ mọi thứ đơn giản cho tới khi usage thực sự buộc bạn thay đổi.
Dùng Postgres cho metadata bạn cần truy vấn và bảo mật (owner, permissions, state, checksum, pointer). Đặt bytes vào object storage để các lượt tải xuống và truyền tải lớn không chiếm kết nối cơ sở dữ liệu hay làm phình sao lưu.
Nó bắt cơ sở dữ liệu phải kiêm luôn vai trò máy chủ tệp. Điều đó làm tăng kích thước bảng, làm sao lưu và khôi phục chậm hơn, tăng tải replication và khiến hiệu năng khó dự đoán khi nhiều người tải cùng lúc.
Có. Giữ một file_id ổn định trong ứng dụng, lưu metadata trong Postgres và bytes trong object storage được đánh địa chỉ bằng bucket và object_key. API của bạn nên cấp quyền ngắn hạn cho upload/download thay vì proxy bytes.
Tạo một hàng pending trước, sinh một object_key duy nhất, rồi để client tải trực tiếp lên storage bằng quyền ngắn hạn. Sau upload, client gọi endpoint finalize để server kiểm tra kích thước và checksum (nếu dùng) trước khi đánh dấu uploaded.
Vì upload thực tế có lỗi và retry. Trường state giúp phân biệt tệp mong đợi nhưng chưa có (pending), đã hoàn tất (uploaded), hỏng (failed) và bị xóa (deleted) để UI, job dọn dẹp và công cụ hỗ trợ hoạt động chính xác.
Xem original_filename chỉ để hiển thị. Sinh một storage key duy nhất (thường là đường dẫn dựa trên UUID) để tránh trùng tên, ký tự lạ và các vấn đề bảo mật. Bạn vẫn có thể hiển thị tên gốc trong UI.
Dùng một URL ứng dụng ổn định như /files/{file_id} làm cửa kiểm quyền. Sau khi kiểm quyền trong Postgres, trả về redirect hoặc một URL GET được ký ngắn hạn để client tải từ object storage trực tiếp, nhờ đó API không nằm trên đường truyền nóng.
Egress và lượt tải thường chiếm phần lớn chi phí, chứ không phải lượng bytes lưu trữ thô. Giới hạn kích thước tệp và quota, dùng quy tắc lifecycle để chuyển tệp cũ sang lớp rẻ hơn hoặc xóa, dedupe theo checksum khi hợp lý, và theo dõi số liệu để cảnh báo trước khi hóa đơn tăng vọt.
Lưu permissions và visibility trong Postgres làm nguồn sự thật, giữ storage mặc định là private. Xác thực kiểu và kích thước trước và sau upload, dùng HTTPS end-to-end, mã hóa khi lưu trữ, và lưu trường audit để điều tra khi cần.
Bắt đầu với một bảng metadata, một luồng upload trực tiếp tới storage và một endpoint gate cho download, rồi thêm job dọn dẹp orphan và soft-delete. Nếu muốn prototype nhanh trên stack React/Go/Postgres, Koder.ai (koder.ai) có thể sinh endpoints, schema và job nền từ chat để bạn lặp nhanh mà không phải viết lại phần khung.