后端系统工程 E03:大群历史的冷热分层——归档之后,删除还要能追上去
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
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 | |
这些数字与 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 | |
每一步中断后的状态都是可读且可重跑的:
| 中断位置 | 留下的状态 | 读路径 | 重跑 |
|---|---|---|---|
| 写文件之后、登记之前 | 一个没有清单的孤儿文件 | 只读热层,正确 | 覆盖同名文件;或由 gc_orphans() 删除 |
登记 sealed 之后、激活之前 |
文件 + sealed 清单 + 热层行 |
只读热层,正确,不重复 | 跳过写文件,直接激活 |
| 激活事务之后 | 完成 | 读冷层 | 无事可做 |
关键在第 3 步:清单激活和热层删除在同一个事务里,读路径看到的要么全是热层,要么全是冷层,不会出现两边都有或两边都没有的窗口。这依赖元数据与热数据在同一个数据库中。如果热层在一个数据库、清单在另一个系统,这一步就变成跨系统的两阶段问题,需要另外设计(例如先激活清单、读路径按 (conv, seq) 去重,再异步删除热层)。
5. 删除传播
删除最容易出错的地方是并发:归档任务已经把一批行读到内存里,此时用户删除了其中一条,归档随后把整批写进段文件并删除热层。被删除的消息就这样复活在冷层里。
本实现的规则是:删除总是写 tombstone,即使消息当时只在热层。
1 | |
读路径对热层和冷层的结果都过滤 tombstone;compact() 找出“含有未清除 tombstone 的 active 段”,重写为新一代文件,把对应 tombstone 标为 purged,最后删除旧文件。pending_deletes(now) 列出所有未清除的删除及其等待秒数,H4 的“约定时限”就落在这个列表上:它可以接告警,例如最老的未清除删除超过 7 天时报警。
撤回不需要物理删除,只需要“读不到正文”。recalls 表在读路径上叠加到冷层数据上,段文件保持不变。
6. 读路径
history(conv, before_seq, limit) 先从热层取,数量不够时按 lo 从大到小逐段读冷层,每读一段都校验 sha256:
1 | |
校验失败抛出 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 不阻止删除标记。
本批导航
- 上一篇选修:E02 定时调度与日历语义
- 下一篇选修:E04 消息通道替换与迁移
- 相关:38 数据备份、恢复与地域切换、E05 幂等导出与断点续传;11 撤回、历史保留与消息系统集成验收






