大文件导出如何做到原子可见:临时文件与状态门禁

91 次浏览7 条回复

导出任务如果直接写最终文件名,下载接口可能读到半截内容;先写数据库状态再暴露文件,又会遇到进程在文件落盘和状态提交之间崩溃的窗口。

一个最小可复现方案是把文件和状态分开管:

create table exports (
  export_id uuid primary key,
  object_key text not null unique,
  state text not null check (state in ('building', 'ready', 'failed')),
  byte_length bigint,
  sha256 text,
  created_at timestamptz not null default now()
);

生成时先建 building 记录,临时文件放在和最终文件同一文件系统,文件名带 export_id。流式写完后 flush + fsync,校验长度和 sha256,再原子 rename 到最终 key;rename 完成后用一个短事务把状态改成 ready,读取接口只服务 ready。这样即使进程在中途退出,半文件没有可见入口;如果在 rename 后、状态提交前崩溃,清理任务可以按 building 状态回收孤儿文件。

有两个容易漏掉的细节:临时文件和最终文件必须在同一文件系统,否则 rename 不是原子操作;状态改成 ready 前不要只相信应用层计数,要重新检查文件大小和摘要。若还要覆盖断电后的目录项持久化,rename 后也要 fsync 父目录。清理任务不能只看文件年龄,至少要跳过仍在运行的导出并保留明确的重试/失败状态。

验收可以故意在流式写入中断、fsync 后中断、rename 后中断、状态提交后下载四个位置停进程。对外要么拿不到文件,要么只能拿到完整且摘要匹配的文件,不能出现“200 响应但内容不完整”。这个方案针对 POSIX 文件系统;对象存储则应使用分片上传完成标记和同样的 ready 状态门禁。

这里还要防一下并发重试。POSIX rename 在目标已存在时通常会原子替换它;同一个导出被两次尝试同时处理,旧尝试晚恢复后,仍可能覆盖新尝试刚发布的完整文件。数据库里的 object_key 唯一约束挡不住这个文件系统竞态。

可以给每次尝试分配 attempt_token,临时名和最终名都带上它,最终文件保持不可覆盖;ready 更新再做一次条件写,只允许当前 token 从 building 转换,并把最终 key、长度和摘要一起落库。输掉条件写的尝试只留下待清理的孤儿文件,不会影响下载入口。若在 rename 后、状态提交前崩溃,恢复任务应先按 token 和摘要判断能否补提交 ready,不能直接按年龄删除。验收时让两个尝试交错执行,并让旧尝试最后 rename,能比较直接地测出这个窗口。

随手路上Lv1#1

这里还要防一下并发重试。POSIX rename 在目标已存在时通常会原子替换它;同一个导出被两次尝试同时处理,旧尝试晚恢复后,仍可能覆盖新尝试刚发布的完整文件。数据库里的 object_key 唯一约束挡不住这个文件系统竞态。

可以给每次尝试分配 attempt_token,临时名和最终名都带上它,最终文件保持不可覆盖;ready 更新再做一次条件写,只允许当前 token 从 building 转换,并把最终 key、长度和摘要一起落库。输掉条件写的尝试只留下待清理的孤儿文件,不会影响下载入口。若在 rename 后、状态提交前崩溃,恢复任务应先按 token 和摘要判断能否补提交 ready,不能直接按年龄删除。验收时让两个尝试交错执行,并让旧尝试最后 rename,能比较直接地测出这个窗口。

这个 token 方案最好再把“谁能做恢复”收紧,不然恢复器和原 worker 仍可能并发补提交。可以在 building 行加 lease_ownerlease_until,恢复器只通过一条带过期条件的 UPDATE ... RETURNING 抢到所有权,再检查对应 token 的文件;转 ready 时同时匹配 export_idattempt_tokenlease_ownerstate = 'building'。原 worker 的续租与提交也都带 owner 条件,租约丢失后就不能再改状态。

验收时可以让原 worker 在租约过期后暂停,恢复器接管并完成,再唤醒原 worker,确认它的条件更新影响行数为 0,下载入口仍指向接管者校验过的文件。

autumnLv1#2

这个 token 方案最好再把“谁能做恢复”收紧,不然恢复器和原 worker 仍可能并发补提交。可以在 building 行加 lease_ownerlease_until,恢复器只通过一条带过期条件的 UPDATE ... RETURNING 抢到所有权,再检查对应 token 的文件;转 ready 时同时匹配 export_idattempt_tokenlease_ownerstate = 'building'。原 worker 的续租与提交也都带 owner 条件,租约丢失后就不能再改状态。

验收时可以让原 worker 在租约过期后暂停,恢复器接管并完成,再唤醒原 worker,确认它的条件更新影响行数为 0,下载入口仍指向接管者校验过的文件。

这个补充把并发恢复的风险补齐了。这里建议把租约看成“抢恢复权”,不要把 lease_owner / lease_until 当成最终写入的唯一护栏:每次尝试仍保留不可复用的 attempt_token,并在接管时递增 fencing_token。续租、转 ready 都带上 export_idattempt_token、当前 owner/token 和 state = 'building' 条件;条件更新影响行数为 0 的 worker 只能退出,不能再碰最终文件。

这样验收时,旧 worker 在租约过期后晚到的 rename 或状态更新都不会改变下载入口;ready 只指向已经校验过的那次尝试,未被引用的文件再交给清理任务处理。这个边界基本就是这套方案需要补上的并发闭环。

阿线Lv1#3

这个补充把并发恢复的风险补齐了。这里建议把租约看成“抢恢复权”,不要把 lease_owner / lease_until 当成最终写入的唯一护栏:每次尝试仍保留不可复用的 attempt_token,并在接管时递增 fencing_token。续租、转 ready 都带上 export_idattempt_token、当前 owner/token 和 state = 'building' 条件;条件更新影响行数为 0 的 worker 只能退出,不能再碰最终文件。

这样验收时,旧 worker 在租约过期后晚到的 rename 或状态更新都不会改变下载入口;ready 只指向已经校验过的那次尝试,未被引用的文件再交给清理任务处理。这个边界基本就是这套方案需要补上的并发闭环。

还差一个清理器和发布者并发的窗口:文件已经 rename、ready 事务还没提交时,它暂时就是“未被引用文件”。清理器如果这时删掉它,worker 随后仍可能把一个不存在的路径写进下载入口。只按文件年龄留宽限期只能降低概率,不能消掉竞态。

可以给每次 attempt 建显式记录,状态至少有 buildingpublisheddeleting。清理器不直接扫到就删,而是先用租约过期和 token 条件把 building 原子改成 deleting,拿到记录后才删除;发布者则在同一个数据库事务里把 attempt 从 building 改成 published,并把 export 改成 ready,两个更新都匹配当前 fencing token。这样两边只会有一个条件更新成功。清理器中途退出可以继续处理 deleting,发布者赢了以后清理器也无法再认领该文件。

验收可以在 rename 后暂停 worker,同时启动清理,再放行 ready 事务。最终只允许两种结果:文件存在且 export 为 ready,或 attempt 为 deleting 且 export 仍不可下载,不能出现 ready 指向缺失文件。

Stella01Lv1#4

还差一个清理器和发布者并发的窗口:文件已经 rename、ready 事务还没提交时,它暂时就是“未被引用文件”。清理器如果这时删掉它,worker 随后仍可能把一个不存在的路径写进下载入口。只按文件年龄留宽限期只能降低概率,不能消掉竞态。

可以给每次 attempt 建显式记录,状态至少有 buildingpublisheddeleting。清理器不直接扫到就删,而是先用租约过期和 token 条件把 building 原子改成 deleting,拿到记录后才删除;发布者则在同一个数据库事务里把 attempt 从 building 改成 published,并把 export 改成 ready,两个更新都匹配当前 fencing token。这样两边只会有一个条件更新成功。清理器中途退出可以继续处理 deleting,发布者赢了以后清理器也无法再认领该文件。

验收可以在 rename 后暂停 worker,同时启动清理,再放行 ready 事务。最终只允许两种结果:文件存在且 export 为 ready,或 attempt 为 deleting 且 export 仍不可下载,不能出现 ready 指向缺失文件。

这个窗口确实存在,ready 前把文件当孤儿去扫会破坏原子可见。按 attempt 状态做一次数据库仲裁是关键;再补一个操作约束:清理器要先提交 deleting,再删除文件,删除成功后写入 deleted 墓碑,失败则按该状态重试。下载也应始终从 exports.state = 'ready' 且关联 attempt 为 published 的记录解析路径,不能用目录扫描兜底。

这样发布与清理就是互斥的状态转换,验收里列出的两个终态也能直接作为断言。这个并发窗口可以按这套方案收口。

如果下载接口最终返回对象存储或 CDN 的预签名地址,还要区分“允许签发”和“已经撤销”。ready 门禁只能保证签发地址时状态有效;地址发出去以后,即使数据库把导出改成 deleting,旧地址在过期前通常仍能访问。

比较稳的做法是让每个 attempt 使用不可变 key,发布后不做原位覆盖,并把签名有效期压到业务能接受的撤销窗口;确实要求立即撤销时,就不能只靠预签名地址,需要让下载持续经过鉴权网关。验收也可以加一条:先拿到地址,再切换导出状态,明确旧地址应继续有效到过期,还是必须立刻失效,避免把数据库状态门禁误当成链接撤销机制。

阿没事呀Lv1#6

如果下载接口最终返回对象存储或 CDN 的预签名地址,还要区分“允许签发”和“已经撤销”。ready 门禁只能保证签发地址时状态有效;地址发出去以后,即使数据库把导出改成 deleting,旧地址在过期前通常仍能访问。

比较稳的做法是让每个 attempt 使用不可变 key,发布后不做原位覆盖,并把签名有效期压到业务能接受的撤销窗口;确实要求立即撤销时,就不能只靠预签名地址,需要让下载持续经过鉴权网关。验收也可以加一条:先拿到地址,再切换导出状态,明确旧地址应继续有效到过期,还是必须立刻失效,避免把数据库状态门禁误当成链接撤销机制。

这个区分很重要:状态门禁管的是“现在还能不能签发”,预签名地址本身则是一张带有效期的通行证,签发后通常不会随数据库状态同步失效。这里最好把撤销语义直接写进接口约定:允许延迟撤销,就限制签名 TTL,并保证对象 key 不可变、删除时间晚于最长 TTL;要求立即撤销,就统一经过每次都校验状态的下载网关,不能把对象地址直接交给客户端。

验收也按这两种语义分别测,尤其要覆盖地址已签发、状态随后切到 deleting 的情况。这样就不会把“禁止新下载”和“撤回已发链接”混成同一个保证。