《互联网产品的服务端架构·内容、社交与实时互动》3.2 推模式:写时扩散

从一条粉丝 500 万的明星动态出发,拆解推模式为什么把成本放在写时:一条内容要扩散多少行、写队列如何被大 V 堵死、普通用户为什么跟着延迟,并用可测试的 Go 策略选择与成本函数固定粉丝门槛、写读成本与负数校验的边界,最后给出队列隔离与幂等重试的取舍。

一条动态要写 500 万行

「回声」里假设有一位粉丝 500 万的明星账号。产品希望“发完立刻出现在粉丝首页”,第一版实现自然选了推模式:发布时把这条动态写进每个粉丝的收件箱。粉丝 500 万,就是 500 万行;如果一条动态还要带图片、话题、排序分和权限标记,写入放大的就不只是 500 万行,而是 500 万条收件箱记录背后的多次索引更新。

推模式的直觉很吸引人:发布时辛苦一次,读取时只读自己的收件箱,简单、快、可预测。问题在于“辛苦一次”的代价不是常数,而是随粉丝数线性增长。粉丝 500 的普通用户发一条动态,写 500 行;粉丝 500 万的明星发一条动态,写 500 万行。同样是“发一条”,成本差了一万倍。

站内《关注关系与时间线:推拉模式、Fanout 与混合架构》已经把推模式、Fanout 和混合架构的基本形态讲清楚,可配合 /miniblog-follow-timeline-push-pull/ 阅读。本文不重述定义,只讨论写时扩散在什么规模下会从“快”变成“堵”,以及怎样把策略选择写成可测试的代码。

推模式的核心不是“提前写”,而是“用写放大换读简单”。读路径只读一份预计算的时间线,代价是发布路径要为每个粉丝维护一份副本。这个交换在小规模下非常划算,在大规模下会把写队列、存储和重试全部推到极限。

推模式把成本放在写时

推模式的写成本可以粗略写成:粉丝数 × 每条收件箱记录的写入成本。读取成本则接近常数:读一份预计算列表,再截断成一页。粉丝数越多,写成本越高,读成本越稳定;这正是推模式适合小账号、不适合大账号的原因。

下面的数字是案例假设值,只用于比较量级,不是任何真实系统的实测。扩散行数按“每个粉丝一行”估算,没有计算多端同步、索引和通知。

作者规模(案例假设)单条发布扩散行数写放大读时来源数主要瓶颈默认策略
500 粉丝500500 倍1基本没有push
5 万粉丝50,0005 万倍1写队列与存储hybrid
500 万粉丝5,000,000500 万倍1写队列、存储、重试pull

表中的“默认策略”来自一个经验分界:粉丝数小于 1000 时,写 1000 行以内的成本可控,推模式最省事;粉丝数大于 10 万时,写扩散的放大倍数已经不适合同步或准同步完成,读时聚合更稳;中间地带用混合,把一部分粉丝的收件箱预计算出来,另一部分读时再合并。

这些门槛是案例假设,不是行业标准。真实阈值取决于单行写入成本、存储成本、粉丝活跃度和读取频率。一个粉丝很少但极其活跃的账号,可能比一个粉丝很多但全是僵尸粉的账号更值得推;策略选择必须基于可观测数据,而不是只看粉丝数。

推模式还有一个容易被忽略的成本:删除和修改。发布时要写 500 万行,删除时也要删 500 万行,修改可见性或置顶时还要更新 500 万行。读时聚合只需要改一份源数据,写时扩散要把变更重新扩散一遍。很多团队在上线半年后才发现,删除比发布更难。

推模式的读路径也不是完全没有成本。预计算的时间线要占存储,要进缓存,要按粉丝分片;粉丝数越多,存储和缓存的副本越多,缓存失效时的回源也会更集中。只是这些成本被摊到了读侧,不像写队列那样容易在一个明星发布时集中爆发。

如果 fanout 是异步的,还会出现“半成品时间线”:发布已经成功,部分粉丝的收件箱写完了,另一部分还没写完。用户刷新时看到的内容取决于自己落在哪一批,产品上表现为“有人能看到、有人看不到”。这个窗口在大账号发布时会被拉长,客服很难解释。

异步 fanout 还会打乱顺序。多个消费者并行写同一个收件箱,后发布的内容可能先落库;如果没有统一的排序键和合并逻辑,时间线会出现“新内容排在旧内容后面”。读时聚合没有这个问题,因为它每次读取都重新排序。

所以推模式的关键不是“要不要提前算”,而是“每个账号的写工作量是否有上限”。只要一个账号可以让系统写几百万行,它就有能力拖垮共享资源;阈值、隔离和跳过机制都是在给这个工作量设上限。

明星发一条动态,写队列堵住所有普通用户

案例假设:某天晚上,粉丝 500 万的明星发了一条动态。发布服务把这条动态拆成 500 万个 fanout 任务,投进一个所有作者共用的写队列。队列的消费速度假设是每秒 2 万行,500 万行至少需要 250 秒才能全部扩散完。

问题不在 250 秒本身,而在队列是共用的。普通用户在这 250 秒内发布的动态,全部排在明星任务后面;他们的粉丝刷新首页时看不到新内容,以为发布失败,于是重复发布,产生更多任务。队列越积越长,延迟从几秒涨到几分钟,最后连删除、点赞计数这些依赖同一队列的操作也开始超时。

重试会让情况更糟。fanout 任务失败后自动重试,但没有幂等键,同一条动态可能被写进同一个收件箱两次;消费端为了避免重复,又加了一层去重查询,单行写入成本上升,队列消费更慢。团队先加消费者,发现数据库连接不够;再分片队列,发现热点仍然集中在明星那一条任务上。

这类故障的后果不是“明星的动态晚了几分钟”,而是普通用户的发布、删除、互动一起被拖慢。写队列是一个共享资源,一个大账号的突发流量可以占满它,让所有小账号替它排队。推模式的风险从来不只是“写得多”,而是“写得多的那一个把大家堵住”。

修复方向有三层:第一层是隔离,按作者或粉丝规模拆队列,大账号的任务不进入普通队列;第二层是跳过,超过阈值的账号直接走读时聚合,不生成 fanout 任务;第三层是幂等,任务带唯一键,重复消费不产生重复行。三层里最便宜的是跳过,最难的是隔离之后的容量规划。

用 Choose 和 FanoutCost 把策略选择写成函数

策略选择如果只写在文档里,很容易变成“上次开会说大 V 用拉”。把它写成两个纯函数:Choose 根据粉丝数返回 push、pull 或 hybrid;FanoutCost 按策略估算一次发布和一次读取的成本。这样门槛可以被测试锁定,新增策略时也只需要补用例。

下面的代码里,Choose 只负责阈值判断,FanoutCost 负责成本计算和负数校验。混合策略没有作者活跃度输入,所以代码用“一半粉丝走推、一半走拉”作为占位假设;真实系统应该把这一半换成活跃粉丝比例,而不是固定 50%。

// file: internal/feed/push.go
// tier: L1
// verify: go test ./internal/feed/

package feed

import "fmt"

// Strategy 是内容分发的成本承担方式。
type Strategy string

const (
	Push   Strategy = "push"
	Pull   Strategy = "pull"
	Hybrid Strategy = "hybrid"
)

// Fanout 描述一次发布面对的粉丝规模,以及单次写/读的假设成本(毫秒)。
// 成本是案例参数,生产环境应由压测得到,而不是沿用这里的默认值。
type Fanout struct {
	Followers   int
	WriteCostMS float64
	ReadCostMS  float64
}

// Choose 按粉丝规模选择默认策略:小账号写扩散,大账号读聚合,
// 中间地带交给混合时间线。门槛是案例假设,不是行业标准。
func Choose(f Fanout) Strategy {
	switch {
	case f.Followers < 1000:
		return Push
	case f.Followers > 100000:
		return Pull
	default:
		return Hybrid
	}
}

// FanoutCost 返回一次发布(writes)和一次读取(reads)的估算成本。
// 成本单位是毫秒,按 Choose 选出的策略计算:
//
//	push   发布时写 N 份,读取时只读一份预计算结果;
//	pull   发布时只写一份,读取时合并 N 个来源;
//	hybrid 假设一半粉丝走推、一半走拉,读取同时包含预计算和合并。
//
// 粉丝数、写成本、读成本任一为负都报错。
func FanoutCost(f Fanout) (writes, reads float64, err error) {
	if f.Followers < 0 {
		return 0, 0, fmt.Errorf("feed: negative followers: %d", f.Followers)
	}
	if f.WriteCostMS < 0 || f.ReadCostMS < 0 {
		return 0, 0, fmt.Errorf("feed: negative cost: write=%.2f read=%.2f", f.WriteCostMS, f.ReadCostMS)
	}

	switch Choose(f) {
	case Push:
		return float64(f.Followers) * f.WriteCostMS, f.ReadCostMS, nil
	case Pull:
		return f.WriteCostMS, float64(f.Followers) * f.ReadCostMS, nil
	default:
		pushed := f.Followers / 2
		pulled := f.Followers - pushed
		return float64(pushed) * f.WriteCostMS, f.ReadCostMS + float64(pulled)*f.ReadCostMS, nil
	}
}

这段代码有几个值得说明的边界。Choose 的签名不返回错误,所以负数粉丝数只能由 FanoutCost 拦截;调用方应该先算成本,再使用策略,而不是直接拿 Choose 的结果去建任务。FanoutCost 对粉丝数、写成本、读成本分别检查负数,是因为这三个输入来自不同系统,任何一个口径错了都应该早失败。

成本函数的单位和语义也要固定:writes 是一次发布的估算成本,reads 是一次读取的估算成本,单位都是毫秒。推模式下 writes 随粉丝数增长、reads 接近常数;拉模式下 writes 接近常数、reads 随粉丝数增长;混合模式两者都取一部分。这个模型很粗,但它能把“推还是拉”的讨论从感觉变成可比较的数字。

还要注意 Choose 的阈值是闭区间边界:1000 和 100000 都落在 hybrid,只有小于 1000 才是 push,大于 100000 才是 pull。边界写错一位,线上就会多出一批本不该扩散的账号。测试里专门覆盖了 999、1000、100000、100001 四个点。

测试:门槛、成本与负数

测试分三组:第一组锁定 push、hybrid、pull 的边界;第二组验证三种策略在一次发布和一次读取上的成本;第三组确认负数粉丝数、负数写成本、负数读成本都会报错。成本用整数倍的假设值,避免浮点误差让断言变得脆弱。

// file: internal/feed/push_test.go
// tier: L1
// verify: go test ./internal/feed/

package feed

import "testing"

func TestChooseThresholds(t *testing.T) {
	cases := []struct {
		followers int
		want      Strategy
	}{
		{0, Push},
		{999, Push},
		{1000, Hybrid},
		{50000, Hybrid},
		{100000, Hybrid},
		{100001, Pull},
		{5000000, Pull},
	}
	for _, c := range cases {
		if got := Choose(Fanout{Followers: c.followers}); got != c.want {
			t.Fatalf("Choose(%d) = %s, want %s", c.followers, got, c.want)
		}
	}
}

func TestFanoutCostPerStrategy(t *testing.T) {
	cases := []struct {
		name       string
		f          Fanout
		wantWrites float64
		wantReads  float64
	}{
		{"push", Fanout{Followers: 500, WriteCostMS: 2, ReadCostMS: 3}, 1000, 3},
		{"pull", Fanout{Followers: 200000, WriteCostMS: 2, ReadCostMS: 3}, 2, 600000},
		{"hybrid", Fanout{Followers: 10000, WriteCostMS: 2, ReadCostMS: 3}, 10000, 15003},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			writes, reads, err := FanoutCost(c.f)
			if err != nil {
				t.Fatal(err)
			}
			if writes != c.wantWrites || reads != c.wantReads {
				t.Fatalf("cost = (%.1f, %.1f), want (%.1f, %.1f)", writes, reads, c.wantWrites, c.wantReads)
			}
		})
	}
}

func TestFanoutCostRejectsNegativeInput(t *testing.T) {
	if _, _, err := FanoutCost(Fanout{Followers: -1}); err == nil {
		t.Fatal("want error for negative followers")
	}
	if _, _, err := FanoutCost(Fanout{Followers: 10, WriteCostMS: -1}); err == nil {
		t.Fatal("want error for negative write cost")
	}
	if _, _, err := FanoutCost(Fanout{Followers: 10, ReadCostMS: -1}); err == nil {
		t.Fatal("want error for negative read cost")
	}
}

第一组用例里最值得看的是 1000 和 100000 两个点。它们不是“大概的范围”,而是策略切换的精确边界;如果后人为了某个业务把 > 改成 >=,这两个用例会立刻失败。第二组用例把三种策略的成本并列,方便比较写放大和读放大的方向,而不是只记住一个策略名字。

测试没有覆盖的部分同样要说清楚:它不验证 50% 的混合比例是否合理,不验证单行写入成本是否随批量大小变化,也不验证队列隔离是否真的生效。这些需要压测和线上观测来回答,代码只负责把策略选择和成本估算的边界固定下来。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/feed/ -run 'TestChoose|TestFanoutCost' -count=1 -v

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

=== RUN   TestChooseThresholds
--- PASS: TestChooseThresholds (0.00s)
=== RUN   TestFanoutCostPerStrategy
=== RUN   TestFanoutCostPerStrategy/push
=== RUN   TestFanoutCostPerStrategy/pull
=== RUN   TestFanoutCostPerStrategy/hybrid
--- PASS: TestFanoutCostPerStrategy (0.00s)
    --- PASS: TestFanoutCostPerStrategy/push (0.00s)
    --- PASS: TestFanoutCostPerStrategy/pull (0.00s)
    --- PASS: TestFanoutCostPerStrategy/hybrid (0.00s)
=== RUN   TestFanoutCostRejectsNegativeInput
--- PASS: TestFanoutCostRejectsNegativeInput (0.00s)
PASS
ok  	webapp/internal/feed	0.290s

输出里的子测试名对应三种策略,PASS 表示包内测试通过。ok 后面的耗时是本次运行的真实耗时,不是生产环境的写入延迟。真正上线前,要用自己的单行写入成本、粉丝分布和队列吞吐替换代码里的假设值,再决定阈值落在哪里。

小结

推模式用写放大换读简单:发布时写 N 份,读取时只读一份。粉丝数小的时候这笔交易很划算,粉丝数大到几百万时,写队列、存储和重试会被同时推到极限。明星发一条动态堵住所有普通用户,不是明星的代码特殊,而是共享队列把局部放大变成了全局故障。

Choose 和 FanoutCost 把策略选择写成可测试的函数:小于 1000 用 push,大于 100000 用 pull,中间用 hybrid,负数输入报错。阈值和 50% 的混合比例都是案例假设,上线前必须用真实数据替换。

下一节把推和拉放进同一条时间线,看看混合策略为什么必须按作者分层,以及合并顺序怎样决定大 V 内容能不能出现在第一屏。

阅读导航:上一节:3.1 拉模式:读时聚合 · 下一节:3.3 推拉结合与混合时间线 。

常见错误与边界

  • 只看平均写耗时。大账号发布是突发流量,平均值会掩盖队列排队和 p99 延迟。
  • 让大账号和普通用户共用写队列。一个明星任务就能占满队列,普通用户的发布和删除跟着超时。
  • fanout 任务没有幂等键。重试会把同一条内容写进同一个收件箱多次,去重查询又进一步拖慢消费。
  • 把粉丝数阈值当行业标准。阈值取决于单行成本、活跃粉丝比例和读取频率,必须用自己的数据重新测。
  • 忽略存储和删除放大。写 500 万行意味着删除、修改可见性也要处理 500 万行,成本不是一次性的。
  • 在发布请求里同步做 fanout。发布接口的延迟会直接等于扩散延迟,粉丝越多越容易超时。
  • 把 hybrid 当免费方案。混合策略同时保留推和拉两套路径,监控、排障和数据一致性都更复杂。
  • 阈值边界写成开区间。1000 和 100000 落在哪个策略必须用测试锁死,不能靠口头约定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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