《互联网产品的服务端架构·内容、社交与实时互动》3.3 推拉结合与混合时间线

从普通用户关注 800 人的首页出发,拆解混合时间线为什么必须按作者分层而不是按用户分层:活跃作者推、长尾作者拉,合并顺序与名额分配怎样决定大 V 内容能否出现在第一屏,并用可测试的 Go 混合函数固定比例、去重与上限的边界,最后给出覆盖率监控与灰度顺序。

一个普通用户的首页,作者分成三种速度

「回声」里一个普通用户假设关注了 800 人:20 个日更的大 V,180 个每周更新几次的作者,600 个一个月才发一条的长尾作者。如果全用推模式,每个作者发一条内容都要写 800 份;如果全用拉模式,用户每次刷新都要合并 800 个来源。两种极端都不合适。

混合时间线的目标不是“推一半、拉一半”这么简单,而是让每个作者的写成本和他的内容价值匹配:高频作者的内容值得提前算好,长尾作者的内容读时再查也不贵。判断的依据是作者的发布频率和粉丝活跃度,而不是用户的关注数或使用时长。

站内《关注关系与时间线:推拉模式、Fanout 与混合架构》对混合架构有整体介绍,可配合 /miniblog-follow-timeline-push-pull/ 阅读。本文把重点收窄到一个容易做错的细节:分层对象到底是作者还是用户,以及推、拉两路结果按什么顺序合并。

上一节已经说明,推模式的风险是写放大,拉模式的风险是读放大。混合时间线要同时接住这两种风险,所以它必须回答两个问题:哪些作者的写扩散值得提前做,哪些作者的条目可以等到读时再合并。回答错了,混合就会变成“两边的缺点都保留”。

混合时间线按作者分层,不按用户分层

最常见的错误是按用户分层:重度用户走推,轻度用户走拉。这个切法看起来合理,实际不成立。同一个重度用户既关注日更大 V,也关注几百个长尾作者;如果按用户分层,他要不然全部走推,要不然全部走拉,无法只对高频作者提前计算。

正确的切法是按作者分层。作者的发布频率决定写放大,作者的粉丝活跃度决定预计算值不值得做。一个日更大 V 的内容值得推给活跃粉丝;一个一个月发一条的长尾作者,读时查一次就够了。用户只是这些作者条目的消费者,不应该成为分发策略的主键。

作者类型判定依据(案例假设)分发方式单条写成本单次读成本主要风险
高频大 V日更且粉丝超过 10 万推给活跃粉丝,拉作为兜底高但可控低活跃粉丝判定漂移
中频作者周更,粉丝 1000 到 10 万推中低写队列抖动
长尾作者月更或粉丝少于 1000拉低中读取来源数增长

表里的判定是案例假设,不是固定规则。真实系统可以按“近 30 天发布条数 × 粉丝 7 日活跃率”分层,也可以按作者的历史阅读完成率分层。关键不是用哪个公式,而是分层的主语必须是作者,并且分层结果要能随作者行为变化而更新。

按作者分层还有一个好处:策略可以按作者维度灰度。先对 10% 的高频作者开启推,观察写队列和读取覆盖率;稳定后再扩大。按用户分层很难灰度,因为同一个用户的首页会同时受到两套策略影响,出问题时很难判断是哪一层导致的。

分层结果不能一次算完就固定。作者的发布频率会变化,粉丝活跃度也会随活动和季节波动;一个上个月还是长尾的作者,这个月可能因为一条爆款变成高频作者。分层任务应该定期重算,并且保留人工干预的入口,否则策略会跟着旧数据一起过期。

按作者分层也不意味着“所有粉丝都推”。同一个大 V 的粉丝里,活跃粉丝值得预计算,长期不登录的粉丝推了也不会读,只会增加存储和删除成本。所以推的对象应该是活跃粉丝集合,拉则作为不活跃粉丝和活跃粉丝的兜底路径;这比“大 V 全部拉”更细一层。

混合时间线的读路径通常是:先取推模式预计算的时间线,再取拉模式实时合并的条目,最后按统一规则排序、去重、截断。这个顺序看起来自然,但“先取推、再取拉”很容易变成“推优先、拉兜底”,下一节会看到它怎样让大 V 内容永远排在后面。

只对大 V 用拉,结果大 V 内容永远排在后面

案例假设:团队决定“大 V 粉丝太多,不推,改成拉;普通作者继续推”。普通用户关注了 20 个大 V 和 180 个中频作者。首页每次取 50 条,推模式预计算的时间线里已经有 180 条中频内容,拉模式再去查 20 个大 V 的最新条目。

如果合并逻辑是“先放推的结果,再用拉的结果补足剩余名额”,那么前 50 条全部来自推模式,大 V 内容一条也进不来。推模式的时间线越完整,拉模式的内容越没有机会出现。这不是大 V 内容质量差,而是合并顺序把它固定在了队尾。

另一种实现是“拉模式只作为兜底”:推模式结果不足 50 条时,才去拉大 V 内容。中频作者足够多时,推模式永远能填满 50 条,拉模式永远不会被触发。大 V 的内容在系统里存在,在用户首页上却不存在。

更隐蔽的问题是超时预算。拉模式要查 20 个大 V 的收件箱,如果读路径给拉模式的时间预算只有 50 毫秒,任何一个大 V 的查询慢一点,整批拉取就被放弃,页面仍然返回推模式的结果。用户看到的首页“正常”,但最重要的内容被静默丢掉了。

后果会自我强化:大 V 内容的曝光下降,互动数据变差,产品团队据此认为“大 V 内容不受欢迎”,进一步降低分发权重。真正的原因是合并顺序和超时预算,不是内容本身。要发现这个问题,不能只看首页总点击率,必须看“大 V 内容在首页的覆盖率”和“拉模式超时率”。

修复分三步:第一,推和拉的结果先合并再截断,不能先截断再合并;第二,给拉模式预留名额或时间预算,不能让它永远排在推模式后面;第三,大 V 不应该对所有粉丝都用拉,而应该推给活跃粉丝、只对不活跃粉丝用拉兜底。三步的核心都是同一句话:分层按作者,合并按统一排序。

用 Mix 把比例和合并写成函数

Mix 把“推、拉各占多少名额”和“合并去重”拆成两步:先按 pushRatio 计算两侧各取多少条,再从各自排好序的列表里取头部,最后交给 Merge 统一排序、去重、截断。这样比例只影响候选集,不影响最终排序规则。

下面的代码里,pushRatio=0 表示全拉,pushRatio=1 表示全推,中间值按 limit × pushRatio 四舍五入分配推模式名额。比例越界、NaN、limit 小于等于 0 都会报错。跨侧重复的内容由 Merge 按 ID 去重,保留分数更高的一条。

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

package feed

import (
	"fmt"
	"math"
)

// Mix 按 pushRatio 决定时间线里推、拉两侧各占多少名额,
// 分别取两侧分数最高的条目,再合并去重。
// pushRatio=0 表示全拉,1 表示全推;越界、NaN、limit<=0 都报错。
func Mix(push, pull []Item, pushRatio float64, limit int) ([]Item, error) {
	if math.IsNaN(pushRatio) || pushRatio < 0 || pushRatio > 1 {
		return nil, fmt.Errorf("feed: pushRatio %.3f outside [0,1]", pushRatio)
	}
	if limit <= 0 {
		return nil, fmt.Errorf("feed: limit must be positive, got %d", limit)
	}

	pushN := int(math.Round(float64(limit) * pushRatio))
	if pushN > limit {
		pushN = limit
	}
	pullN := limit - pushN

	left := head(ranked(push), pushN)
	right := head(ranked(pull), pullN)
	return Merge([][]Item{left, right}, limit)
}

// head 取前 n 条;n<=0 或来源为空时返回 nil,避免调用方再判空。
func head(items []Item, n int) []Item {
	if n <= 0 || len(items) == 0 {
		return nil
	}
	if n > len(items) {
		n = len(items)
	}
	return items[:n]
}

这段代码有几个刻意的设计。math.IsNaN 必须单独判断,因为 NaN 和任何数比较都返回 false,如果不显式检查,pushRatio < 0 || pushRatio > 1 会放过 NaN,后面转成整数时行为不可预测。head 在 n 小于等于 0 或来源为空时返回 nil,让 Merge 统一处理空来源,而不是在 Mix 里写两套分支。

比例分配的是名额,不是用户比例,也不是作者比例。pushRatio=0.5 表示这次读最多从推模式候选集里取一半名额,剩下的名额留给拉模式;它不表示“一半用户走推”。把名额比例和用户分层混为一谈,是混合时间线最常见的概念错误。

去重后结果可能少于 limit。比如推模式和拉模式都返回了同一条内容,合并后只剩一条,Mix 不会为了凑满 limit 再去拉更多来源。这是有意的:补位需要额外的读取和排序规则,应该由调用方决定,而不是藏在 Mix 里。调用方可以多取一些候选,或者在结果不足时触发一次兜底查询。

测试:比例、全推全拉与跨侧去重

测试覆盖四类边界:50% 比例下两侧各取多少条、比例为 0 和 1 时是否只保留一侧、跨侧重复内容是否保留高分、比例越界和 NaN 是否报错。测试数据都用手写的 Item,不依赖排序服务的输出,方便复现。

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

package feed

import (
	"math"
	"testing"
)

func TestMixSplitsByRatio(t *testing.T) {
	push := []Item{
		{ID: "p1", Score: 10, At: 100},
		{ID: "p2", Score: 8, At: 90},
		{ID: "p3", Score: 6, At: 80},
	}
	pull := []Item{
		{ID: "l1", Score: 9, At: 95},
		{ID: "l2", Score: 7, At: 85},
		{ID: "l3", Score: 5, At: 75},
	}

	got, err := Mix(push, pull, 0.5, 4)
	if err != nil {
		t.Fatal(err)
	}
	want := []string{"p1", "l1", "p2", "l2"}
	if len(got) != len(want) {
		t.Fatalf("got %d items, want %d: %+v", len(got), len(want), got)
	}
	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 TestMixAllPullAndAllPush(t *testing.T) {
	push := []Item{{ID: "p", Score: 10}}
	pull := []Item{{ID: "l", Score: 9}}

	got, err := Mix(push, pull, 0, 5)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || got[0].ID != "l" {
		t.Fatalf("ratio 0 = %+v, want only l", got)
	}

	got, err = Mix(push, pull, 1, 5)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || got[0].ID != "p" {
		t.Fatalf("ratio 1 = %+v, want only p", got)
	}
}

func TestMixDeduplicatesAcrossSides(t *testing.T) {
	push := []Item{{ID: "same", Score: 10, At: 100}}
	pull := []Item{
		{ID: "same", Score: 12, At: 90},
		{ID: "other", Score: 8, At: 80},
	}

	got, err := Mix(push, pull, 0.5, 2)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 {
		t.Fatalf("got %d items, want 1 after dedupe: %+v", len(got), got)
	}
	if got[0].ID != "same" || got[0].Score != 12 {
		t.Fatalf("got %+v, want same with score 12", got[0])
	}
}

func TestMixRejectsInvalidInput(t *testing.T) {
	items := []Item{{ID: "x", Score: 1}}
	for _, ratio := range []float64{-0.1, 1.1, math.NaN()} {
		if _, err := Mix(items, items, ratio, 10); err == nil {
			t.Fatalf("want error for ratio %v", ratio)
		}
	}
	if _, err := Mix(items, items, 0.5, 0); err == nil {
		t.Fatal("want error for zero limit")
	}
	if _, err := Mix(items, items, 0.5, -1); err == nil {
		t.Fatal("want error for negative limit")
	}
}

TestMixSplitsByRatio 用 3 条推、3 条拉、limit 4、比例 0.5,期望推取 2 条、拉取 2 条,再按分数合并成 p1、l1、p2、l2。这个用例同时验证了“按比例取候选”和“合并后重新排序”两件事,避免实现变成简单的拼接。

TestMixDeduplicatesAcrossSides 故意让两侧返回同一个 ID,且拉侧分数更高。合并后只剩一条,长度小于 limit,用来锁定“去重后不补位”的行为。如果后人为了凑满一页改成循环补位,这个用例会失败,提醒他补位需要新的读取预算和超时策略。

测试没有覆盖的部分:它不验证 50% 比例是否适合真实产品,不验证拉模式来源的超时和限流,也不验证大 V 内容在首页的覆盖率。这些需要线上指标和 A/B 实验来回答。Mix 只保证给定候选集之后,名额分配、去重和排序是可复现的。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestMixSplitsByRatio
--- PASS: TestMixSplitsByRatio (0.00s)
=== RUN   TestMixAllPullAndAllPush
--- PASS: TestMixAllPullAndAllPush (0.00s)
=== RUN   TestMixDeduplicatesAcrossSides
--- PASS: TestMixDeduplicatesAcrossSides (0.00s)
=== RUN   TestMixRejectsInvalidInput
--- PASS: TestMixRejectsInvalidInput (0.00s)
PASS
ok  	webapp/internal/feed	0.327s

输出里的四个用例对应四类边界,PASS 表示包内测试通过。ok 后面的时间是本次运行耗时,不是首页读取延迟。上线前要用真实的推、拉候选集大小和超时预算做压测,确认 Mix 的排序和去重不会成为新的瓶颈。

小结

混合时间线要同时控制写放大和读放大,所以分层对象必须是作者,不是用户。高频作者值得推,长尾作者适合拉;同一个用户首页里的不同作者可以走不同策略。按用户分层会把策略绑死在用户身上,无法只对高频作者提前计算。

推、拉两路结果必须先合并再截断,并且使用统一的排序规则。只对大 V 用拉、再把拉的结果放在推的结果后面,会让大 V 内容永远排在队尾;如果拉模式还只作为兜底,它甚至不会被触发。Mix 把名额分配和合并去重写成可测试的函数,但比例是否合理、超时预算是否足够,仍然要靠线上指标回答。

下一章从时间线继续往前走,讨论时间、热度与多样性怎样进入排序信号,以及为什么“按时间倒序”在内容多起来以后不够用。

阅读导航:上一节:3.2 推模式:写时扩散 · 下一节:4.1 时间、热度与多样性 。

常见错误与边界

  • 按用户分层。重度用户也关注长尾作者,按用户切会把推和拉绑死,无法按作者优化。
  • 把推、拉结果简单拼接。先截断再合并会让后一路永远排在队尾,大 V 内容可能一条都进不了首页。
  • 把拉模式只当兜底。推模式结果足够时拉模式永不触发,重要作者的内容被静默丢掉。
  • 把 pushRatio 当用户比例。它分配的是本次读取的候选名额,不是用户或作者的占比。
  • 拉模式没有独立的时间预算。任何一个慢来源都会拖垮整次读取,最后被整体放弃。
  • 去重后不补位也不告警。结果少于 limit 时页面看起来正常,实际曝光已经下降。
  • 不监控大 V 内容覆盖率。只看总点击率无法发现“某类作者永远排在后面”的结构性问题。
  • 比例越界或 NaN 不报错。NaN 参与比较会绕过范围检查,必须在转换前显式拦截。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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