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

用户上传的真正问题
用户上传看起来很简单:接收文件、保存它、稍后显示。对少量用户和小文件这确实行得通。但当流量增长、文件变大时,痛点会出现在与“上传按钮”本身无关的地方。
下载变慢,因为你的应用服务器或数据库在做繁重工作。备份变大且耗时,恢复也会更慢,恰好在你最需要时变得困难。存储费用和带宽(出口)费用可能因为文件被低效提供、重复存储或从未清理而飙升。
你通常需要的是平凡而可靠的特性:在高负载下依然快速传输、清晰的访问规则、简单的运维(备份、恢复、清理),以及随着使用增长仍能保持可预测的成本。
要做到这点,请把两个常被混在一起的概念分离开来:
元数据(Metadata) 是关于文件的小信息:谁拥有它、名称、大小、类型、上传时间以及存放位置。这类信息应该放在你的数据库(如 Postgres)里,因为你需要对它们查询、过滤并与用户、项目和权限关联。
文件字节(File bytes) 是文件的实际内容(照片、PDF、视频)。把字节放在数据库的 Blob 里可以工作,但会增加数据库负担、备份体积,并使性能更难以预测。把字节放到对象存储能让数据库专注于它擅长的查询和一致性,而文件由为此设计的系统快速且廉价地提供。
简明对比:对象存储 vs 数据库中的 blobs
当有人说“把上传存到数据库”时,通常指的是数据库的 blobs:要么是行内的 BYTEA(行中原始字节),要么是 Postgres 的“大对象”特性(把大值分开存放)。这两种方式都可行,但都会让数据库负责提供文件字节。
对象存储则是另一种思路:文件作为对象存放在 bucket 中,用一个键来寻址(比如 uploads/2026/01/file.pdf)。对象存储为大文件设计,存储成本低,支持流式下载,并能很好地处理大量并发读取,而不会占用你的数据库连接。
Postgres 在查询、约束和事务方面表现出色。它适合存放谁拥有文件、文件是什么、何时上传、是否可下载等元数据。这些元数据体积小、易于索引并且易于保持一致性。
一个实用的经验法则:
- 使用 Postgres 存放文件元数据、权限和关系。
- 当文件可能超过几 MB 或下载频繁时,把字节放到对象存储。
- 只有在资产非常小且必须事务性地附着到某条记录(例如小图标),并且你确定数据库增长会保持适度时,才考虑 DB blobs。
一个快速的检查:如果把文件字节放进数据库会让备份、复制和迁移变得痛苦,那就把字节放到外部存储。
一种易于管理的简单架构
大多数团队最终采用的方案很直接:字节放对象存储,文件记录(谁拥有、是什么、存放在哪里)放 Postgres。你的 API 负责协调和授权,但不代理大型的上传和下载数据流。
这样你有三项清晰的职责:
- Postgres 保存每个文件的一小行:稳定的
file_id、owner、大小、内容类型与对象指针。 - 对象存储 保存实际字节,针对大文件和低成本优化。
- 你的 API 创建并授权文件记录,分发短期的存储权限。
那个稳定的 file_id 成为所有引用的主键:引用附件的评论、指向 PDF 的发票、审计日志和支持工具。用户可能会重命名文件,你可能会在桶间移动对象,但 file_id 保持不变。
尽量把存储对象视为不可变的。如果用户替换文档,创建一个新对象(通常也创建新行或新版本行),而不是就地覆盖字节。这样能简化缓存,避免“旧链接返回新文件”的惊讶,并提供清晰的回滚方案。
尽早决定隐私策略:默认私有,仅在例外情况下公开。一个好的规则是:数据库是谁能访问文件的真实来源;对象存储执行由你的 API 授权的短期权限。
在 Postgres 中建模文件元数据
清晰分工后,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 负责协调而不是传递字节来保持上传快速。你的数据库保持响应性,对带宽的消耗交给对象存储。
先在任何数据发送前创建上传记录。你的 API 返回一个 upload_id、文件将存放的位置(object_key)和一个短期的上传权限。
常见流程:
- 客户端请求上传:你的 API 创建一条
pending行,同时记录期望大小和内容类型。 - API 返回预签名 URL:对于大文件,生成一个预签名的上传 URL。对于很小的文件(如头像),如果想简化客户端代码,也可以通过后端代理上传。
- 客户端直接上传到对象存储:浏览器或移动端将字节发送到存储,而不是通过你的 API。
- 完成上传:客户端用
upload_id和存储返回的字段(如 ETag)调用你的 API。服务器验证大小、校验和(若使用),然后把行标记为uploaded。 - 安全失败处理:若校验不通过,标记为
failed,并可选地删除对象。
重试和重复是常态。让 finalize 调用具有幂等性:若同一 upload_id 重复 finalize,应返回成功而不改变状态。
为减少重试与重传造成的重复,存储校验和,并把“同一拥有者 + 相同校验和 + 相同大小”视为相同文件。
下载流程逐步说明(快速且利于缓存)
良好的下载流程从应用中一个稳定的 URL 开始,即便字节实际存放在别处。想想:/files/{file_id}。你的 API 用 file_id 在 Postgres 查询元数据、校验权限,然后决定如何交付文件。
- 客户端请求你的稳定 URL(
file_id)。 - API 核实用户访问权限且文件为
uploaded。 - API 返回重定向到对象存储(通常最好),或为私有文件返回一个短期的预签名 GET URL。
- 客户端直接从对象存储下载,保持你的 API 和应用服务器不在热路径上。
对于公共或半公共文件,重定向简单且快速。对于私有文件,预签名 GET URL 在保持存储私有的同时仍允许浏览器直接下载。
对于视频和大文件,确保对象存储(以及任何代理层)支持范围请求(Range 头)。这能让用户跳转播放和断点续传。如果你把字节通过 API 中转,范围请求往往会中断或变得昂贵。
缓存是速度的来源。你的稳定 /files/{file_id} 端点通常应不可缓存(它是权限门),而对象存储的响应可以根据内容设置缓存策略。如果文件不可变(新上传 = 新 key),可以设置长缓存时间。若覆盖文件,则保持短缓存或使用版本化键。
当你有大量全球用户或大文件时,CDN 会有帮助。如果用户量小或用户主要集中在一个区域,仅使用对象存储通常够用且更便宜作为起点。
让成本随时间可预测
意外账单通常来自下载与 churn,而不是原地存放的字节。
关注四个会影响成本的驱动因素:存储总量、读写频次(请求数)、流出到外部的流量(egress),以及是否使用 CDN 来减少源站重复下载。一个小文件被下载 10,000 次,可能比一个无人触碰的大文件更昂贵。
控制成本的措施:
- 限制单次上传大小并根据套餐设置每用户配额。
- 对上传和下载做速率限制以防滥用或意外循环。
- 使用生命周期规则将旧文件转到更便宜的存储层,或在不再需要时删除它们。
- 用校验和去重,避免重试或重复上传产生多份副本。
- 在 Postgres 中存储使用计数,以便计费和告警基于事实,而不是估算。
生命周期规则通常是最容易的收益点。例如:保持原始照片在 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 是一个实用的基线。
若有数据驻留要求,请有意识地选择存储区域,并与运行计算的区域保持一致。
常见会导致变慢和事故的错误
大多数上传问题并不是单纯的速度问题,而是那些早期看似方便但在真实流量、真实数据和真实客服场景下变得痛苦的设计选择。
- 把文件字节存在 Postgres:对小型应用可行,但会让备份膨胀、恢复耗时、常规维护变得危险。单个大型表会影响 vacuum、复制乃至简单查询。
- 使用用户提供的文件名作为对象键:会发生冲突(两个用户都上传
invoice.pdf),奇怪字符也会引发边界问题。把文件名作为展示数据,用唯一键(如 UUID)作为存储键。 - 在 finalize 阶段跳过校验:即便客户端做了校验,服务端也需要在标记完成时再次校验大小、类型和所属关系。
- 意外把对象设置为公开并从未轮换访问权限:一次“临时”的公开策略或长期有效的 URL 常常变成永久。优先使用短期下载链接,并具备快速撤销访问的机制。
- 只删除一侧(元数据或字节):仅删除 Postgres 行但保留对象会导致费用泄漏。删除对象但保留元数据会导致下载失败并增加客服工作量。
一个具体例子:如果用户更换头像三次,除非安排清理,你可能会永远为三个旧对象付费。一个安全模式是:在 Postgres 做软删除,然后由后台任务删除对象并记录结果。
上线前的快速检查清单
大多数问题在第一个大文件到来、用户在上传中刷新页面或有人删除账号但字节仍留在存储时显现。
确保你的 Postgres 表记录文件大小、校验和(用于完整性验证)和清晰的状态路径(例如:pending、uploaded、failed、deleted)。
最后的检查项:
- 确认重试安全:重复尝试不得产生额外对象或出现缺字节的 “uploaded” 行。
- 让上传具备可续传或至少可重启能力(超时和移动网络会发生)。
- 验证下载支持范围请求,以便大文件能迅速开始并在中断后续传。
- 定义端到端的删除:元数据打 tombstone、删除对象字节,并在后台任务失败时处理延迟清理。
- 增加基础监控:上传/下载错误率、存储增长和突发出口流量。
一个具体测试:上传一个 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”。在真实使用迫使你改变之前,保持简单。
常见问题
Should I store uploaded files in Postgres or in object storage?
使用 Postgres 存放你需要查询和保护的元数据(如 owner、permissions、state、checksum、 pointer 等)。将文件字节放到对象存储,这样下载和大文件传输不会占用数据库连接或膨胀备份。
What’s the main downside of storing file bytes in Postgres blobs?
这会让数据库承担文件服务器的职责。表会变大、备份与恢复变慢、复制负载增加,而且在大量用户同时下载时性能变得难以预测。
What’s the simplest upload architecture that scales without getting complicated?
是的。保持一个稳定的 file_id,把元数据放在 Postgres,把字节放在通过 bucket 和 object_key 定位的对象存储。你的 API 负责授权并发放短期的上传/下载权限,而不是代理数据流量。
How should the upload flow work if I don’t want my API to handle file bytes?
先创建一个 pending 行,生成唯一的 object_key,然后让客户端用短期权限直接上传到存储。上传完成后,客户端调用 finalize 接口,由服务端校验大小和校验和(如果使用),然后把行标记为 uploaded。
Why do I need an upload “state” like pending/uploaded/failed/deleted?
因为真实的上传会失败和重试。状态字段让你分辨预计但不存在的文件(pending)、已完成(uploaded)、出问题的(failed)和已移除的(deleted),从而让 UI、清理任务和客服工具正确工作。
Can I use the user’s filename as the object key in storage?
original_filename 作为展示用。为存储生成唯一键(通常基于 UUID 的路径)以避免命名冲突、奇怪字符和安全问题。UI 中仍可显示原始文件名,但存储路径保持干净和可预测。
What’s the recommended download pattern for private files?
使用像 /files/{file_id} 的稳定应用 URL 作为权限门。Postgres 校验访问权并确认文件为 uploaded 后,返回重定向或短期签名的下载权限,让客户端直接从对象存储下载,从而把你的 API 排除在热路径之外。
What usually causes surprise costs with user uploads?
通常是出口流量(egress)与重复下载,而不是原始存储占比在账单中占主导。设置文件大小限制和配额,使用保留/生命周期规则,按需用校验和去重,并跟踪使用量计数以便在账单暴涨前触发告警。
What security checks are worth doing for uploads on day one?
把权限和可见性信息存在 Postgres(作为真实来源),默认将存储设为私有。上传前后都要校验类型和大小,端到端使用 HTTPS,确保存储端加密,并记录审计字段,方便后续调查。
How can I implement this quickly without overbuilding?
从一个元数据表、一个直连存储的上传流和一个下载门(权限核验)开始,然后再加清理孤儿对象和软删除的后台任务。如果想快速在 React/Go/Postgres 栈上验证,Koder.ai (koder.ai) 可以从对话生成端点、模式和后台任务,帮助你避免重复造样板代码。