《互联网产品的服务端架构·内容、社交与实时互动》4.1 时间、热度与多样性

从三天前爆款占满首页的事故写起,拆解时间衰减、点赞热度与作者亲和度三种信号各自的偏差,说明多样性其实是限制同作者与同话题占比,而不是随机打散;给出带权重校验和稳定排序的可测试 Go 实现,以及新内容起不来的排查与修正路径,并附真实测试输出。

一条三天前的爆款,把首页变成了旧闻墙

「回声」的首页推荐在周三做了一次小改动:热度权重从 0.3 提到 0.7,时间权重从 0.5 降到 0.2。改动本身只有两个数字,评审时所有人都觉得“让用户先看到好内容”没有错。

周四上午,运营在群里贴了一张截图:首页前十条里有七条来自三天前的一篇爆款,作者是同一个账号,内容是同一件事的连续更新。当天新发的二十多条内容,只有一条挤进前二十。新人作者的曝光量在案例假设值下从每天 1200 次掉到 260 次,客服开始收到“发了没人看”的反馈。

更麻烦的是,团队最初把这当成“推荐不准”,又去调召回和特征。真正的问题在排序:当热度是一个累计值、时间只占很小权重时,过去积累的互动会一直压过今天的内容。排序函数对“老”没有惩罚,只对“新”有奖励,而且奖励太弱。

三个信号各自只看一个方向

排序不是把“好内容”挑出来,而是把几种互相冲突的目标压成一个分数。每加一个信号,就同时引入它看不见的东西。

信号它在奖励什么它看不见什么单独使用时的偏差常见的错误用法
时间新鲜、及时内容是否值得看首页变成时间线,旧的好内容永远沉底只看发布时间,不看用户是否已读
热度多数人已经认可小圈子、新作者、长尾老爆款长期霸榜,马太效应用累计点赞而不是单位时间增速
亲和度用户与作者的历史互动探索新作者、公共议题信息茧房,熟人内容反复出现把关注关系直接当亲和度,不做衰减
多样性约束作者与话题的分布单条内容的质量为凑比例塞入低质内容理解成随机打散,破坏相关性

时间信号的价值不只是“新”,而是让排序有机会重新洗牌。一个三天前的爆款如果仍然值得看,它应该靠热度或亲和度回来,而不是靠时间权重被动保留。时间衰减函数的选择会影响“半衰期”:1/(1+AgeHours) 在 1 小时处剩一半,24 小时处只剩约 4%,适合小时级刷新的信息流;如果是周更的深度内容,这个衰减就太快了。

热度信号最容易骗人,因为点赞是累计的。一篇昨天发布的文章和一个小时前发布的文章,如果都显示 500 赞,后者显然更热。正确的热度通常要看增量:过去一小时新增多少互动、曝光转化率是多少。直接用累计值,等于把“发布时间早”又奖励了一遍。

亲和度信号解决“我关心的人发了什么”,但它天然排外。用户越常互动,系统越确定他喜欢什么,也越少给他没见过的东西。亲和度需要和探索预算一起设计:比如首页固定留出两成位置给非关注作者,而不是让亲和度权重无限增大。

多样性不是随机打散。随机打散会让排序结果不可解释,也会把低质内容硬塞进首页。可执行的多样性是约束:同一个作者在首页前 N 条里最多出现 K 次,同一个话题最多占多少比例,超出部分往后挪。它改变的是分布,不是单条得分。

失败场景:热度权重 0.7,新内容永远起不来

把权重设成时间 0.2、热度 0.7、评论 0.05、亲和度 0.05,看起来时间仍然有一席之地。但热度用的是累计点赞,量级是几十到几千;时间项最大只有 0.2。一条 500 赞的旧内容,热度项就有 350 分,而一条刚发布、0 赞的新内容时间项最多 0.2 分。新内容除非在很短时间内爆发出大量互动,否则在排序里没有任何机会。

这个结构会自我强化。旧内容排在前面,获得更多曝光,继续积累点赞;新内容排在后面,曝光少,互动更少,下一轮排序更低。案例假设值下,一周后新人作者的次日留存下降 9%,内容发布量下降 18%。团队看到的是“供给变少”,于是加大创作者激励,但激励来的内容仍然排不上去。

修复不是简单把热度权重调回去。更稳的做法是三步:第一,热度改用时间窗口内的增量,比如过去 6 小时的点赞加评论,让“老”自然衰减;第二,时间项保留一个下限,保证新内容至少能被抽样展示;第三,加作者维度的多样性约束,同一作者在首页前 20 条最多 3 条,超出的降权而不是删除。

还要区分“排序”和“过滤”。已经读过的内容、同一事件的重复报道、明显低质的内容,应该在排序前处理,而不是靠权重把它们压下去。排序函数只负责在候选集里排先后,候选集本身的质量决定了上限。

用 Score 与 Sort 把权重和衰减写死

下面的实现来自 webapp-lab 的 internal/rank 包。Score 只做一件事:把一条内容和一组权重算成一个分数,并在输入不合法时明确报错。Sort 在副本上排序,保证调用方拿到的顺序变化不会污染缓存里的原切片。

时间衰减固定为 1/(1+AgeHours),不引入可调参数。这样做的代价是灵活性,好处是测试可以精确断言:0 小时得 1,1 小时得 0.5,3 小时得 0.25。权重必须全部非负且总和大于 0,负数点赞和负数评论直接报错,避免脏数据被静默算成一个看似正常的低分。

// file: internal/rank/score.go
// tier: L1
// verify: go test ./internal/rank/

package rank

import (
	"fmt"
	"sort"
)

// Weights 是四类信号在最终得分中的权重。
type Weights struct {
	Recency  float64
	Likes    float64
	Comments float64
	Affinity float64
}

// Item 是排序的最小输入,不依赖存储层。
type Item struct {
	ID       string
	AuthorID string
	AgeHours float64
	Likes    int
	Comments int
	Affinity float64
}

// Score 计算单条内容的得分。时间使用 1/(1+AgeHours) 衰减。
func Score(i Item, w Weights) (float64, error) {
	if w.Recency < 0 || w.Likes < 0 || w.Comments < 0 || w.Affinity < 0 {
		return 0, fmt.Errorf("weights must be >= 0: %+v", w)
	}
	if w.Recency+w.Likes+w.Comments+w.Affinity <= 0 {
		return 0, fmt.Errorf("weights must sum to a positive value")
	}
	if i.AgeHours < 0 {
		return 0, fmt.Errorf("item %q age hours must be >= 0, got %f", i.ID, i.AgeHours)
	}
	if i.Likes < 0 {
		return 0, fmt.Errorf("item %q likes must be >= 0, got %d", i.ID, i.Likes)
	}
	if i.Comments < 0 {
		return 0, fmt.Errorf("item %q comments must be >= 0, got %d", i.ID, i.Comments)
	}

	recency := 1 / (1 + i.AgeHours)
	return w.Recency*recency +
		w.Likes*float64(i.Likes) +
		w.Comments*float64(i.Comments) +
		w.Affinity*i.Affinity, nil
}

// Sort 返回按得分从高到低排列的副本,不修改入参。
func Sort(items []Item, w Weights) ([]Item, error) {
	out := make([]Item, len(items))
	copy(out, items)

	scores := make([]float64, len(out))
	for idx, it := range out {
		s, err := Score(it, w)
		if err != nil {
			return nil, err
		}
		scores[idx] = s
	}

	// 先算分再排序,避免比较函数里重复计算或吞掉错误。
	order := make([]int, len(out))
	for i := range order {
		order[i] = i
	}
	sort.SliceStable(order, func(a, b int) bool {
		return scores[order[a]] > scores[order[b]]
	})

	sorted := make([]Item, len(out))
	for i, idx := range order {
		sorted[i] = out[idx]
	}
	return sorted, nil
}

Sort 先为每条内容算分,再用稳定排序按分数从高到低重排索引。稳定排序在这里不是细节:分数相同的两条内容应该保持候选集原来的顺序,否则同一批请求可能因为排序不稳定而抖动,用户刷新一次就看到完全不同的首页。

返回副本而不是原地排序,是为了让上游可以安全地复用候选切片。如果调用方在多个实验组之间共享同一份候选集,原地排序会让后一个实验组拿到已经被前一个实验组打乱的数据,实验结论也就不可信了。

测试:权重、负数与稳定排序

排序逻辑的 bug 往往不在主路径,而在边界:权重全是 0、点赞是负数、两条内容分数相同。下面的测试把这些情况固定下来。

// file: internal/rank/score_test.go
// tier: L1
// verify: go test ./internal/rank/

package rank

import (
	"math"
	"testing"
)

func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }

func TestScoreCombinesSignals(t *testing.T) {
	item := Item{
		ID:       "p1",
		AuthorID: "u1",
		AgeHours: 1,
		Likes:    10,
		Comments: 2,
		Affinity: 0.8,
	}
	w := Weights{Recency: 0.4, Likes: 0.3, Comments: 0.2, Affinity: 0.1}

	got, err := Score(item, w)
	if err != nil {
		t.Fatalf("Score returned error: %v", err)
	}
	// 0.4*0.5 + 0.3*10 + 0.2*2 + 0.1*0.8 = 3.68
	if !almostEqual(got, 3.68) {
		t.Fatalf("Score = %f, want 3.68", got)
	}
}

func TestScoreDecaysWithAge(t *testing.T) {
	w := Weights{Recency: 1}

	fresh, err := Score(Item{ID: "fresh", AgeHours: 0}, w)
	if err != nil {
		t.Fatalf("Score returned error: %v", err)
	}
	old, err := Score(Item{ID: "old", AgeHours: 3}, w)
	if err != nil {
		t.Fatalf("Score returned error: %v", err)
	}
	if !almostEqual(fresh, 1) {
		t.Fatalf("fresh score = %f, want 1", fresh)
	}
	if !almostEqual(old, 0.25) {
		t.Fatalf("old score = %f, want 0.25", old)
	}
}

func TestScoreRejectsNegativeWeights(t *testing.T) {
	_, err := Score(Item{ID: "p1"}, Weights{Recency: 1, Likes: -0.1})
	if err == nil {
		t.Fatal("expected an error for a negative weight")
	}
}

func TestScoreRejectsZeroWeights(t *testing.T) {
	_, err := Score(Item{ID: "p1"}, Weights{})
	if err == nil {
		t.Fatal("expected an error when every weight is zero")
	}
}

func TestScoreRejectsNegativeCounts(t *testing.T) {
	w := Weights{Likes: 1}

	if _, err := Score(Item{ID: "p1", Likes: -1}, w); err == nil {
		t.Fatal("expected an error for negative likes")
	}
	if _, err := Score(Item{ID: "p1", Comments: -1}, w); err == nil {
		t.Fatal("expected an error for negative comments")
	}
}

func TestScoreRejectsNegativeAge(t *testing.T) {
	if _, err := Score(Item{ID: "p1", AgeHours: -0.5}, Weights{Recency: 1}); err == nil {
		t.Fatal("expected an error for a negative age")
	}
}

func TestSortDescendingAndStable(t *testing.T) {
	items := []Item{
		{ID: "low", AgeHours: 10, Likes: 1},
		{ID: "tie-a", AgeHours: 0, Likes: 5},
		{ID: "high", AgeHours: 0, Likes: 9},
		{ID: "tie-b", AgeHours: 0, Likes: 5},
	}
	original := make([]Item, len(items))
	copy(original, items)

	got, err := Sort(items, Weights{Likes: 1})
	if err != nil {
		t.Fatalf("Sort returned error: %v", err)
	}

	want := []string{"high", "tie-a", "tie-b", "low"}
	for i, id := range want {
		if got[i].ID != id {
			t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
		}
	}

	for i := range items {
		if items[i] != original[i] {
			t.Fatalf("Sort modified the input at position %d", i)
		}
	}
}

func TestSortPropagatesScoreError(t *testing.T) {
	items := []Item{
		{ID: "ok", Likes: 1},
		{ID: "bad", Likes: -1},
	}
	if _, err := Sort(items, Weights{Likes: 1}); err == nil {
		t.Fatal("expected the invalid item to fail the sort")
	}
}

func TestSortEmpty(t *testing.T) {
	got, err := Sort(nil, Weights{Recency: 1})
	if err != nil {
		t.Fatalf("Sort returned error: %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("len = %d, want 0", len(got))
	}
}

TestScoreCombinesSignals 用一组手工可算的输入验证四项相加,结果是 3.68。这个用例的价值在于它同时覆盖了时间衰减和三个加权项,任何一项写错都会让结果偏离。

TestSortDescendingAndStable 构造了两条同分内容 tie-a 和 tie-b,要求它们保持输入顺序;同时检查原切片没有被修改。排序稳定性和“不修改入参”是产品层面能感知的性质:前者影响刷新抖动,后者影响多个实验组共享候选集时的正确性。

TestScoreRejectsNegativeCounts 和 TestScoreRejectsNegativeAge 覆盖脏数据。点赞数在数据库里可能是负数(回滚、补偿、迁移错误),如果排序函数默认接受,它会安静地把内容压到后面,排查时很难发现根因。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/rank/ -count=1 -run 'TestScore|TestSort' -v

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

=== RUN   TestScoreCombinesSignals
--- PASS: TestScoreCombinesSignals (0.00s)
=== RUN   TestScoreDecaysWithAge
--- PASS: TestScoreDecaysWithAge (0.00s)
=== RUN   TestScoreRejectsNegativeWeights
--- PASS: TestScoreRejectsNegativeWeights (0.00s)
=== RUN   TestScoreRejectsZeroWeights
--- PASS: TestScoreRejectsZeroWeights (0.00s)
=== RUN   TestScoreRejectsNegativeCounts
--- PASS: TestScoreRejectsNegativeCounts (0.00s)
=== RUN   TestScoreRejectsNegativeAge
--- PASS: TestScoreRejectsNegativeAge (0.00s)
=== RUN   TestSortDescendingAndStable
--- PASS: TestSortDescendingAndStable (0.00s)
=== RUN   TestSortPropagatesScoreError
--- PASS: TestSortPropagatesScoreError (0.00s)
=== RUN   TestSortEmpty
--- PASS: TestSortEmpty (0.00s)
PASS
ok  	webapp/internal/rank	0.293s

-run 的正则同时匹配得分计算和排序两组测试。最后的 ok webapp/internal/rank 表示整个包在当前筛选下通过;耗时只代表这次本机运行,不能当成性能结论。

小结

时间、热度、亲和度各自只看见一个方向。时间让新内容有机会,热度奖励已经被验证的内容,亲和度照顾用户已有关系,多样性约束防止任何一类内容占满首页。把它们压成一个分数时,权重的量级比权重的比例更重要:一个累计点赞项可以轻易压过时间项,哪怕时间权重看起来并不小。

Score 把衰减和校验写死,Sort 保证稳定且不修改入参。代码本身不复杂,难的是承认“热度高”不等于“现在该看”,以及给新内容留出结构性的位置,而不是指望调一个权重就能解决。

阅读导航:上一节:目录与学习路径 · 下一节:4.2 去重、折叠与已读 。

常见错误与边界

  • 用累计点赞当热度。发布时间越早,累计值越大,等于把“老”又奖励了一遍。
  • 只调权重比例,不看各项量级。时间项最大是 1,点赞项可能是几百,比例相同不代表影响相同。
  • 把多样性理解成随机打散。随机化会破坏相关性,应该用作者和话题的占比约束。
  • 认为时间权重小一点没关系。只要热度是累计值,新内容仍然会被结构性地压住。
  • 在排序函数里做过滤。已读、重复、低质应该在候选集阶段处理,排序只负责排先后。
  • 原地排序共享候选集。多个实验组会互相污染,结论无法归因。
  • 接受负数计数并静默计算。脏数据会表现成“内容质量差”,而不是“输入错误”。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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