《互联网产品的服务端架构·内容、社交与实时互动》4.2 去重、折叠与已读

从用户点过一条内容、刷新后它就消失的投诉写起,区分按内容 ID 去重与按事件去重,讨论已读是否应该影响排序,以及同作者相邻内容从哪里开始折叠;给出保持顺序、容忍 nil 入参的 Go 实现与边界测试,避免误删与重复曝光,并附上真实运行输出。

刷新之后,刚点过的那条不见了

「回声」的首页在周五换了一版去重逻辑。上线当天下午,客服收到一条很具体的投诉:用户点开一条动态看了详情,返回首页刷新,那条动态不见了。用户以为作者删了内容,又去搜索作者主页,发现内容还在,于是投诉“首页会吞内容”。

排查结果并不复杂:新逻辑把用户点开过的内容 ID 写进了已读集合,下一次请求时在候选阶段直接剔除。工程上这叫“已读不重复曝光”,听起来合理;但用户看到的是内容凭空消失,而且没有任何解释。更糟的是,这位用户当时正在对比两条相似动态,其中一条消失后,他以为是自己误操作。

同一版逻辑还有第二个问题:同一作者连续发了五条更新,首页把它们全部展开,占满了前五屏。运营想要的是折叠成一条“该作者还有 4 条更新”,而不是全部展示或者全部删掉。去重、已读和折叠被混成了一个“过滤”动作,边界自然就乱了。

去重按 ID 还是按事件

去重听起来只有一句话,实际要先回答“什么算同一条”。按内容 ID、按事件 ID、按作者加时间窗口,得到的结果完全不同。

去重口径判断依据适合解决主要风险不该用它的场景
内容 ID同一条内容的唯一 ID同一内容被多个来源重复召回同一事件的不同报道仍然重复转载、快讯、同一事件多角度
事件 ID内容背后的事件或话题 ID同一事件的多篇报道聚合成一条事件识别错误会把不同内容误合并事件边界本身有争议时
作者加时间窗同作者在 N 分钟内的多条内容刷屏、连续更新正常的高频作者被误伤直播、赛事等合理高频场景
已读集合用户打开过或明确看过的 ID减少重复曝光用户想回看时找不到收藏、对比、正在跟进的线索

按内容 ID 去重是最安全的一种:ID 相同就是同一条,不会误杀。它解决不了转载和同一事件的多篇报道,但至少不会把两条不同内容合成一条。按事件去重更强,也更危险,因为事件识别的错误会直接表现为“内容被吞”。

已读是另一维度。已读集合回答的是“这个用户看过没有”,不是“这条内容重不重复”。把已读直接当成过滤条件,等于假设用户永远不想回看。实际上,用户可能正在对比、可能误触、可能想分享给朋友,内容消失会让他怀疑系统出了问题。

更稳的做法是把已读当成排序信号,而不是硬过滤:已读内容降权、折叠,或者在末尾保留一个“看过的更新”入口。至少要让用户能找回来,而不是让内容凭空消失。

折叠也有边界。同一作者连续两条就开始折叠,会让正常作者看起来像刷屏;完全不折叠,又会让高频作者占满首页。常见做法是前两条正常展示,从第三条开始折叠成“还有 N 条”,并且折叠组要可展开。阈值应该按产品场景定,而不是写死在排序函数里。

失败场景:点过一条内容,刷新后它消失了

案例假设值下,首页每次返回 20 条,其中约 35% 会被用户点开。已读硬过滤上线后,一个中度用户第二次刷新时,候选池里已经有 7 条被剔除。如果候选池本来只有 30 条,第二次刷新就只能补入 7 条新内容;补不满时,页面变短,用户继续下拉,触发更多请求。

后果有三层。第一层是体验:内容消失且没有提示,用户以为被删除或屏蔽,投诉和重复搜索增加。第二层是数据:已读用户不再产生二次曝光,点击率的分母变小,报表上的点击率反而上升,团队误以为新逻辑有效。第三层是供给:作者发现自己的内容只被看到一次,复访读者流失,更新意愿下降。

修复分三步。第一,已读不再硬过滤,改为降权或折叠,并在结果里保留“已读”标记。第二,去重只处理同一内容 ID 的重复召回,不处理“同一作者”或“同一事件”,那属于折叠和聚合。第三,折叠从同作者第三条开始,保留前两条,并记录被折叠条目的去向,保证下一页不会把它们当成新内容再次展开。

还有一个容易漏掉的边界:翻页。用户看到第 2 页时,第 1 页已经展示过的内容不能因为重新计算而出现在第 2 页。折叠映射需要跨页保存,否则用户会看到同一条内容在相邻两页各出现一次。

用 Dedupe 与 Fold 区分已读和折叠

下面的实现来自 webapp-lab 的 internal/rank 包。Dedupe 负责两件事:剔除已读内容,以及限制同一作者在结果中的条数;Fold 只负责折叠相邻的同作者内容,并把被折叠的 ID 记录到 exists,供下一页判断。

两个函数都保持输入顺序,都不修改入参。perAuthor <= 0 表示不限制作者条数,nil 入参返回空切片而不是 panic。这些约定看起来琐碎,但它们决定了调用方能不能安全地在分页和多实验组之间复用同一份候选集。

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

package rank

// Dedupe 剔除用户已读的内容,并限制同一作者在结果中的条目数。
// perAuthor <= 0 表示不限制作者条数;顺序保持不变。
func Dedupe(items []Item, seen map[string]bool, perAuthor int) []Item {
	out := make([]Item, 0, len(items))
	counts := make(map[string]int, len(items))

	for _, it := range items {
		if seen != nil && seen[it.ID] {
			continue
		}
		if perAuthor > 0 && it.AuthorID != "" && counts[it.AuthorID] >= perAuthor {
			continue
		}
		if it.AuthorID != "" {
			counts[it.AuthorID]++
		}
		out = append(out, it)
	}
	return out
}

// Fold 折叠同一作者的相邻内容,只保留每组的第一条。
// exists 记录已展示过的 ID;被折叠的条目会写入 exists,指向保留条目的 ID,
// 这样下一页不会把同一条内容再放回来。
func Fold(items []Item, exists map[string]string) []Item {
	out := make([]Item, 0, len(items))
	var lastAuthor, anchorID string

	for _, it := range items {
		if exists != nil {
			if _, hidden := exists[it.ID]; hidden {
				continue
			}
		}

		if it.AuthorID != "" && it.AuthorID == lastAuthor && anchorID != "" {
			if exists != nil {
				exists[it.ID] = anchorID
			}
			continue
		}

		out = append(out, it)
		lastAuthor = it.AuthorID
		anchorID = it.ID
	}
	return out
}

Dedupe 先看已读集合,再看作者计数。这个顺序有实际意义:已读内容不应该占用作者配额,否则一个用户读过某作者的两条内容后,该作者的第三条会被误折叠,而实际上用户只在这个页面里见过零条。

Fold 只折叠相邻内容,不折叠跨页或跨作者的重复。相邻是刻意的限制:同作者在首页中间隔了别人的内容,通常意味着用户已经接受了上下文切换,再折叠反而打断阅读。被折叠的 ID 写入 exists,指向保留条目的 ID,下一页就能识别“这条已经在上一页的折叠组里”。

exists 同时是输入和输出。调用方传入上一页的映射,函数会跳过其中已有的 ID;函数返回前会把本页新折叠的 ID 补进去。这样翻页时不会把折叠组里的内容重新展开成新条目。

测试:已读、作者上限与 nil 入参

去重和折叠的 bug 大多在边界:已读集合为空、作者上限是 0、输入是 nil、同作者不相邻。下面的测试逐条固定这些行为。

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

package rank

import "testing"

func ids(items []Item) []string {
	out := make([]string, len(items))
	for i, it := range items {
		out[i] = it.ID
	}
	return out
}

func TestDedupeRemovesSeenItems(t *testing.T) {
	items := []Item{
		{ID: "a", AuthorID: "u1"},
		{ID: "b", AuthorID: "u2"},
		{ID: "c", AuthorID: "u3"},
	}
	got := Dedupe(items, map[string]bool{"b": true}, 0)
	want := []string{"a", "c"}
	if len(got) != len(want) {
		t.Fatalf("len = %d, want %d", len(got), len(want))
	}
	for i, id := range want {
		if got[i].ID != id {
			t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
		}
	}
}

func TestDedupeLimitsPerAuthorAndKeepsOrder(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "b1", AuthorID: "u2"},
		{ID: "a2", AuthorID: "u1"},
		{ID: "a3", AuthorID: "u1"},
		{ID: "b2", AuthorID: "u2"},
	}
	got := Dedupe(items, nil, 2)
	want := []string{"a1", "b1", "a2", "b2"}
	if len(got) != len(want) {
		t.Fatalf("len = %d, want %d", len(got), len(want))
	}
	for i, id := range want {
		if got[i].ID != id {
			t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
		}
	}
}

func TestDedupeNonPositivePerAuthorIsUnlimited(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "a2", AuthorID: "u1"},
		{ID: "a3", AuthorID: "u1"},
	}
	for _, limit := range []int{0, -1} {
		if got := Dedupe(items, nil, limit); len(got) != 3 {
			t.Fatalf("perAuthor=%d kept %d items, want 3", limit, len(got))
		}
	}
}

func TestDedupeNilReturnsEmpty(t *testing.T) {
	got := Dedupe(nil, nil, 3)
	if got == nil {
		t.Fatal("Dedupe returned nil, want an empty slice")
	}
	if len(got) != 0 {
		t.Fatalf("len = %d, want 0", len(got))
	}
}

func TestFoldCollapsesAdjacentSameAuthor(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "a2", AuthorID: "u1"},
		{ID: "a3", AuthorID: "u1"},
		{ID: "b1", AuthorID: "u2"},
	}
	exists := map[string]string{}

	got := Fold(items, exists)
	want := []string{"a1", "b1"}
	if len(got) != len(want) {
		t.Fatalf("len = %d, want %d", len(got), len(want))
	}
	for i, id := range want {
		if got[i].ID != id {
			t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
		}
	}
	if exists["a2"] != "a1" || exists["a3"] != "a1" {
		t.Fatalf("exists = %v, want a2 and a3 mapped to a1", exists)
	}
}

func TestFoldKeepsNonAdjacentSameAuthor(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "b1", AuthorID: "u2"},
		{ID: "a2", AuthorID: "u1"},
	}
	got := Fold(items, map[string]string{})
	want := []string{"a1", "b1", "a2"}
	if len(got) != len(want) {
		t.Fatalf("len = %d, want %d", len(got), len(want))
	}
	for i, id := range want {
		if got[i].ID != id {
			t.Fatalf("position %d = %q, want %q", i, got[i].ID, id)
		}
	}
}

func TestFoldSkipsAlreadyKnownItems(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "a2", AuthorID: "u1"},
	}
	exists := map[string]string{"a1": "page-1", "a2": "page-1"}
	got := Fold(items, exists)
	if len(got) != 0 {
		t.Fatalf("len = %d, want 0 when every item is already known", len(got))
	}
}

func TestFoldKeepsNewContentFromSkippedAuthor(t *testing.T) {
	items := []Item{
		{ID: "a1", AuthorID: "u1"},
		{ID: "a2", AuthorID: "u1"},
	}
	got := Fold(items, map[string]string{"a1": "page-1"})
	if len(got) != 1 || got[0].ID != "a2" {
		t.Fatalf("got %v, want only the unseen item a2", ids(got))
	}
}

func TestFoldNilInputsDoNotPanic(t *testing.T) {
	got := Fold(nil, nil)
	if got == nil {
		t.Fatal("Fold returned nil, want an empty slice")
	}
	if len(got) != 0 {
		t.Fatalf("len = %d, want 0", len(got))
	}
}

TestDedupeLimitsPerAuthorAndKeepsOrder 用两个作者交错的输入验证“每个作者保留前两条”,结果是 a1、b1、a2、b2。顺序必须保持,因为候选集本身可能已经按时间排过,去重不应该重新排序。

TestFoldCollapsesAdjacentSameAuthor 检查折叠组的第一条被保留,后续条目写入 exists 并指向第一条。TestFoldKeepsNonAdjacentSameAuthor 则确认 a1、b1、a2 不会被折叠成两条,避免把正常的交错发布误判成刷屏。

TestDedupeNilReturnsEmpty 和 TestFoldNilInputsDoNotPanic 固定 nil 行为。分页接口在最后一页经常拿到空候选,如果函数在这里 panic,故障会从一个边界请求扩散到整个首页。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestDedupeRemovesSeenItems
--- PASS: TestDedupeRemovesSeenItems (0.00s)
=== RUN   TestDedupeLimitsPerAuthorAndKeepsOrder
--- PASS: TestDedupeLimitsPerAuthorAndKeepsOrder (0.00s)
=== RUN   TestDedupeNonPositivePerAuthorIsUnlimited
--- PASS: TestDedupeNonPositivePerAuthorIsUnlimited (0.00s)
=== RUN   TestDedupeNilReturnsEmpty
--- PASS: TestDedupeNilReturnsEmpty (0.00s)
=== RUN   TestFoldCollapsesAdjacentSameAuthor
--- PASS: TestFoldCollapsesAdjacentSameAuthor (0.00s)
=== RUN   TestFoldKeepsNonAdjacentSameAuthor
--- PASS: TestFoldKeepsNonAdjacentSameAuthor (0.00s)
=== RUN   TestFoldSkipsAlreadyKnownItems
--- PASS: TestFoldSkipsAlreadyKnownItems (0.00s)
=== RUN   TestFoldKeepsNewContentFromSkippedAuthor
--- PASS: TestFoldKeepsNewContentFromSkippedAuthor (0.00s)
=== RUN   TestFoldNilInputsDoNotPanic
--- PASS: TestFoldNilInputsDoNotPanic (0.00s)
PASS
ok  	webapp/internal/rank	0.303s

-run 的正则覆盖去重和折叠两组测试。输出里的 ok webapp/internal/rank 表示包级通过;单条用例的 0.00s 不代表真实网络或存储耗时,这些函数本身只做内存计算。

小结

去重、已读和折叠解决的是三个不同问题:同一条内容被重复召回、用户已经看过、同一作者连续刷屏。把它们合并成一个“过滤”动作,最直接的后果就是用户点过的内容消失,而且没有任何解释。

Dedupe 把已读和作者配额分开处理,Fold 只折叠相邻内容并记录映射。代码很短,真正的设计决定在于边界:已读应该降权还是过滤,折叠从第几条开始,翻页时映射存在哪里。这些问题没有统一答案,但必须显式回答。

阅读导航:上一节:4.1 时间、热度与多样性 · 下一节:4.3 排序实验与效果回收 。

常见错误与边界

  • 把已读当成硬过滤。用户想回看、正在对比或误触时,内容消失会被理解成删除。
  • 用作者去重代替内容去重。同一作者的不同内容是正常供给,不应该被 ID 级去重误杀。
  • 折叠阈值写死在排序函数里。不同产品、不同频道的刷屏标准不同,应该配置化。
  • 跨页不保存折叠映射。上一页折叠掉的条目会在下一页重新出现。
  • 去重后重新排序。候选集原来的顺序可能承载了业务含义,去重应保持相对顺序。
  • 让已读内容占用作者配额。用户在这个页面还没看到该作者的内容,却被提前折叠。
  • nil 入参直接 panic。分页末页和空候选是常态,边界输入必须返回空结果。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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