共同好友查询跑了 12 秒
「回声」上线“可能认识的人”时,产品希望展示“你和某人有几个共同好友”。开发按最直觉的方式实现:先查出我的一万多个好友,再把这些 ID 拼进 IN 条件,去关系表里找同时关注对方的人。测试环境只有几百个好友,查询很快;灰度到一批活跃用户后,接口开始超时。
一次本机压测里,这个查询耗时 12 秒(案例假设值,不是线上实测)。数据库 CPU 被打满,连带影响了动态列表和登录接口。更糟的是,另一个接口会一次性拉取大 V 的全部粉丝用来做去重,单个响应超过几十万行,直接把服务内存推高。
这些问题不是 SQL 写得不够花哨,而是三种关系查询被当成了同一种:我关注谁、谁关注我、共同好友,方向不同、索引不同、结果规模也不同。用一套“先查全量再在应用层算”的方式处理,数据量一大就会同时遇到延迟和内存两个瓶颈。
三种查询,三种代价
关系链的查询可以收敛成三个问题。把方向、索引和结果规模写清楚,才能判断哪些能实时算、哪些必须分页或预计算。
| 查询 | 方向 | 合适的索引 | 结果规模 | 不能怎么做 |
|---|---|---|---|---|
| 我关注谁 Following | from = me | (from_id, kind) | 通常几十到几千 | 全表扫描后过滤 |
| 谁关注我 Followers | to = 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当成异常还是空结果没有统一标准,返回空数组更稳定。 - 在请求链路里做全量图计算。三度关系、全量交集这类操作应该离线或异步,在线只读预计算结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。