从 LIKE 到 Elasticsearch:博客搜索引擎可插拔改造全记录

本人个人博客地址: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);

两个细节:

  1. 短语匹配AGAINST('"关键词"' IN BOOLEAN MODE),ngram 会把短语切成 bigram 并要求连续出现,语义和原来的 LIKE '%keyword%' 完全一致,但走的是索引;
  2. 输入清洗:双引号和反斜杠会破坏短语语法,必须洗掉,同时把关键词最小长度从 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.optionslog4j2.properties 等必需文件,被宿主机的空目录一盖,全没了。

正确姿势 :config 目录不挂 ,配置全部用 -e 环境变量传(discovery.typeES_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,调用方代码一行都不用动。

架构的意义就在于此------给变化留好位置,而不是把现在的选择焊死。


参考:

相关推荐
未秃头的程序猿1 小时前
MCP协议火了,但到底怎么用?我花了一个周末用Java接入了5个MCP Server
后端·ai编程·mcp
Cosolar1 小时前
Claude Opus 5 系统提示词被完整泄露,共 135027 字符、约 3.4 万 token
人工智能·后端·架构
Alan_751 小时前
RESTful API 落地的三个核心:资源建模、语义约束与工程化
后端
不一样的少年_1 小时前
原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
前端·后端·agent
Conan在掘金1 小时前
鸿蒙报错速查:struct 里嵌 class 声明就炸,Unexpected token 编译报错,根因 + 真解法
后端
Conan在掘金1 小时前
鸿蒙报错速查:arkts-no-destruct-decls 禁用解构声明,const { x, y } = obj 就炸,根因 + 真解法
后端
她说彩礼65万2 小时前
ASP.NET Core 环境配置
后端·asp.net
SimonKing2 小时前
OpenCode 桌面版这 10 天偷偷迭代了 5 个版本,你还在用旧版吗?
java·后端·程序员
爱勇宝2 小时前
你以为自己性格不好,其实只是被环境反复训练
前端·后端·程序员