《互联网产品的服务端架构·产品、流量与基础能力》8.1 读多写少的产品事实

从一个社区首页按 DAU 估算缓存容量、结果命中率只有 22% 的事故写起,说明读多写少不是拍脑袋的常识,而是要用读次数、写次数、独立键数与值大小测出来的事实,并给出读写比、工作集和缓存建议的可测试 Go 实现,避免继续用请求量代替缓存容量。

一次按 DAU 算出来的缓存容量

「回声」是一个内容社区。上线前,团队要给首页信息流加缓存。运营给出一个数字:DAU 50 万(案例假设值),每人每天平均看 40 条内容,团队据此算出每天 2000 万次读,再按“二八定律”假设 20% 的内容贡献 80% 的访问,于是给 Redis 申请了 1.6 GB 内存。

上线一周后,缓存命中率停在 22%,Redis 的内存却一直贴着上限,源站数据库的 QPS 比压测时高了近三倍。值班同学把监控拉出来,发现被淘汰的键里有不少是当天最热的帖子,而缓存里长期躺着一批只被访问过一次的冷门内容。

团队最初的解释是“二八定律不准”。真正的问题不在这里:他们从来没有测量过独立键的数量、值的平均大小和键的访问分布,只测量了“用户很多、读很多”这一层。读多写少是一个方向性的描述,不是一个可以拿来做容量决策的事实。

读多写少是一个可测量的事实

“读多写少”这句话至少包含五个需要分别测量的量。少测任何一个,容量估算都会偏。

观测项要回答的问题只看 DAU 会漏掉什么
读次数与写次数请求方向是什么比例写入放大发生在存储层,不在 API 计数里
独立键数量工作集到底多大热门内容占请求量,不等于占键空间
单值平均大小内存成本是多少一个大 value 能让工作集直接翻倍
键的访问分布长尾有多长大量只访问一次的键会挤掉真正的热键
写路径扇出一次写会碰几个存储点赞、计数、状态变更的真实写成本

先看读和写。API 层面的读写比很容易测,但它不是缓存收益的全部。一次点赞在接口上是一次写,落到存储可能同时更新帖子计数、用户的点赞集合、作者的消息、推荐系统的反馈表。接口读写下 30:1,到了存储层可能只剩 6:1。如果只看 API 计数就断定“写很少”,缓存失效的成本会被低估。

独立键数量决定工作集。工作集是“可能被缓存的不同键”乘以“每个键的平均值大小”,它和请求量没有直接关系。一个日请求一亿的产品,如果绝大多数请求集中在几千个键上,工作集可能只有几十 MB;反过来,一个日请求一千万的产品,如果每天有几百万个长尾键,工作集反而更大。用 DAU 推请求量,再用请求量推缓存容量,中间漏掉的正是这个乘法。

值的平均大小同样不能拍脑袋。同样是 100 万个键,存用户昵称和存整页 HTML 的内存差两个数量级。序列化方式、压缩开关、字段裁剪都会改变这个数字,应该从真实样本里抽样统计,而不是拿一个“大概 1 KB”的常数。

访问分布决定缓存策略。二八定律描述的是请求量的集中度,不是键数量的集中度。大量冷键只访问一次,却会真实占用内存;如果淘汰策略不够好,它们会把热键挤出去,形成“缓存越加越不命中”的怪现象。

最后是写路径的扇出。点赞、收藏、关注、状态变更这些操作,在产品上都是一次点击,在服务端却可能触发多条写路径和多次缓存失效。读多写少的产品事实必须把这一层算进去,否则会得出“写很少,所以缓存可以随便加”的错误结论。

从这些量出发,可以给出一个很粗但可执行的判断:读多写比达到 20 倍以上,缓存才值得作为默认方案。20 倍不是行业标准,而是一条显式的产品—工程约定。按这个比例,写请求约占总请求的 4.8%,缓存只要挡住超过这个比例的回源就是净收益。低于 20 倍时,失效、穿透和一致性带来的复杂度往往超过它省下的那点读放大。

失败场景:按 DAU 估容量,缓存里 80% 是冷数据

回到「回声」的例子。真实的测量结果是:每天 1200 万次缓存读、60 万次写,独立键 800 万个,单值平均 800 字节,工作集约 6.4 GB。团队按 20% 热数据申请了 1.6 GB,只覆盖了真实工作集的四分之一。

更糟的是访问分布。当天 800 万个键里,约 640 万个只被访问过一次,占键数量的 80%,却只贡献了不到 10% 的请求。这些冷键被写入缓存后长期占着内存,Redis 的淘汰策略为了腾空间,把一些仍有余温的键提前赶走。结果是命中率只有 22%,源站仍然要承担接近八成的读流量。

后果分三层。第一层是成本:内存从 1.6 GB 一路扩到 8 GB,扩容两次才稳住命中率,账单翻了几倍(案例假设值)。第二层是稳定性:扩容期间出现过一次主从切换,缓存短暂不可用,数据库连接池被打满,首页 p99 从 220 毫秒涨到 900 毫秒。第三层是判断失真:团队一开始以为是“热点太集中、缓存不够大”,继续加内存,直到有人把键的访问次数分布画出来,才发现问题在冷键污染,而不是热键太多。

这件事的教训不是“不要用二八定律”,而是不能用请求量的集中度去代替键空间的分布。容量要按工作集算,命中率要按访问分布看,写成本要按写路径扇出算。三个数字都测出来,缓存才是一个工程决策;只测一个,它就是一个赌注。

用 Analyze 把流量变成画像

下面的实现来自 webapp-lab 的 internal/cache 包。Analyze 接收一段时间的真实计数,输出两个决策用的量:读多写比和工作集字节数。写次数为 0 时,读多写比用 -1 表示“没有写”,而不是做一次除零;工作集则是独立键数乘以单值平均大小。所有计数都做非负校验,避免上游把“未知”填成负数后悄悄参与计算。

// file: internal/cache/profile.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import "fmt"

// Traffic 是一段时间内可测量的读写事实,不是按 DAU 拍出来的假设。
// Reads 与 Writes 统计到达缓存层的请求次数,DistinctKeys 统计
// 这段时间内出现过的独立键数量,AvgValueBytes 是单个值的平均大小。
type Traffic struct {
	Reads         int64
	Writes        int64
	DistinctKeys  int64
	AvgValueBytes int
}

// Profile 是读多写的事实画像。
// ReadPerWrite 为 -1 表示完全没有写,而不是除零。
type Profile struct {
	ReadPerWrite    float64
	WorkingSetBytes int64
}

// Plan 是缓存建议。NeedsCache 为 false 时其余字段没有意义。
// MinHitRate 是盈亏平衡命中率,低于它缓存只会增加一层复杂度。
type Plan struct {
	NeedsCache bool
	MinHitRate float64
}

// CacheReadWriteThreshold 是建议缓存的读多写比下限。
const CacheReadWriteThreshold = 20.0

// Analyze 把原始流量换算成可用于决策的画像。
func Analyze(t Traffic) (Profile, error) {
	if t.Reads < 0 {
		return Profile{}, fmt.Errorf("reads must be >= 0, got %d", t.Reads)
	}
	if t.Writes < 0 {
		return Profile{}, fmt.Errorf("writes must be >= 0, got %d", t.Writes)
	}
	if t.DistinctKeys < 0 {
		return Profile{}, fmt.Errorf("distinct keys must be >= 0, got %d", t.DistinctKeys)
	}
	if t.AvgValueBytes < 0 {
		return Profile{}, fmt.Errorf("avg value bytes must be >= 0, got %d", t.AvgValueBytes)
	}

	ratio := -1.0
	if t.Writes > 0 {
		ratio = float64(t.Reads) / float64(t.Writes)
	}

	return Profile{
		ReadPerWrite:    ratio,
		WorkingSetBytes: t.DistinctKeys * int64(t.AvgValueBytes),
	}, nil
}

// Recommend 只回答两个问题:要不要缓存,以及命中率至少要到多少。
// 纯读流量没有写放大,任意命中都是净收益,因此不设命中率下限。
func Recommend(p Profile) Plan {
	if p.ReadPerWrite < 0 {
		return Plan{NeedsCache: true}
	}
	if p.ReadPerWrite < CacheReadWriteThreshold {
		return Plan{}
	}
	// 写请求占总请求的比例,就是缓存至少要挡掉的比例。
	return Plan{
		NeedsCache: true,
		MinHitRate: 1 / (1 + p.ReadPerWrite),
	}
}

Recommend 把画像翻译成一个很小的计划:要不要缓存,以及命中率至少要达到多少。它没有试图给出容量、TTL 或淘汰策略,那些需要结合具体产品和存储成本判断。把建议限制在两个字段里,是为了让调用方明确知道自己还需要补哪些决策,而不是把一份“最佳实践”直接当成答案。

纯读流量的处理值得单独说明。ReadPerWrite 为 -1 时,Recommend 直接建议缓存,且不设命中率下限。没有写就没有失效成本,任何一次命中都是净收益。这看起来是一个小分支,但它避免了“除以零”和“把纯读误判成低收益”两个常见错误。

需要强调的是,这里的 20 倍阈值是包内的显式常量,不是从数据里学出来的。产品可以按自己的写放大和一致性要求调整它,但调整必须是一个有记录的决定,而不是散落在各个服务里的魔法数字。

测试:读多写比、只读与非法输入

这段逻辑的边界比正常路径更重要:写次数为 0、计数为负、恰好落在阈值上,这三种输入最容易在上线后制造分歧。下面的测试把它们固定下来。

// file: internal/cache/profile_test.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import (
	"math"
	"testing"
)

func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }

func TestAnalyzeComputesRatioAndWorkingSet(t *testing.T) {
	got, err := Analyze(Traffic{
		Reads:         4000,
		Writes:        100,
		DistinctKeys:  12000,
		AvgValueBytes: 512,
	})
	if err != nil {
		t.Fatalf("Analyze returned error: %v", err)
	}
	if !almostEqual(got.ReadPerWrite, 40) {
		t.Fatalf("ReadPerWrite = %v, want 40", got.ReadPerWrite)
	}
	if got.WorkingSetBytes != 12000*512 {
		t.Fatalf("WorkingSetBytes = %d, want %d", got.WorkingSetBytes, 12000*512)
	}
}

func TestAnalyzeReadOnlyUsesMinusOne(t *testing.T) {
	got, err := Analyze(Traffic{Reads: 90, Writes: 0, DistinctKeys: 3, AvgValueBytes: 100})
	if err != nil {
		t.Fatalf("Analyze returned error: %v", err)
	}
	if got.ReadPerWrite != -1 {
		t.Fatalf("ReadPerWrite = %v, want -1", got.ReadPerWrite)
	}
	if got.WorkingSetBytes != 300 {
		t.Fatalf("WorkingSetBytes = %d, want 300", got.WorkingSetBytes)
	}
}

func TestAnalyzeRejectsNegativeInput(t *testing.T) {
	cases := []Traffic{
		{Reads: -1},
		{Writes: -1},
		{DistinctKeys: -1},
		{AvgValueBytes: -1},
	}
	for i, tc := range cases {
		if _, err := Analyze(tc); err == nil {
			t.Fatalf("case %d: expected an error", i)
		}
	}
}

func TestRecommendBelowThreshold(t *testing.T) {
	got := Recommend(Profile{ReadPerWrite: 4})
	if got.NeedsCache {
		t.Fatalf("NeedsCache = true for ratio 4, want false")
	}
}

func TestRecommendAtThreshold(t *testing.T) {
	got := Recommend(Profile{ReadPerWrite: 20})
	if !got.NeedsCache {
		t.Fatal("NeedsCache = false for ratio 20, want true")
	}
	if !almostEqual(got.MinHitRate, 1.0/21.0) {
		t.Fatalf("MinHitRate = %v, want %v", got.MinHitRate, 1.0/21.0)
	}
}

func TestRecommendReadOnly(t *testing.T) {
	got := Recommend(Profile{ReadPerWrite: -1})
	if !got.NeedsCache {
		t.Fatal("NeedsCache = false for a read-only workload")
	}
	if got.MinHitRate != 0 {
		t.Fatalf("MinHitRate = %v, want 0", got.MinHitRate)
	}
}

TestAnalyzeComputesRatioAndWorkingSet 同时验证两个乘法和一个除法:4000 次读、100 次写得到 40 倍,12000 个键乘以 512 字节得到 6,144,000 字节。这个用例的意义在于把“工作集按独立键算”写死,防止以后有人改成按请求量估算。

TestAnalyzeReadOnlyUsesMinusOne 锁定 -1 这个哨兵值。它比返回一个很大的数更好,因为很大的数会在后续计算里被当成真实比例,而 -1 能明确表达“没有写”这个特殊状态。

TestAnalyzeRejectsNegativeInput 覆盖四个字段的负数输入。测试不关心错误文案,只关心“必须报错”,这样错误信息可以继续打磨而不破坏契约。

TestRecommendBelowThreshold、TestRecommendAtThreshold 和 TestRecommendReadOnly 分别固定三个决策分支:低于 20 倍不缓存、等于 20 倍缓存且最小命中率为 1/21、纯读缓存且不设下限。阈值上的浮点比较使用了一个很小的容差,避免因为 1.0/21.0 的计算顺序不同而误报。

这些测试不验证真实流量是否被正确采集,也不验证缓存容量是否足够。采集属于埋点和数据管道,容量属于压测和容量规划;单元测试只负责把“从计数到建议”的语义钉死。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/cache/ -count=1 -run 'TestAnalyze|TestRecommend' -v

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

=== RUN   TestAnalyzeComputesRatioAndWorkingSet
--- PASS: TestAnalyzeComputesRatioAndWorkingSet (0.00s)
=== RUN   TestAnalyzeReadOnlyUsesMinusOne
--- PASS: TestAnalyzeReadOnlyUsesMinusOne (0.00s)
=== RUN   TestAnalyzeRejectsNegativeInput
--- PASS: TestAnalyzeRejectsNegativeInput (0.00s)
=== RUN   TestRecommendBelowThreshold
--- PASS: TestRecommendBelowThreshold (0.00s)
=== RUN   TestRecommendAtThreshold
--- PASS: TestRecommendAtThreshold (0.00s)
=== RUN   TestRecommendReadOnly
--- PASS: TestRecommendReadOnly (0.00s)
PASS
ok  	webapp/internal/cache	0.231s

-run 的正则只挑选本节相关的测试,避免把窗口和热点探测的输出混进来。0.231s 只是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

读多写少不是常识,而是一组要测出来的事实:读多少次、写多少次、有多少独立键、每个值多大、键的访问有多集中、一次写会扇出多少存储操作。只测请求方向,容量估算就会偏;只按 DAU 推请求量,长尾冷数据就会挤掉真正的热键。

Analyze 把计数换成人能用来决策的画像,Recommend 把画像收窄成两个字段:要不要缓存、命中率下限是多少。代码很小,但它的价值在于让“加不加缓存”从口头判断变成一个可以复现、可以测试、可以随产品数据更新的结论。

下一节会讨论缓存放在哪里,以及每多一层缓存,用户可能看到多久之前的数据。

阅读导航:上一节:目录与学习路径 · 下一节:8.2 缓存位置与一致性窗口 。

常见错误与边界

  • 用 DAU 乘人均请求数直接推缓存容量。请求量和键空间是两个维度,中间还隔着值的平均大小。
  • 把二八定律当成键数量的分布。20% 的热键贡献 80% 的请求,不代表 80% 的键都值得缓存。
  • 只在 API 层统计读写比。点赞、计数、状态变更在存储层的写放大可能让真实写成本高出一个数量级。
  • 用固定常数估算 value 大小。字段裁剪、压缩和序列化方式都会改变它,应该从真实样本抽样。
  • 写次数为 0 时做除法。用 -1 或单独的状态位表达“没有写”,比返回无穷大或 0 更不容易误判。
  • 把 20 倍阈值当成行业标准。它是本包的显式约定,不同产品应按写放大和一致性要求调整并记录理由。
  • 缓存命中率低就加内存。先看淘汰策略和键的访问分布,冷键污染时加内存只会把问题放大。
  • 把“建议缓存”直接当成“已经设计好缓存”。容量、TTL、失效和降级仍然要单独决策。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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