一个普通用户的首页,作者分成三种速度
「回声」里一个普通用户假设关注了 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 参与比较会绕过范围检查,必须在转换前显式拦截。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。