ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot集成Lucene与HanLP:中文全文检索毫秒级实战

SpringBoot集成Lucene与HanLP:中文全文检索毫秒级实战 做后端这几年搜索这块一直是能用就行的心态直到上周线上一个 SpringBoot 项目的列表页被模糊查询卡到 800ms被业务方追着问能不能毫秒级我才真正把中文全文检索这件事从头到尾捋了一遍。这篇文章就是那次重构的记录如何在 SpringBoot 里自建一套全文检索能力让中文模糊搜索的响应稳定在毫秒级。方案不复杂核心只有三件事分词、倒排索引、缓存。我会把选型对比、关键代码、调优参数和踩过的坑都摊开讲项目规模在百万级数据量以内的团队可以直接照着抄。1. 先聊聊痛点MySQL 的 LIKE 到底慢在哪1.1 一次普通的模糊查询问题现场还原业务场景很简单一张告警记录表130 万行数据页面上有个搜索框用户输入任意关键词要按标题字段做模糊过滤。最初实现就是最常见的WHERE title LIKE %关键词%数据量在 20 万以内的时候没什么感觉但到了百万级以后打开页面明显能感到转圈接口平均耗时 400ms 到 800ms遇到并发稍高直接飙到两秒开外。我刚开始也怀疑是慢查询没走索引但即使给 title 加了全文索引%关键词%这种写法依旧是全表扫描。MySQL 的 BTree 索引只能加速前缀匹配也就是LIKE 关键词%一旦通配符放在最前面优化器只能放弃索引用整表扫的方式逐个判断。更麻烦的是用户搜的是中文自然语言就算你把字段做成前缀索引搜索项目管理的时候用户也可能输入项目管理目管这类片段前缀匹配根本覆盖不了这些输入习惯。1.2 问题的三个本质扫描、分词、索引结构把这次慢查询拆开看本质上是三个问题叠加。第一个是扫描范围%关键词%意味着每一行都要做字符串匹配这是一个 O(n) 的操作数据量线性增长耗时也跟着线性上涨。第二个是缺少中文分词能力MySQL 内置的全文索引对中文支持很弱默认按字符切分一个 10 个字的长标题会被切成一堆单字搜上海自来水这种词时它不知道自来水是一个语义单元匹配出来的结果相关性乱得一塌糊涂。第三个是缺少倒排索引结构关系型数据库的行式存储天生不适合做关键词检索它擅长的是主键定位和范围扫描。当时我列了一张对比表把几种常见改进方案都过了一遍方案原理数据量到百万后的表现落地成本MySQL LIKE全表扫描/前缀索引O(n) 线性劣化并发扛不住几乎为零但性能天花板低MySQL 内置 FULLTEXT倒排索引但中文分词弱英文尚可中文召回率差改动小体验一般Redis 扫描内存加速但本质还是遍历内存开销大百万 key 明显迟钝需要额外维护缓存Elasticsearch分布式倒排索引秒级以内扩展性强要独立集群运维成本高Lucene 内嵌本地倒排索引百万级毫秒级无额外服务代码量可控1.3 为什么最终没上 ES而选择内嵌 Lucene团队不是没考虑过 Elasticsearch但项目只有 1 个 SpringBoot 服务数据量 130 万距离需要分布式索引的阶段还很远。上一套 ES 集群意味着要维护至少 3 个节点、处理分片策略、同步数据管道为了一个列表页搜索框引入整套基础设施性价比太低了。我更倾向于先验证一个思路把索引建在应用本地让检索只发生在一个进程内。这就是我最后选择的方案Lucene 内嵌索引 HanLP 中文分词器 Caffeine 热点缓存。Lucene 本身就是搜索引擎的底层引擎Elasticsearch 的核心也是它单机百万级文档的检索性能完全不成问题。把索引文件落在应用服务器的磁盘上SpringBoot 启动时加载请求直接命中本机索引省去了网络开销和集群协调这是响应能到毫秒级的大前提。2. 中文分词的坑Lucene 默认分词器根本不认识中文2.1 分词是整个搜索体验的分水岭开始动手后遇到的第一个问题就是分词。Lucene 自带的 StandardAnalyzer 把中文按单字拆分搜自来水会被拆成自来水三个单独的 token一个查询要把这三个字都命中才行召回结果乱七八糟。这不是 Lucene 的错它本身没有内置成熟的中文词典需要我们自己接一个分词器。网上经常被提到的是 IK Analyzer老牌中文分词库但维护频率一般词典更新也慢。这次选型我用了 HanLP主要是看中它中文支持完善、有标准分词和 NLP 分词等多套算法而且针对行业词、人名、地名这些场景有现成词典。在 SpringBoot 里集成 HanLP 也很简单引入依赖后直接调用分词接口就能拿结果。2.2 HanLP 接入 SpringBoot一段能跑的代码Maven 依赖部分只加上两个核心包Lucene 版本和 HanLP 版本注意保持一致。dependency groupIdcom.hankcs/groupId artifactIdhanlp/artifactId versionportable-1.8.4/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-core/artifactId version8.11.2/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-analysis-common/artifactId version8.11.2/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-queryparser/artifactId version8.11.2/version /dependencyHanLP 提供了一个HanLPTokenizer适配 Lucene 的 Tokenizer 接口关键是把分词器配置到字段分析链路上。我单独建了一个配置类来管理 Analyzer方便后续替换或增加同义词扩展。Configuration public class SearchAnalyzerConfig { Bean(hanLpAnalyzer) public Analyzer hanLpAnalyzer() { return new Analyzer() { Override protected TokenStreamComponents createComponents(String fieldName) { Tokenizer tokenizer new HanLPTokenizer(); return new TokenStreamComponents(tokenizer); } }; } Bean(searchIndexManager) public SearchIndexManager searchIndexManager( Qualifier(hanLpAnalyzer) Analyzer analyzer) { return new SearchIndexManager(analyzer); } }2.3 不只是分词这么简单词库和停用词分词器接上以后测试时又暴露了一个问题某些业务词汇被切错了。比如产品里的运维工单HanLP 默认切成了运维 / 工单看起来没问题但堡垒机被切成堡 / 垒 / 机搜堡垒机时因为查询也要分词所以勉强能命中但相关性打分很怪。解决方式是为 HanLP 加载自定义词典。HanLP 支持在 classpath 下放自定义词典文件每行一个词条格式是词语 词性 词频。把业务术语批量录入后分词效果立刻正常了。这里有个经验词典要跟着业务走不能只依赖通用词典。告警系统里的CPU内存带宽这类词属于通用词不用管但类似工单号设备SN这种带业务属性的词必须提前喂给分词器。停用词同样重要。中文索引里 的了和是这类词如果全量进入倒排索引会白白膨胀索引体积还会给相关性计算制造噪音。我通过停用词表把这部分 token 过滤掉减少索引体积也能提升检索精度。3. 建立索引从数据库数据到毫秒级检索的关键一步3.1 文档建模的取舍数据同步过来之后先要设计文档字段。以告警记录为例我建的 Lucene Document 大概长这样id: 数据库主键titleKeyword: 标题原文存 Keyword 类型用于排序和兜底展示titleHanlp: 标题经过 HanLP 分词后的结果用 TextFieldcontentSummary: 正文摘要同样走 HanLP 分词createTime: 毫秒时间戳LongPoint 类型供时间范围过滤alarmType: 告警类型StringField 类型精确匹配这里有个很容易踩的坑不要把原文和分词结果混在一个字段里。如果把原始标题存成 TextFieldLucene 会把整句当成一个很长的 token既占空间又降低查询效率。我建议原文存一个字段分词结果存另一个字段查询时只搜分词字段返回时展示原文。3.2 全量索引 增量更新的运行机制首次上线时要对历史数据做全量索引我是用一个一次性任务分页从数据库拉存量数据批量写进 Lucene。130 万条数据大概跑了 2 分多钟索引文件生成后落在配置好的目录下。之后的数据变更走增量更新。写接口时新增一条记录就同步调用 IndexManager.addDocument()修改和删除也类似。这个流程里需要特别注意并发问题Lucene 的 IndexWriter 不是线程安全的我在管理类里用一个独立线程池串行处理所有写操作避免多个线程同时拿到同一个 writer 导致锁冲突。public void indexDocument(SearchDoc doc) { executor.execute(() - { try { Document luceneDocument buildDocument(doc); indexWriter.addDocument(luceneDocument); indexWriter.commit(); } catch (IOException e) { log.error(索引写入失败, e); } }); }3.3 NGram 字段这才是模糊搜索的灵魂分词索引解决了搜整词的问题但用户输入往往不是完整词。比如标题是Kubernetes 集群节点异常用户可能搜Kubernetes集群节点异常这些通过 HanLP 分词都能命中但如果用户只记得一半搜群节呢HanLP 会把它切成群 / 节两个单字去匹配索引召回效果很不稳定。为了覆盖这种中间片段的模糊搜索场景我额外加了一个 NGram 字段。写入时把标题按连续滑窗切成长度为 2 到 4 的片段比如集群节点会生成集群群节节点这些 NGram token。这样无论用户输入的是前缀、后缀还是中间片段都能在索引里找到对应的 token。代价是索引体积会变大实测 130 万条大约膨胀了 20%但换来的检索覆盖面是值得的。NGram 字段的实现不复杂Lucene 提供了现成的 NGramTokenFilter在自定义 Analyzer 里链式调用即可public class NGramAnalyzer extends Analyzer { Override protected TokenStreamComponents createComponents(String fieldName) { Tokenizer source new StandardTokenizer(); TokenStream filter new LowerCaseFilter(source); filter new NGramTokenFilter(filter, 2, 4, true); return new TokenStreamComponents(source, filter); } }看到这里你可能会问那还要 HanLP 分词干什么两个字段作用不同。汉语言模型分词负责理解语义处理北京欢迎你和欢迎你到北京这类需要词边界的问题NGram 负责兜底碎片输入。两个索引字段配合查询结果同时考虑两路信号再用 Lucene 的评分机制综合排序。4. 检索链路设计让模糊搜索又快又准4.1 查询构造BooleanQuery 多字段组合检索这一步最忌讳的做法是拿着用户输入直接拼一个titleKeyword:*关键词*的 wildcard 查询。Lucene 里通配符查询虽然能用但性能极差因为*在最前面意味着要遍历大量词元做前缀匹配数据量大一点就会重回秒级。我的做法是把用户输入拆开同时向多个索引字段发查询对输入用 HanLP 分词得到一组语义词对每个语义词在 titleHanlp 字段上做 TermQuery对输入原文直接在 NGram 字段上用 TermQuery 做碎片匹配各查询之间用 BooleanQuery 的 SHOULD 组合让任意一路命中都能返回结果这样做的好处是既能命中完整词又能命中用户输入一半的碎片同时每一项都是 TermQuery走的是倒排索引最直接的查链性能远优于 wildcard。查询代码大概这样public SearchResult search(String keyword, int pageNum, int pageSize) { BooleanQuery.Builder builder new BooleanQuery.Builder(); // 1. HanLP 语义分词查询 ListString terms hanLpTokenizer.segment(keyword); for (String term : terms) { builder.add(new TermQuery(new Term(titleHanlp, term)), BooleanClause.Occur.SHOULD); } // 2. NGram 碎片查询 builder.add(new TermQuery(new Term(titleNgram, keyword)), BooleanClause.Occur.SHOULD); builder.add(new TermQuery(new Term(contentNgram, keyword)), BooleanClause.Occur.SHOULD); // 3. 时间过滤条件用 BooleanClause.Occur.MUST 强制 builder.add(LongPoint.newRangeQuery(createTime, startTime, endTime), BooleanClause.Occur.MUST); TopDocs topDocs searcherManager.search(builder.build(), pageNum * pageSize); return convertResult(topDocs); }4.2 排序策略Lucene 自带评分 业务权重搜索结果排序不能只靠 Lucene 默认的相关性。默认的 TF-IDF/BM25 会倾向于长度短的字段一条只有内存告警四个字的短标题往往比一条内容丰富但包含关键词的详细标题排得更靠前。我的处理是在返回阶段做一次二次排序优先按业务规则评分。具体做法是把业务权重量化成一个 scoreBoost 字段存进文档比如重要告警 10普通告警 1已恢复 0.5查询取回 TopN 之后在内存里用基础分 boost 因子 业务分重新排一遍。因为百万级数据下 Lucene 召回 Top 200 再在内存排序开销很小但用户体验会好很多重要告警不再被淹没。4.3 高亮与摘要别把全文捞出来搜索接口不是只返回列表页面还要显示命中的关键词高亮这需要从索引里拿出原文片段。这里有一个性能陷阱如果用 Lucene 自带的 Highlighter 对每个结果重新分析原文100 条结果就要做 100 次分析接口耗时会明显上升。我的办法是索引写入时额外存一个 preHighLight 字段把标题和摘要的原文切片按句号、逗号切成短句存起来。返回结果时搜索命中分词的字符位置只从包含关键词的短句里截取前后 60 个字作为摘要彻底避开 Highlighter 的重复分析。视觉效果差不多性能差了好几倍。5. 毫秒级响应背后索引管理、缓存与实测数据5.1 复用 SearcherManager 别每次 new IndexSearcherLucene 使用中一个比较普遍的低级错误是每次请求都创建一个新的 IndexReader。IndexReader 加载时会全量读取索引文件的段信息百万级索引在机械盘上能吃掉几百毫秒这样每次查询都被拖累成了慢查询。正确做法是使用 SearcherManager 统一管理 reader 的打开和释放。构建好 SearcherManager 后每次查询通过acquire()拿 searcher执行完用release()归还只在数据变更提交后调用maybeRefresh()刷新 reader。这样可以保证查询不重复加载索引文件冷启动后再也见不到 search too slow 的现象。5.2 Caffeine 热点缓存让重复搜索稳定在亚毫秒即使索引查询已经到了 50ms 左右仍然有优化的空间。既然用户搜得最多的往往是那几十个热词那就把它们的查询结果缓存进本地内存。我选了 Caffeine它淘汰策略比手写 ConcurrentHashMap 靠谱支持基于频率的 LFU不常用的词自动清理不会撑爆堆内存。缓存键设计成了关键词 页码 筛选条件的拼接串值就是排序完成后的前 100 条结果。设置 expireAfterWrite 为 60 秒所以同一热词在一个窗口内直接返回缓存结果整个接口耗时降低到 3ms 以内。命中率我看了一下日志热词缓存命中率大约在 20%虽然比例不高但把最卡的部分稳住了。5.3 实测数据400ms 到 10ms 的对比我把整个流程重构前后做了一组对比压测机器配置是 4C8G 的普通服务器数据量 130 万条搜索关键词选的是比较常见的中文词内存告警。结果如下场景平均耗时P99 耗时说明MySQL LIKE 模糊查询480ms1200ms全表扫描并发 10 就明显变慢Lucene HanLP 冷查询52ms78ms首次搜索需要加载段Lucene HanLP 热查询18ms25msreader 复用后表现稳定加 Caffeine 热词缓存3ms8ms重复热词直接命中内存这个数据在百万级数据量下非常有参考价值。即使冷查询只需要 50ms 左右也比原来的 480ms 快了近 10 倍加上缓存之后基本是无感了。5.4 JVM 参数与写索引线程的配套调优为了让 Lucene 索引不至于拖垮应用进程几个 JVM 参数也值得改。IndexWriter 的 RAMBufferSizeMB 我调到了 128MB这样批量写入时内存能攒够一批再落盘减少磁盘随机写次数。同时把 merge 线程数控制在一个因为小规格服务器上 CPU 核数有限merging 和查询并发会抢资源。GC 方面因为 Lucene 的查询会产生不少短命对象建议使用 G1 垃圾回收器并开启-XX:UseStringDeduplication能减少相同关键词字符串的重复内存占用。这些参数不需要很精通 JVM 也能抄直接放在启动脚本里即可。6. 上线以后才遇到的坑大索引的深分页、段合并与多实例部署6.1 深分页别再用 from size原来 MySQL 分页翻到后面几页性能差Lucene 也有类似的问题。TopDocs从第 10000 条开始取数据时Lucene 仍然要完整计算前面所有文档的得分再丢弃页数越深越慢。解决方案是改用searchAfter机制第一次查询拿 TopDocs返回结果最后一篇文档的 score docID 作为游标翻页时把它传进去Lucene 直接从游标位置往后取。搜索框场景下用户很少有深度翻页需求但后台导出、数据核对偶尔会有人翻到几百页这个改动顺手就能做完。6.2 段合并引发的查询毛刺上线后我注意到凌晨两点左右会出现一次查询耗时超过 200ms 的毛刺。查了监控之后发现是 Lucene 在后台执行段合并把多个小索引段合并成大段以提升后续检索效率合并瞬间磁盘和 CPU 都被占住极大一部分查询就被顶起来了。Segment merge 不是坏事但要给它安排合适的时间窗口避免和业务高峰冲突。在索引配置里通过MergePolicy把合并任务的触发时机限制在每天凌晨 3 点到 5 点之间执行同时把单个 merge 线程的并发数调低查询毛刺立刻消失。6.3 多实例部署时索引文件的并发写冲突项目上线自然是双节点部署但 Lucene 索引文件如果两个实例都去写就会遇到LockObtainFailedException两个进程同时争夺 write.lock结果就是服务反复重启。我的解决方式是在架构上单独让一个实例负责索引写入另一个实例只读查询读写分离。如果你不想引入额外的协调组件就用一个简单的 ShedLock 定时任务保证只有主实例执行增量更新副实例定时同步索引目录后加载。多实例部署宁可让搜索数据有 30 秒延迟也不能让索引文件损坏。这里还是那句话方案从简开始规模到了再换更重的手段。如果你的项目数据量已经到千万级甚至亿级再考虑引入独立的 Elasticsearch 集群也不迟逻辑依然是分词 倒排索引 缓存这套核心只不过把索引从本地挪到了分布式存储上。最后分享一个实际操作中的小习惯重建索引后不要急着开流量先跑一遍巡检查询脚本把每个索引字段的 term 数量、空值比例、命中失败率打出来看一眼。我总能在这一步发现业务脏数据导致的分词异常或字段类型配置错误省去线上排障的功夫。这套方案本质上是把复杂问题拆成了中文理解和索引查询两件事每一件都不需要造轮子但组合起来效果立竿见影。
返回列表