对象存储 vs 数据库 BLOB:把文件元数据放在 Postgres、字节放在对象存储,并通过可预测的费用和高速下载保持系统可扩展。

用户上传看起来很简单:接收文件、保存它、稍后显示。对少量用户和小文件这确实行得通。但当流量增长、文件变大时,痛点会出现在与“上传按钮”本身无关的地方。
下载变慢,因为你的应用服务器或数据库在做繁重工作。备份变大且耗时,恢复也会更慢,恰好在你最需要时变得困难。存储费用和带宽(出口)费用可能因为文件被低效提供、重复存储或从未清理而飙升。
你通常需要的是平凡而可靠的特性:在高负载下依然快速传输、清晰的访问规则、简单的运维(备份、恢复、清理),以及随着使用增长仍能保持可预测的成本。
要做到这点,请把两个常被混在一起的概念分离开来:
元数据(Metadata) 是关于文件的小信息:谁拥有它、名称、大小、类型、上传时间以及存放位置。这类信息应该放在你的数据库(如 Postgres)里,因为你需要对它们查询、过滤并与用户、项目和权限关联。
文件字节(File bytes) 是文件的实际内容(照片、PDF、视频)。把字节放在数据库的 Blob 里可以工作,但会增加数据库负担、备份体积,并使性能更难以预测。把字节放到对象存储能让数据库专注于它擅长的查询和一致性,而文件由为此设计的系统快速且廉价地提供。
当有人说“把上传存到数据库”时,通常指的是数据库的 blobs:要么是行内的 BYTEA(行中原始字节),要么是 Postgres 的“大对象”特性(把大值分开存放)。这两种方式都可行,但都会让数据库负责提供文件字节。
对象存储则是另一种思路:文件作为对象存放在 bucket 中,用一个键来寻址(比如 uploads/2026/01/file.pdf)。对象存储为大文件设计,存储成本低,支持流式下载,并能很好地处理大量并发读取,而不会占用你的数据库连接。
Postgres 在查询、约束和事务方面表现出色。它适合存放谁拥有文件、文件是什么、何时上传、是否可下载等元数据。这些元数据体积小、易于索引并且易于保持一致性。
一个实用的经验法则:
一个快速的检查:如果把文件字节放进数据库会让备份、复制和迁移变得痛苦,那就把字节放到外部存储。
大多数团队最终采用的方案很直接:字节放对象存储,文件记录(谁拥有、是什么、存放在哪里)放 Postgres。你的 API 负责协调和授权,但不代理大型的上传和下载数据流。
这样你有三项清晰的职责:
file_id、owner、大小、内容类型与对象指针。那个稳定的 file_id 成为所有引用的主键:引用附件的评论、指向 PDF 的发票、审计日志和支持工具。用户可能会重命名文件,你可能会在桶间移动对象,但 file_id 保持不变。
尽量把存储对象视为不可变的。如果用户替换文档,创建一个新对象(通常也创建新行或新版本行),而不是就地覆盖字节。这样能简化缓存,避免“旧链接返回新文件”的惊讶,并提供清晰的回滚方案。
尽早决定隐私策略:默认私有,仅在例外情况下公开。一个好的规则是:数据库是谁能访问文件的真实来源;对象存储执行由你的 API 授权的短期权限。
清晰分工后,Postgres 存事实,对象存储存字节。这样能让数据库更小、备份更快、查询更简单。
一个实用的 uploads 表只需要少数字段以回答诸如 “这是谁的?”、“它存在哪?”、“能否安全下载?” 这类真实问题。
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);
一些早期决策能节省大量麻烦:
bucket + object_key 作为存储指针。上传后保持不变。state。当用户开始上传时插入 pending 行。只有在系统确认对象存在并且大小(最好还有校验和)匹配后,才翻为 uploaded。original_filename 仅用于展示。不要用它判断类型或做安全决定。如果支持替换(比如用户重新上传发票),新增 upload_versions 表,包含 upload_id、version、object_key 和 created_at。这样你能保留历史、回滚错误,并避免打破旧引用。
通过让 API 负责协调而不是传递字节来保持上传快速。你的数据库保持响应性,对带宽的消耗交给对象存储。
先在任何数据发送前创建上传记录。你的 API 返回一个 upload_id、文件将存放的位置(object_key)和一个短期的上传权限。
常见流程:
pending 行,同时记录期望大小和内容类型。upload_id 和存储返回的字段(如 ETag)调用你的 API。服务器验证大小、校验和(若使用),然后把行标记为 uploaded。failed,并可选地删除对象。重试和重复是常态。让 finalize 调用具有幂等性:若同一 upload_id 重复 finalize,应返回成功而不改变状态。
为减少重试与重传造成的重复,存储校验和,并把“同一拥有者 + 相同校验和 + 相同大小”视为相同文件。
良好的下载流程从应用中一个稳定的 URL 开始,即便字节实际存放在别处。想想:/files/{file_id}。你的 API 用 file_id 在 Postgres 查询元数据、校验权限,然后决定如何交付文件。
file_id)。uploaded。对于公共或半公共文件,重定向简单且快速。对于私有文件,预签名 GET URL 在保持存储私有的同时仍允许浏览器直接下载。
对于视频和大文件,确保对象存储(以及任何代理层)支持范围请求(Range 头)。这能让用户跳转播放和断点续传。如果你把字节通过 API 中转,范围请求往往会中断或变得昂贵。
缓存是速度的来源。你的稳定 /files/{file_id} 端点通常应不可缓存(它是权限门),而对象存储的响应可以根据内容设置缓存策略。如果文件不可变(新上传 = 新 key),可以设置长缓存时间。若覆盖文件,则保持短缓存或使用版本化键。
当你有大量全球用户或大文件时,CDN 会有帮助。如果用户量小或用户主要集中在一个区域,仅使用对象存储通常够用且更便宜作为起点。
意外账单通常来自下载与 churn,而不是原地存放的字节。
关注四个会影响成本的驱动因素:存储总量、读写频次(请求数)、流出到外部的流量(egress),以及是否使用 CDN 来减少源站重复下载。一个小文件被下载 10,000 次,可能比一个无人触碰的大文件更昂贵。
控制成本的措施:
生命周期规则通常是最容易的收益点。例如:保持原始照片在 30 天内为“热”数据,然后迁移到更便宜的存储类;发票保存 7 年,但失败的上传部分 7 天后删除。即便是基本的保留策略也能阻止存储膨胀。
去重可以很简单:在元数据表中存储内容哈希(如 SHA-256),并在拥有者范围内强制唯一。当用户两次上传相同 PDF 时,复用已有对象并仅创建新的元数据行。
最后,把使用情况记录在你已有的用户计费系统中:Postgres。存储 bytes_uploaded、bytes_downloaded、object_count 与 last_activity_at,按用户或工作区归档。这样方便在 UI 展示限制并在费用到达阈值前触发告警。
上传安全归结为两件事:谁能访问文件,以及一旦出事你能证明什么。
从一个清晰的访问模型开始,并把它编码在 Postgres 元数据中,而不是散落在服务间的临时规则里。
一个覆盖多数应用的简单模型:
对于私有文件,避免暴露原始对象键。发放时限短、作用域受限的预签名上传与下载 URL,并经常轮换它们。
验证传输中与静态存储的加密。传输中加密意味着端到端都用 HTTPS,包括直接到存储的上传。静态加密意味着在提供商端启用服务端加密,并确保备份与副本也被加密。
增加安全与数据质量的检查点:在发放上传 URL 前校验内容类型与大小,然后在上传后基于实际存储的字节再校验一次(不要只信任文件名)。若你的风险更高,可异步进行恶意软件扫描并将文件隔离,直到通过检查。
记录审计字段以便调查事件并满足基本合规要求:uploaded_by、ip、user_agent 和 last_accessed_at 是一个实用的基线。
若有数据驻留要求,请有意识地选择存储区域,并与运行计算的区域保持一致。
大多数上传问题并不是单纯的速度问题,而是那些早期看似方便但在真实流量、真实数据和真实客服场景下变得痛苦的设计选择。
invoice.pdf),奇怪字符也会引发边界问题。把文件名作为展示数据,用唯一键(如 UUID)作为存储键。一个具体例子:如果用户更换头像三次,除非安排清理,你可能会永远为三个旧对象付费。一个安全模式是:在 Postgres 做软删除,然后由后台任务删除对象并记录结果。
大多数问题在第一个大文件到来、用户在上传中刷新页面或有人删除账号但字节仍留在存储时显现。
确保你的 Postgres 表记录文件大小、校验和(用于完整性验证)和清晰的状态路径(例如:pending、uploaded、failed、deleted)。
最后的检查项:
一个具体测试:上传一个 2 GB 的文件,在 30% 时刷新页面然后恢复。再在慢速连接上下载并跳到中间。如果任一流程不稳,尽早修复,不要等到上线后再处理。
一个简单的 SaaS 应用通常有两种非常不同的上传类型:头像(频繁、小、可缓存)和 PDF 发票(敏感、必须保持私有)。这正是把元数据放 Postgres、字节放对象存储带来价值的场景。
下面是一个 files 表中元数据在两种场景下可能的取值,带出影响行为的字段:
| field | profile photo example | invoice PDF example |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (served 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 |
当用户替换头像时,把它视为新文件而非覆盖:创建新行和新 object_key,然后把用户资料指向新的文件 ID。将旧行标记为 replaced_by=<new_id>(或写入 deleted_at),并由后台任务稍后删除旧对象。这样能保留历史、便于回滚并避免竞态条件。
支持与调试也更简单,因为元数据讲述了一段故事。当有人说 “我的上传失败” 时,客服可以查看 status、可读的 last_error、storage_request_id 或 etag(用于追踪存储日志)、时间戳(是否卡住了?)以及 owner_id 与 kind(访问策略是否正确?)。
从小做起,让常规路径变得平凡:文件能上传、元数据能保存、下载快速且没有丢失。
一个好的首个里程碑是:一个最小的 Postgres 元数据表加上一套上传流和下载流,能在白板上解释清楚。端到端工作正常后,再添加版本、配额和生命周期规则。
为每种文件类型选择并写下明确的存储策略。例如,头像可缓存;发票应保持私有并仅通过短期下载 URL 访问。在同一桶前缀内混合策略而没有计划,往往会导致意外泄露。
尽早加入度量。上线第一天你就想要的指标包括:上传完成失败率、孤儿率(无匹配 DB 行的对象以及反之)、按文件类型的出口流量、P95 下载延迟和平均对象大小。
如果你想更快地原型化这个模式,Koder.ai (koder.ai) 是围绕从对话生成完整应用构建的,它与这里常见的技术栈(React、Go、Postgres)匹配。它能帮你快速迭代模式、端点和后台清理任务,无需一遍又一遍地重写样板。
之后只添加那些你能一句话解释清楚的内容:“我们保存旧版本 30 天” 或 “每个工作区 10 GB”。在真实使用迫使你改变之前,保持简单。
使用 Postgres 存放你需要查询和保护的元数据(如 owner、permissions、state、checksum、 pointer 等)。将文件字节放到对象存储,这样下载和大文件传输不会占用数据库连接或膨胀备份。
这会让数据库承担文件服务器的职责。表会变大、备份与恢复变慢、复制负载增加,而且在大量用户同时下载时性能变得难以预测。
是的。保持一个稳定的 file_id,把元数据放在 Postgres,把字节放在通过 bucket 和 object_key 定位的对象存储。你的 API 负责授权并发放短期的上传/下载权限,而不是代理数据流量。
先创建一个 pending 行,生成唯一的 object_key,然后让客户端用短期权限直接上传到存储。上传完成后,客户端调用 finalize 接口,由服务端校验大小和校验和(如果使用),然后把行标记为 uploaded。
因为真实的上传会失败和重试。状态字段让你分辨预计但不存在的文件(pending)、已完成(uploaded)、出问题的(failed)和已移除的(deleted),从而让 UI、清理任务和客服工具正确工作。
original_filename 作为展示用。为存储生成唯一键(通常基于 UUID 的路径)以避免命名冲突、奇怪字符和安全问题。UI 中仍可显示原始文件名,但存储路径保持干净和可预测。
使用像 /files/{file_id} 的稳定应用 URL 作为权限门。Postgres 校验访问权并确认文件为 uploaded 后,返回重定向或短期签名的下载权限,让客户端直接从对象存储下载,从而把你的 API 排除在热路径之外。
通常是出口流量(egress)与重复下载,而不是原始存储占比在账单中占主导。设置文件大小限制和配额,使用保留/生命周期规则,按需用校验和去重,并跟踪使用量计数以便在账单暴涨前触发告警。
把权限和可见性信息存在 Postgres(作为真实来源),默认将存储设为私有。上传前后都要校验类型和大小,端到端使用 HTTPS,确保存储端加密,并记录审计字段,方便后续调查。
从一个元数据表、一个直连存储的上传流和一个下载门(权限核验)开始,然后再加清理孤儿对象和软删除的后台任务。如果想快速在 React/Go/Postgres 栈上验证,Koder.ai (koder.ai) 可以从对话生成端点、模式和后台任务,帮助你避免重复造样板代码。