首页刷新时,500 个收件箱同时被打开
「回声」的首页时间线假设有 800 个关注,其中 500 个是活跃作者。产品经理的要求是“下拉刷新要快”,工程师的第一版实现也很直接:用户点刷新,服务端取出关注列表,逐个作者查最近内容,合并后返回。关注 500 人就是 500 次查询,关注 5000 人就是 5000 次查询,代码没有变,成本却随关注数线性增长。
站内已经有一篇《关注关系与时间线:推拉模式、Fanout 与混合架构》把推拉模式、Fanout、分页一致性讲得很完整,本文不重述定义,只把“读时聚合”这笔账算到代码、数字和一次案例故障的边界上,可配合 /miniblog-follow-timeline-push-pull/ 阅读。
拉模式的核心不是“读的时候再查”,而是“每次读都要为关注关系付一次合并成本”。这个成本由三个变量决定:关注数、单个来源的查询耗时、以及一次刷新允许多少并发。三者相乘,决定了首页 p99 的下限,也决定了故障时最先被打满的是连接池、线程池还是下游数据库。
很多人把拉模式理解成“简单”,因为它不需要在发布时做额外工作。简单的是发布路径,复杂的是读取路径:一次刷新要访问几十到几千个来源,任何一个来源变慢都会抬高整次刷新的尾延迟。拉模式不是没有成本,只是把成本从写时挪到了读时。
一次读要合并多少源
把关注关系摊开看,拉模式的输入不是“一个用户的收件箱”,而是“这个用户关注的所有作者的收件箱”。用户刷新一次,服务端就要把这些来源的近期条目取回来,去掉重复内容,按分数和时间排序,再截断成一页。关注数越多,合并的源越多,单次读的工作量越大。
下面的数字是案例假设值,只用来比较量级,不是任何真实系统的实测。单源查询按 2 毫秒估算,并发度按 32 路估算,忽略网络抖动和调度排队。
| 关注数 | 单次刷新合并源数 | 串行最坏耗时 | 32 路并发理论耗时 | 实际风险 |
|---|---|---|---|---|
| 100 | 100 | 200ms | 约 8ms | 低,普通连接池可承受 |
| 500 | 500 | 1000ms | 约 32ms | 中,尾延迟开始可见 |
| 1000 | 1000 | 2000ms | 约 64ms | 中高,需要限并发与缓存 |
| 3000 | 3000 | 6000ms | 约 188ms | 高,队列与连接池先满 |
| 5000 | 5000 | 10000ms | 约 313ms | 很高,必须预计算或分层 |
表里的“32 路并发理论耗时”只是把总查询数除以并发度再乘单源耗时,真实系统还要加上连接获取、调度、序列化和下游排队。关注 500 人和 5000 人的区别,不是请求数多 10 倍,而是连接占用多 10 倍、尾延迟暴露面多 10 倍、缓存命中率下降后回源压力多 10 倍。
这也是拉模式最容易被低估的地方:平均耗时看起来还能接受,p99 却可能因为少数慢源被拉到几秒。一次刷新合并 3000 个源,只要有 1% 的源变慢到 100 毫秒,就会有 30 个慢源同时拖住这次刷新;并发度再高,也要等最慢的那一批。
可以把单次刷新的理论下界写成:关注数 / 并发度 × 单源耗时。这个公式没有考虑排队,所以它只能回答“最好情况有多快”,不能回答“高峰时会不会超时”。容量评估必须把并发刷新数、连接池大小和下游限额一起代入,而不是只看单个用户的关注数。
3000 个关注怎样把首页 p99 推到 3 秒
案例假设:某天晚高峰,一位重度用户关注了 3000 人,首页刷新触发 3000 次收件箱查询。服务端为每次刷新开一个 32 路的并发池,单源查询平均 2 毫秒。按理论值,3000 次查询分 94 轮,约 188 毫秒就能跑完,看起来完全可控。
问题出在并发刷新上。假设同一时刻有 1 万个用户在刷新,总查询量就是 3000 万次;数据库连接池只有 200 个连接,每个连接同一时刻只能服务一个查询。查询开始在连接池前排队,排队时间迅速超过查询本身。理论上的 188 毫秒,变成了 p50 800 毫秒、p99 3 秒。
更糟的是重试。客户端在 1 秒后超时重试,服务端又收到一批同样的刷新请求,查询量翻倍;健康检查因为线程池被占满而超时,负载均衡把节点摘掉,剩余节点压力更大。最后不是数据库慢,而是读路径的 fan-out 把整个服务拖垮,连不相关的接口也一起超时。
这类故障有三个典型后果:首页白屏或长时间转圈,用户反复下拉,进一步放大流量;重试请求没有幂等或合并,同一用户的多次刷新各查一遍;团队看到数据库 CPU 高,第一反应是加索引或加副本,但真正的问题是单次读放大了 3000 倍。修复顺序应该是先限源、再缓存、最后才考虑扩容。
限源不是简单地“最多查 100 个”,而是按活跃度、亲密度和内容新鲜度排序,只查最可能进入首页的那一批来源;缓存不是把整页结果缓存一小时,而是缓存每个来源的近期条目,并给合并结果一个很短的 TTL;扩容只能提高上限,不能改变 fan-out 的放大倍数,所以它排在最后。
用 Merge 把多源合并写成纯函数
拉模式的读路径可以拆成“取源”和“合并”两段。取源涉及网络、缓存和超时,必须先限并发、限源数;合并则是纯计算,适合写成可测试的函数。把合并逻辑独立出来,至少能保证三件事:跨源重复内容只保留一份、排序规则不随实现漂移、返回条数不超过上限。
下面的 Merge 接收多个来源,先按 ID 去重,保留分数更高的一条;再按 Score 降序、At 降序排序;最后截断到 limit。去重很重要,同一条内容可能同时出现在作者收件箱、话题收件箱和推荐收件箱里,如果不去重,用户会在一屏里看到两次。
// file: internal/feed/pull.go
// tier: L1
// verify: go test ./internal/feed/
package feed
import (
"fmt"
"sort"
)
// Item 是时间线上的一条候选内容。Score 由排序服务给出,
// At 是发布时间(Unix 秒),用于同分时保持新鲜度。
type Item struct {
ID string
AuthorID string
Score float64
At int64
}
// Merge 把多个来源的条目合并成一条时间线。
// 先按 ID 去重,保留分数更高的一条;再按 Score 降序、At 降序排列,
// 最后截断到 limit。nil sources 表示调用方忘了传来源,直接报错。
func Merge(sources [][]Item, limit int) ([]Item, error) {
if sources == nil {
return nil, fmt.Errorf("feed: sources is nil")
}
if limit <= 0 {
return nil, fmt.Errorf("feed: limit must be positive, got %d", limit)
}
all := make([]Item, 0)
for _, src := range sources {
all = append(all, src...)
}
out := ranked(all)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// ranked 去重并按 Score 降序、At 降序排序,供 Merge 与 Mix 共用。
// 同分同时间时用 ID 兜底,避免 map 遍历顺序让输出在多次运行之间漂移。
func ranked(items []Item) []Item {
best := make(map[string]Item, len(items))
for _, it := range items {
cur, ok := best[it.ID]
if !ok || better(it, cur) {
best[it.ID] = it
}
}
out := make([]Item, 0, len(best))
for _, it := range best {
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
if out[i].At != out[j].At {
return out[i].At > out[j].At
}
return out[i].ID < out[j].ID
})
return out
}
// better 定义同 ID 条目谁更值得保留:分数优先,同分时保留更新的。
func better(a, b Item) bool {
if a.Score != b.Score {
return a.Score > b.Score
}
return a.At > b.At
}
这段代码有几个刻意保留的边界。sources == nil 直接报错,而不是当成空列表,因为 nil 通常意味着调用方忘了把关注列表传进来,静默返回空首页比报错更难排查。limit <= 0 也报错,分页参数为 0 或负数时,返回空列表只会把问题推给客户端。
去重时保留分数更高的条目,而不是“先到先得”。同一条内容在不同来源里的分数可能不同,推荐来源的分数通常高于关注来源;如果按遍历顺序保留,最终展示哪一条就取决于 map 的遍历顺序,结果不可复现。ranked 在分数和时间都相同时用 ID 兜底排序,也是为了消除这种不确定性。
测试:去重、排序与上限
合并函数的边界比正常路径更容易出事,测试要把它们固定下来。下面五个用例分别覆盖:跨源去重保留高分、同分按时间排序、同分同时间按 ID 稳定排序、limit 截断,以及 nil 源与非法 limit 报错。
// file: internal/feed/pull_test.go
// tier: L1
// verify: go test ./internal/feed/
package feed
import "testing"
func TestMergeOrdersAndDeduplicates(t *testing.T) {
sources := [][]Item{
{
{ID: "a", AuthorID: "u1", Score: 10, At: 100},
{ID: "b", AuthorID: "u2", Score: 9, At: 50},
},
{
{ID: "a", AuthorID: "u1", Score: 12, At: 90},
{ID: "c", AuthorID: "u3", Score: 9, At: 70},
},
}
got, err := Merge(sources, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d items, want 3: %+v", len(got), got)
}
want := []string{"a", "c", "b"}
for i, id := range want {
if got[i].ID != id {
t.Fatalf("position %d = %s, want %s (all=%+v)", i, got[i].ID, id, got)
}
}
if got[0].Score != 12 {
t.Fatalf("duplicate kept score %.1f, want 12", got[0].Score)
}
}
func TestMergeTieBreaksByNewerThenID(t *testing.T) {
sources := [][]Item{{
{ID: "old", Score: 5, At: 10},
{ID: "new", Score: 5, At: 20},
{ID: "also-new", Score: 5, At: 20},
}}
got, err := Merge(sources, 10)
if err != nil {
t.Fatal(err)
}
want := []string{"also-new", "new", "old"}
for i, id := range want {
if got[i].ID != id {
t.Fatalf("position %d = %s, want %s (all=%+v)", i, got[i].ID, id, got)
}
}
}
func TestMergeKeepsHigherScoreAcrossSources(t *testing.T) {
sources := [][]Item{
{{ID: "x", Score: 3, At: 999}},
{{ID: "x", Score: 8, At: 1}},
}
got, err := Merge(sources, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d items, want 1", len(got))
}
if got[0].Score != 8 || got[0].At != 1 {
t.Fatalf("kept %+v, want score 8 at 1", got[0])
}
}
func TestMergeRejectsInvalidInput(t *testing.T) {
if _, err := Merge(nil, 10); err == nil {
t.Fatal("want error for nil sources")
}
if _, err := Merge([][]Item{}, 0); err == nil {
t.Fatal("want error for zero limit")
}
if _, err := Merge([][]Item{}, -3); err == nil {
t.Fatal("want error for negative limit")
}
}
func TestMergeHandlesEmptySources(t *testing.T) {
got, err := Merge([][]Item{{}, nil}, 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("got %d items, want 0", len(got))
}
}
前三个用例检查排序和去重,后两个检查错误路径。TestMergeTieBreaksByNewerThenID 里的三个条目分数相同,只有时间和 ID 不同,用来确保排序结果不是“碰巧正确”。TestMergeHandlesEmptySources 则明确:空来源不是错误,nil 的 sources 才是错误,这两个边界不能混在一起。
测试没有覆盖的部分也要说清楚:它不验证单源查询的超时、不验证 32 路并发是否合理,也不验证限源策略是否真的把最相关的内容排在前面。这些需要容量模型、压测和线上指标来回答,合并函数只负责把“给定来源之后”的计算做对。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/feed/ -run 'TestMerge' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestMergeOrdersAndDeduplicates
--- PASS: TestMergeOrdersAndDeduplicates (0.00s)
=== RUN TestMergeTieBreaksByNewerThenID
--- PASS: TestMergeTieBreaksByNewerThenID (0.00s)
=== RUN TestMergeKeepsHigherScoreAcrossSources
--- PASS: TestMergeKeepsHigherScoreAcrossSources (0.00s)
=== RUN TestMergeRejectsInvalidInput
--- PASS: TestMergeRejectsInvalidInput (0.00s)
=== RUN TestMergeHandlesEmptySources
--- PASS: TestMergeHandlesEmptySources (0.00s)
PASS
ok webapp/internal/feed 0.286s
输出里的每一行都对应一个边界用例。PASS 表示包内测试通过,ok 后面的时间是本次运行耗时,不是性能承诺。真正上线前,合并函数还要在接近真实基数的来源集合上跑基准测试,确认排序和去重的内存占用不会随关注数失控。
小结
拉模式把成本放在读时:发布简单,读取要为关注关系付一次合并成本。关注 500 人和 5000 人的区别不是多几次请求,而是连接占用、尾延迟暴露面和回源压力同时放大。一次刷新合并几千个源时,最先被打满的通常不是 CPU,而是连接池和线程池。
Merge 把合并规则写成纯函数:按 ID 去重、保留高分、按分数和时间排序、截断到 limit,并对 nil 来源和非法 limit 报错。它不能替代限源、缓存和容量规划,但能保证“拿到来源之后”的计算可复现、可回归。
下一节换一个方向:把成本从读时挪到写时,看看一条动态扩散给几百万粉丝时,写队列会怎样堵住普通用户。
阅读导航:上一节:目录与学习路径 · 下一节:3.2 推模式:写时扩散 。
常见错误与边界
- 把关注数当常量。用户关注数会随产品运营和推荐策略变化,容量模型必须按分位数而不是平均数准备。
- 无限开并发查所有来源。并发度上去以后,瓶颈会从单次查询转移到连接池和下游限流,反而让 p99 更差。
- 不去重或按遍历顺序去重。同一内容在多个来源里分数不同,按 map 顺序保留会让结果不可复现。
- 排序只按分数。同分内容如果没有时间或 ID 兜底,顺序会在多次运行之间漂移,测试也会变得不稳定。
- 把 limit 当软限制。先在内存里合并全部来源再截断,关注数很大时内存和 CPU 都会失控。
- 把 nil 来源静默当空列表。调用方漏传关注列表时,空首页比报错更难定位。
- 只压测平均延迟。拉模式的故障通常先出现在 p99,平均值好看不代表高峰可用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。