一次文件上传跨越两个互不共享事务的存储:字节落在文件系统或对象存储里,文件名、归属、处理进度落在数据库里。两边各自可以做到原子,合起来却没有一次提交能同时覆盖它们。于是有三种残局必须单独处理:字节写完了、元数据没提交,字节成了没人认领的孤儿;后台处理任务因为租约过期被执行了两次,发布动作也跟着做了两次;处理还没结束,读取接口已经把文件交了出去。示例实现基于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
CREATE TABLE upload_session (
session_id text PRIMARY KEY,
owner text NOT NULL,
state text NOT NULL CHECK (state IN
('INIT','UPLOADING','COMPLETED','COMMITTED','ABORTED','EXPIRED')),
part_count int NOT NULL CHECK (part_count BETWEEN 1 AND 10000),
declared_size bigint NOT NULL CHECK (declared_size >= 0),
declared_sha256 text NOT NULL CHECK (declared_sha256 ~ '^[0-9a-f]{64}$'),
blob_sha256 text,
expires_at timestamptz NOT NULL,
closed_reason text,
CHECK (state NOT IN ('COMPLETED','COMMITTED') OR blob_sha256 IS NOT NULL)
);
CREATE TABLE upload_part (
session_id text NOT NULL REFERENCES upload_session(session_id),
part_no int NOT NULL CHECK (part_no >= 1),
size int NOT NULL CHECK (size >= 0),
sha256 text NOT NULL,
PRIMARY KEY (session_id, part_no)
);
CREATE TABLE file_object (
file_id text PRIMARY KEY,
session_id text NOT NULL UNIQUE REFERENCES upload_session(session_id),
owner text NOT NULL,
name text NOT NULL,
content_type text NOT NULL,
blob_sha256 text NOT NULL,
size bigint NOT NULL,
state text NOT NULL CHECK (state IN ('PROCESSING','PUBLISHED')),
derived_path text,
derived_sha256 text,
published_attempt int,
CHECK (state <> 'PUBLISHED' OR (derived_path IS NOT NULL AND published_attempt IS NOT NULL))
);
CREATE TABLE process_job (
file_id text PRIMARY KEY REFERENCES file_object(file_id),
state text NOT NULL CHECK (state IN ('READY','RUNNING','DONE')),
attempt int NOT NULL DEFAULT 0,
lease_owner text,
lease_until timestamptz
);
CREATE TABLE publish_event (
event_id bigserial PRIMARY KEY,
file_id text NOT NULL UNIQUE,
attempt int NOT NULL,
worker text NOT NULL
);

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
2
3
4
CASE U1 :: 正常路径:乱序上传 3 块 → 完成 → 提交元数据 → 处理 → 发布 → 读取
ok PROCESSING 时读取被拒绝 (expected=NOT_READY, actual=NOT_READY)
ok 派生文件解压后与原文件一致
ok 发布事件条数 (expected=1, actual=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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int ins = Db.update(c, """
INSERT INTO upload_part(session_id, part_no, size, sha256) VALUES (?, ?, ?, ?)
ON CONFLICT (session_id, part_no) DO NOTHING""", sessionId, partNo, body.length, actual);
String result;
if (ins == 0) {
String existing = Db.queryString(c,
"SELECT sha256 FROM upload_part WHERE session_id=? AND part_no=?", sessionId, partNo);
if (!actual.equals(existing)) throw new FileError("PART_CONFLICT", "part " + partNo);
result = "DUPLICATE";
} else {
Path dir = Files.createDirectories(store.sessionDir(sessionId));
Files.move(tmp, store.partPath(sessionId, partNo), StandardCopyOption.ATOMIC_MOVE);
Store.fsyncDir(dir);
Db.update(c, "UPDATE upload_session SET state='UPLOADING' WHERE session_id=? AND state='INIT'", sessionId);
result = "STORED";
}

改名发生在提交之前。若提交失败,暂存目录里会多一个没有登记的分块文件,但后续成功的登记会用新的改名覆盖它(依赖 Linux/OpenJDK 下 ATOMIC_MOVE 替换已存在的目标,Javadoc 说明这一行为由实现决定,见限制一节),complete 读取时又会逐块对照登记摘要,这个残留文件不会进入组装结果。

F1 与 F2 能否同时成立,取决于 complete 的后半段。整体摘要比对通过之后,先取 blob 级咨询锁,再改名、fsync 目录、更新会话:

1
2
3
4
5
6
7
8
9
10
11
12
hook.accept("after-fsync");
lockBlob(c, sha);
Path target = store.blobPath(sha);
if (Files.exists(target) && Store.sha256(target).equals(sha)) {
Files.delete(tmp); // 相同内容已存在:去重
} else {
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
}
Store.fsyncDir(store.blobs);
hook.accept("after-rename");
Db.update(c, "UPDATE upload_session SET state='COMPLETED', blob_sha256=? WHERE session_id=?", sha, sessionId);
c.commit();

去重分支复核了已有文件的摘要,没有因为路径存在就直接认定内容正确。NC4 会展示只看路径是否存在的后果。fsync(2) 手册指出对文件 fsync 不保证包含它的目录项落盘,所以改名之后还要对目录 fsync,Store.fsyncDir 用只读方式打开目录并调用 FileChannel.force(true)。两个 hook 是崩溃注入点,生产代码里为空操作。

回收的引用判定只有一条查询,三类引用缺一不可:

1
2
3
4
static final String REFERENCED = """
SELECT (EXISTS (SELECT 1 FROM file_object WHERE blob_sha256 = ?)
OR EXISTS (SELECT 1 FROM upload_session WHERE state = 'COMPLETED' AND blob_sha256 = ?)
OR EXISTS (SELECT 1 FROM upload_session WHERE state IN ('INIT','UPLOADING') AND declared_sha256 = ?))::int""";

第三类引用来自会话创建时声明的整体摘要,它覆盖的正是“已改名、未提交”这一段:进程在这里崩溃,数据库里的会话仍是 UPLOADING,但它的 declared_sha256 已经指向磁盘上那个 blob。回收对每个候选 blob 先取同一把咨询锁,再查引用、看修改时间、删除,最后提交。complete 的改名与会话更新也在这把锁内,两者因此串行:要么回收先删、complete 后改名,留下的是新文件;要么 complete 先提交,回收看到 COMPLETED 引用。

发布是两条条件更新,任一返回 0 行就整体回滚:

1
2
3
4
5
6
7
8
int job = Db.update(c, """
UPDATE process_job SET state='DONE', lease_until=NULL
WHERE file_id=? AND state='RUNNING' AND attempt=? AND lease_owner=?""",
cl.fileId(), cl.attempt(), name);
int file = job == 0 ? 0 : Db.update(c, """
UPDATE file_object SET state='PUBLISHED', derived_path=?, derived_sha256=?, published_attempt=?
WHERE file_id=? AND state='PROCESSING'""",
store.root.relativize(a.path()).toString(), a.sha256(), cl.attempt(), cl.fileId());

派生文件路径只由源 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。事件序列:

  1. 子进程已把完整文件改名进 blobs/sha256/<hex> 并 fsync 了目录,持有会话行锁和 blob 咨询锁,事务未提交。
  2. SIGKILL 终止子进程,退出码 137;PostgreSQL 后端读到连接断开,回滚事务并释放两把锁。
  3. 数据库里会话仍为 UPLOADING,没有 file_object;磁盘上 blob 完整。
  4. 宽限期之后、TTL 之内回收:blob 被第三类引用保护,保留。
  5. TTL 加宽限期之后回收:会话转 EXPIRED,blob、暂存分块被删除。
1
2
3
4
5
CASE P1a :: 多进程:改名进 blob 目录之后、提交之前 kill -9,再执行回收
ok 子进程被 SIGKILL 终止(退出码 128+9) (expected=137, actual=137)
ok 数据库事务随连接断开回滚,会话仍为 UPLOADING (expected=UPLOADING, actual=UPLOADING)
note reclaim(宽限期后、TTL 内) expired=0 stagingRemoved=0 blobsRemoved=0 blobsKept=1 tmpRemoved=0
note reclaim(TTL + 宽限期后) expired=1 stagingRemoved=1 blobsRemoved=1 blobsKept=0 tmpRemoved=0

同一崩溃点还有两条出路。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
2
3
note worker-b 输出 [ARTIFACT worker=worker-b file=file-0001 attempt=2 sha=1bb6590a76615a53f95d19376c591d79d62ae5ab14eeb0f749ce42cd3e0360d4, PUBLISHED worker=worker-b attempt=2]
ok worker-a 的迟到发布被拒绝 (expected=REJECTED worker=worker-a attempt=1, actual=REJECTED worker=worker-a attempt=1)
ok 发布事件恰好 1 条 (expected=1, actual=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
SUMMARY passed=137 failed=0

表 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/ 目录,与快照包内布局一致。