本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 batch-4/ 目录(examples/、evidence/ 等子目录);业务数据均为合成数据。

大群的历史消息有一个典型的访问形状:最近几天的消息被反复翻看,几个月前的消息很少有人读,但必须保留、能检索,并且在用户删除或撤回之后不能再被读到。把旧消息搬到更便宜的存储上,可以大幅降低成本;问题在于搬动本身是一个多步骤操作,任何一步都可能中断,而搬走之后,删除、撤回这些“对历史的修改”还要能追上去。

本篇实现一个热层(SQLite 表)加冷层(按会话、按序号区间切分的压缩段文件)的历史存储,验证归档的中断恢复、跨层分页、段文件损坏检测、删除传播与撤回叠加,并用一个参数化的成本模型说明分层阈值怎样随访问分布变化。

1. 需求、不变量与规模假设

  • H1 历史分页:按 seq 从新到旧翻页,跨越冷热边界时不重复、不遗漏。
  • H2 归档在任意步骤中断后可以重跑,读路径在中断期间仍然正确。
  • H3 冷层数据损坏能被读路径发现,并以错误返回,不能返回错误的内容。
  • H4 删除对读路径立即生效;冷层中的物理副本在约定时限内被清除,未清除的删除可以被列出并计算等待时长。
  • H5 撤回对冷层同样立即生效,不需要重写段文件。

实验数据:一个合成大群会话,2000 条消息,每 10 分钟一条,正文为“合成消息 big-序号”。归档阈值取第 1500 条之前,每段最多 300 条。成本模型的规模参数是教学假设:保留 730 天,每天 20 万条,每条 400 字节,冷层压缩后为原大小的 35%,每天 5 万次历史读取。

2. 操作契约与容量估算

操作 语义 错误
history(conv, before_seq, limit) 返回 seq < before_seq 的最近 limit 条可见消息,新到旧;已撤回的返回 body=null 冷层校验失败:明确的存储错误,不返回部分页面
delete(conv, seq) 返回时读路径已不可见;物理清除异步完成 —
recall(conv, seq) 返回时读路径已不可见正文 —
archive(conv, older_than, max_rows) 把最老的一段移到冷层;可重复执行,返回段名或无事可做 —
compact(now) 重写含未清除删除的段;返回物理移除条数 —
pending_deletes(now) 列出未清除的删除与等待秒数 —

“返回时已不可见”与“已物理删除”是两个承诺,接口文档要分开写。前者是同步的,后者有时限,并且只覆盖本系统控制的副本(第 8 节)。

按教学参数估算两层的大小:

1
2
3
4
每日原始写入   = 200,000 条 × 400 字节 = 80 MB
热层(60 天) = 60 × 80 MB = 4.8 GB
冷层(670 天) = 670 × 80 MB × 0.35 = 18.76 GB
冷读次数/天 = 50,000 × P(读取年龄 ≥ 60 天)

这些数字与 7.5 节表格中阈值 60 天的一行一致。索引、副本、备份与 WAL 不在估算中;把它们计入后,热层的实际成本倍数通常更高,最优阈值会进一步向“更短”移动。

段文件按会话切分,而不是把多个会话的消息混在一个文件里。按会话切分时,删除整个会话只需要删除它的段,读取也只触碰一个会话的数据;代价是小群会产生很多很小的段文件,与对象存储“小对象不转换、按对象收费”的条款冲突(见 7.5 节)。可行的折中是只对消息量超过阈值的大群按会话切段,小群合并到共享段,并接受“删除小群消息需要重写共享段”的放大。本篇只实现了按会话切段。

3. 存储布局

---
title: 图 E03-1 冷热两层的状态归属(元数据与热数据同库,冷数据为不可变段文件)
---
flowchart LR
    subgraph META ["SQLite meta.db"]
      H[hot: conv, seq, body, recalled]
      M[segments: seg, lo, hi, path, sha256, state, generation]
      T[tombstones: conv, seq, purged]
      R[recalls: conv, seq]
    end
    subgraph COLD ["cold/ 目录"]
      F1[big-000000001-000000300.jsonl.gz]
      F2[big-000000301-000000600.g2.jsonl.gz]
    end
    M -- path + sha256 --> F1
    M -- path + sha256 --> F2
    Q[history 读路径] --> H
    Q --> M
    Q --> T
    Q --> R

几个决定:

  • 段文件内容是确定的:行按 seq 排序,JSON 键排序,gzip 头的修改时间固定为 0。同样的输入总能得到同样的字节,重跑归档可以直接覆盖,校验和也稳定。
  • 段文件不可变。删除不在原文件上修改,而是生成新一代文件(generation+1),在一个事务里把清单切换过去,再删除旧文件。
  • 读路径只认 state='active' 的段。sealed 表示文件已写好、清单已登记,但热层的行还没有删除。

4. 归档事务

1
2
3
4
5
6
7
8
9
10
11
# 1. 写段文件到临时名,fsync,原子改名
with open(tmp, "wb") as f:
f.write(data); f.flush(); os.fsync(f.fileno())
os.replace(tmp, path)
# 2. 清单登记为 sealed
self.db.execute("INSERT OR REPLACE INTO segments(...) VALUES (..., 'sealed')", ...)
# 3. 单个事务:激活清单 + 删除热层行
self.db.execute("BEGIN IMMEDIATE")
self.db.execute("UPDATE segments SET state='active' WHERE seg=?", (seg,))
self.db.execute("DELETE FROM hot WHERE conv=? AND seq BETWEEN ? AND ?", (conv, lo, hi))
self.db.execute("COMMIT")

每一步中断后的状态都是可读且可重跑的:

中断位置 留下的状态 读路径 重跑
写文件之后、登记之前 一个没有清单的孤儿文件 只读热层,正确 覆盖同名文件;或由 gc_orphans() 删除
登记 sealed 之后、激活之前 文件 + sealed 清单 + 热层行 只读热层,正确,不重复 跳过写文件,直接激活
激活事务之后 完成 读冷层 无事可做

关键在第 3 步:清单激活和热层删除在同一个事务里,读路径看到的要么全是热层,要么全是冷层,不会出现两边都有或两边都没有的窗口。这依赖元数据与热数据在同一个数据库中。如果热层在一个数据库、清单在另一个系统,这一步就变成跨系统的两阶段问题,需要另外设计(例如先激活清单、读路径按 (conv, seq) 去重,再异步删除热层)。

5. 删除传播

删除最容易出错的地方是并发:归档任务已经把一批行读到内存里,此时用户删除了其中一条,归档随后把整批写进段文件并删除热层。被删除的消息就这样复活在冷层里。

本实现的规则是:删除总是写 tombstone,即使消息当时只在热层。

1
2
3
4
self.db.execute("DELETE FROM hot WHERE conv=? AND seq=?", (conv, seq))
# 即使消息只在热层,也写 tombstone:并发的归档可能已经读出这一行
if self.tombstone_hot_deletes:
self.db.execute("INSERT OR IGNORE INTO tombstones(conv, seq, reason, ts) VALUES (?,?,?,?)", ...)

读路径对热层和冷层的结果都过滤 tombstone;compact() 找出“含有未清除 tombstone 的 active 段”,重写为新一代文件,把对应 tombstone 标为 purged,最后删除旧文件。pending_deletes(now) 列出所有未清除的删除及其等待秒数,H4 的“约定时限”就落在这个列表上:它可以接告警,例如最老的未清除删除超过 7 天时报警。

撤回不需要物理删除,只需要“读不到正文”。recalls 表在读路径上叠加到冷层数据上,段文件保持不变。

6. 读路径

history(conv, before_seq, limit) 先从热层取,数量不够时按 lo 从大到小逐段读冷层,每读一段都校验 sha256:

1
2
3
4
5
6
7
def _read_segment(self, seg_row) -> list[dict]:
seg, path, sha = seg_row
with open(path, "rb") as f:
data = f.read()
if hashlib.sha256(data).hexdigest() != sha:
raise CorruptSegment(seg)
...

校验失败抛出 CorruptSegment,接口层应当把它转成明确的错误。返回空页面会让客户端以为“没有更早的消息了”。

7. 实验

运行入口 examples/e03_tiering/:python3 -m unittest test_tiering -v,12 个用例通过(evidence/E03/test-output.txt)。

7.1 正常:跨层分页

以 300 条为一段把第 1500 条之前的消息全部归档,热层最小 seq 变为 1500。history(before=1600, limit=400) 返回 1599 到 1200,连续无缺口,跨越了热层(1500–1599)和两个冷段。以 137 条为页长从最新翻到最旧,合并结果恰好是 2000 到 1,每条出现一次。页长特意取 137,让页边界落在段内部而不是段边界上。

7.2 失败:归档中断

  • 写文件后中断:清单为空,热层完整,读取 1–9 正常。重跑归档覆盖同名文件,gc_orphans() 返回空列表(文件已被登记)。另一用例中,中断后直接调用 gc_orphans(),删除了 1 个孤儿文件。
  • 登记 sealed 后中断:读取 1–300 只来自热层,没有重复。重跑后清单变为 active,热层中 seq ≤ 300 的行数为 0,读取结果不变。
  • 两个独立目录用相同数据各自归档第一段,两个段文件的 sha256 相同(确定性编码)。

7.3 失败:段文件损坏

把段文件第 40 个字节按位取反,读取该段时抛出 CorruptSegment,没有返回任何内容。

7.4 删除与撤回

用例 观察
删除已归档的第 150 条 读路径立即不再返回;原段文件中仍能找到“合成消息 big-150”;pending_deletes 有 1 条;compact() 物理移除 1 条,旧文件被删除,新一代文件中找不到该正文,pending_deletes 为空
删除热层的第 1999 条 热层行立即消失;compact() 后 tombstone 标记为已清除
先删除第 10 条,再归档 段文件中没有第 10 条,读路径也不返回
归档读出行之后、写段之前删除第 77 条 写 tombstone:读路径不返回,compact() 清除 1 条;负向对照(热层删除不写 tombstone):第 77 条从冷层复活,读路径返回它
撤回已归档的第 42 条 返回 body=None, recalled=1,段文件未改写

7.5 成本模型

成本只算两项:存储(热层单价 1.0、冷层 0.1,单位为每 GB 每天,教学参数)和冷读(每次 0.002)。读取的年龄分布按 exp(-λ·age) 衰减。

阈值(天) λ=0.08 时冷读比例 λ=0.08 日成本 λ=0.02 时冷读比例 λ=0.02 日成本
7 0.571 59.7 0.869 89.5
30 0.091 13.4 0.549 59.2
60 0.008 7.5 0.301 36.8
90 0.001 9.1 0.165 25.5
180 0.000 15.9 0.027 18.7
365 0.000 30.2 0.001 30.3

访问衰减快(λ=0.08)时,最优阈值在 60 天附近;衰减慢(λ=0.02)时移到 180 天附近。阈值太小,冷读费用占主导;太大,热层存储占主导。最优点由访问分布决定,而访问分布只能从真实的读取日志中统计,这张表只说明了“该测什么”,不给出任何推荐值。

真实的冷存储还有本模型没有计入的条款。以 AWS S3 为例,Standard-IA 有 30 天最短计费期,Glacier Instant Retrieval 与 Flexible Retrieval 为 90 天,Deep Archive 为 180 天;Flexible Retrieval 与 Deep Archive 的数据不能实时访问,需要先恢复一个临时副本;自 2024 年 9 月起,生命周期规则默认不转换小于 128 KB 的对象。第一条直接影响删除传播:在最短计费期内删除并重写段文件,旧文件仍按完整期限计费。第三条说明了为什么要把消息聚合成段文件,而不是一条消息一个对象。

8. 删除传播的边界

H4 只约束本系统控制的两份副本:热层行与冷层段文件。下列副本不在其中,需要单独列入删除流程:

  • 备份与 WAL 归档(第 38 篇)。PITR 能恢复到删除之前,这正是它的用途;删除的“最终完成时间”因此受备份保留期限约束。
  • 版本化对象存储。在启用版本控制的 S3 存储桶中,普通 DELETE 只插入一个删除标记,作为对象的新当前版本,旧版本仍然存在;Object Lock 的保留期也不阻止删除标记的添加,但被锁定的版本本身不能被删除。用版本化存储存放段文件时,compact() 删除旧文件要显式指定版本。
  • 已下载到客户端的消息、搜索索引、推送通知内容、导出文件(E05)。

如果用 PostgreSQL 的分区表实现热层,按时间分区并整体 DETACH PARTITION 再导出,比逐行 DELETE 快得多,也避免了 VACUUM 的开销;DETACH ... CONCURRENTLY 只需要 SHARE UPDATE EXCLUSIVE 锁,但不能在事务块中运行,并且分区表不能有默认分区。分区方式把“归档单位”从本文的 seq 区间换成了时间区间,删除传播的 tombstone 机制仍然需要。

分层还给备份与恢复带来一个新的一致性问题。热层与清单在 meta.db 中,冷数据在段文件目录中,两者通常由不同的机制备份:数据库用第 38 篇的基础备份加 WAL 归档,段文件用对象存储的版本或复制。恢复时两边的时间点不可能完全对齐,会出现两种错位:

  • 清单比段文件旧:恢复出的清单没有记录较新的段文件,这些文件成为孤儿,由 gc_orphans() 清理;对应的消息仍在恢复出的热层中,读路径不受影响。
  • 清单比段文件新:清单指向的段文件在恢复出的目录中不存在,或者是旧一代内容,读路径会遇到文件缺失或 CorruptSegment。这是不可接受的方向。

所以恢复顺序应当是:段文件按“不早于清单”的时间点恢复,并且旧一代段文件在清单切换之后要保留一段时间,不能立即删除(与本文 compact() 立即删除旧文件的做法相反)。保留期至少要覆盖“数据库恢复点可能回退的最大时长”。这条约束同时与删除传播冲突:为恢复保留的旧一代文件仍含已删除的消息,删除的物理完成时间因此又被推后了一个保留期。本篇没有演练这一恢复路径,只给出约束。

9. 方案比较

方案 归档单位 删除一条旧消息 读旧消息 主要风险
单表不分层,只靠保留期清理 — 直接删除 与新消息相同 全部历史按热层单价计费;大表的索引与 VACUUM 压力
按时间分区,整分区迁到归档库 时间区间 在归档库中删除 访问归档库 归档库仍是一个需要运维的数据库;大群与小群混在同一分区
热表 + 按会话的不可变段文件(本文) 会话内的 seq 区间 tombstone 立即隐藏,compact 重写段 读段、校验、过滤 段重写放大;元数据与热数据必须同库才能原子切换
全部写入对象存储,数据库只存索引 单条或小批 删除对象(注意版本与删除标记) 每次都是对象读取 小对象数量与请求费用;最近消息的读延迟

选择本文方案的前提有两个:热数据与元数据在同一个事务型数据库中,删除频率远低于读取频率。如果删除很频繁,例如合规要求按用户批量删除,段重写的放大会成为主要成本,这时更适合把段切得更小,或者在冷层中按用户而不是按会话组织数据。分层的目的是降低成本,而删除传播的复杂度是它的主要代价,评审时应把两者放在一起比较,而不是只看存储单价。

10. 限制

  • 只实现单会话、单进程;多个归档任务并发处理同一会话的情况没有覆盖(需要按会话加锁或领取租约)。
  • 冷层是本地目录,没有对象存储的最终一致性、请求费用、限速与恢复延迟。
  • compact() 是同步全量重写,没有限速;大段文件中只删一条时,重写放大明显,可以改为累积到阈值再重写。
  • 读路径每次读取整段并解压,没有段内索引和缓存;冷读延迟没有测量。
  • 成本模型的单价、衰减参数都是教学假设,结论只在形状上成立。

11. 来源

核对日期 2026-10-18。

  • PostgreSQL 16 文档 5.11 分区表:DROP 或 DETACH 分区远快于批量操作;DETACH CONCURRENTLY 的锁级别与限制;分区裁剪只依赖分区键约束。https://www.postgresql.org/docs/16/ddl-partitioning.html
  • AWS S3 用户指南:存储类别最短计费期(Standard-IA 30 天、Glacier Instant/Flexible Retrieval 90 天、Deep Archive 180 天);归档类别需要恢复临时副本;2024 年 9 月起默认不转换小于 128 KB 的对象。
  • AWS S3 用户指南:版本化存储桶中的删除标记;Object Lock 不阻止删除标记。

本批导航