本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 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
2
3
4
snap = self.db.execute("SELECT coalesce(max(gseq),0) FROM log").fetchone()[0]
q = "SELECT conv, from_gseq, coalesce(to_gseq, ?) FROM member WHERE user=? AND from_gseq<=?"
...
perms = [list(r) for r in self.db.execute(q + " ORDER BY conv, from_gseq", args)]

导出时只取 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
2
3
4
5
prev = self.db.execute("SELECT sha256, state FROM part WHERE job=? AND idx=?", (jid, idx)).fetchone()
if prev and prev[1] == "done" and os.path.exists(path) and _sha_file(path) == prev[0]:
if prev[0] != sha:
raise RuntimeError("non-deterministic part content") # 同一快照必须得到相同字节
continue

重跑时,已完成且文件校验通过的分片被跳过;文件存在但校验不符(被截断或篡改)的分片被重写。重新计算出的内容与记录的校验和不同,说明生成过程本身不确定,这时直接报错,不在不确定的基础上继续。全部分片完成后按顺序拼接,用 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
2
3
4
5
if rng and (if_range is None or if_range == etag):
...
if start >= size or start > end:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{size}")

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 天,临时凭证过期则链接失效。

本批导航