Appearance
21 搜索、Feed 与 IM 的设计套路
详细程度:标准 · 约 40 分钟 材料:W16-2、W17-1、W17-2、W17-3、W19-1、W19-2、W20-1、W20-2、C:dachang/radic · 旧长版:无 前置:13 缓存与消息队列、19 微服务拆分
先看一个让人愣住的数字
同样是"发表一篇文章",一个 2000 粉丝的普通作者让系统写了 2000 次收件箱,而一个 300 万粉丝的大 V 只写了 1 次。差的三个数量级不来自代码技巧,来自一个设计决策:这条内容是"推给每个粉丝",还是"放在一处等粉丝来拉"。下面是这两条路径的内存版实现,先自己猜输出,再往下读:
go
package main
import (
"fmt"
"sort"
)
type Event struct {
Author int64
ID int64 // 全局递增,当时间戳用
}
// Feed 演示推拉结合:小作者写扩散进粉丝收件箱,大 V 读扩散进发件箱。
type Feed struct {
bigV int
inbox map[int64][]Event // 收件箱:uid,推给这个用户的动态
outbox map[int64][]Event // 发件箱:作者,他发出的动态
followers map[int64][]int64
}
// Publish 返回这次发文产生的写入次数。
func (f *Feed) Publish(e Event) int {
fs := f.followers[e.Author]
if len(fs) > f.bigV { // 粉丝超过阈值:只写一份发件箱,粉丝读时来拉
f.outbox[e.Author] = append(f.outbox[e.Author], e)
return 1
}
writes := 0
for _, u := range fs { // 普通作者:推给每个粉丝,一人一份
f.inbox[u] = append(f.inbox[u], e)
writes++
}
return writes
}
// Get 是读取侧:先收件箱,再补关注的大 V 的发件箱,合并按时间倒序。
func (f *Feed) Get(uid int64, followees []int64) []Event {
res := append([]Event{}, f.inbox[uid]...)
for _, a := range followees {
res = append(res, f.outbox[a]...)
}
sort.Slice(res, func(i, j int) bool { return res[i].ID > res[j].ID })
return res
}
func main() {
f := &Feed{
bigV: 2000,
inbox: map[int64][]Event{},
outbox: map[int64][]Event{},
followers: map[int64][]int64{},
}
fs := make([]int64, 2000) // 一个 2000 粉丝的普通作者
for i := range fs {
fs[i] = int64(i) + 1
}
f.followers[42] = fs
big := make([]int64, 3000000) // 一个 300 万粉丝的大 V
for i := range big {
big[i] = int64(i) + 1
}
f.followers[99] = big
fmt.Println("2000 粉丝的作者发文,写入", f.Publish(Event{Author: 42, ID: 1}), "次")
fmt.Println("300 万粉丝的大 V 发文,写入", f.Publish(Event{Author: 99, ID: 2}), "次")
feed := f.Get(1, []int64{42, 99}) // 用户 1 两个人都关注
fmt.Println("用户 1 的 Feed 最新一条来自作者", feed[0].Author)
}text
2000 粉丝的作者发文,写入 2000 次
300 万粉丝的大 V 发文,写入 1 次
用户 1 的 Feed 最新一条来自作者 99搜索、Feed、IM 三个业务看起来不相干,骨架是同一句:数据在写入时多做一点,读取时就少做一点,再叠加"扩散给谁、谁来扩散"。搜索把文档在写入时拆成倒排链,换查询时不扫全表;Feed 在写入时决定进收件箱还是发件箱;IM 要回答"消息怎么找到 B 连接的那个节点"。这一节按这个顺序拆,每个范式都能回到开头那两行输出上。
和你已知语言的差异
| 主题 | Java/Kotlin 生态里常见 | Go(本节) |
|---|---|---|
| 全文检索 | Lucene/Solr 与 ES 同源,生态偏 JVM | ES 靠 HTTP API 解耦,Go 客户端偏薄;radic 证明纯 Go 也能写索引内核〔C:dachang/radic/internal/reverse_index/skiplist_reverse_index.go:14-58〕 |
| 双向长连接 | Spring WebSocket/STOMP 代管会话 | net/http 加升级器,连接、Hub、转发逻辑都要自己持有〔W20-1 p31-33〕 |
| 并发聚合 | CompletableFuture 组合异步结果 | errgroup 起一组 goroutine 后 Wait〔W17-2 p14〕 |
| 消息队列客户端 | Kafka 原生客户端功能最全 | 常用 sarama 等第三方库,消费者组语义一致〔W20-1 p43-44〕 |
一、搜索:倒排索引怎么把全表扫变成走链
正排是"拿着 ID 找属性",倒排是"拿着属性找 ID"。资料的比喻:正排像面试官拿着你的简历问你有什么技能,倒排像面试官在网站输入"5 年 Go",搜出一堆人。〔W17-1 p7〕ES 里每个字段各有一张"词到文档"的表,叫 posting list;这张表必须有序,因为查一个词就是在表里定位,无序就只能全表扫。〔W17-1 p8〕(材料说的"表"是词到文档的整张对照表。下面求交集用的是每个词后面挂的那条文档 ID 链,链内按 ID 升序排列是 Lucene 系的通行做法,材料没单独展开,radic 的跳表链也是这样。)
查多词(AND)就是把两条链求交集。两条链都有序,双游标从前往后走:谁小谁前进,相等就收进结果,不需要回头。
go
package main
import "fmt"
// intersect 求两条有序倒排链的交集:双游标,谁小谁前进,相等就收。
func intersect(a, b []int) []int {
var out []int
for i, j := 0, 0; i < len(a) && j < len(b); {
switch {
case a[i] == b[j]:
out = append(out, a[i])
i++
j++
case a[i] < b[j]:
i++
default:
j++
}
}
return out
}
func main() {
fmt.Println(intersect([]int{1, 2, 3, 7}, []int{2, 3, 8})) // "go" 与 "接口" 两条链
fmt.Println(intersect([]int{1, 2, 3, 7}, nil)) // 一条链为空,交集为空
}text
[2 3]
[]复杂度是两条链长度之和而不是乘积,这就是"列表必须有序"换来的东西。三条链、十条链同理。课程的开源索引框架 radic 把这个算法落在了跳表上:每条链分配一个游标,先找出当前最大值,值最大的不动、其余全部前进,全部相等时收下一个交集,任何一条走完就结束。〔C:dachang/radic/internal/reverse_index/skiplist_reverse_index.go:70-116〕
radic 的整体结构值得看一眼,因为它把"倒排索引"还原成了普通 Go 数据结构:外层是一个分段的并发哈希表,key 是"字段加词"(源码里用 \001 分隔符拼接,比如 title 和 go 拼成一个 key),value 是一条跳表;跳表的 key 是文档内部 ID,value 带业务文档 ID 和一个位图。写入时对每个词按哈希拿到同一把读写锁再加进链,读多写少的场景读写分离。〔C:dachang/radic/internal/reverse_index/skiplist_reverse_index.go:14-58〕结构化属性(是否已发布、是否删除、类别)不建链,编码成位图,遍历倒排链时顺手按位过滤,不用回表查主存储。〔C:dachang/radic/internal/reverse_index/skiplist_reverse_index.go:140-157〕过滤规则极简(radic 的 FilterByBits 在此之外还有一组 orFlags,要求至少命中一位,这里略去):
go
// bitsFeature:每一位的含义由业务自己约定,这里假设第 0 位表示已发布,第 1 位表示已删除
func visible(bits, onFlag, offFlag uint64) bool {
if bits&onFlag != onFlag { // onFlag 的每一位都必须是 1
return false
}
if bits&offFlag != 0 { // offFlag 的每一位都必须是 0
return false
}
return true
}词到文档的定位还有一层结构:ES 用 FST(一种连后缀都共享的前缀树)组织词表,先定位词再走 posting list。Gin 的路由树是前缀树,FST 在它的基础上把 cat 和 ct 的公共后缀也共享掉,省内存。〔W17-1 p9〕
ES 的几组概念要分清。数据组织上,索引类比 MySQL 的表,文档类比的行;部署上,分片类比分库分表,解决单节点撑不住,副本类比从库,解决节点挂了数据全丢。〔W17-1 p6〕字段类型上,text 分词,用于全文搜索;keyword 不分词,用于精确匹配和聚合,互相不能替代:拿 keyword 存文章正文就搜不了关键词,拿 text 存手机号就没法精确等值。〔W17-1 p17〕节点角色上,一个实例可以兼任候选主节点、协调节点、数据节点,生产环境建议不兼职。〔W17-1 p10〕
最后是写入路径,面试热点:文档先写内存 buffer,默认每秒 refresh 一次到 Page Cache,再刷盘并记 Commit Point,所以写入后约 1 秒才可见,不是立刻,这叫近实时。〔W17-1 p11〕资料原话是"只有 Commit Point 里的数据才可搜",与 ES 官方行为有出入:官方语义是 refresh 生成新 segment 后即可搜,刷盘和 Commit Point 管的是持久化。面试按"约 1 秒可见"答不会错,但要知道这两个概念各管什么。
二、搜索平台化:统一接入与标签联动
一两个业务时,直接在搜索模块里 if-else 就够了;业务一多,每接入一个都要大改搜索模块,是平台型服务的典型失败。解法是把"接入"本身标准化。〔W17-2 p6-7〕
接口形态有两种,可以并存:通用接口收一个通用结构(索引名、文档 ID、JSON 数据),灵活但没有编译期检查;专属接口一业务一个,有检查但接入慢,每接一个业务都要加接口。紧急需求走通用的,常规业务走专属的。〔W17-2 p7-8〕
〔W17-3 p28〕总结就是:通用的接入方式解决共性问题,扩展接口解决个性问题。
同步语义必须是 Upsert(有则更新无则插入):同一个用户会在注册、改资料时反复同步,ES 的文档 ID 直接用业务 ID,同一条业务数据永远对应同一份文档,重复同步只是覆盖。〔W17-2 p10〕
数据进 ES 有两条路:业务方主动发消息(可以引入 Kafka 削峰,统一 Event 只带 index_name 和 data 两个字段,直接穿透到 DAO 写 ES);或者搜索服务被动监听业务方已有的消息,转成自己的统一 Event。业务方强势、不肯适配你的接口时只能走被动。〔W17-2 p19-20〕走消息队列还要自己保证顺序:同一资源的前后两次变更落进不同分区,消费端可能先新后旧,所以生产者的 partitioner 要选哈希算法,把同一个人或同一个资源的消息固定到同一个分区。〔W17-3 p22〕
部署上用隔离保大不保小:同步数据和查询是两种负载,分成两个集群;核心页面(比如首页)的搜索栏再独占一个集群,其他业务降级也拖不垮它。〔W17-2 p21〕
查询侧,Service 层做聚合:先预处理查询词(材料里是按空格切分,并提到用户可能手抖输错分隔符),再用 errgroup 并发查用户和文章两个索引,合并返回。〔W17-2 p14〕查文章时状态必须是已发表。〔W17-2 p15〕大型平台的完整链路是预处理、解析表达式生成搜索计划、执行计划、按平台政策调整输出,套路相同,只是每一步都更重。〔W17-2 p14〕
标签服务是"跨索引查询"的现成教材。标签是"某个人给某个资源打的",天然和个人挂钩,所以不适合并进文章索引,也就用不了 Nested 文档。〔W17-3 p23〕ES 内置的跨索引关联方案(Nested、Parent-Child)性能都差,实践中用得不多;更好的是两次查询:先查标签索引拿到资源 ID 列表,再查文章索引,把这些 ID 命中的结果用 boost 提权。boost 可以直观理解为相关性,默认 1.0,越高排得越靠前。〔W17-3 p24-26〕效果是标题命中且标签也命中的文章明显前移,标签只命中的文章仍留在结果里。
标签自己的读写也有两个可复用的套路。写侧:标签创建后基本不变(没有更新删除场景),缓存按 uid 用 list 存;应用启动时分批预加载全部标签进缓存。〔W17-3 p11〕〔W17-3 p13〕材料的代码里用 goroutine 异步执行,注释说同步执行在一些场景下会占用很长时间。读侧:在关联表里冗余标签名,目的只有一个,加速查询;风险是源字段可变时不一致,所以只冗余不可变字段才安全。〔W17-3 p16〕
三、Feed:推、拉与推拉结合
Feed 流是把"和你有关的数据"按时间倒序合成一条流:关注的人发了新作品、你的作品被点赞收藏评论、有人关注了你、系统通知。〔W19-1 p4〕〔W19-1 p7-8〕先把术语对齐,讨论时别鸡同鸭讲:读扩散等于拉模型等于发件箱,写扩散等于推模型等于收件箱。〔W19-1 p12〕
拉模型是读的时候现拼:先查你关注了谁,再去每个人的发件箱查,聚合排序分页。缺陷有两个:每次读都放大成 N 个数据库查询;分页难,不知道前 20 条散落在哪些库上,只能每个库都取前 20 条再内存排序。〔W19-1 p9-10〕小集群里发件箱就是一张表,大集群必然分库分表甚至跨分区,性能都差。〔W19-1 p14〕
推模型是写的时候铺开:B 发文,同步写进每个粉丝的收件箱。粉丝读时只查自己的收件箱,按 uid 分库分表后一次查询出结果,分页交给数据库。〔W19-1 p15-16〕缺陷是写放大:一百万粉丝的作者发文,瞬间产生一百万条记录;B 站百万粉 UP 主很多,微博千万粉也不少,写入过程本身就慢。〔W19-1 p17〕
开头那个例子就是两个缺陷的数值版:2000 粉丝推,写 2000 次;300 万粉拉,读时合并两路。生产上用推拉结合:粉丝数低于阈值走推,超过走拉;进一步只对活跃用户推。读的时候先查自己的收件箱,再补关注的大 V 的发件箱,合并排序分页;叠加了活跃用户设计的话,活跃用户连发件箱都不用查。〔W19-1 p18-19〕注意推拉结合没有消灭缺陷,只是都变轻了:
〔W19-1 p44〕这一类的问题,有一个简单的口诀:读扩散查询慢,写扩散数据多。另外推拉结合的形态,并没有解决这两个问题,而仅仅是缓解了这个问题。
存储设计上有一个普适套路:事件的公共字段(Type、uid、时间)之外,业务个性化的数据塞进一个 map,存成一个大 JSON 字段,代价是这部分数据不能参与 WHERE 运算;好处是新业务接入只需要新增一个 Handler,Service 不动。〔W19-1 p23-25〕〔W19-1 p30-33〕map 里有哪些 key 靠线下流程约定:产品经理把 Feed 团队和业务团队拉到一起,定下 Type 和字段名。〔W19-1 p29〕查询出来之后由 BFF 拼详情和文案。〔W19-1 p26〕极端方案是 Feed 直接存前端要展示的 HTML 片段,查询性能极好但扩展性差,很少人用。〔W19-1 p36-37〕
一条设计原则值得单独记住:Feed 明面上"实时性要求高",实际要求是秒级甚至十秒级;只要业务没有强制同步,就用异步,消息队列带来解耦、削峰和更高的可用性。〔W19-1 p35〕
Feed 的上游是用户关系,它比 Feed 更早碰到规模问题:假设每个用户平均关注 100 人,关注数据的行数就是用户数的 100 倍;打开一篇文章要判定"你是否关注了作者",是高并发读。〔W16-2 p7〕表设计是多对多中间表,唯一索引必须 follower 在前,否则 WHERE follower = ? 无法命中索引。〔W16-2 p11〕关注、拉黑、屏蔽不要合并成一张表:关注远比后两者多,拆开存省空间、提性能。〔W16-2 p18〕关注数和粉丝数用 Redis 维护,加与减放进 TxPipeline 一起执行,避免一成一败。〔W16-2 p16〕"某人是否关注某人"只在大 V 场景下值得缓存,小透明之间命中率太低。〔W16-2 p13-14〕再往后是按 follower 分库分表,统计粉丝数要跨库;不分库的另一条路是换存储中间件,材料提到阿里的 Tablestore,但作者自己没在实践中用过。〔W16-2 p21-22〕
最后是压测,没有数据的推拉阈值都是猜。工具用 k6 加 Prometheus 加 Grafana,脚本是 JS,照着 k6 的例子写,并不复杂;运行时三个关键参数:输出到 Prometheus、时长、虚拟用户数。〔W19-2 p4-5〕〔W19-2 p8〕看延迟分解别只看总数:req_blocked 非零说明压测机自己出了问题,不是服务端的;req_waiting 约等于 req_duration 才正常,其余指标应该接近 0。〔W19-2 p12〕读接口要分只 push、只 pull、混合三个维度压,数据量也要分档,这样阈值才有依据。〔W19-2 p15-18〕
四、IM:连接在谁手里
IM 要解决的需求很多:实时聊天、群组、权限、用户、好友、消息存储与同步。但骨架问题只有一个:A 和 B 连在不同节点上,消息怎么找到 B。〔W20-1 p4〕〔W20-1 p13-14〕两种答案。〔W20-1 p14-16〕
- 注册:B 连上后把"用户在哪个节点"登记到注册中心,发消息先查再定向转发。机制和服务注册中心一致;缺点是每个用户乘以多端登录都要注册,注册中心容易成为瓶颈。
- 广播:不知道 B 在哪,就把消息发给所有节点,每个节点查自己持有的连接,命中就转发。MQ 可能成为新瓶颈,但注册中心那一口锅没有了。
工程上第一刀是把网关和后端分开:网关稳定、少变更、轻量、易横向扩展,因为用户连接不能经常迁移;后端是普通微服务集群。〔W20-1 p17〕
传输层用 WebSocket 而不是轮询。轮询的缺点是流量放大、加重后端负担,高并发下会雪崩:服务端响应不过来,前端更频繁地轮询,压力更大。〔W20-1 p20-21〕WebSocket 的建立是一次 HTTP 升级:客户端发带 Upgrade 头的请求询问能否升级,服务端同意后协议切换,之后全双工通信,任何一方可以关闭。〔W20-1 p22-23〕"长连接"这个词有两层含义,面试要分清:TCP 层的 Keep-Alive 是协议栈自己的保活报文;应用层的长连接是 HTTP Keep-Alive 或 WebSocket 这类由应用维持的连接。〔W20-1 p26-28〕网关节点要监控接入的连接数,数量多了会显著拖慢性能。〔W20-2 p22〕
单节点的转发机制就是一个并发 map:连上就存,收到消息遍历转发,注意不要转发给发送者自己。〔W20-1 p34-35〕多节点才是关键。群聊消息的分布式处理:网关收到消息转给后端,后端查群成员,逐成员发 Kafka,Key 用成员 ID,保证同一个人的消息在同一个分区里有序;然后每个网关节点消费消息,命中本节点的连接就发,不在就忽略。〔W20-1 p39-40〕〔W20-1 p43-44〕这里有个反直觉的点:每个网关节点必须是独立的消费者组。消费者组内分区只投给一个成员,组内是"分流";而每个网关都需要看到每一条消息才能查自己的连接,所以每个节点自成一组,各自拿全量。下面的程序把这个逻辑跑出来:
go
package main
import "fmt"
// Event 是群里的一条消息,经 Kafka 的 topic 发给所有网关节点。
type Event struct {
Receiver int64
Msg string
}
// Gateway 是一个 WebSocket 网关节点,conns 记着连在本节点上的用户。
type Gateway struct {
id int64
conns map[int64]bool
}
// consume 对应"每个网关是独立消费者组":每条消息,所有节点各收到一份。
func (g *Gateway) consume(e Event) {
if !g.conns[e.Receiver] { // 目标用户不在我这里:直接忽略
fmt.Printf("网关 %d:用户 %d 不在我这里,忽略\n", g.id, e.Receiver)
return
}
fmt.Printf("网关 %d:把消息转给了用户 %d\n", g.id, e.Receiver)
}
func main() {
g1 := &Gateway{id: 1, conns: map[int64]bool{1001: true, 1002: true}}
g2 := &Gateway{id: 2, conns: map[int64]bool{1003: true}}
msg := Event{Receiver: 1003, Msg: "晚上吃火锅?"}
g1.consume(msg) // A 连在节点 1,消息先到节点 1
g2.consume(msg) // Kafka 广播给独立消费者组,节点 2 也收到一份
}text
网关 1:用户 1003 不在我这里,忽略
网关 2:把消息转给了用户 1003消息格式上,前端传的每条消息至少四个字段:seq 是前端生成的连接内唯一序列号,用于前后端关联发送结果;Type 标识消息类型(文本、多媒体、系统消息);Content 按 Type 解析;Cid 是聊天 ID,单聊群聊统一。〔W20-1 p41-42〕
生产环境不自己写这一套,用 OpenIM。服务端从上到下是 SDK 接入层、access、service(核心模块)、消息队列、msgtransfer、缓存、存储;业务系统只做两件事:前端嵌 OpenIM SDK,后端同步用户数据。同步也是监听 Canal 读 binlog 异步进行,用户服务零修改。〔W20-2 p4-7〕〔W20-2 p15-18〕资料里 gorilla/websocket 的 chat 例子是单机版教材:Hub 用 channel 处理注册、注销、广播,相当于用 channel 取代了 Kafka。〔W20-1 p45〕
面试提醒:初中级岗位一般不直接问 IM,大概率问 WebSocket:为什么用、连接怎么创建(HTTP 升级过程)、长连接的两层含义。〔W20-1 p47〕
小结
- 三件事一个骨架:写入时多做一点,换读取时少做一点。搜索做在索引,Feed 做在收发件箱,IM 做在连接登记与广播。
- 倒排索引:每个字段一条有序 posting list,交集用双游标;radic 用跳表存链、位图过滤属性,词表用 FST。〔W17-1 p7-9〕〔C:dachang/radic/internal/reverse_index/skiplist_reverse_index.go:70-116〕
- ES 写入近实时:buffer 每秒 refresh,约 1 秒可见;刷盘与 Commit Point 管持久化,不管可见性。〔W17-1 p11〕
- 平台化:统一接入标准(通用加专属接口,Upsert 语义,文档 ID 用业务 ID),主动或被动同步,隔离部署保大不保小;跨索引查询用两次查询加 boost。〔W17-2 p7-10〕〔W17-2 p21〕〔W17-3 p24-26〕
- Feed:读扩散查询慢,写扩散数据多;推拉结合按粉丝阈值分流,事件用 Type 加 Metadata map,同步一律优先异步。〔W19-1 p12〕〔W19-1 p18-19〕〔W19-1 p35〕
- IM:注册(定向)或广播(全节点)两条路;网关轻量稳定,每个网关是独立的消费者组;生产用 OpenIM。〔W20-1 p14-16〕〔W20-1 p43-44〕〔W20-2 p7〕
常见坑
- 倒排链没排序,求交退化成嵌套循环:数据入库时保持有序,这是索引侧的纪律。
- 丢掉可见性:草稿、已删除的文章仍被搜到。索引里保留 status,查询里过滤。〔W17-2 p11〕
- 同步数据不指定文档 ID:同一用户反复同步变成多份文档,Upsert 语义就没了。〔W17-2 p10〕
- 消息顺序:同一资源的消息落进不同分区,消费端先新后旧;partitioner 选哈希,按资源或人固定分区。〔W17-3 p22〕
- 大 V 全推:一次发文瞬时百万写。阈值按压测数据定,并叠加活跃用户过滤。〔W19-1 p18〕
- 轮询当实时:高并发下反向放大压力直至雪崩,换成 WebSocket。〔W20-1 p20-21〕
- WebSocket 节点不加连接数监控:每节点连接数多了会显著拖慢性能。〔W20-2 p22〕
判断题与答案
- 为什么 ES 写入后不能立刻搜到? 答案:写入先进内存 buffer,默认每秒一次 refresh 生成可搜的段,所以从写入到可见平均约半秒、最坏约一秒;刷盘和 Commit Point 管的是持久化,不是可见性。〔W17-1 p11〕
- 粉丝 2000 的作者发文,该推还是拉?答案取决于什么? 答案:取决于推拉阈值和写放大、读放大的成本比较,阈值由压测数据定;2000 通常在阈值之下,推更划算,读取只查收件箱。〔W19-1 p18-19〕
- 为什么 IM 的每个网关节点必须是独立的消费者组? 答案:消费者组内是"分流",一条消息只投给组里一个成员;而每个网关都要看到每条消息才能查自己持有的连接,所以每个节点自成一组,各自拿全量,命中就发、不在就忽略。〔W20-1 p43-44〕
- 标签和文章为什么不并进同一个索引? 答案:标签始终与个人挂钩,是独立维度,并进文章索引用不了 Nested,更新标签还要动文章文档;分开两个索引,查询用两次查询加 boost 提权,写入互不干扰。〔W17-3 p23〕〔W17-3 p24-26〕
在项目里
这里没有现成例子:dnsproxi-go 不涉及搜索、Feed、IM;minifeed 现有的 user、post、counter、rank、auth 也没有。25 节的蓝图规划了 internal/feed:推拉结合,阈值可配,FeedService.OnPostCreated 就是开头 Publish 的直译(取粉丝列表时多取一个来判断是否超阈值,超了只写发件箱,否则逐个写收件箱)。做完可以拿 k6 压一把:先压发帖接口,再压 Feed 读接口,给阈值一个自己的数。
想深入:正文之外的干货
这一小节收 8 个单元里正文没展开的干货,页码都出自 digest。
W17-1 ES 的实操层
- 建索引靠 HTTP 的 PUT:settings 里定
number_of_shards和number_of_replicas,mappings 里逐字段定类型(email 给 text,phone 给 keyword,birthday 给 date)。主分片数建索引时就锁死,后续改不了,副本数随时能调;数据量估不准宁可先多分片,实在不行 reindex。〔W17-1 p15〕 - 常用查询十来种就够了:match 全文、term 精确、range 范围、bool 把 must/must_not/should 组合起来,再加 match_phrase(保词序)、prefix、wildcard、fuzzy(fuzziness 控制模糊度)、nested、聚合。用的时候查文档,不必背。〔W17-1 p21-23〕
- 最经典的新手坑是拿 term 查 text 字段:text 先分词,posting list 里存的是切出来的词,拿完整串做 term 是碰运气。要么字段就用 keyword,要么用
field.keyword子字段,一个字段两种需求就靠它两头兼顾。〔W17-1 p21-22〕 - Go 接入用
github.com/elastic/go-elasticsearch/v8,Indices.Create建索引、Index写文档、Search查询,链式传 WithContext/WithBody;返回结果集的处理公认难用,官方 SDK 本质是构造 HTTP 请求,难用就自己封一层,或直接用 resty 拼请求。〔W17-1 p24-27〕
W17-2 平台化的补充
- 用户索引里 email、phone 在公开平台不可搜,是隐私;toB 私有 IM 才需要拿手机号搜人。nickname 若只存 keyword,用户拿昵称搜人很难命中,昵称难读难记,得走 text 分词。〔W17-2 p9〕
- 为什么不图省事用 multi_query 一次查所有字段:各业务可搜的字段和权重不同,统一的 multi 查询会把不该参与相关性的字段也卷进打分。Service 层按空格切词之后,到 DAO 里再 Join 回去,是同一件事的两半。〔W17-2 p14〕
- 请求里可以预留一个 expression 字段做通用化,GitHub 搜索栏那种表达式就往这里放;web/bff 只透传,解析和执行都收在搜索服务自己手里。〔W17-2 p18〕
- 平台型服务的面试话术就两句:改造前每次接入业务都大改搜索模块,研发效率低、交付质量差;改造后统一接入标准,新业务迅速接入。高可用那句的完整版是"保大不保小":按业务分组,非核心降级,力保核心业务,多个核心业务之间也互不拖垮。〔W17-2 p23〕
- 写文档的 DAO 用 olivere/elastic 这类二次封装库,
Index().Index(name).Id(文章 ID).BodyJson(art)一行完成 Upsert;课程的原话是官方原生库过于垃圾。〔W17-2 p12〕
W17-3 标签自己的账
- Tag 表防重名靠 uid+name 唯一索引;打标签是覆盖语义,事务里先删(条件带 uid+biz+biz_id 三元组,不同人打的标签互不干扰)再插,空列表直接返回,只插不删会累积历史标签。〔W17-3 p10〕〔W17-3 p14〕
- 缓存标签值不值分场景:toC 用户打标签是低频行为,缓存命中率差;toB 用户量小、总量可控,全量预加载加本地缓存才划算。预加载分批(batch 100、单批 1 秒超时)查库,goroutine 并发写缓存,InitRepository 后用 goroutine 异步跑,同步执行会拖住启动。〔W17-3 p12-13〕
- 标签进 ES 之前还有一条岔路:标签服务直推独立 tags_index(通用性最强)还是文章服务把标签并进自己的索引(查询只碰一个索引,简单),课程选前者。〔W17-3 p20〕
- 相关性排序之上全是业务:字段权重和 boost 只是轻量手段,现实里还有充值、合作协议、平台倾向、政策法规;更重的诉求(收藏置顶)在应用层重排。〔W17-3 p19〕〔W17-3 p26,p32〕
- 标签同步的推送低频,同步调用也可以;上 Kafka 的动机里保序占大头,同人同资源固定分区。〔W17-3 p22〕
- 标签的用例清单里藏着两个设计依据:交互上"先加载用户全部标签再选择",所以要有批量加载接口;多数系统压根不提供删改标签,这正是"标签不可变、敢用 list 缓存"前提的来源。〔W17-3 p7〕
- 资源标签的查询模型是"某人给某资源打的":查询条件带 uid,系统打的标签用 uid=-1 存,查询时两路结果合并;GORM 的 Preload 能把标签名直接带出来,不想 JOIN 就在关联表冗余 Tag.Name。〔W17-3 p15〕
- 标签同步接进搜索平台走的就是第二节那套统一接入:SyncDataEvent{IndexName,DocID,Data} 三字段,新建一个 Sync 服务,标签服务调它;ES 侧一个通用 AnyESDAO,
Index(index).Id(docId).BodyString(data)一行写入,任何索引都能复用。〔W17-3 p21〕 - "要不要缓存、缓存多久"有个可操作的判据:过期时间设成"资源被重复访问的时间段",短期不会被二次访问的数据不缓存;缓存预加载的极端版本是把数据直接预加载进本地缓存。〔W17-3 p30〕
- ES 里控制搜索结果的空间其实很宽泛,面试别把它答死成排序参数;ES 中做 JOIN(Nested/Parent-Child)面试官若追问,答案就是正文的两次查询方案。〔W17-3 p30〕
W16-2 用户关系的细账
- 关注/取关和点赞同款:唯一索引冲突时 OnConflict 把 status 更新回 Active,取关就是 Inactive 软删,行为流水留着可审计,再关注幂等;这条链路也能扔进 Kafka 异步化。〔W16-2 p12〕
- 粉丝计数有两案。维护计数:关注时双向更新 A 的关注数和 B 的粉丝数,DB 事务、Redis TxPipeline 各保一次原子,拆开就是一成一败。Count 兜底:不建计数表,缓存 miss 就 Count 回写,省表但大表 Count 慢、且弱一致;要快照级精度(大 V 粉丝数)就选维护计数。〔W16-2 p14-16〕
- Count 有两个顺手的优化:唯一索引已经去重,不用 COUNT DISTINCT;
WHERE follower=?走唯一索引,WHERE followee=?命中不了,所以 followee 要单独建索引,这是中间表最常见的性能坑。〔W16-2 p17〕 - Tablestore 真要用时有两个硬约束:Upsert 得用 UpdateRow 加 RowExistenceExpectation_IGNORE,不是 Insert;SQL 形态不支持参数化,只能拼串,业务参数进来就是注入面,表名列名必须白名单。作者的结论是收费加 API 难用,不到逼不得已别上。〔W16-2 p24-26〕
- 特殊关注是个现成的扩展位:普通关注抽象出"类型/优先级",Feed 排序时特别关注优先展示,对应 B 站的全部/特别/悄悄三类。〔W16-2 p5〕
W19-1 Feed 的几处补刀
- 发文章 Handler 有个小优化:先不查粉丝数,直接查粉丝列表开写,大多数作者是推模型根本触发不了阈值判断,取回来的列表顺手多取一个就能判阈值,25 节蓝图照抄的就是这个。〔W19-1 p34〕
- 个性化字段不止 map 一种传法:string 是业务自解释格式(人类可读,平台不解析),bytes 是压缩二进制(人不可读);三者取舍就是可读性换灵活性。〔W19-1 p37〕
- 查询有两个思路:Service 统一查(查关注列表拿 limit,发件箱收件箱 errgroup 并发查,Ctime 倒序,截到 limit)适合无业务差异的场景;Handler 各自查再聚合,排序策略就能按业务定制,代价是每加一个业务都要写查询。〔W19-1 p40-42〕
- 事件分双元和广播:点赞、关注、评论回复都是"操作者对被操作者",走收件箱;只有"发表作品"这种广播事件才需要发件箱。判断一个事件推给谁,是 Handler 的核心职责。〔W19-1 p28,p32-34〕
- 关注时间点会改变 Feed 内容:读扩散能看到关注之前的历史(发件箱里都在),写扩散只能看到关注之后(收件箱从那一刻起才有)。产品要哪种得先问清。〔W19-1 p13-16〕
W19-2 压测数字怎么读
- 工具分场合:wrk 干快速 smoke,k6 才值得进 CI(脚本即用例,可复现可存档);k6 支持 HTTP/WebSocket/gRPC,脚本让 ChatGPT 代写基本无 bug。运行参数就三个:输出
-o experimental-prometheus-rw、duration、vus。〔W19-2 p4-5〕〔W19-2 p8〕 - 图形要导入 Grafana 看板,dashboard ID 19665,数据源指到 Prometheus;设 N 个 vus,Grafana 里最大 vus 会到约 2N,那是 k6 的机制不是异常,容量判断看 QPS 拐点而不是 vus 数。〔W19-2 p9-10〕
- 写扩散压测分百、千、万三档,千人档的参考数字:Peak RPS 约 1.25k,P99 约 106ms;读接口压 push、pull、混合三路乘数据量档位。混合读 10 万数据级时,课程那台本机撑一百多并发就速降,数字只做相对比较,容量结论要在目标环境复测。〔W19-2 p15-18〕
W20-1 协议与报文
- IM 协议三选:XMPP 是开放 XML 标准,扩展性强,要跟别的 IM 互通时才考虑,影响力在衰退;WebSocket 是主流底座,全双工低延迟;WebRTC 专管浏览器音视频,P2P 直连。〔W20-1 p7-10〕
- 升级请求的头要认全:Upgrade 加 Connection:Upgrade 是主角,Sec-WebSocket-Key 由服务端算出 Sec-WebSocket-Accept 回去(验签防篡改),Sec-WebSocket-Protocol 选子协议,Origin 头做跨域控制;之后才有 FIN/Opcode/Mask 那一套帧格式。〔W20-1 p22-25〕
- gorilla/websocket 的 Upgrader 有几个值得调的选项:ReadBufferSize/WriteBufferSize、WriteBufferPool(缓冲池,避免每次分配,对象池的特例)、CheckOrigin(WS 的跨域是主动检测)、EnableCompression。服务端模式固定:Upgrade 之后一个 goroutine 源源读,ticker 定时写。〔W20-1 p31-33〕
- 持久连接是容量黑洞:C10K 连接加频繁消息,内存和 fd 双压力;WS 持久连接单机表现并不好,缓冲池、压缩、合理心跳是标配。〔W20-1 p21〕〔W20-1 p33〕
- seq 是前后端关联键,不是消息 ID:前端生成、连接内唯一,后端拿它告诉前端"你发的第几条"结果如何(成功/失败/审核中),后端自己的消息 ID 是另一回事。〔W20-1 p42〕
- sync.Map 的 Range 回调 return true 是继续遍历、false 才中断;Hub 转发跳过自己时,这个返回值写反就只转发了一个人。〔W20-1 p35〕
W20-2 OpenIM 的接线细节
三组件各管一段:OpenIMServer 是 IM 后端;APP Client 前端嵌 OpenIMSDK(存储、长连接、心跳、推送都归 SDK,业务只留逻辑和事件回调);APP Server 是业务后端,走 REST 跟 OpenIM 交互。常见诉求"站内私聊"就是后一种嵌入。〔W20-2 p5〕〔W20-2 p15〕
部署走 openim-docker 的 make init,配 OPENIM_IP;Web 端和后台都拿这个 IP 访问,别用 localhost(消息推送、回调地址有小部分坑)。试用验证码 666666,聊天前要互加好友同意;管理后台 11002 端口,账号 chatAdmin。〔W20-2 p8-11〕
监控两个金指标:messages/s 和 failures/s;Grafana 导入用 JSON model(复制 yaml 删 license),Prometheus 数据源写容器服务名(prometheus:9090),写 localhost 会因容器网络隔离连不上。〔W20-2 p12-14〕
同步请求三件套:secret 从部署时的 config.yaml 读(进代码仓库就是安全事故),operationID 头必传且用 OTel trace ID(OpenIM 拿它串日志,丢了排查靠猜),响应 ErrCode 非 0 即失败。〔W20-2 p17〕
注册是逐个 Sync 没有批量接口,消费者遍历 Data 一条条来;将来引入批量接口就是现成的优化点。〔W20-2 p18〕
补页码引用:W19-1 p20"综合方案两缺陷都还在但轻微、优先保障活跃用户体验"与 p27"FeedEvent 结构宽泛、消费者统一调 Service"已并入正文第三节与上面 Feed 补刀条,不另立。省略未收:W17-1 的 Docker 部署参数与 GET 索引、W16-2 的树形表规范与 COUNT 面试总结、W19-2 的安装命令、W20-2 的作业题;W21-1(42 条必读)不属本节 8 单元,全书另有安排。
想深入去哪查
- 搜索:W17-1(概念、写入、查询、Go 接入)、W17-2(平台化、隔离部署)、W17-3(标签服务、两次查询、缓存预加载);radic 源码在 C:dachang/radic/internal/reverse_index(跳表交集、位图过滤)。
- Feed:W19-1(推拉模型全程)、W19-2(k6 压测与延迟分解);上游用户关系在 W16-2。
- IM:W20-1(从 WebSocket 到分布式群聊)、W20-2(OpenIM 部署与接入)。
- 旧长版:无,这一组主题是新增的。