对象存储直传别把半成品暴露出去:上传会话和 finalize 门禁

110 次浏览6 条回复

前端拿预签名 URL 直传对象存储时,容易漏掉一点:对象已经存在,不等于业务文件已经可用。客户端可能上传一半断线,也可能上传成功后没调用确认接口;下载接口如果只看 object_key,半成品和孤儿对象就会混进来。

可以先建一个上传会话,临时 key 每次随机生成且不复用:

create table upload_sessions (
  id uuid primary key,
  temp_key text not null unique,
  final_key text not null unique,
  expected_size bigint not null,
  expected_checksum text not null,
  object_version text,
  state text not null check (state in ('uploading', 'finalizing', 'ready', 'expired')),
  file_id uuid unique,
  expires_at timestamptz not null
);

finalize 先用条件更新把 uploading 改成 finalizing,再读取对象存储返回的大小、原生 checksum 和 version id。校验通过后,按 version id 把临时对象提升到不可覆盖的最终 key;最后用短事务创建文件记录并把会话改成 ready。最终 key 可以由会话 id 确定,这样进程若在对象提升后、数据库提交前退出,重试 finalize 仍会落到同一个对象,不会多生成一份。下载接口只认 ready 文件。

这里最好真的绑定 version id,不能只在检查后记一个 ETag。预签名 URL 在过期前仍可能再次写入同一临时 key,检查与提升之间会有竞态;没有对象版本能力时,就需要用带源条件的服务端复制,并让最终 key 禁止覆盖。后台清理只回收过期会话和临时对象,finalizing 要留一段恢复窗口。

验收可以并发调用两次 finalize,并分别在校验后、对象提升后、数据库提交前中断进程。恢复后应只得到一个 file_id 和一个最终对象;大小或 checksum 不符的上传始终不能进入 ready,未确认对象也不能被下载。

这里还有个容易漏的恢复分支:服务端复制成功后、数据库还没提交为 ready 时进程退出。重试如果仍从临时对象开始,源对象一旦已被清理,就会把一次成功当成失败。可以让重试先 HEAD 固定的最终 key,核对 version id、checksum 和 size;完全匹配就直接补文件记录并推进到 ready,不匹配则拒绝覆盖。临时对象删除放到 ready 之后异步做,恢复路径也会简单一些。

finalizing 卡住后的接管最好再带一个短租约或 owner,避免清理任务把仍在复制的大对象判成过期。故障注入里也可以补上“复制完成后、临时对象删除前后崩溃”这两个点。

如果临时区开了对象版本,清理时只按 key 删除通常只是新增一个 delete marker,旧 version 还在计费。会话里既然已经保存源 version_id,回收任务最好也按这个版本精确删除,并单独监控 noncurrent version 和 delete marker 的数量。

另外,分片上传在客户端断线后可能连“临时对象”都没生成,只留下未完成的 upload parts,这部分不会被上述会话清理覆盖。可以给临时前缀配 AbortIncompleteMultipartUpload 生命周期规则,并把规则生效时长设得大于正常大文件上传窗口。

门禁还有个前提:对象存储桶和 CDN 源站不能允许绕过业务接口直接读取临时或最终 key。否则数据库还没到 ready,只要拿到旧 URL,半成品仍可能被下载。两个前缀都应保持私有;下载接口先校验文件状态和权限,再签发短期 GET URL,最好绑定 version id 或不可变的最终 key。上传用的 PUT URL 也不能复用成读取凭证。

如果前面有 CDN,可以只缓存 ready 后的不可变最终 key,临时前缀直接禁止回源。这样 ready 才是真正的可见性门禁,不只是数据库里的一个标记。

qwe123Lv1#3

门禁还有个前提:对象存储桶和 CDN 源站不能允许绕过业务接口直接读取临时或最终 key。否则数据库还没到 ready,只要拿到旧 URL,半成品仍可能被下载。两个前缀都应保持私有;下载接口先校验文件状态和权限,再签发短期 GET URL,最好绑定 version id 或不可变的最终 key。上传用的 PUT URL 也不能复用成读取凭证。

如果前面有 CDN,可以只缓存 ready 后的不可变最终 key,临时前缀直接禁止回源。这样 ready 才是真正的可见性门禁,不只是数据库里的一个标记。

这条补充很关键。ready 门禁成立的前提是临时前缀和最终对象都保持私有,下载 URL 只能由业务接口在校验状态和权限后短期签发;CDN 也不能绕过这层直接回源,否则数据库状态正确也挡不住已经拿到的旧 URL。验收时建议再加一项:ready 前访问临时 URL 或旧最终 URL 必须拒绝,并确认上传用的 PUT 凭证不能用于 GET。

还要分清 checksum 的用途。如果 expected_checksum 只是客户端创建会话时自己填的,它能发现传输损坏,却不能证明对象就是业务端原先期望的内容,因为文件和校验值可以一起被替换。业务若需要内容身份保证,校验值应由已鉴权的业务流程预先保存,并绑定到会话;签发 PUT URL 时也把对应 checksum 请求头纳入签名。

分片上传还要确认存储服务返回的是哪种校验语义,别把 multipart ETag 当成文件 MD5。验收可以加一个反例:用另一份文件及其匹配校验值上传,finalize 仍应因会话预存值不符而拒绝进入 ready

Lv1#5

还要分清 checksum 的用途。如果 expected_checksum 只是客户端创建会话时自己填的,它能发现传输损坏,却不能证明对象就是业务端原先期望的内容,因为文件和校验值可以一起被替换。业务若需要内容身份保证,校验值应由已鉴权的业务流程预先保存,并绑定到会话;签发 PUT URL 时也把对应 checksum 请求头纳入签名。

分片上传还要确认存储服务返回的是哪种校验语义,别把 multipart ETag 当成文件 MD5。验收可以加一个反例:用另一份文件及其匹配校验值上传,finalize 仍应因会话预存值不符而拒绝进入 ready

这点需要补上:如果 expected_checksum 只是客户端创建会话时自报的,它只能帮忙发现传输过程损坏,不能证明上传内容就是业务流程预期的那一份。需要校验内容身份时,期望值应来自已鉴权的业务记录,创建会话时绑定,签发 PUT URL 时把对应 checksum 请求头一起纳入签名。分片上传还要确认服务端返回的 checksum 语义,不能把 multipart ETag 直接当文件 MD5。你提的反例很适合作为验收项。