Skip to content

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 同源,生态偏 JVMES 靠 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〕

  1. 注册:B 连上后把"用户在哪个节点"登记到注册中心,发消息先查再定向转发。机制和服务注册中心一致;缺点是每个用户乘以多端登录都要注册,注册中心容易成为瓶颈。
  2. 广播:不知道 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〕

常见坑 ​

  1. 倒排链没排序,求交退化成嵌套循环:数据入库时保持有序,这是索引侧的纪律。
  2. 丢掉可见性:草稿、已删除的文章仍被搜到。索引里保留 status,查询里过滤。〔W17-2 p11〕
  3. 同步数据不指定文档 ID:同一用户反复同步变成多份文档,Upsert 语义就没了。〔W17-2 p10〕
  4. 消息顺序:同一资源的消息落进不同分区,消费端先新后旧;partitioner 选哈希,按资源或人固定分区。〔W17-3 p22〕
  5. 大 V 全推:一次发文瞬时百万写。阈值按压测数据定,并叠加活跃用户过滤。〔W19-1 p18〕
  6. 轮询当实时:高并发下反向放大压力直至雪崩,换成 WebSocket。〔W20-1 p20-21〕
  7. WebSocket 节点不加连接数监控:每节点连接数多了会显著拖慢性能。〔W20-2 p22〕

判断题与答案 ​

  1. 为什么 ES 写入后不能立刻搜到? 答案:写入先进内存 buffer,默认每秒一次 refresh 生成可搜的段,所以从写入到可见平均约半秒、最坏约一秒;刷盘和 Commit Point 管的是持久化,不是可见性。〔W17-1 p11〕
  2. 粉丝 2000 的作者发文,该推还是拉?答案取决于什么? 答案:取决于推拉阈值和写放大、读放大的成本比较,阈值由压测数据定;2000 通常在阈值之下,推更划算,读取只查收件箱。〔W19-1 p18-19〕
  3. 为什么 IM 的每个网关节点必须是独立的消费者组? 答案:消费者组内是"分流",一条消息只投给组里一个成员;而每个网关都要看到每条消息才能查自己持有的连接,所以每个节点自成一组,各自拿全量,命中就发、不在就忽略。〔W20-1 p43-44〕
  4. 标签和文章为什么不并进同一个索引? 答案:标签始终与个人挂钩,是独立维度,并进文章索引用不了 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 部署与接入)。
  • 旧长版:无,这一组主题是新增的。