《互联网产品的服务端架构·内容、社交与实时互动》3.1 拉模式:读时聚合

从关注 500 人与 5000 人的首页刷新出发,拆解拉模式为什么把成本放在读时:一次刷新究竟要合并多少来源、并发查收件箱怎样拖垮 p99、关注 3000 人如何把首页延迟推到 3 秒,并用可测试的 Go 合并函数固定去重、排序与截断的边界,最后给出容量估算与降级顺序。

首页刷新时,500 个收件箱同时被打开

「回声」的首页时间线假设有 800 个关注,其中 500 个是活跃作者。产品经理的要求是“下拉刷新要快”,工程师的第一版实现也很直接:用户点刷新,服务端取出关注列表,逐个作者查最近内容,合并后返回。关注 500 人就是 500 次查询,关注 5000 人就是 5000 次查询,代码没有变,成本却随关注数线性增长。

站内已经有一篇《关注关系与时间线:推拉模式、Fanout 与混合架构》把推拉模式、Fanout、分页一致性讲得很完整,本文不重述定义,只把“读时聚合”这笔账算到代码、数字和一次案例故障的边界上,可配合 /miniblog-follow-timeline-push-pull/ 阅读。

拉模式的核心不是“读的时候再查”,而是“每次读都要为关注关系付一次合并成本”。这个成本由三个变量决定:关注数、单个来源的查询耗时、以及一次刷新允许多少并发。三者相乘,决定了首页 p99 的下限,也决定了故障时最先被打满的是连接池、线程池还是下游数据库。

很多人把拉模式理解成“简单”,因为它不需要在发布时做额外工作。简单的是发布路径,复杂的是读取路径:一次刷新要访问几十到几千个来源,任何一个来源变慢都会抬高整次刷新的尾延迟。拉模式不是没有成本,只是把成本从写时挪到了读时。

一次读要合并多少源

把关注关系摊开看,拉模式的输入不是“一个用户的收件箱”,而是“这个用户关注的所有作者的收件箱”。用户刷新一次,服务端就要把这些来源的近期条目取回来,去掉重复内容,按分数和时间排序,再截断成一页。关注数越多,合并的源越多,单次读的工作量越大。

下面的数字是案例假设值,只用来比较量级,不是任何真实系统的实测。单源查询按 2 毫秒估算,并发度按 32 路估算,忽略网络抖动和调度排队。

关注数单次刷新合并源数串行最坏耗时32 路并发理论耗时实际风险
100100200ms约 8ms低,普通连接池可承受
5005001000ms约 32ms中,尾延迟开始可见
100010002000ms约 64ms中高,需要限并发与缓存
300030006000ms约 188ms高,队列与连接池先满
5000500010000ms约 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,平均值好看不代表高峰可用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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