后端系统工程 E05:幂等导出与断点续传——同一个请求,同一份字节
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
batch-4/目录(examples/、evidence/等子目录);业务数据均为合成数据。
“导出我的聊天记录”看起来只是一次查询加一次下载,实际包含几个各自独立的问题。用户点了两次按钮,是一个任务还是两个;导出期间又有新消息、有人退群、有消息被撤回,文件里该有什么;导出跑到一半进程被杀,重跑出来的文件和不中断时是否一致;下载到 80% 断网,能否从断点继续,断点之后的字节是否属于同一个文件;下载链接被转发出去,别人能不能用。
本篇用 SQLite 和一个真实监听本机端口的 HTTP 服务实现这条链路,每个问题对应一组用例。全部数据为合成数据。
1. 需求与不变量
- X1 同一用户、同一幂等键、同一请求体只产生一个导出任务;同键不同请求体被拒绝。
- X2 导出内容由创建时刻的数据快照和权限快照决定,之后的写入、撤回、成员变化不改变结果。
- X3 任务可以在任意分片处中断并重跑;重跑得到的产物与不中断时逐字节相同,已完成且校验通过的分片不重写。
- X4 下载支持断点续传;续传的字节必须来自同一个产物,产物变化时客户端得到完整的新文件而不是拼接错的文件。
- X5 下载链接短期有效,绑定任务与用户;篡改、过期、换用户、账号停用、任务被撤销都会被拒绝。
规模假设:一个用户在 2 个会话中可见约 930 条消息,分片大小 200 条,产物经 gzip 压缩后约 5 KB。这个规模只用来覆盖分支,不用于评估导出吞吐。
2. 接口契约
| 请求 | 成功 | 错误 |
|---|---|---|
POST /exports,头 Idempotency-Key,体 {"scope": "all" 或 "conv:<id>"} |
202 新建 {job_id, state};200 重放 {job_id, state, replayed: true} |
400 缺少键或 scope 非法;422 同键不同请求体;401 未认证 |
GET /exports/{id} |
200 状态;完成时附签名下载链接(300 秒有效)、sha256、字节数 | 404:任务不存在或不属于当前用户(不区分两者) |
GET /download/{id}?u=&exp=&sig= |
200 全量;206 部分(Range 生效时);头含 ETag、Accept-Ranges: bytes、Cache-Control: private, no-store |
403 签名错误、过期、用户不符、账号停用;410 导出已撤销;416 范围无法满足(带 Content-Range: bytes */长度) |
幂等键的错误码参照 IETF 草案 draft-ietf-httpapi-idempotency-key-header 的第 07 版:缺少键返回 400,同键不同请求体返回 422,原请求仍在处理中又收到重试时返回 409。这份草案在 2026 年 4 月已经过期,没有成为 RFC,所以这里只把它当作约定参考。本接口的创建请求在一个数据库事务内完成,不存在“原请求仍在处理中”的窗口,因而没有用到 409;导出任务本身是异步的,重放时返回任务当前状态,而不是等待任务完成。
草案还要求资源方定义并公布幂等键的过期策略。本实现的键永久有效,因为任务记录本身就是键的存储;如果任务记录会被清理,键的有效期就不能长于记录的保留期。
3. 快照:权限与数据
所有写入(消息、撤回、入群、退群)共享一个全局递增序号 gseq。创建任务时,在同一个写事务内取当前最大 gseq 作为数据快照,并按这个快照计算权限:用户在每个会话中的可见区间 [入群 gseq, 退群 gseq)。
1 | |
导出时只取 gseq ≤ 快照 且落在可见区间内的消息;撤回只认快照之前发生的。导出结果因此等价于“用户在点击导出那一刻,往回翻页能看到的全部内容”。
这个做法依赖一个前提:gseq 的分配顺序与提交顺序一致。SQLite 只有一个写者,前提成立。换到 PostgreSQL 这样的多写者数据库,序列号在事务开始写入时分配,较小的号可能晚于较大的号提交,“取最大序号”得到的快照会漏掉一个还没提交的小号事务,事后又把它读进来,X2 不再成立。PostgreSQL 的做法是使用事务快照:可重复读事务从第一条非事务控制语句开始看到一个固定快照;pg_export_snapshot() 可以把这个快照导出给其他会话,导入方用 SET TRANSACTION SNAPSHOT 在可重复读或可串行化事务的开头导入,并且只能在导出事务结束之前导入。导出任务如果要跨多个进程分片执行,就需要这样一个长时间保持的事务,代价是这段时间内,快照建立之后被删除或更新的旧行版本都不能被 VACUUM 回收。另一种做法是让写入路径维护一个按提交顺序递增的序号,这需要在写入路径上加锁或单点分配,本篇没有实现。
这个异常与修正都在真实的 PostgreSQL 16.15 实例上演示过(examples/e05_export/pg_snapshot.py,输出 evidence/E05/pg-snapshot.json)。表 msg 的 gseq 为 bigserial,预置 3 行,用四个常驻 psql 会话交错执行:
| 步骤 | 会话 | 操作 | 结果 |
|---|---|---|---|
| 1 | W1 | 开启事务,插入一行 | 分到 gseq=4,未提交 |
| 2 | W2 | 插入一行并自动提交 | 分到 gseq=5 |
| 3 | 导出方 | 取 max(gseq) 作为快照值,读取 gseq ≤ 5 |
1,2,3,5 |
| 4 | W1 | 提交 | — |
| 5 | 导出方 | 用同一个快照值再读 | 1,2,3,4,5 |
| 6 | 导出方 | 开启可重复读事务,pg_export_snapshot(),读取 |
1,2,3,4,5 |
| 7 | W1、W2 | 各插入一行并提交 | 当前已提交数据为 1,…,7 |
| 8 | 第二个导出会话 | 可重复读事务开头 SET TRANSACTION SNAPSHOT 导入,读取 |
1,2,3,4,5 |
| 9 | 导出方 | 在原事务中再读 | 1,2,3,4,5 |
| 10 | 第二个导出会话 | 导出事务提交后再导入同一快照 | ERROR: invalid snapshot identifier |
| 11 | 第二个导出会话 | 在读已提交事务中导入 | ERROR: a snapshot-importing transaction must have isolation level SERIALIZABLE or REPEATABLE READ |
第 3、5 步用同一个“快照值”读出了不同的结果:gseq=4 的事务在取快照值时还没提交,之后才出现。如果导出任务按分片执行,前面的分片用的是第 3 步的视图,后面的分片用的是第 5 步的视图,同一个快照值就生成了自相矛盾的文件,中断重跑也无法得到相同的字节。第 6–9 步中,两个会话共享同一个事务快照,期间的两次并发提交对它们都不可见。第 10、11 步对应文档中的两条限制。
权限快照有一个需要明确的取舍:用户在导出之后被移出某个会话,已生成的导出文件是否还要包含这个会话的内容。本实现选择“以创建时的权限为准”,理由是导出内容不超过用户在那一刻能在界面上看到的范围。下载时仍然重新校验账号状态与任务归属,管理员还可以撤销整个导出(410)。更严格的选择是在下载时比较当前权限与快照权限,有任何收缩就让文件失效并要求重建,这适合权限收回本身就是安全事件的场景。
4. 分片、确定性与中断恢复
导出计划把每个会话的可见消息按 200 条切片,每片写成一个 JSONL 文件:行按 seq 排序,JSON 键排序,不含时间戳、主机名等运行时信息。分片的 sha256 记入 part 表。
1 | |
重跑时,已完成且文件校验通过的分片被跳过;文件存在但校验不符(被截断或篡改)的分片被重写。重新计算出的内容与记录的校验和不同,说明生成过程本身不确定,这时直接报错,不在不确定的基础上继续。全部分片完成后按顺序拼接,用 mtime=0 的 gzip 压缩为最终产物,产物的 sha256 就是 ETag。
---
title: 图 E05-1 导出任务的状态与确认位置
---
stateDiagram-v2
[*] --> queued: POST /exports(事务内固定快照与权限)
queued --> running: 工作者领取
running --> running: 分片 i 写临时文件→fsync→改名→记录 sha256
running --> running: 崩溃后重跑:跳过已校验分片
running --> done: 拼接 + gzip(mtime=0) → 记录产物 sha256 与字节数
done --> revoked: 管理员撤销(下载返回 410)
5. 下载:签名、Range 与 If-Range
下载链接的签名是 HMAC-SHA256(job_id | user | exp),比较时使用常量时间比较。签名只证明“这个链接由服务端签发、没有被改过”;服务端在每次下载时仍然重新检查账号是否启用、任务是否属于该用户、是否被撤销。签名不能代替授权检查,否则账号停用之后,已发出的链接在过期之前仍然可用。
断点续传遵循 RFC 9110 的 Range 语义。客户端第一次下载时记下 ETag,中断后带 Range: bytes=N- 与 If-Range: <ETag> 继续。RFC 9110 §13.1.5 把 If-Range 的含义概括为“如果表示没有变化,就发我请求的那部分;否则发完整的表示”,接收方在条件为假时必须忽略 Range;客户端不得在 If-Range 中使用弱 ETag。416 响应应当在 Content-Range 中给出当前长度。RFC 还指出 Range 请求是 HTTP 的可选特性,许多实现会直接返回 200 全量,所以客户端不能假设断点续传一定生效,必须检查状态码是 206 还是 200。
1 | |
If-Range 是 X4 的关键。没有它,产物在两次请求之间如果被重新生成(例如任务被撤销后重建),服务器会把新文件的后半段接到旧文件的前半段后面,客户端得到一个校验失败或者更糟、能解压但内容错乱的文件。
6. 实验
运行入口 examples/e05_export/:python3 -m unittest test_exporter -v,13 个用例通过。所有 HTTP 用例都通过 http.client 连接本机 ThreadingHTTPServer 的真实端口。python3 trace.py 记录了一次完整交互(evidence/E05/http-trace.jsonl):
| 步骤 | 状态码 | 关键结果 |
|---|---|---|
POST /exports,键 k1 |
202 | 新任务,replayed=false |
| 同键同体重放 | 200 | 同一 job_id,replayed=true |
同键、scope 改为单个会话 |
422 | 拒绝 |
GET /exports/{id}(任务完成后) |
200 | 5038 字节,附签名链接 |
| 下载,服务端在 1000 字节后断开 | 200 | 收到 1000 字节与 ETag |
Range: bytes=1000-,If-Range: <ETag> |
206 | Content-Range: bytes 1000-5037/5038,收到 4038 字节 |
| 拼接两段计算 sha256 | — | 与任务记录的 sha256 一致 |
Range 起点等于文件长度 |
416 | Content-Range: bytes */5038 |
其他用例:
- 快照:任务创建后再写入一条消息、撤回一条旧消息。导出中
c-team的序号恰好是 51–550(1–50 发生在用户入群之前),c-big是 1–430(431–440 发生在退群之后),无权查看的c-private不出现;快照前撤回的第 60 条正文为空、recalled=1,快照后撤回的第 70 条仍是原文。 - 中断恢复:写完 2 个分片后注入崩溃,重跑得到的产物 sha256 与另一个目录中用相同数据、不中断生成的产物相同;两次运行合计写入的分片数等于分片总数,说明已完成分片没有重写。
- 分片篡改:中断后在第 0 片末尾追加垃圾行,重跑时该片校验不符被重写,垃圾行消失。
If-Range带一个旧 ETag 请求bytes=100-:返回 200 和完整文件。- 授权:换用户的链接、改动一位签名的链接、过期 301 秒的链接均为 403;他人查询任务状态为 404;任务撤销后下载为 410,恢复撤销标记但停用账号后下载为 403。
- 幂等键按用户隔离:两个用户使用相同的键得到不同的任务。
7. 产物的生命周期与观测
导出产物是一份用户数据的完整副本,它的保留时间应当比消息本身更短,并且有明确的清理路径。本实现没有实现清理任务,契约上应当包括:
- 产物与分片文件在任务完成后保留固定天数,到期删除文件并把任务标为
expired;之后的下载返回 410,用户可以用新的幂等键重新导出。 - 分片文件只在任务运行期间需要,产物生成后即可删除;保留它们只是为了在产物损坏时快速重建,这个收益要和多一份用户数据副本的风险放在一起权衡。
- 用户删除消息或注销账号时,未过期的导出产物是删除流程必须覆盖的一份系统内副本(E03 第 8 节列出了不受系统控制的副本),删除流程要能找到并处理它。
需要记录的观测项:任务从创建到完成的耗时分布;中断重跑次数与每次跳过的分片数;下载请求按状态码(200、206、403、410、416)分别计数;同一个产物的下载次数与来源。最后一项用于发现链接被转发:一个 300 秒有效的链接在短时间内被多个来源使用,签名本身仍然合法,只有下载日志能看出异常。
8. 方案比较
| 方案 | 重复点击 | 中断恢复 | 断点续传正确性 | 代价 |
|---|---|---|---|---|
| 同步流式导出(请求内边查边写响应) | 每次点击一次全量查询 | 只能从头开始 | 无法续传 | 长连接占用;查询期间数据在变 |
| 异步任务,无幂等键 | 多个任务 | 依实现而定 | 依产物是否确定 | 重复计算 |
| 异步任务 + 幂等键 + 快照 + 确定性分片(本文) | 一个任务 | 从断点分片继续,字节一致 | ETag + If-Range |
需要快照能力;分片文件占用临时空间 |
| 产物放对象存储,用预签名链接直传 | 同上 | 同上 | 由对象存储实现 | 授权只在签发时检查 |
最后一种方案把下载流量卸给对象存储,代价是授权检查只发生在签发链接的那一刻。以 AWS S3 为例,用 CLI 或 SDK 生成的预签名链接最长可有 7 天有效期,用临时凭证签发的链接在凭证过期时失效。链接有效期内账号被停用,链接仍然可以下载。这类方案应当把有效期缩到分钟级,需要撤销能力时改为经由服务端代理下载。大文件上传到对象存储时还会遇到分段上传的约束:每段 5 MiB 到 5 GiB(最后一段不限),最多 10000 段;本文的分片是导出计划的单位,与上传分段是两件事,两者可以对齐,但不必相同。
9. 限制
- 导出主流程只在单写者的 SQLite 上实现;PostgreSQL 上只演示了快照异常与事务快照修正(第 3 节),没有把分片导出整体迁到 PostgreSQL,也没有测量长事务对 VACUUM 的影响。
- 工作者的领取、租约与并发执行没有实现;用例中由测试直接调用
run_job。 - 产物在内存中拼接与压缩,没有覆盖大文件的流式写出与磁盘空间不足。
- 签名密钥是写死的测试值,没有轮换;下载没有限速,也没有审计日志。
- 只支持单段
bytes=N-与bytes=N-M范围,不支持多段范围和后缀范围(bytes=-N),遇到时返回 400。 - 导出文件被下载后在用户设备上的副本不受任何约束;撤销只阻止新的下载。
10. 来源
核对日期 2026-10-18。
- RFC 9110 §14(Range 请求为可选特性)、§13.1.5(If-Range 的语义、条件为假时忽略 Range、禁止弱 ETag)、§15.3.7(206)、§15.5.17(416 应带当前长度)。https://www.rfc-editor.org/rfc/rfc9110
- draft-ietf-httpapi-idempotency-key-header-07(2025-10-15,已于 2026-04-18 过期,非 RFC):键不得用于不同请求体;缺键 400、不同请求体 422、并发重试 409;资源应公布键的过期策略。
- PostgreSQL 16 文档 9.27.5 快照同步函数与
SET TRANSACTION:导出的快照只在导出事务结束前可导入;必须在可重复读或可串行化事务开头导入。13.2.2:可重复读快照起点。https://www.postgresql.org/docs/16/functions-admin.html - AWS S3 用户指南:分段上传 5 MiB–5 GiB、最多 10000 段;预签名链接经 CLI/SDK 最长 7 天,临时凭证过期则链接失效。
本批导航
- 上一篇选修:E04 消息通道替换与迁移
- 下一篇选修:E06 配额与多租户公平性
- 相关:E03 大群历史的冷热分层;35 文件上传与异步处理






