十八亿行历史数据,七成没人看
「回声」的订单历史表涨到 18 亿行(案例假设值)。存储团队给了一张账单:热库占用的 SSD 容量里,大约 72% 是 180 天以前的订单;这些订单贡献的读请求不到总读量的 3%。与此同时,客服系统每天仍要查几百笔几个月前的争议订单,财务每月要做一次跨年对账。
产品团队的第一反应是“把旧数据删掉”,法务团队的第一反应是“交易记录必须保留”。两个反应都对,但都不完整。删掉会违反保留要求,全部留在热库又会让存储成本和备份窗口持续失控。真正要回答的问题是:哪些数据需要被快速访问,哪些数据只需要被保留,以及“快”和“留”分别由谁定义。
冷热分离不是把“旧数据”四个字翻译成一条 WHERE created_at < ... 的删除语句。它是一套分层策略:热层服务在线请求,冷层服务低频但可查的历史请求,过期层负责合规删除。每层都有自己的判据、介质、延迟目标和责任人。
这一节先拆判据,再讲一次“按年龄一刀切”造成的事故,最后把分层规则写成一个边界清晰、可以单独测试的 Go 函数。
温度由访问频率和保留期共同决定
“超过 30 天就是冷数据”是一句很常见、也很危险的话。它只用了时间一个维度,忽略了两个事实:有些旧数据最近刚被访问,有些新数据从写入起就没人读过;以及“是否可以删除”由合规保留期决定,不由访问频率决定。
更完整的判据至少包含三组输入:
- 访问新鲜度:最近一次被读取是什么时候。争议订单、售后工单、财务抽查都会让一条很旧的数据重新变热。
- 年龄:数据产生至今多久。年龄决定它是否已经离开热层的保底窗口,但不单独决定它是否该被删除。
- 保留要求:合同、法规或内部审计要求保留多久,是否存在法律冻结。保留期内即使没人访问,也不能删除。
把这三组输入落到三层,可以得到一张比较务实的表:
| 层级 | 判据(案例假设) | 典型介质 | 延迟目标 | 成本占比 | 典型查询 |
|---|---|---|---|---|---|
| hot | 年龄不超过 30 天,或最近 7 天被访问 | SSD、行式数据库 | p99 小于 50 毫秒 | 约 25% | 订单详情、售后、支付回调 |
| cold | 年龄 31 到 180 天,且不在热层 | 对象存储加索引表 | p99 小于 2 秒 | 约 40% | 客服查历史、月度对账 |
| expired | 超过 180 天保留期,且无法务冻结 | 删除或长期归档 | 不服务在线请求 | 约 35% | 审计抽检、监管调取 |
表里的天数和比例都是案例假设值,不是行业标准。真实项目要和法务、财务、客服一起定:保留期由合规要求决定,热层窗口由产品体验决定,冷层查询目标由客服和运营的实际操作决定。三个数字的来源不同,不能由技术团队单独拍板。
两个容易混淆的边界需要提前说清楚。
第一,冷层不是“不可查询”。如果数据搬走之后在线系统查不到,那不是归档,是删除。冷层必须保留可接受的查询路径,哪怕它慢一些、需要走异步任务、需要人工发起。客服在电话里等八秒和等八十秒,是完全不同的产品体验。
第二,过期不等于立即物理删除。合规保留期通常有起算点和例外,法律冻结、投诉处理中、财务审计期间都可能要求延长。删除任务应该先查冻结标记,再执行;删除动作本身要留审计日志,记录谁在什么时候删了什么。
还有一个常被忽略的维度:访问模式。热层适合点查和短范围扫描,冷层适合按主键点查和大批导出,但不适合高并发小查询。把冷层当成“更便宜的热层”来用,通常会因为索引缺失或对象存储的请求延迟,把一次客服查询放大成几十次远程调用。
保留期从哪一天开始算
保留期看似一个数字,实际有三个口径:从数据产生日算、从业务完结日算、从会计年度结束日算。订单在 12 月 31 日创建、次年 1 月 5 日完成退款,按产生日算和按完结日算,删除日期会差几天甚至一个账期。
口径不统一会造成两种问题:一边认为已经过期可以删除,另一边还在审计期内需要保留。解决方式不是技术,而是把口径写进策略配置,并在数据行上存一个明确的 retention_anchor 字段。归档任务只读这个字段,不在代码里重新推算。
另一个容易忽略的是时区。用 UTC 存时间、用本地自然日算年龄,会让同一天产生的数据在跨时区任务里得到不同年龄。统一用 UTC 自然日,或者在策略里显式写明时区,能避免“同一批数据今天该删、明天又不该删”的抖动。
冷层的查询路径要单独设计
对象存储便宜,但它的访问模型和数据库不同:按主键取一个对象很快,列目录、范围扫描、复杂条件过滤都很贵。因此冷层不能照搬热层的查询接口,必须事先设计好访问路径。
- 按订单号点查:对象键可以直接用订单号,一次 GET 完成。
- 按用户或商户查历史:需要一张轻量索引表,记录 user_id、时间范围到对象键的映射;索引表本身也要分层。
- 批量导出与对账:走异步任务,按时间分区顺序读取,避免在在线请求里做全量扫描。
索引表不是免费的。它会增加写入链路和存储成本,但它把“冷层可查”从一句承诺变成了可执行的路径。没有索引的冷层,最终会退化成“只能人工从备份恢复”。
还要定义冷层查询的降级行为。冷层超时或不可用时,页面应该明确告诉用户“历史数据查询暂不可用”,而不是无限转圈或返回空结果。空结果会被误读成“订单不存在”,比报错更危险。
归档任务按什么节奏跑
归档不是一次性脚本,而是长期运行的作业。它至少要回答三个问题:每次处理多少数据、失败后从哪里继续、如何避免和在线流量抢资源。
建议按时间分区小批量推进,每批记录处理位点;失败时从位点重试,而不是从头扫描。任务应该跑在低峰期,并对热库的复制延迟设上限,超过阈值就暂停。归档进度、失败数、冷层校验失败数都要有监控,否则“任务在跑”和“任务有效”是两回事。
一次按年龄一刀切的归档
事故的起点是一份看起来很合理的方案:所有 created_at 早于 30 天的订单,由定时任务搬到对象存储,热库只保留最近 30 天。任务上线第一周,存储成本确实降了,备份窗口也缩短了。第二周,客服团队开始抱怨。
一位用户在处理两个月前的退款争议。客服打开订单详情,系统先去热库查,没查到;再走冷层查询,冷层只按订单号建了前缀索引,没有按用户维度建索引,查询退化成扫描整个月的对象文件。一次查询从 80 毫秒变成 8 秒,客服页面超时,用户在线等了半分钟。当天类似工单积压了四十多单。
更严重的问题出在第三周。归档任务为了“节省空间”,在确认对象存储写入成功后立刻删除热库记录,但确认逻辑有 bug:它只检查了上传请求返回 200,没有校验对象内容长度和校验和。一次网络抖动造成部分对象上传不完整,任务仍然删除了源数据。三天后财务对账发现缺失,团队只能从备份里恢复,恢复了十一个小时。
这次事故有三个独立错误,任何一个都足以造成麻烦:
- 判据只看年龄,没有看最近访问。争议订单在归档前一周还被客服查过,本应留在热层或至少被标记为“高价值历史”。
- 冷层只建了订单号索引,没有覆盖客服实际使用的用户维度查询。归档方案只考虑了“怎么搬”,没有考虑“搬完怎么查”。
- 删除与归档没有做成可验证的两阶段。正确顺序是:写入冷层、校验校验和与记录数、标记可删除、延迟一个观察窗口、再删除热层。任何一步失败都应该停止,而不是继续。
事故之后,团队把策略改成了三层判据:年龄决定保底窗口,最近访问决定是否回热,保留期决定是否允许删除。归档任务也拆成了“搬运”和“清理”两个独立作业,清理作业只处理已经被标记为“冷层校验通过”且超过观察期的数据。
把分层规则写成可测试的纯函数
配套代码 internal/shard/archive.go 把策略收敛成两个字段:热层保留天数与冷层保留天数。它不负责搬数据,也不负责删除,只回答“这条数据现在属于哪一层”和“它是否适合进入冷层归档”。把判断和数据搬动分开,测试才能覆盖边界,事故复盘时也能明确是哪一层出了问题。
// file: internal/shard/archive.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import "fmt"
const (
Hot = "hot"
Cold = "cold"
Expired = "expired"
)
// Policy 描述一条数据的温度生命周期。
// HotDays 是热层保留天数,ColdDays 是热层之后冷层的保留天数。
// 两段都是闭区间:第 HotDays 天仍在热层,第 HotDays+ColdDays 天仍在冷层。
type Policy struct {
HotDays int
ColdDays int
}
func (p Policy) validate() error {
if p.HotDays <= 0 {
return fmt.Errorf("shard: hot days must be positive, got %d", p.HotDays)
}
if p.ColdDays <= 0 {
return fmt.Errorf("shard: cold days must be positive, got %d", p.ColdDays)
}
return nil
}
// Classify 返回数据在给定年龄下所处的层级。
// 年龄按自然日计算,0 表示当天产生的数据。
func Classify(ageDays int, p Policy) (string, error) {
if err := p.validate(); err != nil {
return "", err
}
if ageDays < 0 {
return "", fmt.Errorf("shard: age must not be negative, got %d", ageDays)
}
switch {
case ageDays <= p.HotDays:
return Hot, nil
case ageDays <= p.HotDays+p.ColdDays:
return Cold, nil
default:
return Expired, nil
}
}
// Eligible 判断数据是否可以进入冷层归档。
// 只有 cold 层级可以归档;expired 要走删除与合规流程,hot 还不该移动。
func Eligible(ageDays int, p Policy) bool {
class, err := Classify(ageDays, p)
return err == nil && class == Cold
}
几个设计点需要解释。
Policy 用两个天数描述生命周期,而不是直接传两个时间戳。天数策略是相对年龄的,调用方只需要知道“数据产生多少天了”,不需要在每次判断时处理时区、夏令时和边界时刻。真实系统通常会把“产生时间”存成 UTC 时间戳,再在任务里换算成自然日;换算规则要全站统一,否则同一批数据在不同任务里会得到不同年龄。
HotDays 和 ColdDays 都是闭区间。第 HotDays 天仍然算热数据,第 HotDays+ColdDays 天仍然算冷数据,超过一天才进入过期层。闭区间的选择不是数学美感,而是为了和业务口径对齐:产品说“保留 30 天”,通常指第 30 天结束前都还能查;如果实现成开区间,第 30 天当天就会被搬走,客服会在最后一天发现数据提前消失。
validate 要求两个天数都为正。HotDays 为零意味着没有热层,所有数据一产生就进冷层,这通常不是业务本意;ColdDays 为零意味着热层结束即过期,等于没有冷层。与其让这种配置静默生效,不如在第一次调用时就报错。年龄为负同样报错:负年龄通常意味着时钟回拨、数据写入时间在未来,或者上游把时间单位搞错了。
Eligible 只在 Classify 返回 cold 时为真。它不把 expired 当成“可归档”,因为过期数据应该走删除与合规流程,而不是被搬进冷层继续占空间。这个区分能防止一个常见 bug:归档任务把所有“不在热层”的数据都搬走,结果过期数据在冷层越积越多,成本没有下降,只是换了一个地方。
代码没有处理“最近访问”这个维度。访问新鲜度需要额外的访问日志或热度计数,属于策略输入,不应该塞进这个纯函数。实际做法是:先用 Classify 得到年龄分层,再用访问记录把“冷层但最近被访问”的数据提升回热层。两个判断分开,理由和测试都更清楚。
测试:闭区间和非法策略都要有用例
边界值是最容易在代码评审里被“看起来差不多”放过的地方。下面的测试把热层上界、冷层上界、过期起点,以及非法策略和负年龄全部固定下来。
// file: internal/shard/archive_test.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import "testing"
func TestClassifyClosedBoundaries(t *testing.T) {
p := Policy{HotDays: 30, ColdDays: 150}
cases := []struct {
age int
want string
}{
{0, Hot},
{30, Hot},
{31, Cold},
{180, Cold},
{181, Expired},
}
for _, c := range cases {
got, err := Classify(c.age, p)
if err != nil {
t.Fatalf("Classify(%d): %v", c.age, err)
}
if got != c.want {
t.Fatalf("Classify(%d) = %q, want %q", c.age, got, c.want)
}
}
}
func TestClassifyRejectsInvalidInput(t *testing.T) {
valid := Policy{HotDays: 30, ColdDays: 150}
if _, err := Classify(-1, valid); err == nil {
t.Fatal("want error for negative age")
}
badPolicies := []Policy{
{HotDays: 0, ColdDays: 150},
{HotDays: -1, ColdDays: 150},
{HotDays: 30, ColdDays: 0},
{HotDays: 30, ColdDays: -1},
}
for _, p := range badPolicies {
if _, err := Classify(10, p); err == nil {
t.Fatalf("Classify with policy %+v = nil, want error", p)
}
}
}
func TestEligibleOnlyCold(t *testing.T) {
p := Policy{HotDays: 30, ColdDays: 150}
cases := []struct {
age int
want bool
}{
{0, false},
{30, false},
{31, true},
{180, true},
{181, false},
}
for _, c := range cases {
if got := Eligible(c.age, p); got != c.want {
t.Fatalf("Eligible(%d) = %v, want %v", c.age, got, c.want)
}
}
if Eligible(10, Policy{HotDays: 0}) {
t.Fatal("invalid policy must not be eligible")
}
}
TestClassifyClosedBoundaries 是核心。年龄 30 期望 hot,31 期望 cold,180 期望 cold,181 期望 expired。四个数字分别对应热层闭区间、冷层闭区间和过期起点。只要有人把 <= 改成 <,这个用例就会失败。
TestClassifyRejectsInvalidInput 覆盖了负年龄和四种非法策略。空策略、负热层天数、零冷层天数都应该在判断前被拒绝,而不是被当成“全部热”或“全部过期”。这类输入通常来自配置错误,早失败能让问题停在发布阶段,而不是等到归档任务跑了一周才发现。
TestEligibleOnlyCold 把“只有冷层可归档”锁住。年龄 30 不可归档,31 可归档,180 可归档,181 不可归档。最后一组尤其重要:过期数据不能被归档任务顺手搬进冷层。
测试没有覆盖数据搬动、校验和比对和删除审计。这些属于归档作业的集成测试,需要对象存储、数据库和任务调度一起验证。纯函数测试的价值不是替代集成测试,而是把最容易漂移的业务口径固定住,让集成测试只需要关注“搬运是否正确”。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/shard/ -run 'TestClassify|TestEligible' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestClassifyClosedBoundaries
--- PASS: TestClassifyClosedBoundaries (0.00s)
=== RUN TestClassifyRejectsInvalidInput
--- PASS: TestClassifyRejectsInvalidInput (0.00s)
=== RUN TestEligibleOnlyCold
--- PASS: TestEligibleOnlyCold (0.00s)
PASS
ok webapp/internal/shard 0.242s
三个用例全部通过,退出码为 0。如果要确认全包没有回归,去掉 -run 再执行一次完整测试。
小结
冷热分离的判据不是“旧”,而是“访问频率低且保留要求允许搬动”。年龄只决定保底窗口,最近访问决定是否回热,保留期决定是否允许删除。三层各有自己的介质、延迟目标和责任人,冷层必须可查,过期层必须可审计。
归档和删除要拆成两个作业。先写冷层、再校验、再标记、再延迟删除,任何一步失败都停下。把分层判断写成纯函数,把数据搬动留给作业,是让这套策略可测试、可复盘的最低成本做法。
下一节讨论数据迁移与双读:当分片规则本身要变,怎样在新旧路径之间安全切换,而不是让历史数据在切换那一刻消失。
阅读导航:上一节:4.1 分库分表与路由 · 下一节:4.3 数据迁移与双读 。
常见错误与边界
- 只用年龄判断冷热。旧数据可能刚被客服查过,新数据也可能从未被访问;年龄只是保底窗口。
- 把归档做成删除。冷层如果无法查询,就不是归档;客服和审计仍然需要可用的访问路径。
- 冷层不建索引。只按主键存对象,却要求按用户、按商户查询,会把一次点查放大成整月扫描。
- 先删后验。上传成功不等于内容完整,必须校验长度、校验和与记录数,再进入删除流程。
- 忽略法律冻结与保留期例外。过期判断之前要查冻结标记,删除动作要留审计日志。
- 把“保留 30 天”实现成开区间。第 30 天当天就被搬走,是典型的边界口径错误。
- 让归档任务处理过期数据。过期数据应该走删除与合规流程,搬进冷层只会让成本换一个地方堆着。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。