第17章 · ElasticSearch 与搜索服务(正式版)

2023-03-24T14:11:02+08:00 | 73分钟阅读 | 更新于 2023-03-24T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 17 章,继续第四阶段「高阶业务」的旅程。第 16 章解决了「评论和关注怎么存」,本章解决内容社区的另一块硬骨头——搜索:帖子几百万条了,用户在搜索框里敲下「Go 并发」,系统怎么在几十毫秒内把最相关的帖子找出来、把关键词标红、还按相关度排好序?

这件事 MySQL 真做不了——不是「做得慢」,是从数据结构层面就做不了。所以本章要请出 ElasticSearch(下文简称 ES),你会第一次接触「为搜索而生」的存储:它的世界里没有行和列,只有文档和词项;它的索引不叫 B+ 树,叫倒排索引。

这一版做了三件事:

  1. 每个概念先补课再使用。全文检索、倒排索引、分词器、近实时、Page Cache、BM25、DSL……全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。HR 招聘、图书馆索引、便利店上货、阅卷老师打分——搜索的世界生活里全有原型。
  3. 结尾有 12 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 17 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

先看一个真实的功能上线现场,感受一下这一章在解决什么问题:

事故现场:社区帖子突破了 200 万条,产品要上「全站搜索」。开发同学第一反应是用 MySQL 直接查:SELECT * FROM posts WHERE title LIKE '%并发%' OR content LIKE '%并发%'。压测结果出来,全组沉默了——单次查询 12 秒。更绝望的是产品紧接着提的三个需求:

  1. 「按相关度排序」——标题命中「并发」的应该排在正文随便提了一嘴的前面;
  2. 「关键词标红」——把结果里的「并发」两个字高亮显示;
  3. 「中文按词搜」——搜「并发编程」要能命中,但不能搜「发编」也命中。

这三条,LIKE 一条都做不到。而且第一条性能问题还恰好撞上了你第 16 章刚学的知识:LIKE '%并发%' 的通配符在最前面,B+ 树索引的最左前缀原则直接失效——每次查询都是 200 万行全表扫描。

问题的根源不是 SQL 写得不好,而是用错了工具:MySQL 的 B+ 树索引是「按值找行」的结构,天生不是为「在一堆文本里按词找内容」设计的。本章要请出的 ElasticSearch,才是干这件事的专用引擎。围绕它,本章有两条主线:

  • 原理主线:倒排索引(ES 为什么快)、近实时写入(写进去为什么 1 秒后才搜得到)、分词与字段类型(中文搜索的命门)——这是本章真正的三个新核心概念;
  • 工程主线:把搜索做成平台化的 search-service(第 16 章平台化思维的又一次落地),再做一个标签服务并接进搜索排序(boost 加权)——这条线上的每个积木你都已经学过:Kafka 削峰(第 07 章)、缓存预加载(第 06 章)、事务包裹(第 02 章)、冗余不可变字段(第 16 章)。

学完这一章,你会亲手搭出 search-service(gRPC :9400 / HTTP :8400):帖子自动同步进 ES,搜索「并发」返回带 <em> 高亮的结果,sort=hot 时热度高的排前面,还附带一份「结果集里都有些什么标签」的聚合统计——并且能在面试里把「为什么搜索不用 MySQL」「ES 为什么近实时」「term 查 text 为什么查不到」这几道高频题答得干干净净。

开读前先看一眼全局:搜索服务只有两条链路——写入(帖子进 MySQL 后同步进 ES,建倒排索引)和查询(关键词 → match 查询 → 高亮 + 聚合)。两张小图合起来就是全章地图,正文的每一步都在把这两条链路从"能跑"做到"好用"。

flowchart LR
    subgraph WS["写入:帖子怎么进 ES"]
        A["发帖 / 改帖"] --> B["写入 MySQL"]
        B --> C["同步进 ES
建立倒排索引"] end subgraph SS["查询:关键词怎么变结果"] D["用户输入关键词"] --> E["search-service
match 查询 + 高亮"] E --> F["ES 倒排索引检索"] E --> G["聚合统计标签分布"] end WS -.->|"近实时:写入后约 1 秒可搜"| SS

这张图怎么读:上下两个子图是搜索服务仅有的两条链路。写入链路:发帖 / 改帖先落 MySQL,再双写同步进 ES 建倒排索引;查询链路:用户输入关键词交给 search-service,它对 ES 做倒排索引检索拿结果,同时发起标签分布聚合,最后组装高亮返回给用户。两条链路之间那条虚线是关键时序约束——写入后约 1 秒(refresh 周期)才可搜,这 1 秒空窗怎么来的、能不能接受,就是 3.3 节「近实时」要讲透的内容。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 Docker 起一个带 IK 分词器的单节点 ES,并用 _analyze 接口验证中文分词效果(能亲手「看见」一段文本被切成了哪些词项)。
  2. 用代码幂等地创建 post 索引:写出 mapping——中文文本字段用 text + ik_max_word(查询用 ik_smart)、标签用 keyword、时间用 date,并解释每个选择的理由。
  3. 封装官方 go-elasticsearch/v8 客户端:带重试的健康检查(容器里的 ES 要 30 秒才就绪),内嵌官方 SDK 保留全部能力。
  4. 实现帖子搜索接口:bool 查询(must 算分 + filter 过滤)+ 标题加权(title^3)+ 高亮 + 三种排序(相关性 / 最新 / 热度)+ 标签聚合,并正确解析 ES 的返回 JSON。
  5. 实现 gRPC IndexPost 双写同步:用 post_id 作 ES 文档 _id 达成 Upsert 语义;同步放在主流程之外、失败不阻塞发帖。

理解(能讲清原理和取舍,不要求背代码)

  1. 倒排索引与正排索引的区别(「词找文档」vs「文档找词」)、Posting List 的结构、词项为什么必须排序、FST 为什么省内存(前后缀共享)。
  2. 近实时写入流程:Buffer → refresh(默认 1 秒)→ Page Cache(生成 segment)→ flush 落盘;Page Cache 阶段崩溃靠 translog 兜底。
  3. text 与 keyword 的分词差异;term(精确匹配词项)与 match(先分词再查)的区别;IK 的 ik_max_word(细切)与 ik_smart(粗切)为什么一个用于建索引、一个用于查询。
  4. BM25 相关性评分的三个直觉因素:词频、逆文档频率、字段长度;以及 boost 如何在它之上叠加业务权重。
  5. bool 四个篮子(must / should / filter / must_not)的语义差异:谁参与算分、谁走过滤器上下文(不算分且可缓存)。
  6. 搜索服务平台化的两组取舍:统一 JSON 接口 vs 业务专属接口;同步 gRPC 直写 vs Kafka 削峰;以及读写隔离、业务隔离的「保大不包小」部署思路。
  7. 标签服务的设计:为什么「标签不可变」这个业务前提能换来 Redis List 缓存 + 启动预加载的简单方案;覆盖式打标签为什么必须事务包裹。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. ES 节点角色:候选主节点、协调节点、数据节点——生产建议专节点专角色。
  2. Nested 与 Parent-Child:ES 内置的跨文档关联方案,以及为什么实践中很少用(性能差 / 模型不匹配)。
  3. 深分页问题:from + size 默认上限 10000(max_result_window),翻深页用 search_after。
  4. FST 的名字与定位:Finite State Transducers(有限状态转换器),「前缀树再压缩后缀」。

前置依赖与补课清单

需要你已掌握在哪章学过本章还会补补在哪一节
Gin 路由、Handler 分层第 02 章❌ 不补—
GORM 事务(先删后插)第 02 章❌ 不补—
Redis 基本命令(RPUSH 等)第 06 章❌ 不补—
Kafka 生产消费、按 key 分区保有序第 07 章❌ 不补—
gRPC 服务定义与实现第 10 章❌ 不补—
ETCD 服务注册、优雅停机第 12 章❌ 不补—
最左前缀原则第 16 章❌ 不补(本章用它解释 LIKE 为什么失效)—
冗余字段、平台化思维第 16 章❌ 不补(本章直接复用)—
什么是全文检索未讲过✅ 补3.1 节
什么是倒排索引 / 正排索引未讲过✅ 重点补3.2 节
什么是 FST / 前缀树未讲过(Gin 路由树提过一嘴)✅ 补3.2 节
什么是 Page Cache未讲过✅ 补3.3 节
什么是分词器未讲过✅ 重点补3.4 节
什么是相关性评分(BM25)未讲过✅ 补3.5 节
什么是 DSL未讲过✅ 补3.5 节

不需要你提前懂的:Lucene 的实现细节(知道 ES 底层是它即可)、ES 集群搭建与调优(本地单节点够用)、IK 分词器的词库原理(会用即可)、搜索广告与推荐的算法(知道「搜索能控制流量分发」这个商业视角即可)。

预计时长与学习成果

  • 预计时长:5 小时(概念精读 2 小时 + ES 部署与跟着敲 search-service 3 小时);本章概念较多(倒排索引、近实时、分词是三个新核心),建议分两次学:第一次读完 3.1–3.5 的原理,第二次做「跟着写」。

  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    一个 search-service(gRPC :9400 / HTTP :8400,注册到 ETCD)。往 ES 写两条帖子(Go 并发、Java 并发),GET /api/search/posts?q=并发&sort=hot 返回带 <em>并发</em> 高亮的结果、Java 帖(热度高)排第一,响应里还带着 tag_aggs 标签分布;q=goroutine 走 BM25 相关性排序;同一 post_id 写两次只产生一条 ES 文档。

本章的学习路线(先原理、后工程,最后标签接入):

flowchart LR
    A["3.1-3.2 LIKE 三宗罪
倒排索引"] --> B["3.3 近实时
写入流程"] B --> C["3.4-3.5 分词·字段类型
bool 查询·BM25"] C --> D["3.6 搜索服务
平台化设计"] D --> E["3.7 标签服务
不可变红利"] E --> F["3.8 标签接入搜索
boost 加权"] F --> G["第四节 跟着写
search-service"] G --> H["第七节 面试问答"]

这张图怎么读:一条从左到右的单向学习路线,先原理后工程。3.1-3.2 从 LIKE 三宗罪引出倒排索引,3.3 讲透近实时写入流程(Buffer / refresh / Page Cache / translog),3.4-3.5 补分词、字段类型与 bool 查询、BM25 评分这些查询侧武器,3.6-3.7 转入工程做搜索服务的平台化设计与标签服务,3.8 把标签经多次查询 + boost 加权接进搜索排序,最后进第四节跟着写 search-service,以第七节面试问答收尾。

ES 与搜索服务ES 核心原理倒排索引·FST·近实时分词与查询text/keyword·IK·bool·BM25平台化搜索统一接入·Kafka·隔离部署标签服务不可变·预加载·覆盖式标签 × 搜索多次查询·boost 加权

三、正文

3.1 为什么需要专门的搜索引擎:LIKE 的三宗罪

先补课:什么是全文检索

补课胶囊 · 全文检索(full-text search) 一句话:不匹配整条记录,而是「文档里包含这个词就算命中」,并按相关程度排序的查询方式。 类比:MySQL 的 WHERE title = 'Go 并发' 是「书名一字不差才借给你」;全文检索是图书管理员翻遍所有书,把「正文里提到过 Go 并发」的书全抱来,还按提到次数排好队。 回到主线:搜索框要的就是后者——用户敲的几个词只是「线索」,不是「完整答案」。

第一宗罪:通配符在前,索引全废

第 16 章学过最左前缀原则:索引按「从左到右」的顺序组织。LIKE '并发%'(通配符在后面)还能走索引,因为它相当于「以并发开头的值」这个前缀范围;而 LIKE '%并发%'(通配符在前面)等于「任意开头、任意结尾、中间含并发」——没有任何前缀可言,B+ 树帮不上忙,只能全表扫描。200 万行、每行还要扫 title 和 content 两个长文本字段,12 秒就是这么来的。

第二宗罪:没有「相关性」这个概念

就算 LIKE 查得快,它返回的也只是「命中的行」,顺序由谁定?主键顺序?发布时间?都不是用户想要的。用户想要的是:标题命中「并发」的排在前面,正文里顺带提一句的排在后面;命中次数多的排在前面;词出现越密集的越相关。这套「谁更相关」的打分逻辑,SQL 里根本没有对应的概念——你需要一个会打分的引擎。

第三宗罪:中文没法「按词」匹配

LIKE '%并发%' 是按字符匹配的:搜「并发编程」能命中「并发编程实战」(碰巧包含),但搜「发编」也能命中——因为「并发编程」这个字符串里确实包含「发编」这两个连续字符。中文的词和字符之间没有空格,「哪几个字组成一个词」这个信息 MySQL 完全不知道。要按词搜索,得先有个懂中文的「分词器」把文本切成词——这又是 SQL 世界里不存在的东西。

小收口:三宗罪的共同根源是「工具错位」——MySQL 是按行存数据的账本,擅长「按主键、按索引列精确/范围找行」;而搜索需要的是「按词找文档 + 按相关性排序」的专用结构。这个结构的名字叫倒排索引,ES 就是把它做到极致的引擎。接下来我们就把这层窗户纸捅破。

3.2 倒排索引:ES 快的秘密(核心概念一)

先抛问题

假设你是 HR,手里有一万份简历。老板说「把会 Go 的都找来」。你会怎么做?一份份打开看?那是一万次翻阅。聪明的做法是提前做一本字典:投递简历时顺手登记「Go → 简历 1、3、7」「Java → 简历 2、5」……老板要人,翻字典,秒出名单。这本「技能 → 简历列表」的字典,就是倒排索引。

给直觉:正排 vs 倒排

补课胶囊 · 倒排索引(inverted index) 一句话:「词 → 包含它的文档列表」的字典;正排索引则是「文档 → 它包含哪些词」。 类比:正排 = HR 找上你,问你有什么技能;倒排 = HR 在网站输入「5 年 Go」,搜出一堆人的简历。「倒」的就是查找方向。 回到主线:ES 为每个字段单独维护一张这样的字典,查询时翻字典而不是扫描正文——这就是毫秒级检索的来源。

两种索引的查找路径对比:

flowchart TB
    subgraph 正排["正排:文档 → 词(慢)"]
        A1["搜 'Go'"] --> A2["翻开文档1: 有Go✓
翻开文档2: 没有✗
翻开文档3: 有Go✓
...一万次"] end subgraph 倒排["倒排:词 → 文档(快)"] B1["搜 'Go'"] --> B2["翻字典
Go → [1, 3, 7]"] --> B3["直接返回文档 1、3、7"] end
复习提示:这张图在讲:正排要把每篇文档的正文扫一遍(O(n) 次全文阅读),倒排只翻一次字典(O(log n) 定位词项)——查询快慢的差距是结构性的,不是优化出来的。

Posting List:倒排索引长什么样

ES 为每个字段单独维护一张「词项 → 文档列表」的表,这张表里的文档列表叫 Posting List(倒排列表):

title 字段的倒排索引:
+--------+-------------+
| 词项   | 文档ID列表   |
+--------+-------------+
| Go     | [1, 2]      |
| Java   | [3]         |
| 并发   | [1, 2, 3]   |
+--------+-------------+

tags 字段的倒排索引:
+--------+-------------+
| 词项   | 文档ID列表   |
+--------+-------------+
| 后端   | [1, 2, 3]   |
| 面试   | [3]         |
+--------+-------------+

三个要点:

  1. 按字段隔离:搜 title 含 Go 就翻 title 字段的字典,搜 tags 含 面试 就翻 tags 的字典——互不干扰,这也解释了后面为什么 text 和 keyword 字段的行为差异那么大(3.4 节)。
  2. 词项必须排序:字典里的词项(Go、Java、并发……)按顺序排好,查询才能像查英语词典一样二分定位(O(log n))。不排序,找一个词就得把几十万个词项全扫一遍,字典就白建了。
  3. Posting List 也要压缩存储:文档 ID 列表本身用「增量编码 + 位压缩」存(相邻 ID 做差再压缩),细节不用掌握,知道「ES 在存储层也抠到了极致」即可。

FST:字典本身的压缩术

几十万、上百万个词项都要放进内存里的字典,怎么省空间?ES 的答案是 FST(Finite State Transducers,有限状态转换器)。

补课胶囊 · FST(有限状态转换器) 一句话:一种「公共前后缀都只存一份」的树形结构,用来在内存里装下海量词项。 类比:前缀树(Trie,你在 Gin 路由里已经用过:/user/login 和 /user/logout 共享 /user/lo 这段前缀)只共享前缀;FST 更狠,后缀也共享——「cat」和「ct」连尾巴上的 t 都只存一份。 回到主线:词项是海量且互相相似的长尾词,前后缀共享能省下大量内存,这是 ES 能在内存里放下整本「词典」的关键。

前缀树(只共享前缀)              FST(前缀、后缀都共享)
  0─→a─→b─→l─→e                  0─→t─→a─→b─→l─→e   (table)
  0─→t─→a─→b─→l─→e                     ↗
  (able 与 table 的尾巴           0─→a─→b─→l─→e      (able)
   "able" 存了两遍)               (table 复用 able 的 "able" 尾巴)

最小可运行示例:亲手「看见」倒排

不用装任何东西,等「跟着写」第 1 步的 ES 起来后,你可以用 _analyze 接口看到文本被切成的词项——那正是倒排索引的「字典条目」:

# 把"Go 并发编程"交给标准分词器,看它切出哪些词项
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' \
  -d '{"analyzer": "standard", "text": "Go 并发编程"}'
# 预期输出(节选):tokens: ["go", "并", "发", "编", "程"]
# 注意!中文被逐字切分了——这是 3.4 节要解决的大坑

逐行讲解:_analyze 是 ES 提供的「分词试验台」——把文本和分词器名字递给它,它返回切出来的所有词项(token)。这些 token 正是写入时进入倒排索引字典的词条。现在你看到中文被切成了单字,先记住这个现象,3.4 节会专门处理。

复习提示:⚠️ 新手必踩的坑:以为倒排索引是查询时临时建的。倒排索引在写入时就建好了——每写入一篇文档,分词器立刻把它切碎、登记进字典。查询快的代价是写入时多做活。这是典型的「写时做索引,读时享红利」,和 MySQL 的 B+ 树思路一致,只是「索引的形状」完全不同。

小收口:倒排索引(词→文档的字典)+ FST(字典的压缩存储)回答了「ES 为什么搜得快」。但字典是写入时建的,写入之后多久能被搜到?答案是「约 1 秒」——为什么不是立刻、也不是 1 分钟?这就是下一个核心概念:近实时。

3.3 近实时写入:Buffer、refresh 与 Page Cache(核心概念二)

先抛问题

如果不考虑别的,写入最简单的做法是「每来一条文档就落一次盘」——但磁盘随机写极慢,搜索服务的写入量又大(每个帖子、每次编辑都是一次写入),这么干吞吐量直接崩掉。反过来「攒一大批再落盘」,崩溃时丢的数据又太多。ES 的解法是在「立刻可见」「高性能」「不丢数据」之间做了一个精妙的折中——理解这个折中,就理解了「近实时」三个字。

给直觉:便利店的上货节奏

补课胶囊 · 近实时(Near Real-Time,NRT) 一句话:数据写入后「约 1 秒」就能被搜到——不是实时(0 秒),也不是批处理(分钟级),介于两者之间。 类比:便利店不会每卖出一瓶水就重新盘点货架(太累),也不会月底才盘点一次(货架早乱了);它的做法是每小时快速补一次货——大部分时候你都能买到刚到的新品,偶尔赶上「货在店里但还没上架」的一小会儿。ES 的 refresh 就是这个「上架」动作,默认每小时干 3600 次(每秒一次)。 回到主线:「写完立刻搜不到」不是 bug,是这套节奏的必然结果。

再补一个系统层的前置知识:

补课胶囊 · Page Cache(页缓存) 一句话:操作系统拿空闲内存缓存磁盘文件的机制——文件读进内存后,下次读同一段就不用碰磁盘了。 类比:磁盘是仓库,Page Cache 是店门口的货架;货先搬到货架再慢慢归仓,顾客拿货只从货架取。 回到主线:ES 的关键设计是「数据进了 Page Cache 就可以被搜索」,落没落盘反而无所谓——搜索读的是内存。

写入流程:三段式

flowchart TD
    W["写文档"] --> B["① Buffer
ES 自己的内存缓冲区"] B -->|"每秒 refresh(默认 1 秒)"| P["② Page Cache
生成 segment,从此可被搜索"] P -->|"定时 flush(间隔较长)"| D["③ 磁盘
写入 Commit Point(提交点)"] B -.->|"期间崩溃?"| T["translog 兜底重放"]
复习提示:这张图在讲:文档先进 Buffer(纯内存,搜不到)→ 每秒一次 refresh「上架」到 Page Cache 并生成 segment(段,倒排索引的最小物理单元,一个索引由很多个 segment 组成)→ 从这一刻起可被搜索 → 定时 flush 才真正落盘。「近实时」的「近」就是 refresh 的这 1 秒。

三个关键理解:

  1. 可搜索的分界线是 Page Cache,不是磁盘。这是 ES 最大胆的设计:生成 segment 写进 Page Cache 就让它可搜,落盘交给后台慢慢做。用内存换延迟——搜索天然读多写多,这笔账划算。
  2. refresh 每秒一次,所以「近」实时。写成 0 秒(每写必 refresh)会疯狂生成小 segment,查询时要打开的文件暴涨,性能反而崩;写成 1 分钟又伤体验。1 秒是工程折中的甜点。
  3. flush 之前崩溃,靠 translog 兜底。translog(事务日志)类似 MySQL 的 redo log:每次写操作先追加记一条,崩溃重启后按它重放找回没落盘的数据。translog 默认是 request 级别刷盘——每个写请求 fsync 落盘后才返回成功,所以「已经确认的写入」不会丢;只有显式改成 async(后台每 5 秒刷一次)才可能丢「最后 5 秒」,搜索场景即便丢这点数据也能接受,大不了重新同步一次(第四节的双写兜底)。

最小可运行示例:亲眼看到「1 秒空窗」

# 写一条文档(不加 refresh 参数)
curl -X PUT "localhost:9200/post/_doc/99" -H 'Content-Type: application/json' \
  -d '{"title": "刚写入的帖子"}'
# 立刻搜——大概率搜不到
curl -X POST "localhost:9200/post/_search" -H 'Content-Type: application/json' \
  -d '{"query": {"match": {"title": "刚写入"}}}'
# 预期输出:hits.total.value = 0(refresh 还没触发)
# 等 1 秒再搜:hits.total.value = 1
复习提示:⚠️ 新手必踩的坑:刚写入搜不到,以为代码写错了。这是每个 ES 新手的第一节必修课:写完立刻查、查不到、开始怀疑人生。记住「写进 Buffer ≠ 可搜索,refresh 之后才可搜索」。联调时可以在写入 URL 上加 ?refresh=true 强制立刻刷新(本课程「跟着写」第 4 步就这么干);生产环境千万别全局这么配——等于每写一条强制 refresh,segment 数量爆炸,性能崩盘。

小收口:倒排索引解释了「为什么搜得快」,近实时解释了「写入和可搜索之间隔着 1 秒」。但还有一个根本问题没解决:文本是谁切成词项的? 中文按什么规则切?切错了会怎样?这就是第三个核心概念:分词。

3.4 分词与字段类型:text、keyword 与 IK(核心概念三)

先抛问题

倒排索引的字典里放的是词项,那么问题来了:「Go 并发编程实战」这串文本,要登记进字典的词项是 Go、并发、编程、实战?还是 Go、并、发、编、程、实、战?这个「把文本切成词项」的动作叫分词(analyze),它直接决定:什么查询能命中、什么查询命中不了、命中得准不准。分词是搜索质量的命门——而中文,恰恰是分词的重灾区。

给直觉:分词器是什么

补课胶囊 · 分词器(Analyzer) 一句话:把一串文本切成词项(term)的组件;写入时切一遍(进倒排索引),查询时也切一遍(拿词项去翻字典)。 类比:中文老师把「我爱北京天安门」用斜线断句成「我 / 爱 / 北京 / 天安门」——断句的方式决定你能不能按「北京」这个词查到这句话。断成单字「我/爱/北/京/……」的话,按「京北」查也能命中(两个单字词项都存在,但语义完全不对)。 回到主线:ES 默认的 standard 分词器对英文按空格切、对中文逐字切——这就是中文搜索的坑。

一个分词器由三段流水线组成,依次是:

  1. 字符过滤器(Character Filter):预处理原始文本,比如剥掉 HTML 标签;
  2. 分词器(Tokenizer):按规则切词(空格、标点、词典);
  3. 词项过滤器(Token Filter):对切出来的词做加工——小写化(Go → go,所以 ES 查询默认大小写不敏感)、去停用词(「的」「了」、is/the 这种没有信息量的词)、词干提取(running → run)。

一句话理解三段的分工:先洗澡(字符过滤)、再切菜(分词)、再摆盘(词项过滤)。

text vs keyword:一个字段的两种命运

ES 字段类型有几十种,新手必须先分清的是这两个——它们决定了这个字段建不建倒排索引、怎么建:

类型写入时适合的查询典型字段代价
keyword不分词,整个值当一个词项term 精确匹配、聚合邮箱、手机号、状态码、标签不支持全文搜索
text分词,切成词项进倒排索引match 全文搜索、相关性评分文章标题、正文、描述索引体积大、写入慢
字段值 "Go 并发编程"
  text 类型的倒排字典条目:    go / 并发 / 编程     (ik 切词后)
  keyword 类型的倒排字典条目: Go 并发编程          (整个串当一个词项)

选择原则:要「全文搜」用 text,要「精确等值匹配 / 聚合统计」用 keyword,两个都要就双类型并存(mapping 里用 fields 给同一字段定义两种形态,如 nickname 搜全文、nickname.keyword 精确匹配)。

中文的大坑:standard 逐字切分

ES 默认的 standard 分词器对中文逐字切分:「大明」切成 大 + 明。后果有两个:

  • 搜「明」能命中「大明」——看似智能,实则是碰巧(字典里恰好有「明」这个词项);
  • 搜「明大」也可能误命中——明 和 大 两个词项都存在,某些查询组合下都算命中,但语义完全不对。

解法:装 IK 分词器——它内置中文词典,按「词」切分。它有两种模式,而且建索引和查询应该用不同的模式:

IK 模式切分风格用在哪为什么
ik_max_word细切:把所有可能的词都切出来(「中华人民共和国」→ 中华人民共和国 / 中华人民 / 中华 / 华人 / 人民 / 共和国 / 共和 / 国)建索引时(analyzer)切得细,用户的任何一种搜法都能在字典里找到词项——保召回(不漏结果)
ik_smart粗切:只切最合理的(「中华人民共和国」→ 中华人民共和国)查询时(search_analyzer)查询词切得粗,命中的词项更有分量、排序更准——保精度(结果更相关)

在 mapping 里这样声明(「跟着写」第 4 步会写进代码):

"title": {
  "type": "text",
  "analyzer": "ik_max_word",
  "search_analyzer": "ik_smart"
}
复习提示:

⚠️ 新手必踩的坑①:中文搜不出 / 搜出奇怪结果。第一反应应该是调 _analyze 看看文本到底被切成了什么——「查询切出来的词项,必须能在写入切出来的词项里找到,倒排索引才能命中」,这句话是排查一切「搜不到」问题的总纲。

⚠️ 新手必踩的坑②:分词器必须在建索引时就规划好。对已有数据改分词器 = 换了一种切法 = 历史数据字典里的词项和新切法对不上。正确操作是重建索引(reindex):建一个新索引、把旧数据全部重新写入一遍——不是改个配置就完事。所以 mapping 是 ES 里「改起来最贵的东西」,第一次就要设计对。

最小可运行示例:对比三种切法

# standard(默认):中文逐字切
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' \
  -d '{"analyzer": "standard", "text": "中华人民共和国国歌"}'
# 预期输出 tokens:中 / 华 / 人 / 民 / 共 / 和 / 国 / 国 / 歌(逐字)

# ik_max_word:细切
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' \
  -d '{"analyzer": "ik_max_word", "text": "中华人民共和国国歌"}'
# 预期输出 tokens:中华人民共和国 / 中华人民 / 中华 / 华人 / 人民 / 共和国 / ...

# ik_smart:粗切
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' \
  -d '{"analyzer": "ik_smart", "text": "中华人民共和国国歌"}'
# 预期输出 tokens:中华人民共和国 / 国歌(只切最合理的)

逐行讲解:三个请求只有 analyzer 参数不同,返回的 tokens 数组就是各自的切分结果。细心的你会发现 ik_max_word 的结果包含 ik_smart 的结果——细切是粗切的超集,这正是「索引细切保召回、查询粗切保精度」能成立的原因。

小收口:text/keyword 决定「建不建倒排、怎么建」,分词器决定「切成什么词项」。这两件事都发生在写入时,查询时只是拿词项去翻字典。接下来看查询本身:term 和 match 的区别、bool 怎么组合条件、以及 ES 怎么给结果打分排序。

3.5 查询入门:term、match、bool 四个篮子与 BM25

先抛问题

用户搜「Go 并发」,期望的行为是什么?至少有四层:①「Go」和「并发」两个词都命中的排最前(AND);②只命中一个的也可以出现,但排后面(OR 加分);③没公开发布的帖子绝不能出现(硬过滤);④被拉黑的作者也不能出现(硬排除)。ES 的查询 DSL 怎么表达这四层?——答案是 bool 查询的四个篮子。

补课胶囊 · DSL 一句话:ES 的查询用 JSON 表达,这套 JSON 语法叫 Query DSL(Domain Specific Language,领域专用语言)。 类比:SQL 之于 MySQL = DSL 之于 ES;只不过把 WHERE a=1 AND b>2 换成了嵌套的 JSON {"bool": {"must": [...], "filter": [...]}}。 回到主线:所有复杂查询都由 bool 的四个篮子组合而成。

term 与 match:一字之差,天壤之别

这是 ES 面试题出现率最高的知识点,先把规则钉死:

查询行为搭配的字段类型
term不分词,拿原值精确匹配字典里的词项keyword
match先分词,把查询文本切成词项再去翻字典text
{ "term":  { "email": "john@example.com" } }
{ "match": { "title": "Go 并发" } }

match 查 Go 并发 的实际执行:查询文本先被分词器切成 go、并发 两个词项 → 分别翻字典拿到各自的文档列表 → 求并集(默认 OR)→ 每个文档按命中情况打分排序。所以「搜出来的结果里只有一半包含完整词组」是正常现象,不是 bug。

复习提示:⚠️ 新手必踩的坑:用 term 查 text 字段,基本查不到。原理用 3.4 的知识推一遍就明白:text 字段写入时「大明」已被切成 大、明 两个词项进字典;term: {nickname: "大明"} 不分词,拿完整的「大明」去字典里找——字典里根本没有这个词项,自然查不到。一句话记忆:精确匹配用 term + keyword,全文搜索用 match + text。

BM25:ES 怎么决定谁排前面

补课胶囊 · 相关性评分(BM25) 一句话:给每个命中文档算一个 _score,分越高排越前;打分算法叫 BM25。 类比:阅卷老师打分——一个知识点(词项)写得多(词频高)加分;这个知识点很冷门、没几个人会(稀有词)多加分;答案简短切题(字段短)比长篇大论更加分。 回到主线:不加 sort 时 ES 默认按 _score 降序返回,这就是「按相关性排序」。

BM25 的三个直觉因素(不要求背公式,能讲出直觉即可):

  1. 词频(TF):关键词在文档里出现次数越多越相关——但有饱和效应,出现 10 次不比 5 次强多少(防刷分);
  2. 逆文档频率(IDF):关键词越罕见越值钱——搜「的」命中 100 万篇毫无区分度,搜「ElasticSearch」命中 3 篇就极其相关;
  3. 字段长度:同样命中一次,「命中在标题里」比「埋在万字正文里」更相关——所以「标题命中 > 正文命中」这件事 BM25 天然支持,本章的 title^3 加权是在它之上再放大。
复习提示:注意:一旦在查询里显式加了 sort(比如按时间),就绕过相关性评分了——_score 不再计算。「既要相关性、又要业务排序」的需求,要么用 function_score(在评分公式上叠加业务函数),要么像本课程一样:相关性、时间、热度作为三种互斥的排序模式由用户切换(「跟着写」第 6 步)。

bool 四个篮子:组合查询的标准姿势

子句语义参与算分?类比 SQL
must必须匹配✅ 算分AND
should命中加分(有 must/filter 时是加分项;单独使用时至少命中一个,可用 minimum_should_match 调整)✅ 算分OR / 加分
must_not必须不匹配❌ 过滤器上下文NOT
filter必须匹配❌ 过滤器上下文AND(只过滤不算分)

对照开头的四层需求,一个完整的 bool 查询长这样:

{
  "query": {
    "bool": {
      "must":   [{ "multi_match": { "query": "Go 并发", "fields": ["title^3", "content"] } }],
      "should": [{ "term": { "tags": "后端" } }],
      "filter": [
        { "term":  { "status": 2 } },
        { "range": { "publish_time": { "gte": "2024-01-01" } } }
      ],
      "must_not": [{ "term": { "author_banned": true } }]
    }
  }
}

逐行讲解:

  • must 里放 multi_match(多字段全文匹配):标题和正文都参与搜索,title^3 表示标题命中的得分 ×3——「命中标题比命中正文重要」的直接表达;
  • should 放「带『后端』标签的加分」:命中就加 _score,不命中也不剔除;
  • filter 放「已发布 + 时间范围」这类非黑即白的条件;
  • must_not 放「作者被拉黑」的硬排除。
复习提示:工程要点:能进 filter 就别进 must。filter / must_not 走过滤器上下文——不算相关性评分(省 CPU),而且 ES 能缓存过滤结果,同样的条件第二次查询快得多。判断标准很简单:这个条件是「非黑即白的事实」(状态、时间、是否拉黑)就进 filter;需要「程度参与打分」(文本匹配、标签加分)才进 must/should。本课程「跟着写」第 6 步的 status / visible 都放在 filter,就是这个原因。

顺带认识几个常用查询类型(用到再查文档即可):

查询用途备注
match_phrase短语匹配(词项保持顺序)「Go 后端」要求两个词按顺序相邻出现
range范围查询数字、日期;操作符 gt/gte/lt/lte
prefix前缀查询搜索框自动补全
wildcard / fuzzy通配符 / 容错(编辑距离)性能差,慎用
aggregation聚合统计分组、计数、求和——第 6 步的标签分布就是它

小收口:到这里,ES 的「三大件」——倒排索引(结构)、近实时(写入节奏)、分词(数据加工)——加上查询侧的 term/match/bool/BM25,你已经能看懂绝大多数 ES 查询和面试题了。接下来把视角拉回工程:一个内容平台怎么做搜索服务——不是「能查」就完了,而是让每个业务方都能低成本接进来。

3.6 搜索服务的平台化设计:让业务方零痛苦接入

先抛问题

今天帖子要被搜索,明天用户要被搜索,后天话题、商品、直播间都要被搜索。如果每个业务方都自己连 ES、自己建索引、自己写同步代码——三件事每个都做一遍,代码重复、口径不一、ES 连接数爆炸。这正是第 16 章 3.10 节「平台化思维:解决一类问题,而不是一个问题」在搜索领域的再次落地:搜索服务要做成平台,把共性问题(连接管理、索引维护、削峰、高可用)收拢到自己身上。

给直觉:搜索栏是商场的导购台

平台为什么愿意花大钱养一个搜索团队?表面看搜索栏是「帮用户找内容」,实际上它还有两个更重要的身份:

  1. 广告收入的入口——搜索结果里可以插广告;
  2. 流量分发的总闸门——平台想让谁被看见,搜索排序就是最直接的杠杆。

类比:搜索栏就像商场的导购台。表面是帮你找店铺(用户体验),实际导购先推的永远是「交了钱的店铺」(流量分发 + 广告)。理解这一点,你就懂了为什么搜索值得做成一个独立平台级服务——因为它牵动利益。

决策一:推送接口——统一还是分离?

业务方要把数据同步给搜索服务,接口怎么开?

方案做法优点缺点
通用接口一个接口收 JSON(index_name + 数据体)业务方零适配、接入最快失去编译期检查,字段写错了运行时才发现
业务专属接口每个业务方一个强类型接口(gRPC)编译期类型安全、语义清晰每接一个新业务,搜索服务要改代码

本课程的答案:两者并存——常规业务走专属接口(类型安全),救火的紧急需求走通用接口(免适配)。这是平台化的常态:通用接入解决共性问题(快),专属接口解决个性问题(稳)。

flowchart LR
    U["User 服务"] -->|"业务专属接口"| S["搜索服务"]
    A["Article 服务"] -->|"业务专属接口"| S
    X["紧急新业务"] -->|"通用 JSON 接口"| S
复习提示:这张图在讲:两条接入通道并存——常规业务走有类型保护的专属接口,救火的新业务走免适配的通用接口。不要二选一,平台要同时备着两把钥匙。

决策二:同步链路——直写还是走 Kafka?

业务方每次数据变更(发帖、编辑、撤回)都要同步到 ES。流量小时直接 gRPC 调用没问题;流量一大(比如运营批量改 10 万条帖子的状态),同步调用会把搜索服务和 ES 一起打爆。解法你早就学过——第 07 章的 Kafka 削峰:

flowchart LR
    B["业务方"] -->|"同步 gRPC(低峰)"| S["Service.Save"]
    B -.->|"高并发时"| K["Kafka 统一 Topic"]
    K --> C["消费者"]
    C --> S
    S --> E[("ES 索引")]
复习提示:这张图在讲:写入 ES 有两条路——低峰同步直写(延迟低),高峰走 Kafka 削峰(抗洪)。关键设计:两条路最终都收敛到同一个 Service.Save,保证不管从哪条路进来,写入行为完全一致(同一份 Upsert 逻辑、同一套日志与监控)。

统一 Event 格式很简单——index_name(写到哪个索引)+ data(文档数据),消费者拿到后直接穿透到 DAO 层写入,不需要为每个业务方写一个消费者。

还有一种「被动接入」的场景:业务方很强势(比如公司里最大的那个部门),拒绝适配你的接口,只肯往自己的 Kafka Topic 发消息。这时候你只能去监听它的 Topic——为每个强势业务方写一个消费者,把消息转换成统一 Event 转发到自己的统一 Topic(推荐做法,而不是让每个消费者直接调 Service——统一转发一次,后续逻辑只维护一份)。

决策三:高可用——隔离部署

搜索服务挂了什么最要命?答案是「首页搜索框搜不了」——用户第一眼就能发现。而「个人主页的历史搜索联想」挂了,用户几乎无感。所以部署上要物理隔离,核心思路和第 14 章「降级保大不包小」一脉相承:

  • 读写隔离:同步数据的服务一个集群,用户查询的服务另一个集群——写入洪峰(批量导数据)不会拖垮线上搜索;
  • 业务隔离:核心搜索(首页搜索栏)独享一个 ES 集群,边缘搜索(后台管理页的搜索)共用另一个集群——边缘业务把 ES 打挂,核心搜索无感。
flowchart LR
    W["写集群(数据同步)"] -.独立.-> E1[("ES 写入集群")]
    R["读集群(用户搜索)"] -.独立.-> E2[("ES 查询集群")]
复习提示:这张图在讲:读写两套集群互不干扰——写挂了不影响用户搜,搜挂了不影响数据同步。隔离的本质是「故障域切分」:把可能一起挂的东西拆开部署,一个崩溃不殃及另一个。代价是数据要在两套集群间同步(生产常用跨集群复制CCR,本章不展开),本地学习用单节点足够。

小收口:平台化的三组决策——接口(统一 + 专属并存)、链路(直写 + Kafka 削峰)、部署(读写 / 业务隔离)——共同目标是「业务方接入零痛苦、核心搜索高可用」。接下来做一个具体业务练手:标签服务,它会把「不可变数据」的设计红利展现得淋漓尽致。

3.7 标签服务:把「不可变」的红利吃到饱

先抛问题

给帖子、联系人、相册打标签(「Go」「后端」「面试」),是社区产品的标配功能。标签功能本身很简单,但它的数据特性很特别:绝大部分系统里,标签一旦创建就不可变——不提供改标签名、删标签的接口。为什么?因为「不可变」这个看似自缚手脚的前提,能换来一连串极简的架构设计。这是「业务前提换架构简单度」的教科书案例。

给直觉:便利贴

标签就像给书贴的便利贴分类:「Go」「后端」「面试」贴上去,想找同类内容按便利贴一抽就行——没有便利贴,所有文章就是一堆没分类的纸。便利贴的规矩和标签一样:贴上去就不改字,撕掉重来,但从不出售「涂改液」。

表结构:两张表表达「谁给什么打了哪个标签」

// internal/tag/repository/model.go
// Tag 标签表:一个标签属于一个创建者
type Tag struct {
    ID    int64  `gorm:"primaryKey;autoIncrement"`
    UID   int64  `gorm:"index:idx_uid_name,unique,priority:1;comment:创建者"`
    Name  string `gorm:"type:varchar(128);index:idx_uid_name,unique,priority:2;comment:标签名"`
    Ctime int64
    Utime int64
}

// TagBiz 资源-标签关联表:谁(uid)给哪个资源(biz+biz_id)打了哪个标签(tid)
type TagBiz struct {
    ID    int64  `gorm:"primaryKey;autoIncrement"`
    UID   int64  `gorm:"index:idx_uid_biz,priority:1;comment:打标签的人"`
    Biz   string `gorm:"type:varchar(128);index:idx_uid_biz,priority:2;comment:资源类型,如 article"`
    BizID int64  `gorm:"index:idx_uid_biz,priority:3;comment:资源ID"`
    TID   int64  `gorm:"index:idx_tid;comment:标签ID"`
    Tag   Tag    `gorm:"foreignKey:TID;references:ID;comment:关联的标签(Preload 回填)"`
    Ctime int64
    Utime int64
}

逐行讲解:

  • Tag 表的 (uid, name) 唯一索引:同一个用户不能创建两个重名标签(第 16 章关注表的 (follower, followee) 唯一索引是同一个套路);
  • TagBiz 是典型的中间表(第 16 章讲过的多对多表达方式):一行代表「某人给某资源打了某标签」,查询「这篇帖子有哪些标签」走 (uid, biz, biz_id) 组合,「哪些资源用了这个标签」走 idx_tid;
  • 为什么要记 UID?因为标签是私有的——你打的「面试」和我打的「面试」是两个标签,互不可见(这也是后面标签没法塞进文章索引的原因,3.8 节展开)。

创建标签 + Redis List 缓存:不可变带来的第一份红利

打标签前,前端要展示「你已有的标签列表」让你选。这个列表每次打开都要查 DB 吗?不需要——因为标签不可变,缓存永远不会脏(没有更新操作,何来缓存不一致):

// internal/tag/service/tag.go
// CreateTag 创建标签:落库成功后直接追加进缓存
// 前提:标签不可变(无更新无删除),所以缓存只增不改,永远新鲜
func (s *TagService) CreateTag(ctx context.Context, uid int64, name string) (int64, error) {
    id, err := s.repo.CreateTag(ctx, domain.Tag{UID: uid, Name: name})
    if err != nil {
        return 0, err
    }
    // 单用户不会有并发创建标签的场景,直接 RPUSH 追加,不做复杂的缓存更新
    _ = s.cache.Append(ctx, uid, name)
    return id, nil
}

// internal/tag/cache/tag.go
// Append 把标签名追加到该用户的标签列表尾部(Redis List)
func (c *TagCache) Append(ctx context.Context, uid int64, name string) error {
    return c.client.RPush(ctx, tagKey(uid), name).Err()
}

为什么敢用 List:List(队列结构)只会追加、不会改中间元素——恰好匹配「标签只增不改」的特性。如果标签可变,你就得换 Hash(按 ID 定位)或 Set(去重集合),还要处理「缓存里的旧名字怎么更新」——一大堆麻烦,全部被「不可变」这个业务前提消灭了。

缓存预加载:启动时把全量标签灌进 Redis

「打开打标面板」是高频操作,而且通常发生在用户第一次进入编辑页的那一刻——哪怕 Redis 命中也要一次网络往返,极端追求性能的场景(toB 内部系统,用户总量不大)干脆启动时全量预加载:

// internal/tag/repository/tag.go
// PreloadUserTags 启动时分批扫描全量标签,按用户分组灌进 Redis
// 适用前提:toB 场景用户标签总量可控(toC 亿级用户不适用)
func (r *TagRepository) PreloadUserTags(ctx context.Context) error {
    const batchSize = 100
    var lastUID int64 = 0 // 游标:从 UID=0 开始往后扫
    for {
        tags, err := r.findBatchByUID(ctx, lastUID, batchSize)
        if err != nil {
            return err
        }
        if len(tags) == 0 {
            break // 扫完了
        }
        for uid, names := range groupByUID(tags) {
            _ = r.cache.BatchAppend(ctx, uid, names) // pipeline 批量写
        }
        lastUID = tags[len(tags)-1].UID // 游标推进
    }
    return nil
}

逐行讲解:这就是第 16 章讲过的游标分页思想在「全表扫描」上的应用——lastUID 记录上一批最后一个 UID,下一批 WHERE uid > lastUID ORDER BY uid LIMIT 100。为什么不用 offset?老原因:offset 要数过前面所有行,数据量一大就慢;游标只认位置不数数。启动时在后台 goroutine 里跑一遍(go func() { repo.PreloadUserTags(ctx) }()),跑完之后所有「查用户标签列表」的请求 Redis 直接命中,DB 压力归零。

覆盖式打标签:先删后插,必须事务

用户在打标面板上勾选了 B、C、D,点保存——原来打的 A、B、C 怎么办?业界主流语义是覆盖式(全量替换):直接把该资源上的旧关联全删掉,插入新勾选的。这个「先删后插」是两步 DB 操作,必须用事务包住:

// internal/tag/repository/tag.go
// AttachTags 覆盖式打标签:删除旧关联 → 插入新关联,事务保证原子
func (r *TagRepository) AttachTags(ctx context.Context, uid int64, biz string, bizID int64, tids []int64) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 步骤 1:删掉该 (uid, biz, biz_id) 下的全部旧关联
        if err := tx.Where("uid = ? AND biz = ? AND biz_id = ?", uid, biz, bizID).
            Delete(&TagBiz{}).Error; err != nil {
            return err
        }
        // 步骤 2:插入新关联(空列表 = 清空所有标签,也合法)
        if len(tids) == 0 {
            return nil
        }
        rows := make([]TagBiz, 0, len(tids))
        for _, tid := range tids {
            rows = append(rows, TagBiz{UID: uid, Biz: biz, BizID: bizID, TID: tid})
        }
        return tx.Create(&rows).Error
    })
}
复习提示:⚠️ 新手必踩的坑:先删后插不包事务。删旧成功、插新失败(DB 抖动、连接断开),这个资源的标签就「凭空消失」了——比「保留旧标签」糟糕得多,用户会以为系统弄丢了他的数据。事务保证要么全成要么全败。另一个坑:并发场景两个请求同时覆盖同一个资源的标签,两个事务互相覆盖(后写的赢),生产上要对 (uid, biz, biz_id) 加锁或串行化——单用户操作自己的资源,实际并发概率很低,课程里不展开。

查询:GORM Preload 一次 JOIN 出标签名

// internal/tag/repository/tag.go
// GetTags 查资源上的标签;uid = -1 表示系统默认标签,与用户自己的标签合并返回
func (r *TagRepository) GetTags(ctx context.Context, uid int64, biz string, bizID int64) ([]domain.Tag, error) {
    var res []TagBiz
    err := r.db.WithContext(ctx).
        Preload("Tag"). // GORM 自动按 TID 关联 Tag 表补齐标签详情,等价一次 JOIN
        Where("uid IN ? AND biz = ? AND biz_id = ?", []int64{uid, -1}, biz, bizID).
        Find(&res).Error
    if err != nil {
        return nil, err
    }
    tags := make([]domain.Tag, 0, len(res))
    for _, tb := range res {
        tags = append(tags, domain.Tag{ID: tb.Tag.ID, Name: tb.Tag.Name})
    }
    return tags, nil
}

逐行讲解:Preload("Tag") 让 GORM 在查出 TagBiz 列表后,自动按 TID 批量查 Tag 表回填 Tag 字段——效果等同 JOIN,但代码里不用手写关联条件。uid IN (uid, -1) 是个实用小技巧:把「用户自己打的标签」和「系统预置标签」(约定 uid = -1)一次查回来合并展示。

优化彩蛋(冗余字段的又一次亮相):如果给 TagBiz 冗余一个 tag_name 字段,连这次 JOIN 都省了——但前提还是那个:只冗余不可变字段。标签名不可变,冗余它安全;哪天标签能改名了,冗余值就开始说谎(第 16 章 root_id 冗余的同款权衡)。

小收口:标签服务一行 ES 代码都没写,却把「不可变」这个业务前提变成了三份架构红利——List 缓存免更新、预加载无脑灌、冗余字段不用同步。最后一步,把标签接进搜索,让「带某标签的帖子」在搜索结果里排得更靠前。

3.8 标签接入搜索:多次查询 + boost 加权

先抛问题

搜「Go 面试」,两篇帖子都命中了:一篇只是正文提到 Go,另一篇作者亲手打了「Go 面试」标签。哪个更该排前面?显然是后者——用户明确打的标签是最强的相关性信号。怎么让标签参与搜索排序?

先想清楚:标签数据放哪个索引?

方案做法优点缺点
独立标签索引标签服务建自己的 tag_idx 推给搜索通用性最强,任何业务都能挂标签搜索时要做跨索引关联
塞进文章索引文章服务把标签查出来,跟文章一起写进 article_idx单索引一次查询搞定标签与文章耦合;且标签是私有的(uid 维度),塞进公共文章索引语义就错了

本课程选独立索引——决定性理由是 3.7 节的表结构:标签带 UID(私有的),「A 用户打的标签」不该出现在「B 用户搜文章」的公共索引里。

跨索引关联:为什么不请出 Nested / Parent-Child

ES 内置两种「文档关联」方案,先把它们的坑摆出来(面试常考「为什么不用」):

方案机制实践评价
Nested(嵌套文档)子文档物理上存在父文档内部更新子文档 = 整个父文档重索引;子文档数量受限。我们的标签模型(独立、私有、跨业务)根本套不进去
Parent-Child(父子文档)父子文档同分片存储,靠 routing 关联查询性能差(要维护 join field、查询多一层路由),实践中少用

本课程采用:多次查询——简单、可控、每次查询都是普通查询:

flowchart TD
    Q["搜索关键字"] --> T["第一次查询:查 tag_idx
拿到标签命中的 biz_id 列表"] T --> A["第二次查询:查 article_idx
bool.should 合并两条"] A --> M["基础匹配:title/content 命中
(正常得分)"] A --> H["增强匹配:id 命中标签列表
(boost=2.0 加分)"] M --> R["标签命中的帖子排更前"] H --> R
复习提示:这张图在讲:先用关键词查标签索引(哪些资源被打了含关键词的标签),拿到资源 ID 列表;再查文章索引,把「文本命中」和「在标签命中名单里」两个条件用 should 合并——后者带 boost 加分,于是带标签的帖子排名靠前。

核心代码(完整的工程版在「跟着写」第 6 步,这里是骨架):

// internal/search/repository/article.go(骨架)
func (r *SearchRepository) SearchArticleWithTagBoost(ctx context.Context, keywords []string) ([]domain.Article, error) {
    // 步骤 1:查标签索引,拿到命中关键字的资源 ID 列表
    tagBizIDs, err := r.findBizIDsByTag(ctx, keywords)
    if err != nil {
        return nil, err
    }
    // 步骤 2:查文章索引,should 篮子里放两个条件:
    //   ① 基础匹配(title/content)正常算分
    //   ② id 在标签命中名单里,boost=2.0 给得分乘以 2
    query := map[string]interface{}{
        "query": map[string]interface{}{
            "bool": map[string]interface{}{
                "should": []interface{}{
                    map[string]interface{}{
                        "multi_match": map[string]interface{}{
                            "query": strings.Join(keywords, " "),
                            "fields": []string{"title", "content"},
                        },
                    },
                    map[string]interface{}{
                        "terms": map[string]interface{}{
                            "id": tagBizIDs,
                            "boost": 2.0, // 默认 1.0,越高越靠前
                        },
                    },
                },
            },
        },
    }
    // 步骤 3:执行查询并解析(同 3.5 节的套路,略)
    return r.doSearch(ctx, query)
}

逐行讲解:

  • should 篮子里两个条件是 OR 加分关系(3.5 节的表格):文本命中得基础分,标签命中再乘 2——两个都命中的帖子得分最高,排第一梯队;
  • boost 的直观理解:得分的放大器。默认 1.0 不放大;boost: 2.0 意味着这个条件贡献的分值 ×2。它不改变「谁能被搜到」(那是 must/filter 的职责),只改变「谁排前面」——这正是排序干预的正确工具;
  • 标签数据怎么进 ES?复用 3.6 节的统一接入:标签服务往 Kafka 的统一 Topic 发 {index_name: "tag_idx", data: {...}} 格式的事件,搜索服务的消费者写入即可——平台化的红利,一个新业务接入零新增消费者代码。
复习提示:面试加分点:如果被问「标签搜索还有别的做法吗」,可以补充:生产里标签加权常和 function_score 配合(按标签命中个数阶梯加分),以及「热词标签」可以定期离线计算好直接冗余进文章文档(又一个冗余字段案例)。展示出「boost 是起点不是终点」的视野就够了。

小收口:标签接入搜索是本章原理与工程的合流——倒排索引(标签也要分词进字典)、bool should(合并两类命中)、boost(业务干预相关性评分)、多次查询(避开 Nested 的坑)、统一接入(平台化红利)。到这里,正文的概念全部讲完,接下来把整章变成能跑的代码。

四、跟着写:分步实现 search-service

上一章结束时,评论与用户关系服务已经就位,帖子数据安稳地躺在 MySQL 里,但顶栏那个搜索框还没着落——LIKE '%关键词%' 在百万数据面前就是灾难。这一节把搜索真正落地:新建 search-service 微服务(gRPC :9400 / HTTP :8400,复用第 12 章的 pkg/registry 注册到 ETCD),数据全存 ES、不依赖数据库;用官方 go-elasticsearch/v8 封装客户端,启动时自动建 post 索引(IK 分词),实现带高亮与三种排序的帖子搜索接口,最后让 post-service 通过 gRPC IndexPost 双写同步。步骤总览:

  • 第 1 步:docker-compose 部署 ES 8.x + IK 分词器
  • 第 2 步:search-service 骨架(配置 + 依赖组装 + ETCD 注册)
  • 第 3 步:封装官方 ES 客户端(核心)
  • 第 4 步:post 索引管理(启动时自动建索引)
  • 第 5 步:gRPC IndexPost 双写同步
  • 第 6 步:帖子搜索接口——高亮 + 三种排序 + 标签聚合(核心)
  • 第 7 步:搜索建议与标签搜索(从简)
  • 第 8 步:端到端验证

第 1 步:部署 ES 8.x 与 IK 分词器

先把基础设施立起来。在之前章节的 docker-compose.yml 里追加 ES 服务:

# docker-compose.yml 追加
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: es    # 后面 docker exec 装插件靠这个容器名
    environment:
      - discovery.type=single-node      # 单节点,仅本地开发;生产至少 3 节点
      - xpack.security.enabled=false    # 关闭认证,本地免账号密码(生产必须开启)
      - ES_JAVA_OPTS=-Xms512m -Xmx512m  # 限制 JVM 内存,防止吃光本机内存
    volumes: [es_data:/usr/share/elasticsearch/data]  # 数据落盘,重启不丢索引
    ports: ["9200:9200"]
volumes:
  es_data:

逐行讲解(每个环境变量都有它的道理):

  • discovery.type=single-node:告诉 ES「你是单机版」,跳过集群发现(生产至少 3 个节点,见 3.6 节隔离部署);
  • xpack.security.enabled=false:关掉 8.x 默认开启的 TLS + 账号密码。不关的话,第 3 步的 Go 客户端会报 tls: first record does not look like a TLS handshake 或 401——新手最常见的第一个 ES 报错;
  • ES_JAVA_OPTS=-Xms512m -Xmx512m:ES 是 Java 程序,默认会要好几 GB 内存,本机开发压到 512MB 够用;
  • 命名容器 es 并挂载 es_data 卷:前者让 docker exec 有稳定目标,后者让索引数据在容器重启后依然存在。

装 IK 分词器(版本必须与 ES 镜像一字不差)并验证:

docker compose up -d elasticsearch && sleep 30   # ES 完全启动约需 30 秒
# 安装 IK 分词器(8.12.0 配 8.12.0),装完重启容器才生效
docker exec es elasticsearch-plugin install \
  https://get.infini.cloud/elasticsearch/analysis-ik/8.12.0 \
  && docker restart es && sleep 30
# 验证:应切出「中华人民共和国」「国歌」等词项,而非逐字切分
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' \
  -d '{"analyzer": "ik_max_word", "text": "中华人民共和国国歌"}'

验证:curl localhost:9200 返回集群信息("number" : "8.12.0");_analyze 的返回里 tokens 是成词的中文词项。两条都过,说明集群与 IK 都就绪。

复习提示:⚠️ 新手必踩的坑:IK 插件版本不匹配。IK 版本必须和 ES 镜像完全一致(8.12.0 配 8.12.0),对不上要么装不上、要么容器起来又崩溃反复重启。拿不准就先 curl localhost:9200 看返回的 "number" 字段,再选对应版本的插件。第二个坑:ES 启动很慢(30 秒起步),docker compose up -d 之后立刻 curl 会得到 connection refused——不是坏了,是还没起完,等一等或看 docker logs es。

第 2 步:search-service 骨架

新服务不依赖数据库(数据全在 ES),配置只有四段:

# search-service/configs/config.yaml(全局约定:viper 读取 configs/ 目录)
es:
  addresses: [http://localhost:9200]
grpc:
  addr: ":9400"
http:
  addr: ":8400"
etcd:
  endpoints: [localhost:2379]

main.go 的组装顺序:viper 读配置 + zap 日志 → 第 3 步的 es.NewClient(带健康检查重试)→ 第 4 步的 repository.EnsurePostIndex(启动时幂等建索引)→ 组装 Handler → Service → Repository。与第 12 章模板相比,本章新增的注册只有两行:HTTP 侧 handler.NewSearchHandler(svc).RegisterRoutes(engine)(监听 :8400)、gRPC 侧 searchv1.RegisterSearchServiceServer(gs, grpcSrv)(监听 :9400);ETCD 注册复用第 12 章的 pkg/registry,启动代码与第 16 章 comment-service 的完全同构,不再重复贴。

验证:go build ./... 编译通过;服务启动后 etcdctl get --prefix search-service 能查到注册实例(同第 12 章)。

第 3 步:封装官方 ES 客户端(核心)

先补一句实话:官方 SDK 以「难用」著称——它本质上就是构造 HTTP 请求,没有做太多抽象。正确的姿势不是绕开它,而是薄薄包一层「连接管理 + 启动重试 + 日志」,通过内嵌保留官方 SDK 的全部能力:

// search-service/pkg/es/client.go
package es

import (
    "fmt"
    "time"

    "github.com/elastic/go-elasticsearch/v8"
    "go.uber.org/zap"
)

// Config 对应 configs/config.yaml 的 es 段
type Config struct {
    Addresses []string `mapstructure:"addresses"`
    Username  string   `mapstructure:"username"` // 生产开启 xpack 后填写
    Password  string   `mapstructure:"password"`
}

// Client 官方客户端的薄封装:内嵌 *elasticsearch.Client 后,
// 调用方仍然直接用官方的 esapi 方法(c.Search(...)、c.Index(...)),
// 不损失任何能力,也不多造一层抽象
type Client struct {
    *elasticsearch.Client
    logger *zap.Logger
}

func NewClient(cfg Config, logger *zap.Logger) (*Client, error) {
    raw, err := elasticsearch.NewClient(elasticsearch.Config{
        Addresses: cfg.Addresses, Username: cfg.Username, Password: cfg.Password,
    })
    if err != nil {
        return nil, fmt.Errorf("创建 ES 客户端: %w", err)
    }
    c := &Client{Client: raw, logger: logger}
    // 带重试的健康检查:容器里的 ES 要 30 秒+ 才就绪,不重试会启动即 panic
    if err = c.ping(10, 3*time.Second); err != nil {
        return nil, fmt.Errorf("ES 健康检查失败: %w", err)
    }
    logger.Info("ES 连接成功", zap.Strings("addresses", cfg.Addresses))
    return c, nil
}

再补上重试探测函数:

// search-service/pkg/es/client.go 续
// ping 探测集群就绪:最多 maxRetries 次、每次间隔 interval
// 为什么需要它:docker 里的 ES 冷启动约 30 秒,服务比 ES 先起是常态
func (c *Client) ping(maxRetries int, interval time.Duration) error {
    var lastErr error
    for i := 0; i < maxRetries; i++ {
        resp, err := c.Info() // 官方 SDK 的 Info() 等价 GET /
        if err == nil {
            resp.Body.Close() // 响应体要关掉,防止连接泄漏
            return nil
        }
        lastErr = err
        c.logger.Warn("ES 未就绪,稍后重试",
            zap.Int("retry", i+1), zap.Error(err))
        time.Sleep(interval)
    }
    return lastErr
}

逐行讲解:

  • Client 结构体内嵌 *elasticsearch.Client(Go 的组合):调用方拿到 *Client 后,client.Search(...)、client.Index(...) 这些方法直接可用——零成本保留官方 SDK 全部 API。这是「不造抽象」的典范写法;
  • ping 的重试逻辑:10 次 × 3 秒 = 最多等 30 秒,正好覆盖 ES 冷启动。为什么不用 for 无限重试?启动卡死不如快速失败——日志里「ES 未就绪」刷屏,运维一眼定位是基础设施问题而不是服务 bug。

验证:go vet ./... 通过;服务启动日志出现 ES 连接成功。

复习提示:⚠️ 新手必踩的坑:连 8.x 报 tls 错误或 401。ES 8.x 默认开启 TLS 与安全认证。如果第 1 步没设 xpack.security.enabled=false,Go 客户端会报 tls: first record does not look like a TLS handshake(客户端用 https 语义去解析 http 响应)或收到 401。本地关认证即可;生产要在 elasticsearch.Config 里配 Username / Password 与 CACert。

客户端选型的补充一句:Go 生态另一个流行的 ES 客户端是 olivere/elastic(v7,链式查询构建器,query.Bool().Must(...).Filter(...) 写起来很顺)。本课程选官方 go-elasticsearch/v8 是为了不引入额外抽象、能力与官方 API 严格对齐;olivere 的优势在查询构建的人体工学,维护活跃度上官方库更稳。两者都值得认识,选型沟通时能说出取舍即可。

第 4 步:post 索引管理(启动时自动建索引)

mapping 是 ES 版的「建表语句」,3.4 节的原则在这里全部落地——中文文本用 ik_max_word 建、ik_smart 查,标签用 keyword,时间用 date:

// search-service/internal/repository/post_index.go
package repository

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "strings"

    pes "github.com/yourname/content-community/search-service/pkg/es"
)

const postIndex = "post"

// PostDoc post 索引文档结构(status: 2=已发布;visible: 1=公开)
// 注意 CreatedAt 是 string 存 RFC3339 格式——mapping 里是 date 类型
type PostDoc struct {
    PostID       int64    `json:"post_id"`
    AuthorID     int64    `json:"author_id"`
    Title        string   `json:"title"`
    Content      string   `json:"content"`
    Tags         []string `json:"tags"`
    CoverImage   string   `json:"cover_image"`
    Status       int8     `json:"status"`
    Visible      int8     `json:"visible"`
    LikeCount    int      `json:"like_count"`
    CommentCount int      `json:"comment_count"`
    ViewCount    int      `json:"view_count"`
    CreatedAt    string   `json:"created_at"`
}
// search-service/internal/repository/post_index.go 续
// postMapping 索引定义。settings 面向本地单节点:1 分片 0 副本
// (生产多节点才需要多分片多副本,原理见 3.2/3.6 节)
const postMapping = `{
  "settings": { "number_of_shards": 1, "number_of_replicas": 0 },
  "mappings": { "properties": {
    "post_id": { "type": "long" },       "author_id": { "type": "long" },
    "title":   { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
    "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
    "tags":    { "type": "keyword" },
    "cover_image": { "type": "keyword", "index": false },
    "status": { "type": "byte" }, "visible": { "type": "byte" },
    "like_count": { "type": "integer" }, "comment_count": { "type": "integer" },
    "view_count": { "type": "integer" }, "created_at": { "type": "date" }
  } }
}`

// EnsurePostIndex 启动时调用:不存在则创建;已存在视为成功(幂等,可反复重启)
func EnsurePostIndex(ctx context.Context, client *pes.Client) error {
    create, err := client.Indices.Create(postIndex,
        client.Indices.Create.WithContext(ctx),
        client.Indices.Create.WithBody(strings.NewReader(postMapping)))
    if err != nil {
        return fmt.Errorf("创建 post 索引: %w", err)
    }
    defer create.Body.Close()
    // 索引已存在是"正常情况"而不是错误——服务第二次重启就会走到这里
    if create.IsError() && !strings.Contains(create.String(), "resource_already_exists") {
        return fmt.Errorf("创建 post 索引异常: %s", create.String())
    }
    return nil
}

逐行讲解:

  • title / content:text + 双分词器——3.4 节「索引细切保召回、查询粗切保精度」的直接落地;
  • tags:keyword——标签要 term 精确匹配和聚合统计(第 6 步的 tag_aggs),不要分词;
  • cover_image:"index": false——封面图 URL 只随文档返回、永远不参与搜索,干脆不建倒排索引,省空间;
  • EnsurePostIndex 的幂等处理:ES 对「创建已存在的索引」返回 resource_already_exists 错误,我们把它降级为成功——服务每次重启都调它,第二次起就会命中这个分支。幂等是启动逻辑的生命线(第 15 章讲过的老朋友)。

最后是写入方法:

// search-service/internal/repository/post_index.go 续
// SavePost 用 post_id 作文档 _id 写入:重复写覆盖旧文档(Upsert 语义)
// 发帖、编辑、撤回都复用这一个方法——撤回就是带着下架状态重写一遍
func (r *PostESRepository) SavePost(ctx context.Context, doc PostDoc) error {
    body, err := json.Marshal(doc)
    if err != nil {
        return fmt.Errorf("序列化文档: %w", err)
    }
    // WithRefresh("true") 让文档立刻可搜,方便联调;
    // 生产去掉(默认 1 秒 refresh,见 3.3 节——强制刷新会让 segment 爆炸)
    resp, err := r.client.Index(postIndex,
        r.client.Index.WithContext(ctx),
        r.client.Index.WithDocumentID(fmt.Sprintf("%d", doc.PostID)),
        r.client.Index.WithRefresh("true"),
        r.client.Index.WithBody(bytes.NewReader(body)))
    if err != nil {
        return fmt.Errorf("写入 ES: %w", err)
    }
    defer resp.Body.Close()
    return nil
}

逐行讲解:关键在 WithDocumentID(post_id)——显式指定文档 _id 等于业务主键。ES 里 _id 相同的写入会覆盖旧文档:发帖写一次、编辑再写一次,ES 里始终只有一份该帖子的最新状态。这正是第 16 章 Upsert(ON DUPLICATE KEY UPDATE)思想在 ES 的对应物——不查不改,直接以主键覆盖写。

验证:服务首次启动后 curl localhost:9200/post,返回的 mappings 里能看到各字段与 ik_max_word 分词器。

第 5 步:gRPC IndexPost 双写同步

数据怎么从 post-service 流到 search-service?先定义 gRPC 接口(这就是 3.6 节「业务专属接口」——类型安全的常规接入通道):

// api/proto/search/v1/search.proto
syntax = "proto3";
package search.v1;
option go_package = "github.com/yourname/content-community/api/proto/search/v1;searchv1";

service SearchService {
  rpc IndexPost(IndexPostRequest) returns (IndexPostResponse);
}
message IndexPostRequest {
  int64 post_id = 1;  int64 author_id = 2;  string title = 3;  string content = 4;
  repeated string tags = 5;  string cover_image = 6;  int32 status = 7;  // 2=已发布;撤回传下架状态
  int32 visible = 8;  int64 created_at = 9;  // Unix 秒
}
message IndexPostResponse {}

Service 层做参数映射(领域对象不依赖 pb,方便单测——第 04 章的老原则):

// search-service/internal/service/search_service.go
package service

import (
    "context"
    "time"

    "github.com/yourname/content-community/search-service/internal/repository"
)

// PostIndexData gRPC 入参映射出的业务对象(不依赖 pb,便于单测)
type PostIndexData struct {
    PostID, AuthorID           int64
    Title, Content, CoverImage string
    Tags                       []string
    Status, Visible            int8
    CreatedAt                  int64 // Unix 秒
}

type SearchService struct{ repo *repository.PostESRepository } // 构造函数同第12章模板

// IndexPost 帖子写入 ES(Upsert 语义);CreatedAt 转 RFC3339(mapping 是 date 类型)。
// SearchPosts / Suggest 无额外业务规则,直接透传 repository,不再贴码
func (s *SearchService) IndexPost(ctx context.Context, data PostIndexData) error {
    doc := repository.PostDoc{
        PostID: data.PostID, AuthorID: data.AuthorID,
        Title: data.Title, Content: data.Content, Tags: data.Tags,
        CoverImage: data.CoverImage, Status: data.Status, Visible: data.Visible,
        CreatedAt: time.Unix(data.CreatedAt, 0).Format(time.RFC3339),
    }
    return s.repo.SavePost(ctx, doc)
}

逐行讲解:唯一的业务逻辑是 time.Unix(...).Format(time.RFC3339)——gRPC 里时间用 Unix 秒传输(int64,跨语言无歧义),ES 的 date 类型接受 RFC3339 字符串,这一行做格式转换。gRPC 侧的 SearchGRPCServer 只需把 pb 字段填进 PostIndexData,服务器骨架与第 12 章模板完全一致(searchv1.RegisterSearchServiceServer),不再贴码。

post-service 侧的调用姿势(重点在「放主流程之外」):

// post-service/internal/service/post.go(发帖成功后的片段)
if err = s.repo.Create(ctx, post); err != nil {
    return err
}
// DB 落库成功即返回——ES 同步放 goroutine 异步做,失败只记日志
go func() {
    if er := s.searchSvc.IndexPost(context.Background(), toIndexData(post)); er != nil {
        s.logger.Error("同步 ES 失败,等全量任务兜底",
            zap.Int64("post_id", post.ID), zap.Error(er))
    }
}()
return nil

验证:buf generate 生成代码后 go build ./... 通过;grpcurl 调一次 search.v1.SearchService/IndexPost,再 curl localhost:9200/post/_doc/1 能查到刚写入的文档。

复习提示:⚠️ 新手必踩的坑:双写失败把发帖拖挂。如果同步调用 IndexPost 且不处理失败:ES 一抖,用户发帖就报错——主业务被附属业务绑架,这是架构事故。三条纪律:① 落库成功就返回,ES 同步异步做;② 失败记日志 / 写一张「同步失败表」定时重试;③ 每天凌晨跑一次全量同步兜底(从 DB 全量刷一遍 ES,修复所有漏网之鱼)。ES 挂半天,代价只是「搜索结果旧半天」,而不是「全站发不了帖」。

第 6 步:帖子搜索接口——高亮 + 排序 + 聚合(核心)

本章最核心的一步。先把 DSL 构造出来——3.5 节的四个篮子、title^3 加权、三种排序、高亮、聚合,全部在这一段代码里合流:

// search-service/internal/repository/post_search.go
package repository

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"

    pes "github.com/yourname/content-community/search-service/pkg/es"
)

// PostESRepository ES 数据访问层(本服务无 DB,直接面向 ES)
type PostESRepository struct{ client *pes.Client }

// 搜索结果契约
type SearchedPost struct { // 高亮无命中时回退原文
    PostDoc
    HighlightTitle   string `json:"highlight_title"`
    HighlightContent string `json:"highlight_content"`
}
type TagCount struct {
    Name  string `json:"name"`
    Count int64  `json:"count"`
}
type SearchPostsResult struct {
    Total   int64          `json:"total"`
    Posts   []SearchedPost `json:"posts"`
    TagAggs []TagCount     `json:"tag_aggs"`
}
// search-service/internal/repository/post_search.go 续
// buildPostDSL 构造查询体:must 算分 + filter 过滤 + 高亮 + 排序 + 聚合
func buildPostDSL(q, sort string, from, size int) map[string]any {
    // 三种排序模式:latest=时间倒序;hot=热度脚本;默认(空)= BM25 相关性
    var sortClause []any
    switch sort {
    case "latest":
        sortClause = []any{map[string]any{"created_at": map[string]any{"order": "desc"}}}
    case "hot": // 热度 = 点赞*2 + 评论*3 + 阅读,painless 是 ES 的内嵌脚本语言
        sortClause = []any{map[string]any{"_script": map[string]any{
            "type": "number", "order": "desc",
            "script": map[string]any{
                "source": "doc['like_count'].value*2 + doc['comment_count'].value*3 + doc['view_count'].value",
                "lang":   "painless",
            },
        }}}
    } // default: relevance——不传 sort,ES 默认按 _score 降序(3.5 节 BM25)
    dsl := map[string]any{
        "query": map[string]any{"bool": map[string]any{
            // must 参与算分;title^3 = 标题命中的得分 ×3(3.5 节)
            "must": []any{map[string]any{"multi_match": map[string]any{
                "query": q, "fields": []string{"title^3", "content"}}}},
            // filter 不算分且可缓存(3.5 节工程要点):只搜已发布 + 公开
            "filter": []any{
                map[string]any{"term": map[string]any{"status": 2}},
                map[string]any{"term": map[string]any{"visible": 1}},
            },
        }},
        // 高亮:命中词项包上 <em> 标签,前端标红用
        "highlight": map[string]any{"fields": map[string]any{
            "title": map[string]any{}, "content": map[string]any{}}},
        "from": from, "size": size, // 分页:第 6 步末尾的坑会讲 from+size 的上限
        // 聚合:统计「本次结果集里」各标签出现次数,terms 聚合按文档数取前 10
        "aggs": map[string]any{"tag_agg": map[string]any{
            "terms": map[string]any{"field": "tags", "size": 10}}},
    }
    if sortClause != nil {
        dsl["sort"] = sortClause
    }
    return dsl
}

逐行讲解:

  • multi_match + "fields": ["title^3", "content"]:标题和正文都搜,但标题命中的得分 ×3——「标题党克星」,标题写中关键词的帖子优先展示;
  • filter 里的 status / visible:3.5 节「能进 filter 就别进 must」的落地——状态过滤是非黑即白的事实,不该参与算分,还能吃 ES 的过滤器缓存;
  • highlight:告诉 ES「把命中词项所在的片段返回给我,词外包 <em>」——ES 自动完成「找到命中位置 → 截取上下文片段 → 包标签」全套动作,不用自己写;
  • aggs(聚合):对本次查询的结果集做 terms 分组统计——「搜『并发』出来的帖子里,带 Java 标签的 15 篇、带 Go 标签的 12 篇」,前端可以拿它做筛选导航。

再定义响应结构(只声明关心的字段,其余 JSON 自动忽略):

// search-service/internal/repository/post_search.go 续
// esSearchResp 只声明关心的字段,其余 JSON 自动忽略
type esSearchResp struct {
    Hits struct {
        Total struct{ Value int64 `json:"value"` } `json:"total"`
        Hits []struct {
            Source    PostDoc             `json:"_source"`
            Highlight map[string][]string `json:"highlight"`
        } `json:"hits"`
    } `json:"hits"`
    Aggregations struct {
        TagAgg struct {
            Buckets []struct {
                Key      string `json:"key"`
                DocCount int64  `json:"doc_count"`
            } `json:"buckets"`
        } `json:"tag_agg"`
    } `json:"aggregations"`
}

// pick 取高亮片段,没有则回退原文(ES 只给有命中的字段返回 highlight)
func pick(hl map[string][]string, field, raw string) string {
    if s := hl[field]; len(s) > 0 {
        return s[0]
    }
    return raw
}

最后组装执行与解析:

// search-service/internal/repository/post_search.go 续
// SearchPosts 执行搜索并解析:参数兜底 → 构造 DSL → Search → 解析高亮与聚合
func (r *PostESRepository) SearchPosts(ctx context.Context, q, sort string, page, pageSize int) (SearchPostsResult, error) {
    if page < 1 { // 页码从 1 开始
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }
    // 深分页防护:from+size 不能超过 ES 默认 max_result_window=10000,
    // 所以最大页码要跟着 pageSize 走(page*pageSize ≤ 10000),不能写死 500
    if maxPage := 10000 / pageSize; page > maxPage {
        page = maxPage // 卡在最后一个合法页,而不是跳回第 1 页
    }
    body, err := json.Marshal(buildPostDSL(q, sort, (page-1)*pageSize, pageSize))
    if err != nil {
        return SearchPostsResult{}, fmt.Errorf("序列化查询: %w", err)
    }
    // 官方 SDK 是函数式选项风格:一个参数一个 WithXxx
    resp, err := r.client.Search(
        r.client.Search.WithContext(ctx),
        r.client.Search.WithIndex(postIndex),
        r.client.Search.WithBody(bytes.NewReader(body)),
    )
    if err != nil {
        return SearchPostsResult{}, fmt.Errorf("查询 ES: %w", err)
    }
    defer resp.Body.Close()
    if resp.IsError() {
        return SearchPostsResult{}, fmt.Errorf("查询 ES 响应异常: %s", resp.String())
    }
    var esResp esSearchResp
    if err = json.NewDecoder(resp.Body).Decode(&esResp); err != nil {
        return SearchPostsResult{}, fmt.Errorf("解析响应: %w", err)
    }
    result := SearchPostsResult{Total: esResp.Hits.Total.Value,
        Posts: make([]SearchedPost, 0, len(esResp.Hits.Hits))}
    for _, h := range esResp.Hits.Hits {
        result.Posts = append(result.Posts, SearchedPost{
            PostDoc:          h.Source,
            HighlightTitle:   pick(h.Highlight, "title", h.Source.Title),
            HighlightContent: pick(h.Highlight, "content", h.Source.Content),
        })
    }
    for _, b := range esResp.Aggregations.TagAgg.Buckets {
        result.TagAggs = append(result.TagAggs, TagCount{Name: b.Key, Count: b.DocCount})
    }
    return result, nil
}

逐行讲解:

  • 参数兜底:页码上限按 10000/pageSize 动态计算(from+size 不能超过 ES 的 max_result_window),超出就卡到最后一个合法页——不只是防御性编程,是刻意的业务决策(见下方 hint);
  • (page-1)*pageSize:把「第几页」换算成 ES 的 from(跳过多少条)——ES 是 offset 分页,评论区那套游标分页(第 16 章)在这里用不了,原因见 hint;
  • pick 回退逻辑:ES 只给「有命中」的字段返回 highlight——帖子命中了标题没命中正文时,highlight 里只有 title 条目,content 得用 _source 原文补上,否则前端渲染会缺字段。

HTTP 侧的 search_handler.go 与第 12 章的 Handler 完全同构,不再贴码:NewSearchHandler(svc).RegisterRoutes(engine) 注册 GET /api/search/posts(解析 q / sort / page / page_size,空关键词直接返回空结果,否则调 svc.SearchPosts 包进 {code, msg, data} 返回)。Handler 只做参数解析与响应组装,不碰 ES(分层约定)。

复习提示:⚠️ 新手必踩的坑:from + size 超过 10000 直接报错。ES 默认 max_result_window=10000,from=9980&size=20(恰好 10000)还没事,再多一条就抛 search_phase_execution_exception。原因:ES 是分布式的,from=9980 意味着每个分片都要取回 9980+20 条给协调节点排序合并——翻得越深,搬运的数据越多,不设上限就是自我 DDos。两条出路:用 search_after 翻深页(拿上一页最后一条的排序值当游标,思想和第 16 章 minID 一样);或像本实现一样把页码上限卡在 10000/pageSize(默认 20 条/页就是 500 页)——引导用户换关键词,而不是陪机器人硬翻页。搜索场景 500 页已经远超真实用户的翻页深度。

第 7 步:搜索建议与标签搜索(从简)

搜索建议(GET /api/search/suggest,由第 6 步路由挂载)——就是你在搜索框打字时弹出的「联想词」。用 match_phrase_prefix 拿标题前缀联想:

查询体(发请求与解析的套路和第 6 步 SearchPosts 完全同构):
{
  "query": { "match_phrase_prefix": { "title": "用户输入的前缀" } },
  "_source": ["title"],   // 只要标题字段,省流量
  "size": 10
}

Service 直接透传 repository,几行就能上线;要更专业的联想(拼音、按热度加权)再引入 completion suggester(completion 字段类型),本课程不展开。标签搜索(GET /api/search/tags)同样从简:建一个 tag_idx(字段 id / name(text+ik) / use_count),match 查 name、按 use_count 倒序——写法与第 6 步的 SearchPosts 同构;标签数据推送走 3.8 节的统一 Event 格式(SyncTagToSearch),标签加权排序的完整方案见 3.8 节,不再贴码。

第 8 步:端到端验证

万事俱备,跑通全链路:

# 1. 写入两条测试数据(生产应走 post-service 的 gRPC IndexPost 双写,这里直接写 ES 快速验证)
curl -X PUT "localhost:9200/post/_doc/1?refresh=true" -H 'Content-Type: application/json' -d '{
  "post_id": 1, "author_id": 1, "title": "Go 并发编程实战",
  "content": "goroutine 和 channel 是 Go 并发的两大基石", "tags": ["Go", "后端"],
  "status": 2, "visible": 1, "like_count": 10, "comment_count": 5, "view_count": 200,
  "created_at": "2026-01-01T00:00:00Z" }'
curl -X PUT "localhost:9200/post/_doc/2?refresh=true" -H 'Content-Type: application/json' -d '{
  "post_id": 2, "author_id": 2, "title": "Java 并发面试指南",
  "content": "聊聊 synchronized 和 volatile", "tags": ["Java", "面试"],
  "status": 2, "visible": 1, "like_count": 99, "comment_count": 50, "view_count": 9000,
  "created_at": "2026-02-01T00:00:00Z" }'
# 2. 按热度搜"并发"(Java 帖互动高应排第一);3. 相关性排序;4. 搜索建议
curl "localhost:8400/api/search/posts?q=并发&sort=hot"
curl "localhost:8400/api/search/posts?q=goroutine"
curl "localhost:8400/api/search/suggest?prefix=Go"

q=并发&sort=hot 预期输出(节选,验证高亮 <em> 与热度排序):

{ "code": 0, "msg": "success", "data": { "total": 2,
    "posts": [{ "post_id": 2, "highlight_title": "Java <em>并发</em>面试指南" },
              { "post_id": 1, "highlight_title": "Go <em>并发</em>编程实战" }],
    "tag_aggs": [{ "name": "Java", "count": 1 }, { "name": "Go", "count": 1 }] } }

看懂这串输出你就通关了:q=并发 被 ik_smart 切成词项「并发」→ 翻倒排字典命中两篇 → 高亮把「并发」包上 <em> → sort=hot 的 painless 脚本算出 Java 帖热度更高排第一 → 聚合统计出结果集的标签分布——正文 3.2~3.5 的每一块原理,都在这个响应里留下了脚印。

本章完成后的项目结构

content-community/                        # 本章改动一览(其余省略)
├── api/proto/search/v1/search.proto      # 新增:SearchService.IndexPost
├── docker-compose.yml                    # 修改:追加 elasticsearch 服务
├── search-service/                       # 新增微服务(gRPC :9400 / HTTP :8400)
│   ├── configs/config.yaml               # main.go 组装启动,模板同第12章
│   ├── pkg/es/client.go                  # ES 客户端封装(官方 SDK + 健康检查)
│   └── internal/
│       ├── grpc/search_handler.go        # IndexPost gRPC 实现(骨架同第12章)
│       ├── handler/search_handler.go     # HTTP 搜索 / 建议接口
│       ├── service/search_service.go     # 业务层(透传 repository)
│       └── repository/
│           ├── post_index.go             # PostDoc + EnsurePostIndex + SavePost
│           └── post_search.go            # 搜索 DSL + 结果解析
└── post-service/                         # 修改:发帖/编辑/撤回后异步双写 ES

验证清单

  • curl localhost:9200 返回集群信息,_analyze 用 ik_max_word 能切出中文词项而非逐字切分
  • search-service 首次启动自动创建 post 索引,mapping 含 title 的 ik 配置;实例已注册到 ETCD
  • 同一 post_id 写两次,ES 只有一条文档(_doc/1 被覆盖,Upsert 语义)
  • 搜索结果 highlight_title 含 <em>关键词</em>(无高亮命中时回退原文)
  • sort=latest 按时间倒序、sort=hot 按热度降序、默认按 BM25 相关性排序
  • 未发布(status≠2)或不公开(visible≠1)的帖子搜不到,tag_aggs 反映结果集标签分布
  • 搜索建议接口对 prefix=Go 返回标题联想;深分页被卡在 10000/pageSize 页以内(超出则钳到最后一个合法页,不会触发 search_phase_execution_exception)

五、工程实践要点

正文和「跟着写」覆盖了主线,这一节把生产环境里「面试官最爱追问、上线前必须想清楚」的点集中列出。

1. 双写一致性:ES 是「尽力而为」,DB 是唯一事实

第 5 步的坑值得展开成一套方法论。DB 和 ES 是两个独立存储,没有共享事务,双写必有缝隙:DB 成功 + ES 失败(数据旧)、DB 失败 + ES 成功(脏数据,被主流程拦截所以不会发生)、两边都成功但顺序错乱(编辑覆盖旧编辑)。工程上的三板斧:

手段解决什么代价
异步双写 + 失败记录ES 抖动不阻塞主流程,失败可查搜索结果短暂滞后
同步失败表 + 定时重试偶发失败的最终修复多一张表、多一个定时任务
每日全量同步修复一切漏网之鱼(乱序、丢数据、脏数据)全量刷一遍的开销

本质是把「强一致」降级为「最终一致」——搜索结果旧 5 分钟用户能接受,发帖失败用户不能接受。取舍的天平永远倾向主业务。

2. mapping 是一次性决策:改分词器 = 重建索引

3.4 节的坑在工程上要上升为纪律:字段类型、分词器这些 mapping 决策,第一次就要设计对。改 mapping 的两类后果:改分词器 → 历史数据的词项和新切法对不上,必须 reindex(建新索引、全量重写、别名切换);改字段类型(text → keyword)→ 同样要 reindex。上线前多花一小时设计 mapping,比上线后花一晚上迁移数据便宜得多。

3. 能进 filter 就别进 must

再强调一次(因为太常被忽视):filter 不算分、走缓存、省 CPU。状态过滤、时间范围、可见性判断——所有「非黑即白」的条件全部进 filter;must/should 只留真正需要参与相关性打分的文本匹配和业务加分。第 6 步 DSL 里 status/visible 在 filter,就是这个纪律的样板。

4. 深分页:限制比优化更重要

from + size 默认上限 10000 不是 ES「小气」,是分布式架构的自我保护——每个分片都要把 from+size 条搬给协调节点合并,翻得越深越贵。两个正确姿势:业务上限制翻页深度(本课程按 10000/pageSize 卡页码上限,默认 20 条/页即 500 页);真要深翻用 search_after(游标思想)。错误的姿势是调大 max_result_window 硬扛——等于亲手拆掉保险丝。

5. 隐私字段:先想清楚产品形态再定 mapping

User 索引里的 email/phone 字段是经典的隐私决策题:toC 公开平台不允许通过邮箱/手机号搜索(想象一下:拿到同事手机号就能搜出他的匿名账号——隐私事故);toB 私有 IM 则需要支持(公司内部找人)。这个决策影响 mapping(keyword + 是否索引)和查询接口(能不能按手机号查),产品形态定不了就不要先建索引。

6. 隔离部署:保大不包小

3.6 节的隔离思想在生产上的完整版:核心搜索(首页搜索栏)独享集群 / 独享服务实例,边缘搜索共用另一套。判断标准回到第 14 章的降级思维:这个搜索挂了,用户第几眼能发现? 首页搜索框第一眼就能发现——独享;后台管理的搜索只有运营用——共用。故障域切分的颗粒度,跟着「用户感知度」走。

7. ES 不是数据库:别把主数据放进去

本课程 search-service 没有数据库——因为 ES 里存的只是 MySQL 的「投影」(一份为了搜索而存在的副本),帖子主数据永远在 MySQL。为什么?ES 的近实时意味着可能丢最近几秒数据(translog 异步刷盘)、它的强项是搜索不是事务、它做不了复杂的关系约束。主数据在 DB、ES 只做搜索副本——这个定位错了一切都会乱(比如在 ES 里直接改数据再同步回 DB,那才是灾难的开始)。

8. refresh 参数的三个档位

写入 URL 上的 ?refresh= 有三档,新手常常只知道 true:true(立即 refresh,每次写都生成 segment,性能杀手,仅联调用)、false(默认,等下一次每秒的周期性 refresh)、wait_for(等到下一次 refresh 完成才返回,不额外制造 segment——生产上「写完立刻要搜到」的正确选择)。本课程联调用 true,生产请换成 wait_for 或干脆不传。


六、自测题与动手练习

自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):

  1. 用自己的话讲一遍「倒排」两个字是什么意思。正排和倒排在「查文档」时路径有什么不同?词项为什么必须排序?
  2. ES 写入后为什么默认要等约 1 秒才能搜到?refresh 和 flush 分别做什么?Page Cache 阶段节点崩溃了怎么办?
  3. text 和 keyword 字段在写入时分别发生了什么?为什么用 term 查 text 字段基本查不到?
  4. IK 的 ik_max_word 和 ik_smart 各怎么切词?为什么建索引用细切、查询用粗切?换成 standard 会出什么问题?
  5. BM25 的三个直觉因素是什么?title^3 和 boost: 2.0 分别在什么层面影响排序?
  6. bool 的四个篮子分别什么语义、谁参与算分?status=2 这种条件为什么放 filter 而不是 must?
  7. 标签服务为什么敢用 Redis List 缓存 + 启动预加载?覆盖式打标签的「先删后插」为什么必须事务包裹?
  8. 双写 DB 和 ES 的一致性怎么保证?为什么不能让 ES 同步失败导致发帖失败?

动手练习(建议真做一遍,做到才算过关):

  1. 亲眼看近实时:往 post 索引写一条文档(不带 ?refresh=true),立刻搜索——记录是否查得到;等 2 秒再搜,记录结果。再对比带 ?refresh=true 写入的行为。完成标准:能用「Buffer → refresh → Page Cache」的流程解释两种行为的差异。
  2. term 查 text 的翻车现场:用 match 搜 title=「Go 并发编程实战」的帖子能搜到;再构造 term 查询直接查 title 的完整字符串(把 title 临时建成 text 类型验证)。完成标准:亲眼看到 term 查不到;能讲出「字典里只有词项、没有整串」的原因。
  3. 给搜索加一个 filter:在 buildPostDSL 的 filter 里加一个时间范围条件(只搜最近 90 天的帖子),造一条半年前的旧帖验证它被过滤掉。完成标准:旧帖搜不到但 total 里的计数也变了;说得出 filter 上下文不算分、可缓存的两个好处。
  4. (选做)标签 boost 实验:建一个 tag_idx,给帖子 1 打上含「并发」的标签,用 3.8 节的多次查询 + boost 方案重写搜索。完成标准:同样搜「并发」,带标签的帖子 1 排到了不带标签但正文命中的帖子 3 前面;能画出两次查询的数据流。

七、模拟面试问答

以下 12 道题覆盖本章所有高频考点,按「基础 → 进阶 → 追问」三档排列。每题给出可以直接背的一句话结论、展开思路、面试官想听的关键词、新手最容易答错的地方。

面试官
【基础】 什么是倒排索引?它为什么能让全文检索这么快?
候选人

一句话结论:倒排索引是「词项 → 包含它的文档列表」的字典,查询时翻字典直接拿到文档名单,而不是逐篇扫描正文——快就快在把 O(n) 的全文扫描换成了 O(log n) 的字典查找。

展开:先摆正排:文档 → 它包含哪些词(按 ID 查文档的属性);倒排就是方向反过来:词 → 哪些文档包含它。HR 招聘类比:正排是 HR 找上你问你会什么,倒排是 HR 输入「5 年 Go」搜出一堆简历。实现要点:ES 为每个字段单独维护一张倒排表(Posting List),词项必须排序才能二分定位;字典本身用 FST 存储——前缀树基础上连后缀都共享,海量词项也塞得进内存。最后一个关键认知:倒排索引是写入时建的(写入即分词、登记进字典),查询快是写入时多干活换来的。

面试官想听的关键词:词找文档、Posting List、词项排序、FST、写时建索引。

常见失分点:以为倒排索引是查询时临时构建的;说不出「词项必须排序」的原因(不排序定位词项就是全扫)。

面试官
【基础】 ES 为什么是「近实时」?写入流程讲一下。
候选人

一句话结论:写入先进内存 Buffer,每秒一次 refresh 生成 segment 进 Page Cache——进了 Page Cache 才能被搜索,这个 1 秒就是「近」;真正落盘(flush)间隔长得多,中间靠 translog 兜底。

展开:三段式流程:① 文档写 Buffer(纯内存,搜不到)→ ② 每秒 refresh,生成 segment 写入 Page Cache,从此可搜 → ③ 定时 flush,segment 真正落盘并写 Commit Point。为什么不做成实时?每写一条就 refresh 会疯狂生成小 segment,查询时要打开的文件暴涨,性能崩;为什么不批量大间隔?用户体验差。1 秒是折中。崩溃兜底:translog 类似 MySQL 的 redo log,写操作先追加记录,重启后重放找回未落盘数据(可能丢最后几秒,搜索场景可接受——大不了重新同步)。

面试官想听的关键词:Buffer、refresh(1 秒)、segment、Page Cache、flush、translog、折中。

常见失分点:说「ES 是实时的」(不是,1 秒窗口);把 refresh 和 flush 混为一谈(前者是进 Page Cache 可搜索,后者是真落盘)。

面试官
【基础】 text 和 keyword 有什么区别?term 和 match 呢?
候选人

一句话结论:text 写入时分词(适合 match 全文搜索),keyword 不分词(适合 term 精确匹配和聚合);match 是先分词再查,term 是不分词直接拿原值匹配词项——所以 term 查 text 字段基本查不到。

展开:核心还是倒排索引里存的是什么。text 字段「大明」写入时被切成 大、明 两个词项进字典;term: {nickname: "大明"} 不分词,拿完整的「大明」去字典找——字典里根本没有这个词项,自然空手而归。一句话记忆:精确匹配用 term + keyword,全文搜索用 match + text。实操三连:邮箱/手机号/状态码/标签 → keyword;标题/正文 → text;既要精确又要全文 → fields 双类型并存。排查「搜不到」问题的第一步永远是 _analyze 看文本实际切成了什么。

面试官想听的关键词:分词、词项、精确匹配、全文搜索、_analyze、双类型。

常见失分点:只会背「text 分词 keyword 不分词」,被追问「term 查 text 为什么查不到」答不出「字典里只有词项、没有整串」这层原理。

面试官
【基础】 中文分词为什么是坑?IK 的两种模式怎么选?
候选人

一句话结论:ES 默认的 standard 分词器对中文逐字切分,搜「明大」也会误命中;中文场景要装 IK 分词器,并且建索引用 ik_max_word 细切(保召回)、查询用 ik_smart 粗切(保精度)。

展开:中文没有空格,「哪几个字是一个词」机器得靠词典判断。standard 不带中文词典,只能逐字切——「大明」变 大+明,搜「明」能命中是碰巧(字典里恰好有「明」这个词项),相关性极差。IK 内置中文词典:ik_max_word 把所有可能的词全切出来(召回优先),ik_smart 只切最合理的(精度优先)。为什么索引细切、查询粗切:细切保证用户的任何搜法都能在字典里找到词项(不漏);粗切让命中的词项更有分量(排序更准)。关键工程提醒:分词器是 mapping 的一部分,建索引时就要定好——对已有数据改分词器必须 reindex(建新索引全量重写)。

面试官想听的关键词:逐字切分、IK、ik_max_word / ik_smart、召回与精度、reindex。

常见失分点:知道要装 IK 但说不出两种模式各自用在哪;不知道改分词器要重建索引,答「改个配置就行」直接暴露没实操过。

面试官
【进阶】 bool 查询的四个篮子分别什么语义?filter 和 must 有什么本质区别?
候选人

一句话结论:must(必须匹配,算分)、should(命中加分,算分)、must_not(必须不匹配,不算分)、filter(必须匹配,不算分)——filter/must_not 走过滤器上下文:不算相关性评分、结果可缓存,所以「能进 filter 就别进 must」。

展开:本质区别有两层。第一层是算不算分:must/should 里的条件影响 _score(谁排前面),filter/must_not 只决定「文档能不能进结果集」(非黑即白)。第二层是性能:过滤条件不参与 BM25 计算(省 CPU),而且 ES 会缓存 filter 结果——同样的状态过滤第二次查询直接复用。判断标准:状态、时间范围、可见性、是否拉黑这种「事实型条件」全进 filter;文本匹配、标签加分这种「程度型条件」才进 must/should。举例:搜已发布+公开的帖子里匹配「Go 并发」——multi_match 进 must,status=2 / visible=1 进 filter。

面试官想听的关键词:过滤器上下文、不算分、可缓存、事实型 vs 程度型条件。

常见失分点:把 filter 说成「性能更好的 must」却不点出「不算分」这个本质;不知道 should 在有 must 时只是加分项、单独使用时才是「至少命中一个」。

面试官
【进阶】 ES 怎么决定搜索结果的顺序?怎么让「命中标题的排在命中正文的前面」?
候选人

一句话结论:不加 sort 时按 BM25 相关性评分(_score)降序——词频越高、词越稀有、字段越短,分越高;让标题优先用 title^3 字段加权,业务信号(如标签命中)用 boost 叠加权重。

展开:BM25 三个直觉因素:词频(出现次数多更相关,有饱和防刷分)、逆文档频率(词越稀有命中越值钱——搜「的」没意义,搜「ElasticSearch」极相关)、字段长度(同样命中一次,短字段比长字段相关——所以标题命中天然优于正文命中,title^3 是再放大三倍)。boost 是得分放大器:默认 1.0,标签命中 boost=2.0 意味着该条件贡献分值翻倍——它只影响排序不影响召回。两个边界:显式加 sort 会绕过相关性评分(_score 不再计算);「既要相关性又要业务加权」的更精细方案是 function_score(在评分公式上叠加业务函数)。

面试官想听的关键词:BM25、词频、逆文档频率、字段长度、字段加权(^)、boost、function_score。

常见失分点:只会说「按相关性排」讲不出 BM25 的任何因素;不知道加了 sort 就不算分,被追问「按时间排序时相关性去哪了」就卡住。

面试官
【进阶】 搜索服务怎么做平台化?业务方接入怎么设计?
候选人

一句话结论:平台化 = 通用接入解决共性问题 + 专属接口解决个性问题——通用 JSON 接口(index_name + data)让业务方零适配接入,业务专属 gRPC 接口给常规业务编译期类型安全;写入链路低峰同步直写、高峰 Kafka 削峰,两条路收敛到同一个 Service.Save;部署上读写隔离、业务隔离,保大不包小。

展开:分三层讲。接入层:两类接口并存是常态——紧急业务走通用接口(快),常规业务走专属接口(稳)。链路层:直写 gRPC 延迟低但高峰会把 ES 打爆,Kafka 统一 Topic(Event = index_name + data)削峰,消费者直接穿透到 DAO;强势业务方就监听它的 Topic 转发到统一 Topic。部署层:读写集群隔离(批量导数据不拖垮线上搜索)、业务集群隔离(边缘搜索挂了不影响首页搜索框)——判断标准是「用户第几眼能发现」。加分视角:搜索栏不只是功能,它是广告收入入口和流量分发杠杆,所以值得平台级投入。

面试官想听的关键词:通用接入 vs 专属接口、Kafka 削峰、统一 Event、读写隔离、业务隔离、保大不包小。

常见失分点:只答「统一接口」——被追问「类型安全怎么办」就发现没考虑过专属接口的存在意义;说不出隔离部署的判断标准(用户感知度)。

面试官
【进阶】 标签服务的设计有什么特别的?为什么说「不可变」是红利?
候选人

一句话结论:大部分系统的标签创建后不可变(不提供改名删除),这个业务前提换来三份架构红利——Redis List 缓存永不脏(只增不改)、启动全量预加载无脑灌(不用怕过期)、冗余标签名字段不用同步(只冗余不可变字段)。

展开:逐个展开红利。缓存:标签列表用 List(RPUSH 追加),因为不可变所以缓存永远不会与 DB 不一致——不存在「更新了 DB 忘了改缓存」这类问题。预加载:toB 场景用户总量可控,启动时分批游标扫描全量标签(WHERE uid > lastUID LIMIT 100,游标分页思想的复用)灌进 Redis,之后查询全走缓存。覆盖式打标签:先删旧关联再插新关联,必须事务包裹——否则删成功插失败,标签凭空消失。查询用 GORM Preload 一次 JOIN 出标签名;进阶优化是给关联表冗余 tag_name 免 JOIN——前提还是不可变。一句话总结:架构的简单度可以用业务前提来换,识别「哪些数据天然不可变」是设计功力。

面试官想听的关键词:不可变、List 缓存、预加载、游标扫描、覆盖式、事务、只冗余不可变字段。

常见失分点:把标签讲成普通 CRUD;被问「为什么敢用 List 不怕缓存不一致」答不到「不可变所以不存在不一致」这层因果。

面试官
【追问】 帖子数据在 MySQL 和 ES 各有一份,一致性怎么保证?
候选人

一句话结论:定位先行——MySQL 是主数据、ES 是搜索副本,一致性用「异步双写 + 失败重试 + 全量同步兜底」三板斧做到最终一致,绝不让 ES 同步失败阻塞主流程。

展开:DB 落库成功即返回,ES 同步放 goroutine 异步做;失败记日志或写「同步失败表」,定时任务重试;每天凌晨全量同步从 DB 刷一遍 ES,修复一切漏网之鱼(偶发失败、消息乱序、脏数据)。为什么不追求强一致:两个存储没有共享事务,硬上分布式事务代价不成比例;搜索结果旧五分钟用户无感,发帖失败用户直接流失——取舍的天平永远倾向主业务。如果面试官继续追问「消息乱序怎么办」(编辑两次,旧版本消息后到覆盖新版本):按 post_id 哈希到同一分区保序(Kafka 按 key 分区,第 07 章讲过)、或写入时带版本号/时间戳拒旧纳新;终极兜底还是全量同步。

面试官想听的关键词:主数据 vs 副本、异步双写、失败重试、全量同步兜底、最终一致、按 key 分区保序。

常见失分点:答「用分布式事务保证强一致」(代价与收益完全不成比例,暴露没有权衡意识);不知道全量同步这道最后防线。

面试官
【追问】 ES 深分页为什么会有问题?怎么解决?
候选人

一句话结论:from + size 默认上限 10000(max_result_window),因为分布式架构下深翻页时每个分片都要把 from+size 条搬到协调节点排序合并,翻得越深搬运越多;解决要么业务上限制翻页深度,要么用 search_after 游标翻页。

展开:机制先讲清:查询打到 3 个分片,每个分片各自取出 from+size 条,协调节点拿到 3×(from+size) 条合并排序再截取 size 条返回——from=10000 时实际搬运了 3 万条,纯浪费。两个方案:业务限制(本课程按 10000/pageSize 卡页码上限、默认 20 条/页即 500 页,引导用户换关键词——真实用户没有翻 500 页的);search_after(拿上一页最后一条的排序值当游标,下一页从它之后取——思想和评论区的 minID 游标分页同源,只是 ES 的游标是排序值而不是 ID)。错误答案点名:调大 max_result_window 硬扛——等于拆掉保险丝,一个大查询就能把集群打挂。

面试官想听的关键词:max_result_window、协调节点、分片合并、search_after、游标、业务限制翻页深度。

常见失分点:只报「上限 10000」这个数字,讲不出「为什么是分布式架构的必然」;把 search_after 说成「ES 版 offset」(它是游标,不认页码认位置)。

面试官
【追问】 帖子被删除或撤回了,ES 里怎么处理?
候选人

一句话结论:不物理删除 ES 文档——撤回就是带着下架状态把整份文档重写一遍(post_id 作 _id 覆盖写,Upsert 语义),查询时用 filter 把非公开状态的文档过滤掉;真要物理删才用 delete_by_query 批量清。

展开:先讲为什么不物理删:ES 的删除是「标记删除 + 后台段合并才真删」,高频删改反而贵;而且「下架的帖子」很多场景还要能搜回来(管理员后台、作者自己的草稿箱)。所以主方案是状态覆盖写:SavePost 一个方法通吃发帖、编辑、撤回——撤回就是 status 传下架状态重写一遍,查询 DSL 的 filter(status=2、visible=1)天然把它挡在结果之外。这本质是第 16 章评论软删(status=2)思想在 ES 的对应物。补充边界:如果产品要求「删帖必须立刻从搜索消失」,覆盖写后 ES 有 1 秒 refresh 窗口——可以接受就等 1 秒,不能接受写入时带 refresh=wait_for。

面试官想听的关键词:Upsert 覆盖写、软删状态、filter 过滤、段合并、refresh=wait_for。

常见失分点:上来就答「调 delete API 物理删」,不知道 ES 删除的真实代价和软删方案的通用性;忘了 1 秒近实时窗口的存在。

面试官
【追问】 标签和帖子的关联搜索,为什么不用 ES 的 Nested 或 Parent-Child?
候选人

一句话结论:Nested 更新子文档要重索引整个父文档、Parent-Child 查询性能差(实践中少用),而我们的标签模型(私有、独立、跨业务)也套不进这两种结构——所以用「多次查询」:先查标签索引拿命中资源 ID,再查帖子索引用 bool.should + boost 加权。

展开:先把两个内置方案的坑摆出来(面试官想听「知道但不用」而不是「不知道」):Nested 是子文档物理内嵌在父文档里,标签一变整个帖子文档重写;Parent-Child 靠 join field + 路由维护父子关系,查询要多一层路由,性能差。再讲模型不匹配:标签带 uid(你打的「面试」和我打的是两个标签),是独立、私有、可挂任意资源的数据,硬塞进帖子文档就把「私有标签」变成了「公共内容」。所以多次查询:第一趟查 tag_idx 拿到命中关键词的 biz_id 列表,第二趟查 post 索引,should 篮子里合并「文本命中」和「id 在标签名单里(boost=2.0)」两个条件——每次查询都是普通查询,简单可控。局限也主动说:多一次往返、标签命中名单太大时 terms 查询要注意上限。

面试官想听的关键词:Nested 重索引、Parent-Child 性能、模型不匹配、多次查询、should + boost、terms 上限。

常见失分点:不知道这两个方案的存在(被问「为什么不用」只能干瞪眼);答「多次查询」但说不出 boost 加权怎么把标签命中顶到前面。


八、本章小结

复习提示:
  • LIKE 三宗罪:通配符在前索引失效(最左前缀)、没有相关性概念、中文无法按词匹配——搜索需要专用引擎
  • 倒排索引:「词 → 文档列表」的字典(Posting List),词项必须排序;FST 前后缀共享压缩内存;写入时建索引,查询享红利
  • 近实时:Buffer → refresh(1 秒)→ Page Cache 生成 segment 才可搜 → flush 才落盘;translog 兜底崩溃重放
  • text / keyword:text 分词配 match,keyword 不分词配 term;「term 查 text 查不到」是字典里只有词项没有整串
  • IK 双模式:索引 ik_max_word 细切保召回、查询 ik_smart 粗切保精度;改分词器必须 reindex
  • BM25 三因素:词频(饱和)、逆文档频率(稀有词值钱)、字段长度(短字段命中更相关);title^3 字段加权、boost 业务加权
  • bool 四篮子:must/should 算分,filter/must_not 不算分可缓存——事实型条件全进 filter
  • 平台化搜索:通用接口 + 专属接口并存;直写 + Kafka 削峰两路收敛到同一个 Save;读写/业务隔离保大不包小
  • 双写三板斧:异步双写不阻塞主流程、失败表定时重试、每日全量同步兜底——最终一致,主数据永远在 DB
  • 标签不可变红利:List 缓存永不脏、预加载无脑灌、只冗余不可变字段;覆盖式打标签必须事务包裹
  • 标签接入搜索:独立索引(标签私有)+ 多次查询(避开 Nested/Parent-Child)+ should + boost=2.0 加权
  • 深分页:from+size 上限 10000 是分布式自我保护;业务限深(按 10000/pageSize 卡页码,默认 20 条/页即 500 页)或 search_after 游标,别硬调 max_result_window

下一章(第 18 章)我们转向 ELK 日志体系与 Canal 数据同步——两件生产必备的本领:其一是「系统出问题时怎么快速定位」(日志怎么收集、怎么查),其二是「数据库变更怎么实时同步到别处」。你会发现本章埋的伏笔在那里全面兑现:双写的一致性缝隙,Canal 用「监听 binlog」给出另一条路——业务代码一行不改,数据变更自动流出去;而 ELK 里的那个 E,就是本章的 ElasticSearch——同一个引擎,日志场景换一种用法。

复习提示:伏笔:本章的「每日全量同步兜底」是个笨办法——全量刷一遍又贵又慢。有没有办法在数据发生变更的那一刻就自动同步?MySQL 的 binlog(第 07 章提过一嘴)里记录着每一次数据变更,盯着它就能做到「变更即同步」——这正是第 18 章 Canal 的原理。带着「双写的缝隙怎么从根上堵住」这个问题去读下一章。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!