三个月前的爆款帖被迁走之后
「回声」的内容库有一篇三个月前的帖子,当时因为一次站外转发,单日阅读量假设达到 80 万。之后它安静下来,最近 30 天只有几十次阅读。存储团队按“创建时间超过 90 天”做冷热分层,把它迁到了冷存储。迁移完成后的第二周,一位新用户从搜索结果点进去,页面转了 8 秒才打开。
这次延迟不是网络问题,也不是数据库慢查询,而是冷存储的取回路径:请求先要判断对象在冷层,发起取回任务,等待对象可用,再回源渲染。对批量分析来说,8 秒可以接受;对一次搜索点击来说,8 秒足以让用户返回上一页。分层策略省下了存储成本,却把成本转移给了偶发但关键的长尾访问。
冷热分层的核心不是“老内容放冷”,而是“按访问放层”。创建时间只能说明内容什么时候进入系统,不能说明它现在还有没有人看。下面把判定规则、迁移批次和回源成本拆开,并用一段可测试的 Go 代码固定三层边界。
分层依据是访问,不是创建时间
一条内容的存储层应该由最近的访问行为决定。最近读过、或者最近 30 天读得足够多,说明它仍在活跃路径上,应该留在热层;一段时间没读但还在可预期范围内,放温层;长期无人问津,才放冷层。创建时间可以作为兜底信号,但不能作为唯一依据。
| 层 | 判定(案例假设) | 典型介质 | 取回延迟 | 单位存储成本 | 适用内容 |
|---|---|---|---|---|---|
| hot | 7 天内读过,或 30 天读 10 次以上 | SSD 或本地缓存 | 毫秒级 | 高 | 首页、搜索结果、近期爆款 |
| warm | 90 天内读过 | 普通对象存储 | 几十到几百毫秒 | 中 | 历史文章、低频但可搜索 |
| cold | 90 天以上未读 | 归档存储 | 秒级到分钟级 | 低 | 合规留存、极少访问的旧内容 |
表里的天数和次数是案例假设值,不是行业标准。真正的阈值要结合产品形态:新闻类内容的热窗口可能只有 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 次”会漏掉刚被访问一次的内容。
- 负数静默归层。时间计算错误被当成“今天读过”,脏数据会长期占用热层。
- 迁移后不做读穿缓存。冷层内容第一次访问就要等完整回源。
- 只看存储单价不看取回成本。冷层的解冻、请求和提前删除费用可能抵消节省。
- 迁移后不监控回源率。迁错了只能等用户投诉,无法在批次层面及时回滚。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。