ARTICLE DETAIL

资讯详情

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

Lucene全文检索实战:从倒排索引到中文分词与性能调优

Lucene全文检索实战:从倒排索引到中文分词与性能调优 1. 为什么选择Lucene来做搜索1.1 从数据库LIKE到倒排索引的距离搜索功能听起来是个基础需求但如果真拿数据库的LIKE去顶数据量一上来就非常难受。我自己曾经在一个内容管理项目里表里存了几十万条文章用户输入一个关键词后台直接SELECT * FROM articles WHERE title LIKE %关键词% OR content LIKE %关键词%。刚开始还行数据到20万以后这个查询经常把数据库CPU打到80%以上一个搜索请求动不动就两三秒。后来听DBA的劝勉强加了全文索引但MySQL自带全文索引对中文分词支持也一般查出来的结果排序不理想相关度完全是一笔糊涂账。Lucene在这时候就体现出它的价值了。它不是数据库而是一个开源的、用Java写的全文检索引擎库。它的设计初衷就是帮开发者把搜索这件事从数据库里拆出来用一套独立的索引结构来支撑。你想想LIKE走的是全表扫描外加逐行字符串匹配Lucene走的是倒排索引加评分排序两者在思路上就有本质区别。第一次接触Lucene的时候我花了几天时间才把整个流程走通。后来回头看这套东西其实不难核心就是你得理解它的几个关键概念索引、文档、字段、分词器、查询、评分。只要把这些概念串起来后面的所有操作都是围绕它们打转。这篇博文我就按自己踩坑的经历从原理到实战把Lucene搜索实现这整条链路讲清楚。1.2 这点数据量Lucene凭什么比LIKE快直接说结论Lucene快是因为它预先建立了一套类似于字典的结构。你查一个词它不用把每条记录从头翻到尾而是直接去字典里翻词翻到词之后后面挂着的就是包含这个词的所有文档ID列表。这就好比你去图书馆找包含人工智能这个词的书籍有两种办法一种是一本一本地翻看看哪本里面有这个词这就是数据库的LIKE另一种是先查书末索引表看人工智能对应第几页然后直接翻到那一页这就是Lucene的倒排索引。Lucene更适合搜索引擎场景是因为它对中文分词有完善的扩展机制。你搜索搜索引擎四个字如果用IK分词器它会被切分成搜索、搜索引擎、引擎等多个词项然后分别去索引里查最后合并结果并打分。这种分词加评分机制SQL很难优雅地实现。装了Lucene之后同样的数据量搜索响应时间从秒级降到了毫秒级这是我在项目中体会最直观的收益。2. 核心概念与底层原理2.1 文档、字段、分词器Lucene的三大件Lucene的数据模型很简单一切皆文档Document。一篇博客、一个商品、一条新闻都可以抽象成一个Document对象。Document里面装着若干字段Field每个字段有自己的名字和值比如标题是title字段内容是content字段。Lucene的工作就是把Document写进索引目录然后提供搜索能力把相关的Document找出来。字段不是随便声明的每个字段可以配置不同的存储策略和索引策略。最常用的两个枚举属性是Store.YES和Store.NO。Store.YES表示把原始字段值存下来搜索结果返回时可以直接读取不用回数据库再查一次Store.NO表示只索引不存储节省磁盘空间适合大文本字段搜索时只返回文档ID再通过ID去数据库取内容。我在实际项目中一般这样处理标题字段用Store.YES因为搜索结果列表要展示标题正文内容用Store.NO只做索引展示详情时再查数据库。分词器是Lucene的灵魂组件。它的任务是把一段文本切成一个个词项。英文天然用空格切分就行但中文不行今天天气真好这句话如果按单字切就变成今、天、天、气……搜索天气就匹配不上了。所以中文场景我一般用IK Analyzer它支持细粒度和智能切分两种模式。我习惯用智能切分比如南京市长江大桥会被切分成南京市、长江大桥而不是错误地切成南京、市长、江大桥。2.2 倒排索引、段合并与近实时搜索Lucene索引文件在磁盘上是以段Segment为单位的。每次提交索引不是把所有数据重写一遍而是先生成一个新的段。搜索时Lucene会并行搜索所有段然后合并结果。段少了搜索快段多了会有一定的性能开销所以Lucene后台会自动做段合并把多个小段合并成一个大段。这个机制有点像你整理衣柜平时随手往里塞衣服周末一次性分类整理只不过Lucene的整理是自动的不需要你干预。真正理解段这个概念就理解了Lucene为什么叫近实时搜索。它不像数据库那样插一条数据立刻能被查到。Lucene写IndexWriter之后数据要先进内存缓冲区调用commit()才会真正落盘落盘之后新的段才加入索引。如果你想搜索刚刚写入的内容需要在写入后触发一次刷新refresh让内存中的数据对搜索可见。这里有个很实用的调优点IndexWriter的提交频率决定了搜索的实时性。实时性要求高的场景可以调IndexWriterConfig.setMaxBufferedDocs()或者设置commit间隔让新数据尽快可搜但对实时性要求不高的场景比如每天批量同步一次数据就不需要频繁提交因为提交本身有开销太频繁会拖慢写入速度。我在爬虫项目里每天定时全量重建索引就没有开近实时功能整体性能好很多。3. 环境搭建与依赖引入3.1 Maven依赖版本怎么选不踩坑Lucene版本这东西真的是一言难尽。Lucene的版本号变化很快不同大版本之间API变动还不小。我最早用的是Lucene 4.10后来升级到8.x很多类名和包路径都变了代码改起来很痛苦。所以版本选型的第一原则是查你项目用的JDK版本再选匹配的Lucene版本。一般来说JDK 8对应的Lucene 7.x、8.x都没问题JDK 11及以上可以放心用Lucene 8.x或9.x。如果你用Spring Boot还要注意Lucene版本和ES版本的兼容问题。我项目里用的是Lucene 8.11.2配JDK 8稳定运行一年多没出过兼容性问题。Maven坐标长这样dependency groupIdorg.apache.lucene/groupId artifactIdlucene-core/artifactId version8.11.2/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-analyzers-common/artifactId version8.11.2/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-queryparser/artifactId version8.11.2/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-highlighter/artifactId version8.11.2/version /dependency这里有个细节lucene-analyzers-common这个包在Lucene 8.x里还能用但到了9.x就被拆分了改成lucene-analysis-common。如果你在GitHub上抄了一个老项目的代码记得检查一下artifactId是否对得上。IK分词器也要注意IK Analyzer的Maven坐标是com.janeluo:ikanalyzer:2012_u6但它默认只支持到Lucene 4.x8.x要用需要自己改一下源码或者引入别人适配过的版本。我图省事直接下的IKAnalyzer 8.x适配包放本地仓库引入的省了很多编译麻烦。3.2 目录和分词器两个最基础的初始化动作Lucene所有索引文件都放在一个目录里这个目录可以是本地磁盘路径也可以用内存目录。本地磁盘目录用FSDirectory.open(Paths.get(/data/lucene/index))内存目录用ByteBuffersDirectory。我自己的经验是测试阶段用ByteBuffersDirectory快调试方便生产环境一定用磁盘目录否则服务一重启数据全没了。初始化代码一般是两个对象一个是IndexWriter负责写入索引一个是IndexReader或IndexSearcher负责搜索。它们的生命周期不一样IndexWriter是重量级对象创建一次整个应用生命周期复用不要频繁开关IndexSearcher每次搜索要基于当前索引快照索引有更新时你要重新DirectoryReader.open()老reader再DirectoryReader.openIfChanged()这样能拿到最新的数据。// 初始化分词器 Analyzer analyzer new IKAnalyzer(true); // 索引配置 IndexWriterConfig config new IndexWriterConfig(analyzer); config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND); // 打开索引目录 Directory directory FSDirectory.open(Paths.get(/data/lucene/index)); IndexWriter writer new IndexWriter(directory, config);这段代码是我所有Lucene项目的起手式。有一点值得提醒IndexWriterConfig.OpenMode有三种枚举值CREATE表示重建索引APPEND表示追加CREATE_OR_APPEND表示有就追加没有就创建。我在做全量重建时用CREATE做增量更新时用CREATE_OR_APPEND避免误删旧数据。4. 从建索引到跑通第一个搜索4.1 写一个可以被搜索的DocumentLucene建索引的核心动作就是创建Document往里面塞字段然后交给IndexWriter。我在内容检索项目里一篇文章通常封装成这样一个DocumentDocument doc new Document(); doc.add(new StringField(id, article.getId(), Field.Store.YES)); doc.add(new TextField(title, article.getTitle(), Field.Store.YES)); doc.add(new TextField(content, article.getContent(), Field.Store.NO)); doc.add(new StringField(category, article.getCategory(), Field.Store.YES)); doc.add(new LongPoint(publishTime, article.getPublishTime())); doc.add(new NumericDocValuesField(publishTime, article.getPublishTime())); writer.addDocument(doc);字段类型的选择这里有一段很重要的经验。StringField适合关键词类字段比如ID、分类它不会分词整体作为一个词项索引TextField适合需要全文检索的字段比如标题、正文它会被分词器切分成多个词LongPoint专门用于数值范围过滤比如按时间范围搜索。如果你把时间字段设成TextField那就只能精确匹配没法做范围查询了。有一个坑我踩过好几次很多人在索引时只加了LongPoint用来做范围过滤但搜索结果里需要按照发布时间排序这时候会报错因为排序需要NumericDocValuesField。LongPoint管过滤NumericDocValuesField管排序两者必须同时添加才能又过滤又排序。这不是Lucene故意为难你而是它把索引结构分得很细每种数据结构只干一件事。4.2 Query对象怎么构造建好索引之后搜索就是构造Query对象然后交给IndexSearcher执行。Lucene的Query构造方式有三种我按使用频率排序一下第一种是直接用QueryParser解析查询字符串。这种方式最接近搜索引擎的使用习惯用户输入一句话你拼一个查询表达式丢进去QueryParser parser new QueryParser(content, analyzer); Query query parser.parse(搜索引擎 AND 原理);第二种是手动构造TermQuery、BooleanQuery。这种方式最灵活适合代码里拼查询比如多字段联合搜索Query query1 new TermQuery(new Term(title, 搜索引擎)); Query query2 new TermQuery(new Term(content, Lucene)); BooleanQuery.Builder builder new BooleanQuery.Builder(); builder.add(query1, BooleanClause.Occur.SHOULD); builder.add(query2, BooleanClause.Occur.SHOULD); Query query builder.build();第三种是混合查询范围过滤加全文检索组合。比如搜索标题包含搜索引擎、发布时间在最近一年的文章用BooleanQuery把TermQuery和LongPoint.newRangeQuery拼起来Query termQuery new TermQuery(new Term(title, 搜索引擎)); Query rangeQuery LongPoint.newRangeQuery(publishTime, startTime, endTime); BooleanQuery.Builder builder new BooleanQuery.Builder(); builder.add(termQuery, BooleanClause.Occur.MUST); builder.add(rangeQuery, BooleanClause.Occur.MUST); Query query builder.build();BooleanClause.Occur是查询逻辑的核心它有四个枚举值MUST表示必须满足类似ANDSHOULD表示尽量满足类似ORMUST_NOT表示必须不满足类似NOTFILTER表示过滤不参与打分。这个设计很精巧FILTER和MUST的区别在于FILTER不计算相关度分数纯粹是缩小范围性能会好一些。在搜索条件里如果你只是需要一个筛选条件比如分类必须是技术用FILTER最合适。4.3 搜索结果的排序与评分IndexSearcher执行Query之后返回的是TopDocs对象里面按相关度分数从高到低排好了文档。这个分数是Lucene的经典评分模型算出来的跟词频、文档频率、文档长度都有关系。简而言之一个词在文档中出现次数越多、包含这个词的文档越少、文档越短这个词对文档的贡献分越高。我实际处理排序时很少直接依赖默认评分因为业务场景里热度比相关度更重要。我的做法是先用NumericDocValuesField保存一个热度分字段然后通过SortField指定排序规则Sort sort new Sort( SortField.FIELD_SCORE, new SortField(hotScore, SortField.Type.INT, true) ); TopDocs topDocs searcher.search(query, 20, sort);这个代码的意思是先按相关度分数排序相关度一样时按热度分倒序排。true表示降序。这样搜索结果既能保证跟用户输入相关又能在同等相关度下优先展示更热的文章。另外我还试过直接只用热度排序相关度完全不管但实际效果很怪用户明明搜的是Lucene原理结果第一条是无关热文体验很差。所以最后还是回到了相关度优先、热度次之的组合方案。5. 中文分词与查询高亮5.1 IK分词器那些坑Lucene自带的标准分词器对中文的支持很弱它是按单字切分的。所以中文搜索项目几乎都会引入IK Analyzer。IK是国人开发的开源分词器词库丰富支持自定义词典是中文Lucene方案的标配。我第一次用IK遇到过一个大坑IKAnalyzer的类在org.wltea.analyzer.lucene包下但引入的jar版本不匹配运行时报NoClassDefFoundError。后来排查发现是IK的Maven坐标和Lucene版本没对齐。我的建议是尽量用GitHub上有人维护的IKAnalyzer-lucene8适配版直接把源码编译进本地仓库不要用老旧的ikanalyzer-2012_u6。IK有一个很实用的功能自定义词典。比如你的业务里有专属名词K8s、DevOps、大模型默认词库大概率切分不正确导致搜K8s匹配不上。解决办法是在IK的配置文件IKAnalyzer.cfg.xml里配置自定义词典文件路径把专有名词一行一个写进去。我在一个技术社区项目里维护了一个1000多词的自定义词典搜索命中率和搜索质量提升非常明显。词典还有一个点容易忽略——词典切分策略。IK支持IKAnalyzer(true)智能切分和IKAnalyzer(false)细粒度切分。智能切分更倾向于切出更长的词适合搜索召回细粒度切分会把词切得更碎适合做词频统计。我用智能切分因为搜索场景更看重召回率和准确率的平衡。5.2 高亮显示却不破坏HTML结构搜索结果页把命中的关键词高亮显示这个需求几乎每个项目都有。Lucene自带Highlighter组件可以先在索引里取到原始文本再用查询词把命中片段标注出来。我用的是Lucene 8.x的Highlighter类逻辑大概是这样QueryScorer scorer new QueryScorer(query); Highlighter highlighter new Highlighter(new SimpleHTMLFormatter(em, /em), scorer); String highlightedTitle highlighter.getBestFragment(analyzer, title, doc.get(title)); String highlightedContent highlighter.getBestFragment(analyzer, content, doc.get(content));这里有个很值得注意的点Highlighter返回的片段是纯文本片段如果你索引的content字段本身包含HTML标签直接高亮后拿到前端展示em标签会和原有HTML标签嵌套可能把页面结构搞坏。我踩过这个坑之后统一在索引前做了一次HTML转义把内容里的和转成lt;和gt;高亮之后再整体输出。搜索结果页看到的就是干净的文本加红色的em标记不会再出现页面错乱的问题。高亮片段长度也可以控制。getBestFragment方法默认返回大约150字符的片段有时候命中位置在文章后面截出来的片段没有包含关键词。我一般会设置Fragmenter来调整片段长度或者取多个片段做拼接highlighter.setTextFragmenter(new SimpleFragmenter(100)); String[] fragments highlighter.getBestFragments(analyzer, content, content, 3);getBestFragments的第三个参数是返回片段数量我设成3这是标题摘要展示场景比较合适的值。不过要注意片段越多查询开销越大性能会受影响。搜索量大、索引几十万以上的项目建议高亮只对title做content存储不要启用Store.YES而是用数据库内容代替这样能省不少磁盘和IO。6. 性能调优与索引维护实战6.1 从索引规模出发做写入优化Lucene写索引的速度和搜索的速度是一对矛盾。写入越频繁段越多搜索要合的分片越多性能自然会下去。我处理的索引文档数在500万左右单次全量索引耗时约40分钟。说一下这个场景下的调优经验。第一是IndexWriter的批量提交。我最初用的是每次读一条数据就addDocument一条结果写成千上万条之后越来越慢。后来改成批量攒1000条再调用一次addDocuments速度提升非常明显。一次批量提交能减少很多I/O交互本质上是把大量小段的创建合并成了少量大段的创建。第二是IndexWriterConfig里setRAMBufferSizeMB参数。默认是16MB意思是内存缓冲到了16MB才触发一段写入磁盘。你可以把这个值调大比如64MB或128MB让内存多攒一点再落盘。这个参数调整对全量索引性能影响很大内存充足的情况下占用的内存越多索引写入速度越快。我把项目里的RAMBufferSizeMB从默认16调到128全量索引耗时少了约20%。第三是段合并策略。Lucene默认段合并策略是TieredMergePolicy它会在后台自动做合并。你可以限制setMaxMergeAtOnce和setSegmentsPerTier来控制合并行为。段太多会导致搜索变慢但段太少又会导致内存中有大量数据重写。我的经验是全量索引场景下可以让它自然合并不干预但增量更新频繁的场景建议把setSegmentsPerTier调小一点让段尽快合并避免段数量膨胀。6.2 深分页问题Scroll和SearchAfter哪个合适搜索引擎的分页不是简单地search(query, page*size)能解决的。Lucene的TopDocs只返回前N条一旦页码太深search(query, 100000, 20)会先在内存中排序前10万条再取20条代价极大。这个问题在数据库里叫深分页在Lucene里同样存在。我最早用Lucene做分页也是简单按页码算offset结果发现第100页以后的请求CPU暴涨。后来查文档发现Lucene官方推荐两种方案IndexSearcherAfter和searchAfter。searchAfter的用法是先取第一页的结果拿到最后一个文档的ScoreDoc然后下一次搜索传这个ScoreDoc作为起点ScoreDoc lastDoc null; int pageSize 20; while (hasMore) { TopDocs topDocs searcher.searchAfter(lastDoc, query, pageSize, sort); ScoreDoc[] scoreDocs topDocs.scoreDocs; if (scoreDocs.length 0) { break; } // 处理这一页的数据 lastDoc scoreDocs[scoreDocs.length - 1]; }这个方案的好处是每次搜索不需要从头排序性能开销稳定。坏处是它不支持跳页只能一页一页往下翻。如果你的产品需要跳到第100页这种功能searchAfter就没法直接用了。我后来是在产品需求评审时跟业务方确认了搜索场景基本不会跳页所以用的searchAfter效果很好。6.3 索引重建、增量更新与定期优化维护Lucene索引最常做的就是两件事全量重建和增量更新。全量重建通常在业务数据量产生巨大变化时做比如系统初始化、数据迁移或者索引结构发生变更。增量更新则是日常的数据同步新文章发布、旧文章修改都需要及时反映到索引中。增量更新的手段是IndexWriter.updateDocument(Term, Document)。这个Term是用来定位旧文档的一般用唯一ID字段。比如// 用articleId作为唯一标识更新文档 writer.updateDocument(new Term(id, article.getId()), doc);这个操作是先删除ID匹配的旧文档再添加新文档整个过程在一个事务里不会出现一会儿搜不到一会儿又重复的情况。这个机制是我非常喜欢Lucene的原因它把更新这个数据库里最麻烦的操作简化成了一个API。定期优化方面Lucene有一个IndexWriter.forceMerge(1)方法可以把所有段强制合并成一个段这样搜索时只需读一个文件速度最快。但这个方法非常消耗IO数据量大时可能造成线上服务卡顿一般放在凌晨低峰期执行。我这里文档量不大基本上一个月跑一次forceMerge(1)就够了。7. 常见问题与排查思路7.1 搜不到刚写入的数据这个是最常见的问题。索引写完马上搜索结果一条都搜不到。这里要区分两种情况第一种是IndexWriter还没提交。数据在内存缓冲里没有落盘。解决办法是调用writer.commit()然后重新打开Reader搜索。不过生产环境一般不会手动频繁调commit()因为每提交一次都会生成一个新的索引段频繁提交反而影响查询性能。我的方案是写入数据后调用writer.flush()让缓冲区的数据对Reader可见但不落盘。但要注意flush()之后如果JVM崩溃数据会丢失而commit()之后数据是安全的。第二种是Reader没刷新。IndexSearcher内部持有的是一个旧的DirectoryReader快照这个快照在创建时就固定了后续索引有更新也看不到。需要定期调用DirectoryReader.openIfChanged(reader)来获取最新快照。我写了一个定时任务每5秒检查一次索引是否有更新有更新就重新打开reader并替换掉旧的IndexSearcher。这样既保证了近实时性又不会每次查询都重新打开reader耗费性能。7.2 搜索关键词明明是中文分词结果不对这种情况基本都是分词器的词典问题。我之前遇到过一个案例用户搜索程序员结果只匹配到程序和员没匹配到完整词程序员。排查方法很简单写个测试类直接用IKAnalyzer对关键词做分词打印出切分结果看看是否包含完整词try (TokenStream ts analyzer.tokenStream(content, 程序员如何学习Lucene)) { CharTermAttribute term ts.addAttribute(CharTermAttribute.class); ts.reset(); while (ts.incrementToken()) { System.out.println(term.toString()); } ts.end(); }如果发现程序员没有被完整切分出来说明它不在分词器的词典里会被切成程序和员或者程序和员。这时候就要往自定义词典里加程序员这个词然后重新分词建索引。这里有一个关键点修改自定义词典后必须重建索引因为索引里的词项是分词器切出来的旧索引里存的还是旧分词结果不重建的话新词典不生效。这个点我确实被坑过好多次每次改完词典忘记重建索引排查了半天才发现问题出在索引数据是旧的。7.3 查询结果太多且都不相关搜索结果给了一堆不相关的玩意儿多半是查询时用了SHOULD操作符而且没有给MUST。SHOULD的含义是至少满足一个如果没有其他必须条件它会返回大量只匹配任一词的文档评分也会被拉低。我刚开始写搜索接口时把所有查询条件都用SHOULD拼起来结果用户输入Java 并发 编程系统把只有编程两个字的无关文档也返回来了数量几千条。正确的姿势是核心关键词用MUST附加筛选条件用FILTER扩展关键词用SHOULD。比如用户搜索Java并发编程我是这样拼的BooleanQuery.Builder builder new BooleanQuery.Builder(); builder.add(new TermQuery(new Term(title, Java)), BooleanClause.Occur.MUST); builder.add(new TermQuery(new Term(title, 并发)), BooleanClause.Occur.MUST); builder.add(new TermQuery(new Term(title, 编程)), BooleanClause.Occur.MUST); // 分类过滤不参与打分 builder.add(new TermQuery(new Term(category, 技术)), BooleanClause.Occur.FILTER);这样查询结果跟用户意图就贴近多了。另外如果搜索结果仍然不理想可以调整MinimumShouldMatch要求至少匹配多少个子查询比如builder.setMinimumNumberShouldMatch(2)过滤掉只匹配了一个词的文档。这个方法在召回率过高时很有效。7.4 搜索性能越来越差索引文件越来越大搜索性能下降这几乎是必然的。不过大部分场景下不是Lucene本身的问题而是使用姿势的问题。我排查过几次性能问题总结出几个常见的性能杀手没有复用IndexSearcher每次搜索都重新打开新的Reader导致频繁加载索引文件。搜索结果取太多条一次性search(query, 10000)然后遍历所有结果把大量数据塞进内存。高亮操作在没有命中关键词的字段上执行浪费了额外的文本处理时间。查询条件里用了通配符*和?a*这种前缀查询会遍历整个词项词典代价极高非必要别用。排查性能问题的最直接方式还是看IndexSearcher的耗时统计。Lucene没有内置的性能监控我在封装搜索服务时在查询入口加了简单的耗时日志记录每次请求的query语句、耗时、命中数量、索引文档总数。这样一旦线上访问变慢可以定位是搜索慢还是高亮慢还是结果处理慢针对性优化。优化完再对比耗时日志效果一目了然。8. 项目里的完整封装思路8.1 搜索服务怎么分模块一个能上生产的Lucene搜索服务不能只是几个方法的拼凑。我最终把代码拆成了四个模块索引写入模块、索引查询模块、索引维护模块和高亮处理模块。每个模块只干一件事方便后续替换扩展。索引写入模块只暴露一个addDoc接口和updateDoc接口内部管理IndexWriter外部业务方根本不需要接触Lucene的API。索引查询模块封装了search方法接收关键词、筛选条件、页码、排序规则内部组装Query并返回统一的结果对象。索引维护模块定时重建索引、检查索引健康度、清理过期文档。高亮处理模块负责把搜索结果中的关键词标记出来。这样封装之后上层业务代码完全不需要关心Lucene的细节。后来项目要把搜索换成Elasticsearch我们只改了一个查询实现类上层接口没动迁移成本低了很多。做技术方案时保留一层薄薄的接口层长远来看收益很大。8.2 单机Lucene和多节点ES怎么选很多团队在起步阶段会纠结是先上单机Lucene还是直接上Elasticsearch我的看法是看数据量和团队规模不要一上来就上重型方案。Lucene是一个库你要自己管理索引生命周期、自己处理并发、自己解决高可用。Elasticsearch是基于Lucene的分布式搜索服务器它帮你完成了集群管理、副本备份、滚动升级这些事你可以直接通过REST API操作。如果你只是一个普通业务系统单表数据量在几十万到几百万之间搜索响应时间要求毫秒级单机Lucene完全够用而且架构简单、维护成本低。如果数据量到千万级别以上或者有高并发、高可用要求那直接上Elasticsearch更合适。我自己做的内容搜索系统索引量稳定在几十万用单机Lucene足够每天定时重建一次索引JVM堆内存设置在2GB以内跑得很稳。如果数据量再翻几倍我可能会考虑迁移到ES但在那之前这个轻量方案还能撑很久。选择搜索方案的逻辑本质上是先满足当前需求同时留好迁移的余地。9. 写在最后的实操心得经过这一整套Lucene搜索实现的折腾我在实际项目中积累了一些个人感受。首先Lucene的学习曲线比想象中要平缓核心概念就那么几个只要把IndexWriter、Directory、Analyzer、Query、IndexSearcher这几个对象串起来一个能用的搜索功能就能跑起来。难点不在Lucene本身而是在中文分词、高亮处理、性能调优这些生产环境才会遇到的具体细节上。其次Lucene的文档和社区虽然不如ES活跃但遇到问题还是有迹可循的。多利用官方Javadoc多看源码注释比在网上复制代码靠谱得多。我最初遇到的很多坑最后都是通过翻源码定位的比如LongPoint和NumericDocValuesField的分工就是在源码注释里看到的。最后分享一个小技巧给搜索接口加上一个调试参数允许在URL里带上debugtrue后端就把本次搜索用的Query对象、查询耗时、命中文档数、Top5文档的评分明细打出来。这个功能在线上排查问题时非常有用能快速判断是查询条件构造错了还是分词结果不对还是数据本身就没被正确索引。别小看这一个参数它帮我节省了大量排查问题的时间。搜索这个领域越深入越觉得自己懂的不够多。Lucene只是一个起点倒排索引、TF-IDF、BM25这些经典算法每一个都值得花时间认真研究。希望这篇博文能帮你少踩一些我踩过的坑顺利把搜索功能跑起来。
返回列表