后端系统工程 35:文件上传与异步处理
一次文件上传跨越两个互不共享事务的存储:字节落在文件系统或对象存储里,文件名、归属、处理进度落在数据库里。两边各自可以做到原子,合起来却没有一次提交能同时覆盖它们。于是有三种残局必须单独处理:字节写完了、元数据没提交,字节成了没人认领的孤儿;后台处理任务因为租约过期被执行了两次,发布动作也跟着做了两次;处理还没结束,读取接口已经把文件交了出去。示例实现基于 Java 21、PostgreSQL 16 和一个本地目录替身,覆盖从分块上传到发布读取的完整链路,三种残局分别用崩溃、重复执行和提前读取的实验构造出来。
需求与范围
操作有七个:创建上传会话、上传分块、完成上传、提交元数据、中止会话、读取文件,以及后台的处理与回收。客户端在创建会话时声明分块数、总字节数和整体 SHA-256,分块可以乱序、可以重传。
业务不变量:
- F1 字节不丢、不错。任何进入内容地址
blobs/sha256/<hex>的文件,其内容的 SHA-256 等于<hex>;任何被file_object引用的 blob 在回收后仍然存在。 - F2 孤儿可回收。字节已经写入但元数据从未提交的 blob,在会话过期且超过宽限期之后被删除;暂存分块在会话结束后被删除。
- F3 发布至多一次。同一文件的处理任务无论被执行几次,
file_object从 PROCESSING 到 PUBLISHED 的转换只发生一次,发布事件只有一条。 - F4 未完成不可读。读取接口只返回 PUBLISHED 状态的文件;上传中、处理中的文件一律拒绝,即使字节已经在磁盘上。
明确排除:HTTP 层与断点续传协议头、鉴权与按用户隔离、Range 读取、病毒扫描、多副本与跨机房复制、派生文件的版本迁移。
教学规模假设
以下数字是教学假设,用于选参数,不来自测量。单个文件上限 1 GiB,分块 64 KiB 到 64 MiB,分块数上限 10000(与 S3 multipart 的分块号范围相同,便于对照)。会话 TTL 取 1 小时,回收宽限期取 10 分钟;宽限期必须大于一次 putPart 或 complete 从写临时文件到改名的最长耗时,因为回收删除临时文件只看修改时间、不取锁;已改名的 blob 由引用查询和咨询锁保护,不依赖宽限期。处理任务租约取 30 秒,迟到的执行者靠尝试号识别。实验输入是 150000 字节的伪随机数据,按 65536 字节切成 3 块。
接口与错误语义
表 35-1:接口与拒绝语义
| 操作 | 成功结果 | 拒绝 |
|---|---|---|
init(owner, partCount, size, sha256) |
会话号,状态 INIT,expires_at |
参数违反约束 |
putPart(session, n, body, sha256) |
STORED 或 DUPLICATE | CHECKSUM_MISMATCH、PART_CONFLICT、PART_OUT_OF_RANGE、SESSION_CLOSED、SESSION_EXPIRED |
complete(session) |
整体摘要;重复调用返回同一摘要 | MISSING_PARTS、SIZE_MISMATCH、PART_CORRUPT、DIGEST_MISMATCH(会话转 ABORTED)、SESSION_CLOSED、SESSION_EXPIRED |
commitMetadata(session, name, type) |
file_id,文件 PROCESSING;重复调用返回同一 file_id |
METADATA_INVALID、SESSION_NOT_COMPLETED、SESSION_EXPIRED |
abort(session) |
会话 ABORTED | SESSION_CLOSED |
read(fileId) |
文件字节(返回前复核摘要) | NOT_FOUND、NOT_READY、BLOB_CORRUPT |
分块摘要和整体摘要分开声明,对应 RFC 9530 的两种字段:Content-Digest 针对一次消息携带的内容,Repr-Digest 针对完整的表示。tus 1.0 的 Checksum 扩展规定摘要不符时返回 460 并丢弃该块,偏移不变;这里的 CHECKSUM_MISMATCH 语义相同,分块既不写盘也不登记。同一序号重传不同内容时,S3 的选择是后者覆盖前者,本实现选择报 PART_CONFLICT。理由是整体摘要已在会话创建时声明,覆盖只会把冲突推迟到 complete 才暴露,而且无法回答“到底哪一份是客户端想要的”。
数据模型与目录布局
数据库是状态的唯一权威,目录只存字节。暂存分块放在 staging/<session>/part-NNNNN,组装中的文件放在 tmp/,完整文件按内容放在 blobs/sha256/<hex>,派生文件放在 derived/<hex>.gz。
1 | |
file_object.session_id 的唯一约束让重复提交元数据最多产生一行文件记录。publish_event.file_id 的唯一约束是兜底,不是发布去重的主检查;主检查在发布事务里的条件更新,后文 P2 的输出会显示迟到者是被条件更新拒绝的,没有走到唯一约束。
状态机
图 35-1:上传会话与文件的状态机(括号内为前置检查,虚线目标为拒绝结果)
stateDiagram-v2
[*] --> INIT: init
INIT --> UPLOADING: putPart(摘要相符,序号在范围内)
UPLOADING --> UPLOADING: putPart(新序号或同内容重传)
UPLOADING --> COMPLETED: complete(分块齐全,整体摘要相符,blob 已改名并 fsync)
UPLOADING --> ABORTED: complete(整体摘要不符)
INIT --> ABORTED: abort
UPLOADING --> ABORTED: abort
COMPLETED --> ABORTED: abort
COMPLETED --> COMMITTED: commitMetadata(未过期)
INIT --> EXPIRED: 回收(expires_at 已过)
UPLOADING --> EXPIRED: 回收(expires_at 已过)
COMPLETED --> EXPIRED: 回收(expires_at 已过)
COMMITTED --> PROCESSING: 同一事务插入 file_object 与 process_job
PROCESSING --> PUBLISHED: publish(任务 RUNNING、尝试号与领取者一致、文件仍为 PROCESSING)
PUBLISHED --> [*]
ABORTED、EXPIRED 是会话的终态,之后的 putPart、complete 返回 SESSION_CLOSED,commitMetadata 返回 SESSION_NOT_COMPLETED。COMPLETED 是专门为“字节已落定、元数据未提交”留出的状态:此时 blob 受会话引用保护,客户端可以在 TTL 内反复重试提交;TTL 一过,会话转 EXPIRED,blob 失去最后一个引用。
正常路径
图 35-2:正常路径时序(实线为同步调用,标出每一步的确认位置)
sequenceDiagram
participant C as 客户端
participant S as UploadService
participant D as PostgreSQL
participant F as 本地目录
participant W as Worker
C->>S: init(3 块, 150000 B, sha)
S->>D: INSERT upload_session (INIT)
loop 分块 3、1、2
C->>S: putPart(n, body, sha_n)
S->>F: 写 tmp 并 fsync
S->>D: 锁会话行, INSERT upload_part ON CONFLICT DO NOTHING
S->>F: 改名到 staging, fsync 目录
S->>D: COMMIT(确认点:STORED)
end
C->>S: complete
S->>F: 按序拼接到 tmp, 逐块复核, fsync, 比对整体摘要
S->>D: blob 咨询锁
S->>F: ATOMIC_MOVE 到 blobs/sha256/hex, fsync 目录
S->>D: 会话 COMPLETED, COMMIT(确认点:返回摘要)
C->>S: commitMetadata(name)
S->>D: INSERT file_object(PROCESSING) + process_job(READY), 会话 COMMITTED
W->>D: 领取任务, attempt = 1, 租约 30 s
W->>F: gzip 到 tmp, fsync, 改名到 derived/hex.gz
W->>D: 条件更新任务与文件, INSERT publish_event, COMMIT(确认点:PUBLISHED)
C->>S: read(fileId)
S->>D: 查 state = PUBLISHED
S->>F: 读 blob 并复核摘要
U1 用例按这条路径执行,分块按 3、1、2 的顺序上传,完成后读取和解压派生文件都与原文一致:
1 | |
方案比较
字节和元数据谁先落定,决定了残局是什么形状。元数据先行(先插入文件记录,再写字节)会留下指向不存在字节的记录,读取方必须处理“记录在、内容缺”的情况,而且记录的存在本身就容易被当作可读的信号。字节先行留下的残局是孤儿 blob,它不被任何记录引用,只占空间,不会被读到;回收它只需要判断“没人引用且足够旧”。本实现选择字节先行,并把孤儿判定交给数据库。
组装位置有两种做法。直接把分块依次写进最终路径,写到一半进程死掉,最终路径上就留下一个半截文件;在内容寻址的目录里,这个半截文件的名字声称它是某个摘要,实际内容却对不上。先写临时文件、fsync、比对摘要,再改名到最终路径,则最终路径要么不存在,要么是完整文件。rename(2) 手册写明目标已存在时会被原子替换,其他进程访问目标路径不会发现它缺失;POSIX.1-2024 要求改名期间名为 new 的目录项始终可见,指向改名前 new 或 old 的文件。Java 的 Files.move(..., ATOMIC_MOVE) 在无法原子移动时抛 AtomicMoveNotSupportedException,例如跨 FileStore;目标已存在时是替换还是抛异常由实现决定。因此临时目录和 blob 目录必须在同一文件系统上,跨挂载点的 rename 会返回 EXDEV。
发布去重有三种候选。只靠唯一约束,迟到者要等插入事件时才失败:若文件更新与事件插入不在同一事务(如 NC1 逐句自动提交),文件状态会先被改写;即使在同一事务,事件表之外的副作用也挡不住,失败还表现为 SQL 异常而不是明确的拒绝。只靠租约,持有过期租约的进程并不知道自己已过期,SIGSTOP 恢复后照样提交。本实现在发布事务里同时检查任务行(RUNNING、尝试号相等、领取者是自己)和文件行(仍为 PROCESSING),尝试号在每次领取时加一,相当于一枚只增不减的令牌。PostgreSQL 16 文档 13.2.1 说明 Read Committed 下 UPDATE 遇到被并发更新的行时会等待,对方提交后在新版本上重新求值 WHERE,所以两个发布同时到达时,后到者看到的是已经 DONE、尝试号已经变化的行,条件不再满足。
与 S3 multipart 对照:S3 在服务端按分块号升序拼接,complete 可能持续数分钟,先回 200 OK 再用空白保活,错误可能嵌在 200 的响应体里,失败时调用方需要准备重试;abort 之后仍在进行的分块上传可能成功,文档建议必要时多次 abort 并用 ListParts 确认为空;生命周期规则 AbortIncompleteMultipartUpload 按“启动后天数”中止未完成的上传。本实现的会话 TTL 对应生命周期规则,complete 设计成可重复调用对应“准备重试”,abort 与分块登记共用会话行锁,所以不存在“中止之后又登记进来一个分块”的窗口。tus 1.0 的 Expiration 扩展允许服务端删除过期的未完成上传,并建议对已删除的上传返回 404 或 410;本实现过期后返回 SESSION_CLOSED,并保留会话行用于审计。
关键实现
分块登记在会话行锁内完成,同一序号的并发上传因此被串行化。插入冲突时比较已登记摘要,相同为 DUPLICATE,不同为 PART_CONFLICT;只有插入成功的那一次才把临时文件改名进暂存目录:
1 | |
改名发生在提交之前。若提交失败,暂存目录里会多一个没有登记的分块文件,但后续成功的登记会用新的改名覆盖它(依赖 Linux/OpenJDK 下 ATOMIC_MOVE 替换已存在的目标,Javadoc 说明这一行为由实现决定,见限制一节),complete 读取时又会逐块对照登记摘要,这个残留文件不会进入组装结果。
F1 与 F2 能否同时成立,取决于 complete 的后半段。整体摘要比对通过之后,先取 blob 级咨询锁,再改名、fsync 目录、更新会话:
1 | |
去重分支复核了已有文件的摘要,没有因为路径存在就直接认定内容正确。NC4 会展示只看路径是否存在的后果。fsync(2) 手册指出对文件 fsync 不保证包含它的目录项落盘,所以改名之后还要对目录 fsync,Store.fsyncDir 用只读方式打开目录并调用 FileChannel.force(true)。两个 hook 是崩溃注入点,生产代码里为空操作。
回收的引用判定只有一条查询,三类引用缺一不可:
1 | |
第三类引用来自会话创建时声明的整体摘要,它覆盖的正是“已改名、未提交”这一段:进程在这里崩溃,数据库里的会话仍是 UPLOADING,但它的 declared_sha256 已经指向磁盘上那个 blob。回收对每个候选 blob 先取同一把咨询锁,再查引用、看修改时间、删除,最后提交。complete 的改名与会话更新也在这把锁内,两者因此串行:要么回收先删、complete 后改名,留下的是新文件;要么 complete 先提交,回收看到 COMPLETED 引用。
发布是两条条件更新,任一返回 0 行就整体回滚:
1 | |
派生文件路径只由源 blob 摘要决定,变换是 gzip,同一 JDK 下同一输入得到同一字节。两个执行者即使都写了派生文件,写的也是同一路径上的同一内容,派生文件本身不需要去重;需要去重的只有发布这一个状态转换。
故障反例
表 35-2:每项保证的成立条件、观察位置与失败边界
| 保证 | 成立条件 | 观察位置 | 失败边界 |
|---|---|---|---|
| F1 内容地址名实相符 | 组装写临时文件,摘要比对后才改名;临时目录与 blob 目录同一文件系统 | complete 的摘要比对;NC4、P1c 断言 blob 目录内容 | 跨挂载点改名(EXDEV);绕过 complete 直接写 blob 目录 |
| F1 被引用 blob 不被删 | 回收与 complete 共用 blob 咨询锁,引用查询覆盖三类引用 | REFERENCED 查询;U5、M1、P1a、NC3 |
有不经过数据库登记就写 blob 的写入方;回收只看时间(NC3) |
| F2 孤儿可回收 | 会话有 TTL,回收定期运行,宽限期大于写临时文件到改名的最长耗时 | upload_session.state = 'EXPIRED';M2、P1a、P1d 的 blobsRemoved |
回收进程长期不运行;宽限期小于写临时文件到改名的耗时(临时文件被误删) |
| F3 发布至多一次 | 每次领取尝试号加一;发布同时检查任务行与文件行 | publish_event 行数、published_attempt;P2、NC1 |
发布不带条件(NC1);发布之外的外部副作用不受此检查约束 |
| F4 未完成不可读 | 唯一读取入口检查 state = 'PUBLISHED' |
read 的状态判断;P3、NC2 |
存在按内容地址直接取字节的旁路(NC2) |
第一个反例针对 F2,构造“组装之后、提交之前”的崩溃。子进程执行 complete,在 after-rename 注入点打印标记后停住,父进程对它发送 kill -9。事件序列:
- 子进程已把完整文件改名进
blobs/sha256/<hex>并 fsync 了目录,持有会话行锁和 blob 咨询锁,事务未提交。 - SIGKILL 终止子进程,退出码 137;PostgreSQL 后端读到连接断开,回滚事务并释放两把锁。
- 数据库里会话仍为 UPLOADING,没有
file_object;磁盘上 blob 完整。 - 宽限期之后、TTL 之内回收:blob 被第三类引用保护,保留。
- TTL 加宽限期之后回收:会话转 EXPIRED,blob、暂存分块被删除。
1 | |
同一崩溃点还有两条出路。P1b 中客户端在 TTL 内重试 complete,走去重分支,复核已有 blob 的摘要后删掉自己的临时文件,会话转 COMPLETED。P1d 中客户端崩溃后选择中止,blob 失去所有引用,但修改时间还在宽限期内,立即回收时保留,宽限期后才删。P1c 把注入点前移到 after-fsync,崩溃后 blob 目录为空,只留下一个组装中的临时文件,宽限期后被回收;会话在 TTL 内仍为 UPLOADING,重试 complete 成功。
元数据提交失败本身也是一种残局。M1 用非法文件名让提交被拒绝,会话保持 COMPLETED,宽限期后的回收不删 blob,改正文件名后重试成功。M2 在插入 file_object 之后、提交之前注入异常,事务回滚,客户端不再重试;TTL 之内回收保留 blob,TTL 之后会话过期、blob 被删除,此后再提交得到 SESSION_NOT_COMPLETED。
负向对照 NC3 去掉引用检查,只按宽限期回收。在 TTL 之内、宽限期之后运行一次,一个 PROCESSING 文件的 blob 和一个 COMPLETED 会话的 blob 都被删掉;随后元数据提交照样成功(数据库并不知道字节没了),处理任务读 blob 时抛 NoSuchFileException。宽限期只能挡住“刚写完”的文件,挡不住“写完很久但仍被引用”的文件。
负向对照 NC4 让子进程直接往内容地址写,写完第一块并 fsync 后被 kill -9。内容地址上留下 65536 字节的文件,声明大小是 150000,摘要与文件名不符。若去重逻辑只判断路径存在,这个半截文件会被当成已完成的上传。正确实现的 complete 复核摘要后用完整文件替换了它。
第二个反例针对 F3,构造重复执行。worker-a 以 2 秒租约领取任务,生成派生文件后停在发布之前,父进程对它发送 SIGSTOP,并从 /proc/<pid>/stat 确认进程处于 T 状态。2.5 秒后租约过期,worker-b 领取同一任务,尝试号变为 2,生成派生文件并发布。之后父进程发送 SIGCONT,并放行 worker-a 的发布:
1 | |
两个执行者生成的派生文件摘要相同,磁盘上的派生文件与 derived_sha256 登记值一致。worker-a 的发布返回 REJECTED,说明拒绝来自条件更新返回 0 行;若是唯一约束挡住,输出会是 SQL 异常而非 REJECTED。负向对照 NC1 用同样的时间线,但发布只按 file_id 更新、事件表没有唯一约束:同一文件写出 2 条发布事件,最终 published_attempt 被过期的尝试 1 覆盖。P2b 让两个工作进程同时启动,FOR UPDATE SKIP LOCKED 下一个领到任务,另一个输出 NO_JOB。
第三个反例针对 F4。P3 在文件处于 PROCESSING 时启动独立的读取进程,先断言 blob 已经在磁盘上,读取进程仍然返回 READ REJECTED code=NOT_READY;对上传中的会话构造 file_id 读取,返回 NOT_FOUND;发布后再次读取,返回 150000 字节且摘要一致。NC2 用按内容地址直接读取的旁路,PROCESSING 状态下读出了全部 150000 字节。字节在不在磁盘上与能不能读无关,可读性只由 file_object.state 这一处判断决定。
恢复流程
回收器是唯一的清理入口,按固定顺序执行四步:先把 expires_at 已过的 INIT、UPLOADING、COMPLETED 会话标为 EXPIRED;再删除 COMMITTED、ABORTED、EXPIRED 会话的暂存目录;然后逐个检查 blob 目录,在咨询锁内查引用、看修改时间,无引用且早于宽限期截止点的才删除;最后删除早于截止点的临时文件。顺序保证同一次运行里刚过期的会话不再保护它的 blob。
客户端侧的恢复规则只有两条:complete 成功后、commitMetadata 成功后的重复调用返回同一结果,可以在 TTL 内放心重试;被拒绝的调用按拒绝码处理(例如 DIGEST_MISMATCH 之后会话已转 ABORTED,再调用得到 SESSION_CLOSED);收到 SESSION_EXPIRED 或 SESSION_CLOSED 后只能新建会话重新上传。处理侧的恢复靠租约过期后的重新领取,迟到的执行者由尝试号拒绝,不需要人工介入。派生文件路径确定,重新执行不会产生新的孤儿派生文件;从未发布的文件的派生文件回收没有实现,列在限制里。
证据与限制
run.sh 一条命令完成启动集群(端口 55435)、重建 files35 库、编译、运行全部用例并停集群。最后一次运行退出码为 0:
1 | |
表 35-3:实验清单
| 编号 | 类别 | 内容 | 状态 |
|---|---|---|---|
| U1–U3、U5、U6 | 真实组件 | 正常路径、分块边界、整体摘要不符、去重、中止 | PASS |
| U4 | 真实组件 + 8 线程 | 同序号并发上传,同内容与异内容 | PASS |
| M1、M2 | 真实组件 + 故障注入 | 元数据被拒后重试;提交前失败后过期回收 | PASS |
| P1a–P1d | 多进程集成(kill -9) | 两个崩溃点后的残留、重试与回收 | PASS |
| P2、P2b | 多进程集成(SIGSTOP/SIGCONT) | 重复执行只发布一次;并发领取只一个成功 | PASS |
| P3 | 多进程集成 | 独立读取进程的可见性 | PASS |
| NC1–NC4 | 负向对照 | 无条件发布、旁路读取、不查引用的回收、直写内容地址 | PASS(均暴露错误) |
| 断电持久性 | 未执行 | 掉电或内核崩溃后的 fsync 效果 | NOT_RUN(环境无法模拟) |
U4 中两种内容各 4 个线程并发上传同一序号,本次输出为 1 次 STORED、3 次 DUPLICATE、4 次 PART_CONFLICT,线程间顺序每次运行不同,断言只检查这三个计数。
限制逐条写在 evidence/35/LIMITS.md,要点如下。本地 ext4 目录是对象存储的替身,实验不证明 S3 等对象存储的持久性和一致性,S3 的 complete 在服务端组装,失败模式与本地改名不同。kill -9 只终止用户进程,页缓存仍在,代码里的 force(true) 与目录 fsync 在实验中没有被检验;POSIX 也允许 fsync 的传输方式由实现定义。ATOMIC_MOVE 在目标已存在时替换,只在 Linux 与 OpenJDK 21 上观察到。SIGSTOP 的停止点由子进程等待标准输入固定,信号只负责冻结,不代表真实停顿的分布。摘要按 RFC 9530 第 6.2 节的说法只能发现损坏,不能防篡改。单机单库,未覆盖多个回收器并行、数据库切换和多实例部署。
本批导航
- 上一篇:34 可撤销短链与访问统计
- 下一篇:36 指标采集与告警生命周期
- 本批索引:仓库
batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md - 运行入口:
batch-3/examples/file-pipeline/run.sh - 证据目录:
batch-3/evidence/35/ - 本篇工程与证据快照:backend-systems-35.tar.gz(SHA-256 见同目录
backend-systems-35.tar.gz.sha256)。正文中的examples/、evidence/、SOURCES.md路径都相对仓库的batch-3/目录,与快照包内布局一致。






