《互联网产品的服务端架构·规模化与架构演进》4.2 冷热分离与归档

订单历史涨到十八亿行后,「回声」把三十天前的数据一律搬到对象存储,结果客服查询四十五天前的争议订单要等八秒,归档任务还因为先删后验丢过一批记录。本节说明冷热判据为什么必须同时看访问频率与合规保留期,并给出带闭区间边界和非法策略校验的 Go 实现与测试。

十八亿行历史数据,七成没人看

「回声」的订单历史表涨到 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 天当天就被搬走,是典型的边界口径错误。
  • 让归档任务处理过期数据。过期数据应该走删除与合规流程,搬进冷层只会让成本换一个地方堆着。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查