导出任务如果直接写最终文件名,下载接口可能读到半截内容;先写数据库状态再暴露文件,又会遇到进程在文件落盘和状态提交之间崩溃的窗口。
一个最小可复现方案是把文件和状态分开管:
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 状态门禁。