《互联网产品的服务端架构·内容、社交与实时互动》1.3 内容存储的冷热分层

从一篇三个月前的爆款帖被迁到冷存储后点开要等八秒出发,说明冷热分层为什么按访问而不是创建时间划分,拆解迁移批次、回源延迟与存储成本之间的取舍,并用可测试的 Go 函数固定热、温、冷三层的判定边界,说明迁移批次、读穿缓存与回源监控如何配合,避免误迁。

三个月前的爆款帖被迁走之后

「回声」的内容库有一篇三个月前的帖子,当时因为一次站外转发,单日阅读量假设达到 80 万。之后它安静下来,最近 30 天只有几十次阅读。存储团队按“创建时间超过 90 天”做冷热分层,把它迁到了冷存储。迁移完成后的第二周,一位新用户从搜索结果点进去,页面转了 8 秒才打开。

这次延迟不是网络问题,也不是数据库慢查询,而是冷存储的取回路径:请求先要判断对象在冷层,发起取回任务,等待对象可用,再回源渲染。对批量分析来说,8 秒可以接受;对一次搜索点击来说,8 秒足以让用户返回上一页。分层策略省下了存储成本,却把成本转移给了偶发但关键的长尾访问。

冷热分层的核心不是“老内容放冷”,而是“按访问放层”。创建时间只能说明内容什么时候进入系统,不能说明它现在还有没有人看。下面把判定规则、迁移批次和回源成本拆开,并用一段可测试的 Go 代码固定三层边界。

分层依据是访问,不是创建时间

一条内容的存储层应该由最近的访问行为决定。最近读过、或者最近 30 天读得足够多,说明它仍在活跃路径上,应该留在热层;一段时间没读但还在可预期范围内,放温层;长期无人问津,才放冷层。创建时间可以作为兜底信号,但不能作为唯一依据。

层判定(案例假设)典型介质取回延迟单位存储成本适用内容
hot7 天内读过,或 30 天读 10 次以上SSD 或本地缓存毫秒级高首页、搜索结果、近期爆款
warm90 天内读过普通对象存储几十到几百毫秒中历史文章、低频但可搜索
cold90 天以上未读归档存储秒级到分钟级低合规留存、极少访问的旧内容

表里的天数和次数是案例假设值,不是行业标准。真正的阈值要结合产品形态:新闻类内容的热窗口可能只有 24 小时,工具类文档可能一年后仍有稳定访问;视频的冷层取回成本远高于文本,阈值也应该更保守。阈值可以调,但“按访问判定”这个原则不应该调。

还要区分“读”和“写”。作者修改一篇旧文章,不代表它有读者;审核后台批量扫描,也不应该把内容重新加热。统计口径要排除内部任务和爬虫,否则冷层永远清不空。反过来,分享链接带来的少量外部访问可能是高价值的,不能因为量小就忽略。

迁移要考虑回源延迟与成本

迁移不是把文件从一个桶复制到另一个桶。一次迁移至少涉及四件事:确认内容当前不在热路径、复制数据、校验可读性、更新元数据里的存储位置。复制期间如果有读请求,必须能回源到旧位置;更新元数据之后,缓存和 CDN 里的旧地址要能失效。任何一步缺失,都会表现为“内容突然打不开”。

回源延迟是分层的隐性成本。热层取回假设 5 毫秒,温层假设 100 毫秒,冷层假设 3 秒到 1 分钟;这些数字是案例假设,用来说明量级。一次搜索点击如果命中冷层,用户感知的不是平均延迟,而是这一次的 8 秒。解决办法不是“永不迁冷”,而是给冷层访问加一层读穿缓存:第一次取回慢,之后一段时间留在热层。

成本也要算全。冷存储的单位价格低,但取回请求、数据解冻和提前删除都可能有额外费用。假设一个内容库有 1 亿条内容,冷层单价是热层的十分之一,看起来省了 90%;如果每天有 0.1% 的内容被访问,取回费用和延迟可能抵消大部分节省。分层方案必须用访问分布算账,而不是只看单价。

一次冷存储回源把 p99 推到 8 秒

案例假设:迁移任务按创建时间扫描,把 90 天前的内容批量迁到冷层。那篇三个月前的爆款帖正好在批次里。迁移后的第二周,它出现在一条长尾搜索词的结果第一页。用户点击,服务端查询元数据,发现对象在冷层,发起取回;取回任务排队 2 秒,解冻 4 秒,回源渲染 1 秒,加上网络和重试,总共 8 秒。

如果只有这一个用户,8 秒只是一次糟糕体验。问题是搜索排名会持续把旧内容带给新用户,这条帖子每天仍有几十次访问,每次都触发一次冷层取回。缓存没有兜住,因为冷层内容默认不预热;CDN 也没有兜住,因为页面是动态渲染的。团队看到的是“偶发慢请求”,实际上是分层策略和访问分布不匹配。

事故的修复分三步。第一步,把最近 30 天有访问的内容从迁移候选里剔除,避免把仍在长尾路径上的内容迁走。第二步,冷层访问触发读穿缓存,第一次取回后把内容放回热层一段时间。第三步,迁移批次按访问量从低到高排序,先迁真正无人访问的内容,再观察回源指标。

还要补一个监控:冷层回源率。如果某批迁移之后回源率明显上升,说明判定阈值太激进,应该回滚或调整。没有这个指标,团队只能等用户投诉才知道迁错了。

用 TierOf 与 Plan 把分层规则固定下来

分层规则如果只写在运维文档里,迁移脚本、监控和人工复核会各自实现一遍。把它写成纯函数,至少能保证三件事:边界值只定义一次、负数输入早失败、迁移批次可以先在内存里算出每层数量再执行。

下面这段代码来自本书配套的 webapp-lab。TierOf 按“先热后温再冷”的顺序判断,Plan 遍历一批访问记录并统计每层数量;两者都对负数返回错误,避免脏数据被静默分到某一层。

// file: internal/content/tier.go
// tier: L1
// verify: go test ./internal/content/

package content

import "fmt"

// Tier 是内容所在的存储层。
type Tier string

const (
	Hot  Tier = "hot"
	Warm Tier = "warm"
	Cold Tier = "cold"
)

// Access 是最近一次访问统计。DaysSinceRead 以天为单位,
// ReadsLast30 是最近 30 天的读取次数。
type Access struct {
	DaysSinceRead int
	ReadsLast30   int
}

// TierOf 按访问而不是创建时间决定存储层。
// 7 天内读过或 30 天读到 10 次放 hot;90 天内读过放 warm;其余放 cold。
func TierOf(a Access) (Tier, error) {
	if a.DaysSinceRead < 0 {
		return "", fmt.Errorf("content: negative days since read: %d", a.DaysSinceRead)
	}
	if a.ReadsLast30 < 0 {
		return "", fmt.Errorf("content: negative reads: %d", a.ReadsLast30)
	}
	switch {
	case a.DaysSinceRead <= 7 || a.ReadsLast30 >= 10:
		return Hot, nil
	case a.DaysSinceRead <= 90:
		return Warm, nil
	default:
		return Cold, nil
	}
}

// Plan 统计每个存储层应该放多少条内容,供迁移批次估算。
// 返回的 map 始终包含三个层,值为 0 的层也会出现。
func Plan(items []Access) (map[Tier]int, error) {
	plan := map[Tier]int{Hot: 0, Warm: 0, Cold: 0}
	for i, item := range items {
		tier, err := TierOf(item)
		if err != nil {
			return nil, fmt.Errorf("content: items[%d]: %w", i, err)
		}
		plan[tier]++
	}
	return plan, nil
}

几个边界值得说明。

  • 热层条件是“7 天内读过 或 30 天读 10 次以上”。两个条件是或关系,只要满足一个就留在热层;近期高频访问比“最后一次读是什么时候”更能说明内容仍在活跃路径上。
  • 温层条件是“90 天内读过”。它排在热层之后,所以 7 天内读过的内容不会落到温层。
  • 冷层是兜底分支,不需要额外条件。新增层级时应该显式加分支,而不是让默认分支悄悄改变含义。
  • 负数直接报错。DaysSinceRead = -1 通常意味着时间计算错误或时区问题,静默当成“今天读过”会把脏数据永久留在热层。
  • Plan 始终返回三个键,即使数量为 0。迁移脚本可以直接读取 plan[Cold],不需要先判断键是否存在。

这段代码不负责执行迁移。真实系统还要处理迁移中的读写、缓存失效、失败重试和回滚;Plan 只回答“按当前访问记录,应该有多少条落在每层”,它是迁移决策的输入,不是迁移本身。

测试:7 天、90 天与读数的交叉边界

分层函数的测试要盯住三组边界:7 天与 8 天、90 天与 91 天、以及读数 9 与 10。下面这组用例还覆盖了“很久没读但最近读得很多”的交叉情况,以及负数输入和空批次的返回值。

// file: internal/content/tier_test.go
// tier: L1
// verify: go test ./internal/content/

package content

import "testing"

func TestTierOfBoundaries(t *testing.T) {
	cases := []struct {
		name string
		a    Access
		want Tier
	}{
		{"read-today", Access{DaysSinceRead: 0}, Hot},
		{"read-within-7-days", Access{DaysSinceRead: 7}, Hot},
		{"10-reads-last-30", Access{DaysSinceRead: 40, ReadsLast30: 10}, Hot},
		{"8-days-warm", Access{DaysSinceRead: 8, ReadsLast30: 9}, Warm},
		{"90-days-warm", Access{DaysSinceRead: 90}, Warm},
		{"91-days-cold", Access{DaysSinceRead: 91}, Cold},
		{"never-read", Access{DaysSinceRead: 365}, Cold},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := TierOf(c.a)
			if err != nil {
				t.Fatal(err)
			}
			if got != c.want {
				t.Fatalf("TierOf(%+v) = %s, want %s", c.a, got, c.want)
			}
		})
	}
}

func TestTierOfRejectsNegative(t *testing.T) {
	if _, err := TierOf(Access{DaysSinceRead: -1}); err == nil {
		t.Fatal("want error for negative days since read")
	}
	if _, err := TierOf(Access{ReadsLast30: -1}); err == nil {
		t.Fatal("want error for negative reads")
	}
}

func TestPlanCountsTiers(t *testing.T) {
	got, err := Plan([]Access{
		{DaysSinceRead: 1},
		{DaysSinceRead: 7, ReadsLast30: 10},
		{DaysSinceRead: 30},
		{DaysSinceRead: 90},
		{DaysSinceRead: 91},
		{DaysSinceRead: 200, ReadsLast30: 30},
	})
	if err != nil {
		t.Fatal(err)
	}
	want := map[Tier]int{Hot: 3, Warm: 2, Cold: 1}
	for tier, n := range want {
		if got[tier] != n {
			t.Fatalf("plan[%s] = %d, want %d (all=%v)", tier, got[tier], n, got)
		}
	}
}

func TestPlanKeepsAllTiersAndPropagatesError(t *testing.T) {
	got, err := Plan(nil)
	if err != nil {
		t.Fatal(err)
	}
	for _, tier := range []Tier{Hot, Warm, Cold} {
		if _, ok := got[tier]; !ok {
			t.Fatalf("plan missing tier %s: %v", tier, got)
		}
		if got[tier] != 0 {
			t.Fatalf("plan[%s] = %d, want 0", tier, got[tier])
		}
	}

	if _, err := Plan([]Access{{DaysSinceRead: 1}, {DaysSinceRead: -1}}); err == nil {
		t.Fatal("want error for invalid item")
	}
}

10-reads-last-30 是最容易被实现错的用例:DaysSinceRead 是 40,单看天数应该落温层,但最近 30 天读了 10 次,按或关系应该留在热层。如果实现先判断天数再判断读数,这个用例会失败。8-days-warm 同时给出 8 天和 9 次阅读,确保两个条件都不满足时才进入温层。

91-days-cold 和 never-read 检查冷层兜底。TestPlanKeepsAllTiersAndPropagatesError 则确认两件事:空批次仍然返回三个键;批次中任意一条负数会让整个 Plan 失败,而不是跳过坏数据继续统计。迁移脚本需要后者,因为跳过坏数据会让批次数量对不上。

测试没有覆盖的部分也要说清楚:它不验证访问统计是否准确、不验证迁移后的回源延迟、也不验证成本模型。这些需要埋点、压测和账单数据来回答,分层函数只负责把“给定访问记录之后”的分类做对。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/content/ -run 'TestTierOf|TestPlan' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestTierOfBoundaries
=== RUN   TestTierOfBoundaries/read-today
=== RUN   TestTierOfBoundaries/read-within-7-days
=== RUN   TestTierOfBoundaries/10-reads-last-30
=== RUN   TestTierOfBoundaries/8-days-warm
=== RUN   TestTierOfBoundaries/90-days-warm
=== RUN   TestTierOfBoundaries/91-days-cold
=== RUN   TestTierOfBoundaries/never-read
--- PASS: TestTierOfBoundaries (0.00s)
    --- PASS: TestTierOfBoundaries/read-today (0.00s)
    --- PASS: TestTierOfBoundaries/read-within-7-days (0.00s)
    --- PASS: TestTierOfBoundaries/10-reads-last-30 (0.00s)
    --- PASS: TestTierOfBoundaries/8-days-warm (0.00s)
    --- PASS: TestTierOfBoundaries/90-days-warm (0.00s)
    --- PASS: TestTierOfBoundaries/91-days-cold (0.00s)
    --- PASS: TestTierOfBoundaries/never-read (0.00s)
=== RUN   TestTierOfRejectsNegative
--- PASS: TestTierOfRejectsNegative (0.00s)
=== RUN   TestPlanCountsTiers
--- PASS: TestPlanCountsTiers (0.00s)
=== RUN   TestPlanKeepsAllTiersAndPropagatesError
--- PASS: TestPlanKeepsAllTiersAndPropagatesError (0.00s)
PASS
ok  	webapp/internal/content	0.255s

每个子用例对应一条边界,ok 后面的时间是本次运行耗时,不是性能承诺。上线前还应该用真实访问分布跑一遍 Plan,确认各层比例和迁移批次大小符合预期,再小流量试迁。

小结

冷热分层的依据是访问,不是创建时间。创建时间只能说明内容什么时候进入系统,不能说明它现在还有没有人看;按时间一刀切,最容易把仍有长尾访问的旧爆款迁走,然后在某次搜索点击里用 8 秒暴露问题。

TierOf 把热、温、冷的判定写成纯函数,Plan 把一批访问记录换算成每层数量。它们不能替代迁移系统,但能保证阈值、边界和错误处理只有一份实现,迁移脚本、监控和复核都基于同一套规则。

下一章会从单条内容转向内容之间的连接:关注、好友与关系图。先解决“内容怎么存、怎么改”,再讨论“内容怎么被看见”。

阅读导航:上一节:1.2 内容状态机:草稿、审核、发布、删除 · 下一节:2.1 发布链路的同步与异步 。

常见错误与边界

  • 按创建时间分层。创建时间只说明内容何时进入系统,不说明现在还有没有访问。
  • 把“最近读过”统计成“最近创建”。两个字段含义不同,混用会让冷层永远清不空或误迁热内容。
  • 内部任务和爬虫计入访问。批量扫描会把整库重新加热,分层失去意义。
  • 热层条件写成与关系。要求“7 天内读过且读 10 次”会漏掉刚被访问一次的内容。
  • 负数静默归层。时间计算错误被当成“今天读过”,脏数据会长期占用热层。
  • 迁移后不做读穿缓存。冷层内容第一次访问就要等完整回源。
  • 只看存储单价不看取回成本。冷层的解冻、请求和提前删除费用可能抵消节省。
  • 迁移后不监控回源率。迁错了只能等用户投诉,无法在批次层面及时回滚。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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