本人个人博客地址:Egg-blog
本文记录一次完整的博客搜索重构:为什么原来的搜索每分钟只能搜 5 次、如何用 MySQL ngram 全文索引低成本止血、如何平滑接入 Elasticsearch,以及 Docker 部署 ES + Kibana 过程中踩过的那些坑。
关键词:MySQL ngram、Elasticsearch、IK 分词、Spring Boot 3、Docker、Kibana
一、原方案的三个弊端:为什么搜索要限流到 5 次/分钟
先看改造前的搜索接口实现,问题一目了然:
java
// 改造前:ArticleServiceImpl.searchArticleByContent
List<Article> articles = articleMapper.selectList(
new LambdaQueryWrapper<Article>()
.like(Article::getArticleContent, keyword) // 全表扫描!
.eq(Article::getStatus, SQLConst.PUBLIC_ARTICLE));
// 每次搜索还把分类表整张捞一遍
Map<Long, String> categoryMap = categoryMapper.selectList(null).stream()...;
弊端 1:LIKE '%keyword%' 前后通配,索引完全失效
前后都带通配符的 LIKE 无法走任何 B+ 树索引,每次搜索都是对 article_content(LONGTEXT)字段的全表扫描。文章还少的时候无感,文本量上到几十万字以后,每一次搜索都是对数据库的一次毒打。
弊端 2:被迫用限流当遮羞布
因为查询太贵,接口只能挂 @AccessLimit(seconds = 60, maxCount = 5) 硬扛------每分钟最多搜 5 次,用户体验极差。限流的意义本该是防爬虫防滥用,结果变成了给慢查询擦屁股。
弊端 3:藏了一个静默 bug
原实现里截取搜索摘要的逻辑是这样的:
java
int index = -1; // 定义在循环外!
for (SearchArticleByContentVO articleVo : listVos) {
index = content.toLowerCase().indexOf(keyword.toLowerCase());
...
}
if (index != -1) { // 只判断了最后一篇
return listVos;
}
return List.of();
index 定义在循环体外,循环结束后只剩最后一篇文章的命中状态------只要最后一篇没命中,哪怕前面全命中了,也直接返回空列表。搜索"偶尔抽风查不到"的灵异现象,根源就在这。
二、总体设计:面向接口的可插拔搜索架构
重构的核心思路:调用方只依赖抽象接口,搜索引擎可插拔、可降级。
ini
ArticleController
│ 只依赖接口
▼
┌─────────────────┐
│ SearchService │ ← 搜索引擎抽象层
└─────────────────┘
▲ ▲
│ @Primary │ 永远注册(兜底)
┌──────┴────────┐ ┌───────┴───────────────┐
│ EsSearch │ │ NgramSearch │
│ ServiceImpl │──▶│ ServiceImpl │
│ (engine=es) │降级│ (默认引擎 / es降级用) │
└───────────────┘ └───────────────────────┘
配置驱动,一行切换:
yaml
search:
engine: ngram # ngram | es
es:
host: 127.0.0.1
port: 9200
scheme: http
index-name: blog_article
两个关键的 Spring 技巧:
@ConditionalOnProperty(name = "search.engine", havingValue = "es"):ES 相关的 Bean(RestClient、搜索实现、同步监听器、管理接口)只在 es 模式下装配,ngram 模式下零开销,没装 ES 应用照常启动;@Primary:ES 实现标@Primary,两种实现同时存在时优先注入 ES;ES 异常时 catch 住自动降级到 ngram 实现,用户无感知。
java
@Override
public List<SearchArticleByContentVO> searchArticleByContent(String keyword) {
try {
return doSearch(keyword.trim());
} catch (Exception e) {
log.warn("ES搜索异常,降级到ngram全文索引,keyword={},原因:{}", keyword, e.getMessage());
try {
return ngramSearchService.searchArticleByContent(keyword);
} catch (Exception fallbackError) {
// 兜底也炸了(比如全文索引没建),返回空也不能甩500给用户
log.error("ngram兜底搜索也失败(全文索引建了吗?):{}", fallbackError.getMessage());
return List.of();
}
}
}
三、方案一:MySQL ngram 全文索引(默认引擎,零新增组件)
原理
MySQL 5.7.6+ 内置 ngram 全文解析器,把文本按二元组(bigram)切分建倒排索引,天然支持中日韩文本,不需要部署任何新组件,是个人博客的性价比之王。
建索引(一条 SQL)
sql
ALTER TABLE `t_article`
ADD FULLTEXT INDEX `ft_article_content` (`article_content`) WITH PARSER ngram;
查询改造
MyBatis-Plus 的 LambdaQueryWrapper 不支持 MATCH AGAINST,在 Mapper 里手写:
java
/**
* 双引号短语匹配,语义等同 LIKE '%keyword%' 但走 ft_article_content 索引
* 注意:keyword 传入前必须清洗掉双引号和反斜杠,否则短语查询直接报废
*/
@Select("SELECT * FROM t_article WHERE MATCH(article_content) " +
"AGAINST(CONCAT('\"', #{keyword}, '\"') IN BOOLEAN MODE) " +
"AND status = #{status} AND is_deleted = 0")
List<Article> searchByContentFulltext(@Param("keyword") String keyword,
@Param("status") Integer status);
两个细节:
- 短语匹配 :
AGAINST('"关键词"' IN BOOLEAN MODE),ngram 会把短语切成 bigram 并要求连续出现,语义和原来的LIKE '%keyword%'完全一致,但走的是索引; - 输入清洗:双引号和反斜杠会破坏短语语法,必须洗掉,同时把关键词最小长度从 1 改成 2(ngram 是二元切分,单字搜不到)。
java
String cleanKeyword = keyword.replaceAll("[\"\\\\]", " ").trim();
if (cleanKeyword.isEmpty()) {
return List.of();
}
顺手修掉的历史欠账
- 循环外
index的 bug:每篇文章各自定位命中位置截摘要,定位不到就取开头 30 字兜底; - 全表捞分类:改成按命中文章的
categoryId批量selectBatchIds。
适用边界
百万~千万字符级别、低 QPS 的个人博客,ngram 闭眼用。它的短板不在性能而在效果:二元切词不懂词语边界,相关度排序粗糙,没有高亮、同义词、拼音这些花活。要玩这些,得上 ES。
四、方案二:Elasticsearch(配置切换,降级兜底)
技术选型:为什么是 low-level RestClient,而不是 spring-data-elasticsearch
这是本次最容易踩的坑,先说结论:
Spring Boot 3.1 自带的 spring-data-elasticsearch 5.x 对应 ES 8.x 客户端,连 ES 7.x 服务端会直接被 406 拒绝(请求头
compatible-with=8老服务端不认识)。
而我的 ES 是 7.12.1(本地早已装好),所以选了 elasticsearch-rest-client(low-level):
xml
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.17.25</version>
</dependency>
纯 HTTP 调用,不带 Lucene 全家桶,依赖轻、零版本兼容问题,以后升级 ES 8 也不用大改------JSON DSL 是稳定的。
索引设计(IK 分词)
json
{
"mappings": {
"properties": {
"id": { "type": "long" },
"articleTitle": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"articleContent": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"status": { "type": "short" },
"createTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }
}
}
}
- IK 分词器 :索引时用
ik_max_word切细(召回多),搜索时用ik_smart切准(精度高),中文搜索的标配组合; articleContent存的是 strip 掉 Markdown 语法后的纯文本,搜索更准、高亮片段干净。
搜索设计:职责分离
ES 只负责"哪些文章命中 + 高亮片段",访问量、分类名回 MySQL 取最新值。
json
{
"query": {
"bool": {
"must": [{ "match_phrase": { "articleContent": "关键词" } }],
"filter": [{ "term": { "status": 1 } }]
}
},
"highlight": {
"pre_tags": [""],
"post_tags": [""],
"fields": { "articleContent": { "fragment_size": 50, "number_of_fragments": 1 } }
},
"_source": ["articleTitle"],
"size": 50
}
为什么不把 visitCount 也存进 ES?因为浏览量是高频变动字段,每被看一次就同步一次 ES,那是自找刷写地狱。ES 查出 id 列表后 selectBatchIds 回库补齐,主键查询毫秒级,数据永远最新。
高亮标签 pre_tags/post_tags 特意设为空串:fragment 保持纯文本,前端原有的正则高亮逻辑零改动。
数据同步:Spring 事件 + 事务提交后执行
文章发布/修改/删除时同步 ES,不在业务代码里直接调 ES(会把两个引擎耦合死),改用 Spring 事件解耦:
java
// ArticleServiceImpl.publish() 保存成功后:
eventPublisher.publishEvent(
new ArticleChangedEvent(List.of(article.getId()), ArticleChangedEvent.Type.UPSERT));
java
@Slf4j
@Component
@ConditionalOnProperty(name = "search.engine", havingValue = "es")
public class ArticleEsSyncListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onArticleChanged(ArticleChangedEvent event) {
try {
// UPSERT:回库查最新状态,公开则写入,私密/已删则从ES移除
// DELETE:直接删除ES文档
...
} catch (Exception e) {
// 同步失败只记日志不影响主流程,数据不一致靠全量重建兜底
log.error("ES同步失败:{}", e.getMessage(), e);
}
}
}
两个注解参数都是血泪经验:
AFTER_COMMIT:事务提交后才同步,否则事务回滚了 ES 里却写进去了,脏数据就是这么来的;fallbackExecution = true:项目里updateStatus这种没挂@Transactional的方法,没有事务时事件默认直接丢弃------不加这个参数,状态改了 ES 却万年不更新,灵异 bug 预定。
全量重建接口
首次启用 ES、或 ES 数据损坏时,需要全量灌数据,留了一个管理员接口:
bash
POST /search/es/reindex
流程:删旧索引 → 建索引(IK mapping)→ 查出所有公开文章 → _bulk ndjson 批量导入。顺带在管理后台文章列表页加了"重建搜索索引"按钮(Modal 确认 + loading + 完成提示同步篇数),不用再敲 curl。
五、Docker 部署 ES + Kibana 实战(含踩坑实录)
部署参考了这篇博客的步骤骨架:Docker 部署 ElasticSearch + Kibana(CSDN),并结合实际环境做了修正。版本铁律先说在前面:ES、Kibana、IK 分词器三者版本必须精确一致,本文统一 7.12.1。
1. 创建网络
bash
docker network create es-net
2. 部署 Elasticsearch
bash
# 内核参数必须调(ES 的 mmap 需要,不设 bootstrap 检查直接失败)
sysctl -w vm.max_map_count=262144 && echo "vm.max_map_count=262144" >> /etc/sysctl.conf
# 建目录 + 授权(ES 容器内是 uid 1000,必须 chown,chmod 777 也能用但不优雅)
mkdir -p /www/wwwroot/es/data /www/wwwroot/es/plugins/ik && chown -R 1000:1000 /www/wwwroot/es
启动容器:
bash
docker run -d --restart=always --name es --network es-net -p 9200:9200 \
-e discovery.type=single-node \
-e ES_JAVA_OPTS="-Xms512m -Xmx512m" \
-v /www/wwwroot/es/data:/usr/share/elasticsearch/data \
-v /www/wwwroot/es/plugins:/usr/share/elasticsearch/plugins \
docker.elastic.co/elasticsearch/elasticsearch:7.12.1
镜像拉不动的话,配镜像加速,或者本机
docker save打包传服务器docker load。
3. ⚠️ 踩坑实录一:挂载整个 config 目录 = 自杀
最初参照一些教程写了这样的挂载:
bash
-v /www/wwwroot/es/config:/usr/share/elasticsearch/config # 别这么干!
容器立刻无限重启,日志刷屏:
javascript
java.nio.file.NoSuchFileException: /usr/share/elasticsearch/config/jvm.options
原因 :Docker 挂载目录时,宿主机目录会整个盖住 容器目录。镜像里 config/ 自带 jvm.options、log4j2.properties 等必需文件,被宿主机的空目录一盖,全没了。
正确姿势 :config 目录不挂 ,配置全部用 -e 环境变量传(discovery.type、ES_JAVA_OPTS 已经覆盖单节点博客的全部需求);plugins 目录镜像里本来就是空的,挂载安全。
4. ⚠️ 踩坑实录二:JDK 21 启动 ES 7.x = 白给
本地 Windows 起 ES 时还踩过一个环境坑:
csharp
java.lang.UnsupportedOperationException: The Security Manager is deprecated
at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:71)
系统 JAVA_HOME 是 JDK 21,而 SecurityManager 从 JDK 18 起禁止设置,ES 7.12 启动必用,直接白给。
解法:ES 7.x 发行版自带 JDK,让启动脚本用它:
bash
# Linux(写进 /etc/profile 或启动脚本)
export ES_JAVA_HOME=/path/to/elasticsearch-7.12.1/jdk
# Windows 一次性启动
cmd /c "set ES_JAVA_HOME=E:\elasticsearch\elasticsearch-7.12.1\jdk&& set JAVA_HOME=&& E:\elasticsearch\elasticsearch-7.12.1\bin\elasticsearch.bat"
5. 安装 IK 分词器(离线目录挂载法)
参考文章用的是容器内在线安装(elasticsearch-plugin install <url>),./bin/elasticsearch-plugin install release.infinilabs.com/analysis-ik... 直连慢的话可以把 zip 提前下好,解压到挂载的 plugins 目录:
bash
cd /www/wwwroot/es/plugins \
&& wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip \
&& unzip elasticsearch-analysis-ik-7.12.1.zip -d ik \
&& rm -f elasticsearch-analysis-ik-7.12.1.zip \
&& chown -R 1000:1000 ik \
&& docker restart es
验证 IK 生效:
bash
curl -X POST "http://127.0.0.1:9200/_analyze?pretty" \
-H 'Content-Type: application/json' \
-d '{"analyzer":"ik_max_word","text":"分布式全文搜索引擎"}'
# 返回 ["分布式","分布","式","全文","搜索引擎","搜索","索引","引擎"] 即成功
6. 部署 Kibana
bash
mkdir -p /www/wwwroot/kibana/config /www/wwwroot/kibana/data && chown -R 1000:1000 /www/wwwroot/kibana
kibana.yml(注意:挂单文件,别挂整个 config 目录,同一个坑别踩两次):
bash
printf 'server.host: "0.0.0.0"\nserver.name: egg-kibana\nelasticsearch.hosts: ["http://es:9200"]\ni18n.locale: "zh-CN"\nmonitoring.ui.container.elasticsearch.enabled: true\n' > /www/wwwroot/kibana/config/kibana.yml
bash
docker run -d --restart=always --name kibana --network es-net -p 5601:5601 \
-v /www/wwwroot/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml \
-v /www/wwwroot/kibana/data:/usr/share/kibana/data \
docker.elastic.co/kibana/kibana:7.12.1
要点:
server.host: "0.0.0.0"必须写,不然端口映射了容器外也访问不到;- 首次启动要优化前端 bundle,耐心等 1~2 分钟 ,
docker logs -f kibana看到Status changed from yellow to green再开浏览器。
7. 安全提醒(重要)
- ES 7.12 和 Kibana 7.12 默认无认证 ,9200 / 5601 千万别对公网开放:安全组不放行,
elasticsearch.yml保持network.host: 127.0.0.1(后端同机直连); - Kibana 想在外网用,推荐 SSH 隧道:
ssh -L 5601:127.0.0.1:5601 user@server,本地访问localhost:5601; - nginx 不需要为 ES 加任何配置 ------搜索流量是「浏览器 → 后端 → ES」,浏览器永远不直接碰 ES,这和 MinIO 需要
/minio-file反代(浏览器直接下载文件)是完全不同的。
六、效果对比与上线清单
| 维度 | 原方案(LIKE) | ngram 全文索引 | Elasticsearch |
|---|---|---|---|
| 查询方式 | 全表扫描 | 全文索引(bigram) | 倒排索引(IK 分词) |
| 新增组件 | 无 | 无 | ES(+Kibana 可选) |
| 中文分词质量 | ------ | 二元切分,够用 | 词级切分,优秀 |
| 高亮摘要 | Java 手动截取 | Java 手动截取 | ES highlight 原生 |
| 搜索限流 | 5 次/分钟 | 30 次/分钟 | 30 次/分钟(可再放宽) |
| 适用场景 | ------ | 百万~千万字符的博客 | 想玩相关度/聚合/更多花活 |
上线步骤(顺序别乱):
bash
# ① 数据库执行迁移 SQL(ngram 兜底和降级链路的前提)
ALTER TABLE `t_article` ADD FULLTEXT INDEX `ft_article_content` (`article_content`) WITH PARSER ngram;
# ② ES + IK 部署完毕(本文第五节),9200 可访问
# ③ 后端 search.engine=es 启动
# ④ 管理后台点一次"重建搜索索引"灌数据(或 POST /search/es/reindex)
# ⑤ 前台搜索验证:后端日志没有"降级到ngram"的 warn,说明 ES 在干活
结语
这次改造最值钱的不是接入了 ES,而是那层 SearchService 抽象:ngram 作为默认引擎零成本保底,ES 作为进阶引擎随时可切、挂了还能自动降级。以后无论是换回 ngram,还是把 ES 升级到 8.x,调用方代码一行都不用动。
架构的意义就在于此------给变化留好位置,而不是把现在的选择焊死。
参考: