大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
综合实战:本站题库搜索端到端实现
这一课你将能做到
- 从零搭建题库搜索:建索引(含中文分词与向量)、Bulk 全量导入、应用层增量维护,全程不接数据库同步
- 实现两个搜索入口——搜题目(关键词/混合语义+过滤+高亮+分页)与搜课时,并能用 msearch 一次返回
- 写出运营看板聚合 DSL 和上线 Checklist,完成一次全链路验收
为什么要学
前 11 课分块训练,真实工作是「把块拼成系统」。这一课把 mapping、分词、Bulk、bool、kNN、聚合、别名、权限串成完整链路,直面三个取舍:不做 CDC 时怎么保证一致、题目和课时怎么统一搜索、相关性怎么微调。能跑通这套方案,你就具备在中小团队独立落地搜索的能力。
人话:交付物三样——两个索引、一个搜索服务、一个看板。数据源头在 MySQL 但不建同步管道,保存时应用层双写,每日按 updatedAt 增量兜底。
先建立图像
系统全貌:
[MySQL:权威数据]
│ ① 后台保存 → 事务提交后应用层写 ES(失败记补偿表重试)
│ ② 每日 02:00:按 updatedAt 查增量 → Bulk 覆盖写兜底
▼
[Elasticsearch] questions 索引 + lessons 索引
▲ 搜索服务(API Key 只读):搜题 / 搜课时 / msearch / 看板
[前端:题目 Tab + 课时 Tab + 高亮]
跟着做
步骤 1:创建两个索引的完整 mapping
# 题目索引:1 主 1 副(数据量 <1GB),中文 IK + 向量
curl -u elastic:abcd1234 -X PUT "questions_v1" -H "Content-Type: application/json" -d'
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"stem": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"options": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"analysis": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"answer": { "type": "keyword", "index": false },
"tags": { "type": "keyword" },
"courseId": { "type": "keyword" },
"lessonId": { "type": "keyword" },
"lessonTitle": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": { "keyword": { "type": "keyword" } }
},
"difficulty": { "type": "byte" },
"published": { "type": "boolean" },
"updatedAt": { "type": "date" },
"embedding": { "type": "dense_vector", "dims": 512, "similarity": "cosine" }
}
}
}'
# 课时索引
curl -u elastic:abcd1234 -X PUT "lessons_v1" -H "Content-Type: application/json" -d'
{
"settings": { "number_of_shards": 1, "number_of_replicas": 1 },
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": { "keyword": { "type": "keyword" } }
},
"summary": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"courseId": { "type": "keyword" },
"published":{ "type": "boolean" },
"updatedAt":{ "type": "date" }
}
}
}'
# 应用只认别名:一次把两个索引都挂上
curl -u elastic:abcd1234 -X POST "_aliases" -H "Content-Type: application/json" -d'
{
"actions": [
{ "add": { "index": "questions_v1", "alias": "questions" } },
{ "add": { "index": "lessons_v1", "alias": "lessons" } }
]
}'
步骤 2:Bulk 全量初始化
从 MySQL 导出 JSON 后,用程序拼成 NDJSON,并在同一流程调 embedding 服务生成向量。两个索引都按第 4 课三板斧导入:
# 导前:关 refresh、0 副本
curl -u elastic:abcd1234 -X PUT "questions/_settings" -H "Content-Type: application/json" -d'
{ "index": { "refresh_interval": "-1", "number_of_replicas": 0 } }'
# 分别 Bulk 导入 questions、lessons(参数相同,换索引名和文件)
curl -u elastic:abcd1234 -X POST "questions/_bulk" \
-H "Content-Type: application/x-ndjson" \
--data-binary "@questions-bulk.json"
# 导后:恢复设置并强制刷新
curl -u elastic:abcd1234 -X PUT "questions/_settings" -H "Content-Type: application/json" -d'
{ "index": { "refresh_interval": "1s", "number_of_replicas": 1 } }'
curl -u elastic:abcd1234 -X POST "questions,lessons/_refresh"
程序必须检查响应 errors,收集失败行重试;初始化后用 _count 与 MySQL 对账。
步骤 3:应用层增量维护(不同步数据库的核心)
题目保存接口的顺序:先写 MySQL 事务 → 提交成功后同步写 ES。写 ES 失败不回滚 MySQL,而是写入补偿表 es_sync_retry(ref_type, ref_id, next_retry_at),后台任务指数退避重试。每日兜底:
SELECT id FROM question WHERE updated_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY);
查出完整文档走 Bulk 覆盖写(index 动作天然幂等)。同步双写 + 失败补偿 + 每日增量三层保障,在题库低频变更场景一致性足够,远轻于 CDC。
步骤 4:搜题目——关键词 + 语义混合 + 过滤高亮
curl -u elastic:abcd1234 -X GET "questions/_search" -H "Content-Type: application/json" -d'
{
"size": 10,
"_source": ["stem", "tags", "difficulty", "lessonTitle"],
"query": {
"function_score": {
"query": {
"bool": {
"must": [{
"multi_match": {
"query": "事务失效",
"fields": ["stem^3", "lessonTitle^2", "analysis"]
}
}],
"filter": [
{ "term": { "published": true } },
{ "term": { "courseId": "C_JAVA" } }
]
}
},
"functions": [
{ "field_value_factor": { "field": "difficulty", "factor": 0.02, "modifier": "log1p" } }
],
"boost_mode": "sum"
}
},
"highlight": {
"fields": { "stem": {}, "lessonTitle": {} }
}
}'
highlight 默认 <em> 包裹。function_score 只做轻微加权、主体仍是 BM25;需要语义时按第 9 课同发 query 与 knn 并优先 RRF。
步骤 5:搜课时 + msearch 一次返回两个 Tab
前端「题目」「课时」两个 Tab 用 msearch 一次拿回:
curl -u elastic:abcd1234 -X GET "questions,lessons/_msearch" -H "Content-Type: application/x-ndjson" --data-binary "@msearch.ndjson"
msearch.ndjson(每个请求两行:索引行 + 请求体行):
{"index": "questions"}
{"size":10,"_source":["stem","tags","difficulty"],"query":{"match":{"stem":"倒排索引"}},"highlight":{"fields":{"stem":{}}}}
{"index": "lessons"}
{"size":10,"_source":["title","summary"],"query":{"match":{"title":"倒排索引"}},"highlight":{"fields":{"title":{}}}}
各子请求独立,响应按序返回。
步骤 6:运营看板聚合
一个请求出齐三块统计:
curl -u elastic:abcd1234 -X GET "questions/_search" -H "Content-Type: application/json" -d'
{
"size": 0,
"query": { "term": { "published": true } },
"aggs": {
"by_course": {
"terms": { "field": "courseId", "size": 20 }
},
"top_tags": { "terms": { "field": "tags", "size": 10 } },
"diff_hist": {
"histogram": { "field": "difficulty", "interval": 1, "min_doc_count": 0 }
}
}
}'
Kibana 里用同结构建 Lens 图表即可。
步骤 7:上线 Checklist
数据:显式 mapping + 别名就位,count 对账通过
分词:IK 版本一致,analyze 样例验证通过
查询:过滤走 filter,分页 ≤10000,响应不含 embedding
运维:异地快照 + SLM + 恢复演练,告警就位
安全:API Key 读写分离,密码进 KMS;旧索引保留可回滚
讲清楚要点
- 不做 CDC 的关键是「幂等覆盖 + 多层兜底」:ID 对齐主键、重放无害;同步写、补偿重试、每日增量压小不一致窗口,高频变更时再评估 binlog。
- 多索引搜索优先 msearch:两种结构硬合并会有空字段噪声;各自独立查、前端分 Tab 更清晰。
- 相关性调优按「字段权重 → function_score → 混合 RRF」的顺序:每步用固定评测样本对比,别凭感觉。
- 冗余字段是有意设计:题目冗余 lessonTitle 免关联;课时改名后要随增量刷新——冗余就要接受维护成本。
- 容量回顾:本站 5 万题、<1GB,1 主 1 副;增长后走 ILM rollover。
你可能会懵的地方
- 应用层双写 ES 失败时,为什么不回滚 MySQL? ES 只是搜索副本,主业务不能因搜索故障不可用。失败进补偿队列重试,配合每日增量最终一致。
- msearch 里某个索引查询失败会整体失败吗? 不会,各子请求独立,响应按序返回,程序逐个判断。
- 题目里的 answer 为什么设 index: false?
不需要按答案检索,也不应在未作答前暴露;需要时按 ID 取
_source。 - 什么时候该把 questions 和 lessons 合成一个索引? 只有要求「题目课时混排成一个流」时才考虑(加 type 区分),当前 Tab 形态不需要。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 为了实时一致硬上 binlog 管道 | 小系统复杂度翻倍 | 双写+补偿+每日增量 |
| 题目课时合一个索引硬查 | 空字段、打分互相干扰 | 分索引 + msearch |
| ES 故障时回滚业务事务 | 弱依赖变强依赖 | 补偿重试 |
| 凭手感反复调打分 | 越调越乱 | 固定评测样本对比 |
卡点急救
- 导入后计数对不上:先查 Bulk errors,再比对过滤条件和主键唯一性。
- 搜索排序被业务吐槽:导出 Top50 人工标注,检查字段权重与 function_score。
- 增量任务漏数据:核对 updatedAt 是否随保存更新,死信告警,每周对账。
面试常问
-
设计一个题库搜索系统,你怎么落地? MySQL 为权威源,ES 建 questions/lessons 两索引:显式 mapping、IK 分词、可选 dense_vector;Bulk 初始化并对账;增量靠双写 + 补偿队列 + 每日 updatedAt 覆盖写,不引入 CDC。查询用 bool(multi_match 权重 + filter)、function_score/RRF 微调,配合高亮分页;快照 SLM 与 API Key 兜底。
-
不做数据库同步,一致性怎么保证? ID 对齐主键、覆盖写幂等;双写失败进补偿表;每日增量兜底;定期 count 对账。低频变更场景足够,成本远低于 binlog。
-
搜题和搜课时怎么同时支持? 两个独立索引各建全文索引,msearch 并行查询,前端分 Tab 各自高亮。
自测三问
- 从 MySQL 导出到前端搜到题,完整链路上有哪些环节?每个环节失败分别怎么兜底?
- 为什么本站选择「双写+补偿+每日增量」而不是 binlog?什么信号出现后该重新评估?
- 运营反馈「搜出来的题不靠谱」,你的排查和调优步骤是什么?
小结一句话
题库搜索的完整答案是:分索引设计、中文分词与语义混合检索、应用层幂等维护一致性、聚合看板与备份权限收尾——把每课的零件按工程纪律装成系统。
延伸
- Multi-search API
- Function score query
- Index templates
- 本路线完:回到 /roadmaps/elasticsearch 标记节点并完成一次真实上线。