《互联网产品的服务端架构·内容、社交与实时互动》5.3 关系链的存储与查询

从「回声」共同好友查询在 1 万好友时耗时 12 秒的事故写起,拆解我关注谁、谁关注我、共同好友三类关系查询的代价,说明大 V 粉丝列表为什么不能全量拉,给出 Graph、Followers、Following 与 Common 的 Go 实现、去重排序测试和真实运行输出,并列出关系链存储与查询的常见错误。

共同好友查询跑了 12 秒

「回声」上线“可能认识的人”时,产品希望展示“你和某人有几个共同好友”。开发按最直觉的方式实现:先查出我的一万多个好友,再把这些 ID 拼进 IN 条件,去关系表里找同时关注对方的人。测试环境只有几百个好友,查询很快;灰度到一批活跃用户后,接口开始超时。

一次本机压测里,这个查询耗时 12 秒(案例假设值,不是线上实测)。数据库 CPU 被打满,连带影响了动态列表和登录接口。更糟的是,另一个接口会一次性拉取大 V 的全部粉丝用来做去重,单个响应超过几十万行,直接把服务内存推高。

这些问题不是 SQL 写得不够花哨,而是三种关系查询被当成了同一种:我关注谁、谁关注我、共同好友,方向不同、索引不同、结果规模也不同。用一套“先查全量再在应用层算”的方式处理,数据量一大就会同时遇到延迟和内存两个瓶颈。

三种查询,三种代价

关系链的查询可以收敛成三个问题。把方向、索引和结果规模写清楚,才能判断哪些能实时算、哪些必须分页或预计算。

查询方向合适的索引结果规模不能怎么做
我关注谁 Followingfrom = me(from_id, kind)通常几十到几千全表扫描后过滤
谁关注我 Followersto = me(to_id, kind)普通用户几百,大 V 可达千万一次性全量返回
共同好友 Common两个好友集合求交双向 friend 边 + 批量点查与较小集合同阶用 IN 拼一万个 ID 全表扫

我关注谁是最便宜的一类:按 from_id 走索引,结果规模由用户自己控制。谁关注我则可能非常不均匀,普通用户只有几百个粉丝,大 V 可能有上千万;同一个接口不能假设“粉丝列表总能一次拉完”。共同好友更复杂,它要先确认双方各自的好友集合,再做交集;如果好友集合很大,交集的计算和传输都会变贵。

大 V 的粉丝列表为什么不能全量拉

粉丝列表的第一个问题不是查询慢,而是结果没有上限。一个千万粉丝的账号,即使数据库能在几秒内扫完,序列化成 JSON 也要占用大量内存和带宽;客户端拿到列表后还要渲染、去重、分页,任何一步都可能崩。

「回声」最后把粉丝列表改成游标分页:按关注时间或用户 ID 排序,每次返回固定条数,只提供总数和少量样本。需要“是否关注我”的判断时,不拉列表,而是对单个 viewer 做一次点查。需要给大 V 展示粉丝画像时,走离线聚合,不在请求链路里实时算。

这也是关系链设计的一条基本原则:列表接口必须假设结果可能无限大。只要接口没有 limit 和游标,就一定会有人在最大账号上触发它。

共同好友该在哪一层算

共同好友有三种常见实现。第一种是实时求交:取较小的一方好友集合,逐个点查对方是否也是好友。好友数在几百以内时,这个方案简单且足够快。第二种是预计算:离线任务定期为可能认识的人生成候选和共同好友数,在线只读结果。第三种是图数据库或专用关系服务,把多跳查询下推到存储层。

选择哪一种,取决于产品对新鲜度的要求。刚加完好友就要求共同好友立刻变化,适合实时求交;展示“可能认识的人”可以接受小时级延迟,预计算更稳。最不该做的是把一万个 ID 拼进 IN,让数据库在请求链路里做全表扫描。

失败场景:IN 全表扫描与一次性拉粉丝

回到那 12 秒的查询。SQL 大致是:

SELECT friend_id, COUNT(*)
FROM relation
WHERE from_id IN ('u1', 'u2', ..., 'u10000')
  AND kind = 'friend'
GROUP BY friend_id
ORDER BY COUNT(*) DESC
LIMIT 20;

这条语句有三个问题。第一,IN 列表太长,优化器可能放弃索引,退化成全表扫描;第二,kind 没有进入联合索引的最左列,过滤性很差;第三,查询在请求链路里同步执行,没有超时和降级,数据库一慢,接口就跟着挂。

另一个接口的问题更直接:它把大 V 的粉丝全部查出来,在应用层做去重和排序。案例假设值:某个账号有 300 万粉丝,单次查询返回的 ID 就有几十 MB,序列化和网络传输把内存推到上限。接口没有崩溃,是因为进程被 OOM 杀掉了,监控上只看到一次重启。

后果是连锁的:共同好友接口超时,前端不断重试;重试放大数据库压力;动态列表和登录也开始变慢。事后复盘时,团队发现关系表只有 (from_id) 单列索引,to_id 方向完全没有索引,所有“谁关注我”的查询都是全表扫描。

修正方案分三步:补齐 (from_id, kind) 和 (to_id, kind) 联合索引;共同好友改成取较小集合后批量点查,并加超时;粉丝列表改为游标分页,禁止无上限查询。三步做完,同样的数据量下查询回到毫秒级,但这是案例中的修复结果,不是对所有系统都成立的承诺。

用 Graph 和三个函数把查询收口

查询逻辑散落在各个接口里,索引和去重规则就会各写一套。把关系图抽象成一个 Graph,把三类查询收敛成三个函数,至少能保证语义一致:结果去重、升序、空图返回空切片。

下面的代码来自本书配套的 webapp-lab。Followers 和 Following 只统计 follow 边,Common 要求双向 friend 边;三个函数都会遍历所有分桶,因此重复边和分桶错误不会改变结果。

// file: internal/social/graph.go
// tier: L1
// verify: go test ./internal/social/

package social

import "sort"

// Graph 是按发起方索引的关系图。Out[id] 通常只放 From == id 的边,
// 但查询函数会遍历所有分桶,重复边和分桶错误都不会改变结果。
type Graph struct {
	Out map[string][]Edge
}

// Followers 返回关注 id 的用户,按 ID 升序去重。
// 这里只统计 follow 边;friend 是另一种关系,不自动折算成关注。
func Followers(g Graph, id string) []string {
	seen := make(map[string]struct{})
	for _, edges := range g.Out {
		for _, e := range edges {
			if e.From != "" && e.From != e.To && e.To == id && e.Kind == Follow {
				seen[e.From] = struct{}{}
			}
		}
	}
	return sortedIDs(seen)
}

// Following 返回 id 关注的用户,按 ID 升序去重。
func Following(g Graph, id string) []string {
	seen := make(map[string]struct{})
	for _, edges := range g.Out {
		for _, e := range edges {
			if e.From == id && e.To != "" && e.From != e.To && e.Kind == Follow {
				seen[e.To] = struct{}{}
			}
		}
	}
	return sortedIDs(seen)
}

// Common 返回 a 与 b 的共同好友,按 ID 升序去重。
// 好友必须双向:共同好友 X 要同时与 a、b 各有两条方向的 friend 边。
func Common(g Graph, a, b string) []string {
	if a == "" || b == "" || a == b {
		return []string{}
	}
	fa := friendSet(g, a)
	fb := friendSet(g, b)
	seen := make(map[string]struct{})
	for id := range fa {
		if _, ok := fb[id]; ok {
			seen[id] = struct{}{}
		}
	}
	return sortedIDs(seen)
}

// friendSet 收集与 id 互为好友的节点。先分别收集出边和入边,
// 再取交集,避免把单条 friend 边误判成好友。
func friendSet(g Graph, id string) map[string]struct{} {
	out := make(map[string]struct{})
	in := make(map[string]struct{})
	for _, edges := range g.Out {
		for _, e := range edges {
			if e.Kind != Friend {
				continue
			}
			if e.From == id && e.To != "" && e.From != e.To {
				out[e.To] = struct{}{}
			}
			if e.To == id && e.From != "" && e.From != e.To {
				in[e.From] = struct{}{}
			}
		}
	}
	both := make(map[string]struct{})
	for other := range out {
		if _, ok := in[other]; ok {
			both[other] = struct{}{}
		}
	}
	return both
}

func sortedIDs(set map[string]struct{}) []string {
	ids := make([]string, 0, len(set))
	for id := range set {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	return ids
}

代码里有几个刻意的选择。

  • Graph.Out 按发起方分桶,符合“写时按 from 索引”的存储习惯;查询函数仍然遍历所有分桶,避免调用方因为分桶不一致得到不同结果。
  • Followers 和 Following 过滤空 ID 和自环。自环在写入时应该被 Validate 拒绝,但查询层不能假设历史数据永远干净。
  • Common 先分别构造 a 和 b 的好友集合,再求交集。好友集合要求双向 friend 边,单条 friend 边不算,follow 也不算。
  • sortedIDs 统一做去重和排序。map 的遍历顺序不稳定,接口输出如果不排序,缓存、快照 diff 和回归测试都会出现随机失败。
  • 空图返回非 nil 的空切片,而不是 nil。客户端把 null 当成“查询失败”还是“没有结果”,往往取决于语言和框架;返回空数组能让契约更稳定。

这段代码是语义模型,不是生产查询计划。真实系统不会把整个关系图加载进内存,而是把 Followers、Following、Common 翻译成带索引和分页的数据库查询;函数的价值在于给这些查询提供统一的去重、排序和好友判定规则。

测试:重复边、空图、半条好友

关系查询的测试要覆盖三类边界:重复边不能放大结果、空图不能返回 nil、半条好友不能算共同好友。

下面的测试覆盖关注列表和粉丝列表的去重排序、共同好友的双向要求、重复边的影响,以及空图的返回形状。

// file: internal/social/graph_test.go
// tier: L1
// verify: go test ./internal/social/

package social

import (
	"reflect"
	"testing"
)

func TestFollowersDedupSort(t *testing.T) {
	g := Graph{Out: map[string][]Edge{
		"u3": {{From: "u3", To: "u1", Kind: Follow}},
		"u2": {{From: "u2", To: "u1", Kind: Follow}, {From: "u2", To: "u1", Kind: Follow}},
		"u1": {{From: "u1", To: "u1", Kind: Follow}},
	}}
	got := Followers(g, "u1")
	want := []string{"u2", "u3"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Followers = %v, want %v", got, want)
	}
}

func TestFollowingDedupSort(t *testing.T) {
	g := Graph{Out: map[string][]Edge{
		"u1": {
			{From: "u1", To: "u3", Kind: Follow},
			{From: "u1", To: "u2", Kind: Follow},
			{From: "u1", To: "u3", Kind: Follow},
			{From: "u1", To: "u2", Kind: Friend},
		},
	}}
	got := Following(g, "u1")
	want := []string{"u2", "u3"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Following = %v, want %v", got, want)
	}
}

func TestCommonNeedsMutualFriendship(t *testing.T) {
	g := Graph{Out: map[string][]Edge{
		"u1": {
			{From: "u1", To: "u3", Kind: Friend},
			{From: "u1", To: "u4", Kind: Friend},
			{From: "u1", To: "u5", Kind: Follow},
		},
		"u2": {
			{From: "u2", To: "u3", Kind: Friend},
			{From: "u2", To: "u4", Kind: Friend},
			{From: "u2", To: "u5", Kind: Follow},
		},
		"u3": {
			{From: "u3", To: "u1", Kind: Friend},
			{From: "u3", To: "u2", Kind: Friend},
		},
		"u4": {
			{From: "u4", To: "u1", Kind: Friend},
		},
		"u5": {
			{From: "u5", To: "u1", Kind: Friend},
			{From: "u5", To: "u2", Kind: Friend},
		},
	}}
	got := Common(g, "u1", "u2")
	want := []string{"u3"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Common = %v, want %v", got, want)
	}
}

func TestCommonDuplicateEdgesDoNotChangeResult(t *testing.T) {
	g := Graph{Out: map[string][]Edge{
		"u1": {
			{From: "u1", To: "u3", Kind: Friend},
			{From: "u1", To: "u3", Kind: Friend},
		},
		"u2": {{From: "u2", To: "u3", Kind: Friend}},
		"u3": {
			{From: "u3", To: "u1", Kind: Friend},
			{From: "u3", To: "u2", Kind: Friend},
		},
	}}
	got := Common(g, "u1", "u2")
	want := []string{"u3"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Common with duplicates = %v, want %v", got, want)
	}
}

func TestEmptyGraphReturnsEmptySlices(t *testing.T) {
	for name, got := range map[string][]string{
		"followers": Followers(Graph{}, "u1"),
		"following": Following(Graph{}, "u1"),
		"common":    Common(Graph{}, "u1", "u2"),
	} {
		if got == nil {
			t.Fatalf("%s returned nil", name)
		}
		if len(got) != 0 {
			t.Fatalf("%s = %v, want empty", name, got)
		}
	}
}

TestCommonNeedsMutualFriendship 是最值得看的一条。它构造了五种关系:u3 与双方都是双向好友,u4 只与一方双向,u5 只有 follow 和单向 friend。期望结果只有 u3,能同时挡住“单条 friend 边算好友”和“follow 算好友”两种错误实现。

TestCommonDuplicateEdgesDoNotChangeResult 验证重复边不会让共同好友出现两次。关系表如果没有唯一索引,重复写入很常见;查询层必须去重,不能依赖写入方永远正确。

TestEmptyGraphReturnsEmptySlices 检查三个函数在空图上都返回非 nil 的空切片。这个用例看起来琐碎,但它防的是接口层把 null 当成异常,导致前端在“没有粉丝”时弹出错误提示。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/social/ -run 'TestFollowers|TestFollowing|TestCommon|TestEmptyGraph' -count=1 -v

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

=== RUN   TestFollowersDedupSort
--- PASS: TestFollowersDedupSort (0.00s)
=== RUN   TestFollowingDedupSort
--- PASS: TestFollowingDedupSort (0.00s)
=== RUN   TestCommonNeedsMutualFriendship
--- PASS: TestCommonNeedsMutualFriendship (0.00s)
=== RUN   TestCommonDuplicateEdgesDoNotChangeResult
--- PASS: TestCommonDuplicateEdgesDoNotChangeResult (0.00s)
=== RUN   TestEmptyGraphReturnsEmptySlices
--- PASS: TestEmptyGraphReturnsEmptySlices (0.00s)
PASS
ok  	webapp/internal/social	0.278s

PASS 表示所有查询语义用例通过。输出里的耗时是纯内存遍历的耗时,不代表数据库查询延迟;线上共同好友查询的瓶颈通常在索引、网络和结果规模,而不是这段循环。

小结

关系链有三种基本查询:我关注谁、谁关注我、共同好友。它们的方向、索引和结果规模都不同,不能用一套“先拉全量再算”的方式处理。关注列表通常可控,粉丝列表可能无限大,共同好友要先缩小集合再求交。

Graph 和三个函数把语义固定下来:结果去重、升序、空图返回空切片,好友必须双向。生产实现可以换存储、换索引、换分页方式,但这些语义不能变。下一节进入互动系统,讨论点赞、评论和收藏的计数与一致性。

阅读导航:上一节:5.2 拉黑、屏蔽与可见性判定 · 下一节:6.1 点赞、评论与收藏 。

常见错误与边界

  • 把 IN 列表拼到一万个 ID。优化器可能放弃索引,查询退化成全表扫描;应该取较小集合后批量点查。
  • 只给 from_id 建索引。谁关注我、共同好友都要按 to_id 反向查询,缺少联合索引就会全表扫描。
  • 粉丝列表不设上限。大 V 的粉丝可能有千万级,一次性拉全会同时打爆数据库、内存和带宽。
  • 用 IN 子查询代替交集。数据库在执行计划里未必能把它优化成半连接,结果规模一大就不可控。
  • 把 follow 当成 friend。互相关注不等于好友,共同好友和 friends-only 都必须要求双向 friend 边。
  • 忽略重复边。关系表没有唯一索引时重复写入很常见,查询层不去重就会放大计数和列表长度。
  • 返回 nil 切片。接口把 null 当成异常还是空结果没有统一标准,返回空数组更稳定。
  • 在请求链路里做全量图计算。三度关系、全量交集这类操作应该离线或异步,在线只读预计算结果。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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