在分布式系统横行的今天,“数据查询”早已不是简单的数据库SELECT操作——当数据量突破TB级、并发查询达到万级QPS,传统单体搜索引擎(如Lucene)的性能瓶颈会瞬间暴露,要么查得慢,要么查不到,甚至直接宕机。而分布式搜索引擎,正是为解决“海量数据高效检索”而生的核心组件,它像一个“分布式数据检索大脑”,将海量数据分片存储、并行计算,既能支撑PB级数据存储,又能实现毫秒级查询响应,是电商、日志分析、风控等场景的必备技术。
很多开发者对分布式搜索引擎的理解停留在“会用Elasticsearch(ES)调API”,但对其底层“为什么能分片”“副本如何保证高可用”“查询请求如何路由”“倒排索引怎么构建”等核心逻辑一知半解,导致遇到性能瓶颈、数据不一致、集群宕机等问题时无从下手。
本文将彻底打破“只知其然,不知其所以然”的困境,从底层逻辑出发,结合Java实战讲透分布式搜索引擎的核心原理、架构设计、实战落地及性能优化,兼顾深度与可读性,让新手能夯实基础,让资深开发者能解决实际问题。
一、先搞懂:分布式搜索引擎到底是什么?(通俗版)
我们先抛开复杂的专业术语,用“生活场景”理解分布式搜索引擎的核心价值:
假设你有一个“网上书店”,里面有1000万本书(对应系统中的1000万条图书数据,每条数据包含书名、作者、出版社、价格、简介等信息)。现在用户需要查询“Java编程”相关的图书,你该如何快速找到所有匹配的书?
场景1:不用搜索引擎(纯数据库查询)
如果直接用MySQL存储所有图书数据,查询SQL如下:
SELECT * FROM book WHERE title LIKE '%Java编程%' OR author LIKE '%Java编程%' OR intro LIKE '%Java编程%';
这个SQL的问题很明显:
- 模糊查询(%开头)无法使用索引,只能全表扫描,1000万条数据扫描一次可能需要几秒甚至几十秒,用户体验极差;
- 无法实现“相关性排序”(比如优先显示书名完全匹配的,再显示简介包含的);
- 无法支持复杂查询(比如“Java编程 AND 2024年出版 AND 价格<100元”“Java编程 OR Python编程,排除入门级”);
- 当数据量涨到1亿条、10亿条,MySQL完全扛不住,即使分库分表,查询性能也会急剧下降。
场景2:用单体搜索引擎(如Lucene)
Lucene是一个“全文检索工具包”,核心能力是“构建倒排索引”(后面会详细讲),能实现快速全文检索、相关性排序。但它有一个致命缺陷:只能单机运行。
也就是说,你只能把1000万条图书数据都存在一台服务器上,用Lucene构建索引、处理查询。这会带来两个问题:
- 存储瓶颈:一台服务器的硬盘、内存有限,无法存储PB级数据(比如10亿条图书数据,每条数据1KB,就需要100GB存储,加上索引,可能需要几百GB);
- 高可用风险:一旦这台服务器宕机,整个检索功能就瘫痪了;
- 性能瓶颈:单台服务器的CPU、内存有限,无法支撑万级并发查询(比如双11期间,大量用户同时查询图书)。
场景3:用分布式搜索引擎(如Elasticsearch)
分布式搜索引擎的核心思路是“分而治之+集群协作”:
- 分片存储:把1000万条图书数据,分成10个“分片”(每个分片100万条数据),分别存储在10台不同的服务器上;
- 并行查询:用户查询“Java编程”时,10台服务器同时查询自己分片上的数据,最后汇总结果,原本单台需要10秒的查询,现在1秒内就能完成;
- 副本备份:每个分片再备份1个“副本”,存储在其他服务器上,比如分片1存在服务器A,副本1存在服务器B,一旦服务器A宕机,服务器B能立即接管,保证检索功能不中断;
- 集群管理:有专门的“集群管理节点”,负责监控所有服务器(节点)的状态、分片的分配、请求的路由,确保整个集群正常运行。
简单来说:分布式搜索引擎 = 多个单体搜索引擎(如Lucene)+ 分布式集群管理 + 数据分片 + 副本机制,它解决了单体搜索引擎的“存储瓶颈、性能瓶颈、高可用风险”,同时保留了全文检索的核心能力(快速匹配、相关性排序)。
核心价值总结(干货):
- 海量存储:支持PB级数据存储,通过分片突破单节点存储限制;
- 高效检索:基于倒排索引,实现毫秒级全文检索、复杂条件查询;
- 高可用:通过副本机制,避免单节点故障导致服务中断;
- 高并发:支持万级QPS查询,通过集群并行处理提升吞吐量;
- 可扩展:集群节点可动态增减,分片可灵活调整,适配业务增长。
二、底层核心逻辑:搞懂这5个点,才算真正入门
分布式搜索引擎的所有功能(分片、副本、查询、索引),都基于以下5个核心底层逻辑,搞懂这些,再看任何分布式搜索引擎(ES、Solr、PolarDB-X Search)的用法,都会豁然开朗。
2.1 倒排索引:分布式搜索引擎的“检索灵魂”(最核心)
倒排索引是分布式搜索引擎实现“快速全文检索”的核心,也是和传统数据库“正排索引”的本质区别。
很多开发者听过倒排索引,但说不清楚它的原理,这里用“通俗语言+实例”,彻底讲透。
2.1.1 正排索引 vs 倒排索引(通俗对比)
先看传统数据库的“正排索引”(比如MySQL的主键索引):
- 正排索引:以“文档ID”为key,以“文档内容”为value,相当于“书的目录”——根据目录(文档ID),找到对应的书页(文档内容)。 比如: 文档1:书名《Java编程思想》,作者 Bruce Eckel,简介 本书是Java领域经典书籍... 文档2:书名《Java核心技术》,作者 Cay S. Horstmann,简介 适合Java初学者... 正排索引就是:1→文档1内容,2→文档2内容。
问题:如果要查询“Java编程”相关的文档,只能遍历所有文档ID,逐个比对文档内容,效率极低(就像找一本书,不看目录,逐页翻)。
再看倒排索引:
- 倒排索引:以“关键词”为key,以“包含该关键词的文档ID列表”为value,相当于“书的索引页”——根据关键词(索引页),找到对应的书页(文档ID)。 还是上面的两个文档,先对文档内容进行“分词”(把句子拆成独立的关键词),再构建倒排索引: 关键词“Java”:对应文档1、文档2(两个文档都包含Java) 关键词“编程”:对应文档1(文档1有“编程”,文档2没有) 关键词“思想”:对应文档1 关键词“核心”:对应文档2 关键词“初学者”:对应文档2
优势:查询“Java编程”时,先找到关键词“Java”对应的文档(1、2),再找到关键词“编程”对应的文档(1),取交集(文档1),就能快速找到匹配的文档,无需遍历所有文档(就像找“Java编程”相关的内容,直接查索引页,找到对应的书页)。
2.1.2 倒排索引的底层结构(3个核心组件)
倒排索引不是简单的“关键词→文档ID列表”,而是由3个核心组件组成,缺一不可,直接决定了检索效率和相关性排序的准确性:
1. 词典(Dictionary)
- 作用:存储所有“去重后的关键词”,相当于“索引页的目录”,方便快速定位关键词对应的 postings list(文档ID列表)。
- 通俗理解:就像书的索引页,先列出所有关键词,每个关键词对应一个页码范围(postings list)。
- 底层实现:为了提升查询效率,词典不会用普通的数组/链表存储,而是用“跳表”(Skip List)或“B+树”实现——跳表支持O(log n)的查找效率,适合高频查询场景(ES的词典就是用跳表实现的)。
2. 倒排列表(Postings List)
- 作用:存储“包含某个关键词的所有文档ID”,以及该关键词在文档中的“位置、频率”(用于相关性排序)。
- 实例:还是以“Java”关键词为例,其倒排列表可能是这样的: 文档1:位置(书名第1个词)、频率(出现2次) 文档2:位置(书名第1个词)、频率(出现1次)
- 关键:倒排列表中的文档ID会按“从小到大”排序,并且会存储“词频(TF)”“逆文档频率(IDF)”等信息(后面讲相关性排序会用到),用于计算文档和查询的匹配度。
3. 字段存储(Field Store)
- 作用:存储文档的原始字段内容(比如书名、作者、简介),用于查询结果的展示(比如用户查询后,需要显示完整的图书信息)。
- 注意:倒排列表只存储文档ID和关键词的位置/频率,不存储原始内容,原始内容需要单独存储在字段存储中,这样既能减少倒排索引的体积,又能保证查询结果的完整性。
2.1.3 倒排索引的构建流程
倒排索引的构建是“离线构建+在线更新”的过程,核心流程分为5步,用流程图展示:
各步骤详细说明
假设我们有3条原始文档(图书数据),基于这3条文档,完整演示倒排索引的构建过程:
原始文档: 文档1(ID=1):title:《Java编程思想》,author:Bruce Eckel,intro:Java编程思想是Java领域的经典书籍,适合有一定基础的Java开发者。 文档2(ID=2):title:《Java核心技术》,author:Cay S. Horstmann,intro:Java核心技术涵盖Java基础语法和高级特性,适合Java初学者。 文档3(ID=3):title:《Python编程入门》,author:张三,intro:Python编程入门简单易懂,适合零基础学习者,与Java编程有本质区别。
步骤1:文档解析(Document Parsing)
- 作用:将原始文档(可能是JSON、XML、数据库表等格式)解析成“结构化文档”,提取出需要索引的字段(比如title、author、intro),忽略无关字段(比如创建时间、修改时间,若不需要检索)。
- 解析后结果(结构化文档): 文档1:{id:1, title:"Java编程思想", author:"Bruce Eckel", intro:"Java编程思想是Java领域的经典书籍,适合有一定基础的Java开发者。"} 文档2:{id:2, title:"Java核心技术", author:"Cay S. Horstmann", intro:"Java核心技术涵盖Java基础语法和高级特性,适合Java初学者。"} 文档3:{id:3, title:"Python编程入门", author:"张三", intro:"Python编程入门简单易懂,适合零基础学习者,与Java编程有本质区别。"}
步骤2:分词处理(Tokenization)
- 作用:将每个字段的文本内容,拆分成“独立的词条(关键词)”,这个过程叫“分词”。
- 关键:分词的质量直接影响检索准确性,比如中文分词需要处理“歧义”(比如“Java编程”不能拆成“Java”“编程”两个独立词条,否则查询“Java编程”时会匹配到“Java”和“编程”分开出现的文档)。
- 常用分词器:中文用IK分词器(ES默认分词器对中文不友好,会把中文拆成单个字),英文用Standard分词器(按空格、标点拆分)。
- 分词结果(以title和intro字段为例,author字段暂不分词,仅用于精确匹配): 文档1 title分词:Java、编程、思想 文档1 intro分词:Java、编程、思想、Java、领域、经典、书籍、适合、有、一定、基础、Java、开发者 文档2 title分词:Java、核心、技术 文档2 intro分词:Java、核心、技术、涵盖、Java、基础、语法、高级、特性、适合、Java、初学者 文档3 title分词:Python、编程、入门 文档3 intro分词:Python、编程、入门、简单、易懂、适合、零基础、学习者、与、Java、编程、有、本质、区别
步骤3:词条过滤(Token Filtering)
- 作用:对分词后的词条进行“清洗”,过滤掉无用词条,提升索引质量和检索效率,主要做3件事:
- 去停用词:过滤掉无意义的词(比如“的、是、和、与、有”),这些词对检索无帮助,还会增加索引体积;
- 大小写统一:英文词条统一转小写(比如“Java”转“java”),避免“Java”和“java”被当作两个不同的词条;
- 去重:同一字段中,相同的词条只保留一个(比如文档1 intro中的“Java”出现3次,过滤后只保留1次,词频单独记录)。
- 过滤后结果: 文档1 title分词:java、编程、思想 文档1 intro分词:java、编程、思想、领域、经典、书籍、适合、一定、基础、开发者 文档2 title分词:java、核心、技术 文档2 intro分词:java、核心、技术、涵盖、基础、语法、高级、特性、适合、初学者 文档3 title分词:python、编程、入门 文档3 intro分词:python、编程、入门、简单、易懂、适合、零基础、学习者、java、本质、区别
步骤4:倒排索引构建(Inverted Index Building)
- 作用:根据过滤后的词条,构建“词典+倒排列表+字段存储”,核心是“词条→文档ID列表”的映射。
- 构建结果(核心部分):
- 词典(部分):java、编程、思想、核心、技术、python、入门、领域、经典...
- 倒排列表(部分):
- java:[(文档1, title:1次、intro:3次), (文档2, title:1次、intro:3次), (文档3, intro:1次)]
- 编程:[(文档1, title:1次、intro:1次), (文档3, title:1次、intro:2次)]
- 思想:[(文档1, title:1次、intro:1次)]
- 核心:[(文档2, title:1次、intro:1次)]
- python:[(文档3, title:1次、intro:1次)]
- 字段存储:存储3个文档的原始title、author、intro内容,用于查询结果展示。
步骤5:索引优化(Index Optimization)
- 作用:优化倒排索引的结构,提升检索效率,核心做2件事:
- 词典压缩:对词典中的词条进行压缩(比如用前缀压缩,“java编程”和“java核心”,前缀“java”只存储一次),减少内存占用;
- 倒排列表压缩:对倒排列表中的文档ID、词频等信息进行压缩(比如用差值编码,文档ID为1、2、3,差值为1、1,存储差值即可),减少磁盘占用。
步骤6:索引存储(Index Storage)
- 作用:将优化后的倒排索引,存储在磁盘(持久化)和内存(缓存)中——内存中存储词典和热点倒排列表(提升查询速度),磁盘中存储完整的倒排索引和字段存储(保证数据不丢失)。
2.1.4 倒排索引的更新机制(避免全量重建)
如果每次新增/修改/删除文档,都重新构建整个倒排索引,效率会极低(比如10亿条数据,全量重建可能需要几小时)。因此,分布式搜索引擎采用“增量更新”机制,核心是“分段索引(Segment)”:
- 分段索引:将整个倒排索引,分成多个“小的分段索引”(比如每个分段存储100万条文档的索引),每个分段都是一个独立的倒排索引,可单独查询、更新、删除;
- 新增文档:新增文档时,不会修改已有的分段索引,而是新建一个“临时分段”,将新增文档的索引存储在临时分段中,然后定期将临时分段合并到已有的分段中(后台异步合并,不影响查询);
- 修改文档:分布式搜索引擎中,“修改文档”本质是“删除旧文档+新增新文档”——先给旧文档打一个“删除标记”(不会立即删除旧分段中的索引),然后将修改后的文档新增到临时分段中,合并分段时,再彻底删除带有删除标记的文档索引;
- 删除文档:不会立即删除分段中的索引,只是给文档打一个“删除标记”,查询时会过滤掉带有删除标记的文档,合并分段时再彻底删除。
这种机制的优势:无需全量重建索引,新增/修改/删除文档的效率极高,同时不影响查询性能(查询时只需遍历所有分段,合并结果)。
2.2 数据分片:分布式存储的“核心手段”
分布式搜索引擎要支撑PB级数据存储,核心是“分片(Shard)”——将海量数据拆分成多个小的“数据分片”,每个分片存储一部分数据,分布在不同的节点(服务器)上,实现“分而治之”。
很多开发者会把“分片”和“副本”搞混,这里先明确:分片负责“数据拆分”(解决存储瓶颈),副本负责“数据备份”(解决高可用瓶颈) ,两者分工明确,缺一不可。
2.2.1 分片的核心原理
用架构图展示分片的分布逻辑,清晰明了:
核心说明:
- 主分片(Primary Shard):核心分片,负责“数据的写入、修改、删除”,每个文档只能存储在一个主分片中,主分片的数量在集群创建时指定,后续无法修改(因为主分片数量决定了数据的拆分规则);
- 副本分片(Replica Shard):主分片的备份,负责“数据的查询”和“主分片故障时的容错”,每个主分片可以有多个副本(默认1个),副本的数量可以后续动态调整;
- 分片分布规则:主分片和其副本分片,不会存储在同一个节点上(避免单节点宕机,主分片和副本同时丢失),比如主分片1在节点1,副本1就在节点3;
- 数据分配规则:文档写入时,会根据“文档ID的哈希值”计算出该文档应该存储在哪个主分片上(哈希值%主分片数量),确保数据均匀分布在各个主分片上,避免某个分片数据过多(数据倾斜)。
2.2.2 分片的数量选择
很多开发者在搭建分布式搜索引擎集群时,不知道“主分片数量设多少合适”,这里给出明确的实战建议(基于ES实战经验,有理有据):
- 核心原则:主分片数量 = 集群中“可用于存储数据的节点数” × 每个节点可承载的主分片数(一般每个节点最多承载3-5个主分片,避免单个节点压力过大);
- 具体建议:
- 小型集群(3-5个节点):主分片数量设为6-10个(每个节点承载2-3个主分片);
- 中型集群(10-20个节点):主分片数量设为20-30个(每个节点承载2-3个主分片);
- 大型集群(20个以上节点):主分片数量设为50-100个(每个节点承载3-5个主分片);
- 禁忌:
- 主分片数量不要太少(比如1-2个):无法实现数据均匀分布,单个分片数据量过大,查询和写入性能都会下降;
- 主分片数量不要太多(比如100个以上):集群管理成本增加,查询时需要遍历更多分片,合并结果的效率下降;
- 关键提醒:主分片数量一旦确定,后续无法修改(除非重建集群),因此在搭建集群时,要结合业务未来1-2年的数据增长情况,合理规划主分片数量。
2.2.3 分片路由机制(查询/写入如何找到对应分片?)
分布式搜索引擎中,无论是“写入文档”还是“查询文档”,都需要先找到“文档对应的分片”,这个过程叫“分片路由”,核心是“确定文档属于哪个主分片”。
路由流程(流程图):
详细说明(结合实例):
假设集群中主分片数量为3(分片ID:0、1、2),文档ID为“java_book_123”,路由过程如下:
- 客户端发起“写入文档”请求,携带文档ID“java_book_123”;
- 集群节点获取文档ID,对其进行哈希计算(比如用ES的默认哈希算法:MurmurHash3),得到哈希值(假设为123456);
- 用哈希值对主分片数量(3)取模,123456 % 3 = 0,得到分片ID为0;
- 找到分片ID为0的主分片(假设在节点1上),将文档写入该主分片;
- 主分片写入成功后,同步数据到其副本分片(假设在节点2上),副本同步成功后,返回“写入成功”给客户端。
特殊情况:自定义路由字段
默认情况下,路由字段是“文档ID”,但在某些场景下(比如“按作者分组查询”),可以自定义路由字段(比如“author”),这样同一作者的所有文档,都会存储在同一个分片上,提升查询效率。
实例(ES的自定义路由请求,Java代码后续实战会讲):
PUT /book/_doc/java_book_123?routing=BruceEckel
{
"title": "Java编程思想",
"author": "Bruce Eckel",
"price": 89.0
}
说明:用“author”作为路由字段,同一作者(BruceEckel)的所有文档,都会存储在同一个分片上,查询该作者的文档时,只需查询一个分片,提升效率。
2.3 副本机制:高可用的“保障”
分布式搜索引擎的“高可用”,核心靠副本机制——每个主分片都有一个或多个副本分片,当主分片所在的节点宕机时,副本分片会自动升级为主分片,继续提供服务,避免服务中断。
2.3.1 副本的核心作用(2个核心,无废话)
- 容错:主分片所在节点宕机时,副本分片自动接管,保证服务不中断(比如主分片1在节点1,节点1宕机,副本1在节点3,节点3上的副本1升级为主分片,继续处理请求);
- 提升查询性能:副本分片可以处理查询请求,主分片只负责写入/修改/删除操作,这样查询请求可以分散到多个副本分片上,提升集群的查询吞吐量(比如万级并发查询,主分片处理不过来,副本分片可以分担压力)。
2.3.2 副本的分布规则(实战重点)
副本的分布规则,直接影响集群的高可用和性能,核心有3条规则(必须遵守):
- 主分片和其副本分片,不能存储在同一个节点上(避免单节点宕机,主分片和副本同时丢失);
- 同一个主分片的多个副本分片,不能存储在同一个节点上(避免单节点宕机,多个副本同时丢失);
- 副本分片的数量,不能超过集群节点的数量-1(比如集群有3个节点,副本数量最多为2,因为每个副本需要存储在不同的节点上)。
2.3.3 故障转移流程(主分片宕机后,如何恢复?)
当主分片所在的节点宕机时,集群会自动触发“故障转移”流程,确保服务不中断,流程如下(流程图+详细说明):
详细说明(结合实例):
假设集群节点1(主分片0、副本2)、节点2(主分片1、副本0)、节点3(主分片2、副本1),主分片0所在的节点1宕机,故障转移流程如下:
- 节点1宕机,集群的“主节点”(后面会讲主节点和数据节点的区别)通过心跳检测(默认每1秒检测一次),发现节点1失联,标记节点1为“不可用”;
- 主节点发现主分片0(在节点1上)不可用,查看该主分片的副本分片(副本0在节点2上);
- 主节点选举节点2上的副本0,升级为主分片0,负责处理所有针对分片0的请求;
- 新主分片0(节点2上)接管请求后,主节点重新分配副本分片——在节点3上新建一个副本0(主分片0的副本),确保主分片0有一个副本;
- 节点1恢复后,会自动加入集群,主节点会将其上面的旧主分片0,降级为副本分片,同步新主分片0的数据,集群恢复正常。
2.3.4 副本数量的选择(实战建议)
副本数量不是越多越好,要结合集群节点数量和业务需求,给出明确的实战建议:
- 核心原则:副本数量 = 集群节点数量 - 1(最多),一般建议副本数量为1-2个;
- 具体建议:
- 小型集群(3个节点):副本数量设为1(每个主分片1个副本,总共3主3副,6个分片,每个节点承载2个分片);
- 中型集群(10个节点):副本数量设为1-2个(根据查询并发量调整,并发高则设为2);
- 大型集群(20个以上节点):副本数量设为2(确保主分片宕机后,有多个副本可以选举,提升容错能力);
- 禁忌:
- 副本数量不要为0:没有副本,主分片宕机后,数据丢失,服务中断;
- 副本数量不要过多(比如超过3个):副本过多会增加数据同步的压力(主分片写入后,需要同步到多个副本),占用更多的磁盘和内存,降低写入性能。
2.4 集群架构:分布式协作的“大脑”
分布式搜索引擎的集群,不是“多个节点简单叠加”,而是有明确的节点分工,核心分为3类节点(主节点、数据节点、协调节点),每个节点各司其职,确保集群正常运行。
2.4.1 集群节点分工
用架构图展示集群节点的分工:
1. 主节点(Master Node):集群的“管理者”
- 核心职责(只负责管理,不负责数据存储和查询执行):
- 集群管理:维护集群状态(节点状态、分片状态),管理集群配置(比如主分片数量、副本数量);
- 节点监控:通过心跳检测,监控所有节点的状态,发现节点宕机时,触发故障转移;
- 分片分配:负责将分片(主分片、副本分片)分配到各个数据节点上,确保数据均匀分布;
- 故障转移:主分片宕机时,选举副本分片升级为主分片,重新分配副本分片。
- 实战建议:
- 主节点不要存储数据(避免管理压力和数据压力叠加),单独部署1-3个主节点(奇数个,用于主节点选举,防止脑裂);
- 小型集群(3个节点):可以让数据节点同时充当主节点(节省服务器资源),但生产环境不推荐;
- 生产环境:单独部署3个主节点(仅负责管理),确保集群管理的高可用(一个主节点宕机,另外两个可以正常工作,选举新的主节点)。
2. 数据节点(Data Node):集群的“存储和计算节点”
- 核心职责(负责数据存储和查询/写入执行,是集群的核心工作节点):
- 存储分片:存储主分片和副本分片的数据(倒排索引、字段存储);
- 索引构建:对写入的文档,构建倒排索引(分段索引);
- 执行操作:执行客户端发起的查询、写入、修改、删除请求;
- 数据同步:主分片的数据,同步到副本分片。
- 实战建议:
- 数据节点的数量,根据数据量和并发量调整(小型集群3-5个,中型10-20个,大型20个以上);
- 数据节点的硬件配置:重点提升CPU(索引构建、查询计算)、内存(缓存词典和热点索引)、磁盘(存储数据和索引,推荐用SSD,提升读写速度)。
3. 协调节点(Coordinating Node):集群的“网关”
- 核心职责(只负责请求的路由和结果合并,不负责数据存储和管理):
- 接收客户端请求:接收客户端发起的所有查询、写入请求;
- 请求路由:将写入请求路由到对应的主分片,将查询请求路由到对应的主分片/副本分片;
- 结果合并:查询请求时,收集所有分片返回的结果,进行合并、排序、过滤,得到最终结果;
- 返回结果:将最终结果返回给客户端。
- 实战建议:
- 小型集群:可以让数据节点同时充当协调节点(节省服务器资源);
- 大型集群(高并发场景):单独部署协调节点(3-5个),专门处理请求的路由和结果合并,提升集群的并发处理能力。
2.4.2 主节点选举机制(避免脑裂,实战重点)
主节点是集群的“管理者”,一旦主节点宕机,需要选举新的主节点,确保集群正常运行。主节点选举的核心是“分布式一致性协议”(比如ES用的是“Bully算法”,简化版的Raft协议)。
核心选举流程:
关键说明(避免脑裂):
- 选举条件:只有“有资格成为主节点”的节点(配置文件中node.master: true),才能参与选举;
- 投票规则:每个节点只能投一票,投票给“优先级最高”的节点(优先级由配置文件中的node.master.priority决定,默认都是1);
- 脑裂预防:
- 部署奇数个主节点(3个最佳),确保选举时能产生“多数票”(比如3个主节点,至少需要2票才能当选,避免两个节点同时当选主节点);
- 配置“最小主节点数”(discovery.zen.minimum_master_nodes),比如3个主节点,最小主节点数设为2,确保只有当至少2个主节点存活时,才能选举新的主节点,避免单个节点“脑裂”(认为自己是主节点,其他节点都宕机了)。
2.4.3 集群的运行流程(完整闭环,结合实例)
结合前面的分片、副本、节点分工,完整梳理集群的运行流程(以“客户端查询图书”为例):
假设集群架构:3个主节点(node-master-1、node-master-2、node-master-3)、5个数据节点(node-data-1至node-data-5)、3个协调节点(node-coord-1至node-coord-3),主分片数量3,副本数量1。
运行流程:
- 客户端(Java程序)发起“查询title包含Java编程的图书”请求,发送到协调节点node-coord-1;
- 协调节点node-coord-1解析请求,确定需要查询所有主分片/副本分片(因为查询条件是全文检索,需要遍历所有分片);
- 协调节点根据分片分布情况,将查询请求路由到各个数据节点的分片上(比如分片0的主节点在node-data-1,副本在node-data-2;分片1的主节点在node-data-3,副本在node-data-4;分片2的主节点在node-data-5,副本在node-data-1);
- 各个分片执行查询操作(基于倒排索引,快速匹配文档),将查询结果(文档ID、相关性得分)返回给协调节点node-coord-1;
- 协调节点node-coord-1收集所有分片的查询结果,进行“结果合并”(去重、按相关性得分排序),得到最终结果;
- 协调节点根据文档ID,从字段存储中获取文档的原始内容(title、author、price等),组装成完整的查询结果;
- 协调节点将查询结果返回给客户端,客户端展示给用户。
2.4 分布式事务:数据一致性的“保障”
分布式搜索引擎的“数据一致性”,核心是“确保主分片和副本分片的数据同步一致”,以及“多个分片之间的数据操作一致性”。但由于分布式系统的特性(网络延迟、节点宕机),无法实现“强一致性”(比如MySQL的事务ACID),只能实现“最终一致性”。
2.4.1 最终一致性的通俗理解
最终一致性:客户端写入数据后,主分片立即写入成功,但副本分片可能需要一定时间(毫秒级)才能同步到数据,在同步完成前,查询副本分片可能会得到旧数据,但最终(同步完成后),主分片和副本分片的数据会保持一致。
比如:客户端写入文档A到主分片0,主分片0写入成功后,立即返回“写入成功”给客户端,但副本分片0还未同步到文档A,此时客户端查询副本分片0,会查不到文档A,但10毫秒后,副本分片0同步完成,再查询就能查到文档A,这就是“最终一致性”。
2.4.2 数据同步机制(主分片→副本分片)
主分片和副本分片的数据同步,采用“异步同步+确认机制”,流程如下:
关键说明(确保数据不丢失):
- 主分片写入数据时,会先写入“内存缓冲区”和“事务日志(Translog)”,然后返回“写入成功”给客户端——事务日志是持久化的,即使主分片宕机,重启后也能从事务日志中恢复数据,避免数据丢失;
- 主分片写入成功后,异步将数据同步到副本分片,副本分片写入数据时,同样会写入“内存缓冲区”和“事务日志”;
- 副本分片同步成功后,返回“同步成功”给主分片,主分片记录该副本的同步状态;
- 如果主分片宕机,而副本分片还未同步数据,新主分片(原副本分片)会从主分片的事务日志中,恢复未同步的数据,确保数据不丢失。
2.4.3 分布式事务的实战场景(结合Java代码)
在实际业务中,经常会遇到“数据库和分布式搜索引擎的数据一致性”问题(比如“新增图书”,既要写入MySQL,也要写入ES,确保两者数据一致),此时需要实现“分布式事务”,核心方案是“本地消息表+最终一致性”(最常用、最可靠的方案)。
方案流程(流程图):
详细说明(结合实例):
假设业务场景:新增图书时,先写入MySQL,再写入ES,确保两者数据一致,采用“本地消息表+RabbitMQ”实现分布式事务,步骤如下:
- MySQL中创建“图书表”和“本地消息表”,SQL语句(MySQL 8.0,可直接执行):
-- 图书表
CREATE TABLE `book` (
`id` varchar(64) NOT NULL COMMENT '图书ID',
`title` varchar(255) NOT NULL COMMENT '书名',
`author` varchar(100) NOT NULL COMMENT '作者',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`intro` text COMMENT '简介',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_author` (`author`) COMMENT '作者索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';
-- 本地消息表(用于分布式事务)
CREATE TABLE `local_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID',
`business_type` varchar(50) NOT NULL COMMENT '业务类型(如BOOK_ADD:新增图书)',
`business_id` varchar(64) NOT NULL COMMENT '业务ID(图书ID)',
`message` text NOT NULL COMMENT '消息内容(JSON格式)',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '消息状态:0-未发送,1-已发送,2-已消费,3-消费失败',
`retry_count` int NOT NULL DEFAULT 0 COMMENT '重试次数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_business_id` (`business_id`),
KEY `idx_status_retry_count` (`status`,`retry_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';
- 客户端发起“新增图书”请求,Java代码(SpringBoot3+MyBatisPlus,JDK17,可直接编译运行):
- 第一步:MySQL本地事务,写入图书表和本地消息表;
- 第二步:消息队列消费消息,写入ES;
- 第三步:ES写入成功后,删除本地消息表中的消息;
- 第四步:如果ES写入失败,消息队列重试,重试次数达到上限后,标记消息状态为“消费失败”,人工介入处理。
具体Java代码,后续实战部分会完整给出(包含依赖、实体类、mapper、service、controller),确保可直接编译运行。
2.5 相关性排序:让“最匹配”的结果排在前面
分布式搜索引擎的核心优势之一,是“相关性排序”——查询时,不仅能找到匹配的文档,还能根据“文档和查询的匹配度”排序,让最匹配的文档排在前面(比如用户查询“Java编程思想”,优先显示书名完全匹配的文档,再显示简介包含的文档)。
相关性排序的核心,是“TF-IDF算法”(Term Frequency-Inverse Document Frequency,词频-逆文档频率),通俗来说,就是“关键词在文档中出现的频率越高,同时在所有文档中出现的频率越低,该文档的相关性得分越高”。
2.5.1 TF-IDF算法原理(通俗+公式+实例)
1. 词频(TF):关键词在当前文档中出现的频率
- 定义:关键词在当前文档中出现的次数,除以当前文档的总词条数(避免文档长度影响得分)。
- 公式:TF = 关键词在当前文档中出现的次数 / 当前文档的总词条数
- 实例:文档1(title:Java编程思想,intro:Java编程思想是经典书籍),总词条数为5(java、编程、思想、经典、书籍),关键词“java”出现2次,TF = 2/5 = 0.4。
2. 逆文档频率(IDF):关键词在所有文档中出现的频率的反比
- 定义:所有文档的数量,除以包含该关键词的文档数量,再取对数(避免文档数量过多影响得分)。
- 公式:IDF = log(总文档数 / (包含该关键词的文档数 + 1))(+1是为了避免分母为0)
- 实例:总文档数为3,包含关键词“java”的文档数为2,IDF = log(3/(2+1)) = log(1) = 0。
3. 相关性得分(TF-IDF):TF × IDF
- 定义:词频和逆文档频率的乘积,得分越高,文档和查询的匹配度越高。
- 实例:文档1的关键词“java”的TF=0.4,IDF=0,TF-IDF=0.4×0=0;文档2的关键词“java”的TF=0.3,IDF=0,TF-IDF=0.3×0;文档3的关键词“java”的TF=0.1,IDF=0,TF-IDF=0.1×0——此时,关键词“java”的得分相同,会再结合“关键词位置”(比如标题中的关键词得分高于简介中的)、“词频密度”等因素,调整最终得分。
2.5.2 相关性排序的补充因素(实战重点)
除了TF-IDF算法,分布式搜索引擎还会结合以下因素,调整相关性得分,让排序更合理:
- 关键词位置:关键词出现在标题中的得分,高于出现在简介中的得分(比如用户查询“Java编程思想”,书名完全匹配的文档,得分更高);
- 关键词密度:关键词在文档中出现的密度(均匀分布 vs 集中出现),均匀分布的得分更高;
- 文档新鲜度:对于时间敏感的场景(比如新闻检索),最新的文档得分更高;
- 文档权重:手动给重要的文档设置更高的权重(比如电商场景中,销量高的商品,得分更高)。
2.5.3 实战:自定义相关性排序(ES实例)
在ES中,可以通过“function_score”查询,自定义相关性排序规则(比如结合TF-IDF和文档新鲜度),实例如下(Java代码后续实战会讲):
GET /book/_search
{
"query": {
"function_score": {
"query": {
"match": {
"title": "Java编程"
}
},
"functions": [
{
"tfidf": {
"field": "title",
"boost": 2.0
}
},
{
"gauss": {
"create_time": {
"scale": "30d",
"decay": 0.5
}
}
}
],
"boost_mode": "multiply"
}
},
"sort": [
{
"_score": {
"order": "desc"
}
}
]
}
说明:
- 查询“title包含Java编程”的文档;
- 用TF-IDF算法计算标题的相关性得分,权重为2.0(标题的得分翻倍);
- 用高斯函数(gauss)结合“create_time”(创建时间),30天内的文档得分更高,超过30天,得分衰减50%;
- 最终得分 = TF-IDF得分 × 时间衰减得分,按得分降序排序。
三、主流分布式搜索引擎对比(实战选型必备)
目前市面上主流的分布式搜索引擎有3种:Elasticsearch(ES)、Solr、PolarDB-X Search,很多开发者在选型时不知道该选哪种,这里从底层架构、性能、易用性、适用场景等维度,做详细对比,帮你快速选型(无废话,纯干货)。
3.1 核心对比表(清晰易懂,无空行)
| 对比维度 | Elasticsearch(ES) | Solr | PolarDB-X Search |
| 底层基础 | 基于Lucene,自研分布式架构 | 基于Lucene,分布式架构依赖第三方(如ZooKeeper) | 基于Lucene,阿里自研分布式架构(结合PolarDB-X) |
| 分布式能力 | 原生支持,无需第三方组件,集群管理、分片、副本、故障转移全自研 | 分布式依赖ZooKeeper,配置复杂,集群管理成本高 | 原生集成PolarDB-X分布式数据库,数据同步零成本 |
| 全文检索性能 | 近实时检索(毫秒级),高并发场景表现优异,动态扩容便捷 | 静态数据检索性能优异,实时数据写入性能弱于ES | 近实时检索,与分布式数据库联动性能拉满,适合电商、金融 |
| 易用性 | 开箱即用,API简洁,生态完善,文档丰富,社区活跃 | 配置繁琐,学习成本高,社区活跃度低 | 与阿里云生态深度集成,适合阿里技术栈用户,上手快 |
| 生态支持 | 支持日志分析、ELK栈、APM、向量检索(最新版支持AI向量) | 生态单一,主要用于传统全文检索场景,无向量检索能力 | 支持结构化+非结构化数据混合检索,兼容MySQL语法,学习成本极低 |
| 高可用 | 原生支持,故障转移自动完成,无需人工干预 | 依赖ZooKeeper,故障转移慢,易出现脑裂 | 原生高可用,结合PolarDB-X三副本机制,数据零丢失 |
| 适用场景 | 日志分析、全文检索、电商搜索、风控、AI向量检索、时序数据 | 传统企业站内搜索、静态文档检索、低并发场景 | 电商商品搜索、金融数据检索、分布式数据库+搜索引擎一体化场景 |
| 最新稳定版 | 8.14.0 | 9.5.0 | 2.0.0 |
3.2 选型结论
- 90%的互联网/分布式场景:首选Elasticsearch 8.x,生态完善、高可用、高并发、支持向量检索,适配所有主流分布式架构;
- 传统企业静态文档检索:可选Solr,无需高并发,静态数据检索性能略优;
- 阿里技术栈+分布式数据库场景:首选PolarDB-X Search,数据同步无需额外开发,成本最低。 本文后续实战均基于Elasticsearch 8.14.0(最新稳定版)+SpringBoot3.2.5+JDK17,所有代码可直接编译运行。
四、Java实战:基于SpringBoot3+ES8.x搭建分布式搜索引擎
4.1 项目结构(标准分层)
com.jam.demo
├── config(ES配置、事务配置、Swagger配置)
├── controller(接口层)
├── entity(实体类)
├── mapper(MyBatisPlus mapper)
├── service(业务层)
│ └── impl(实现类)
├── util(工具类)
└── DemoApplication.java(启动类)
4.2 pom.xml核心依赖(最新稳定版)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>distributed-search-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>distributed-search-demo</name>
<description>Distributed Search Engine Demo</description>
<properties>
<java.version>17</java.version>
<elasticsearch.version>8.14.0</elasticsearch.version>
<lombok.version>1.18.30</lombok.version>
<fastjson2.version>2.0.32</fastjson2.version>
<guava.version>32.1.3-jre</guava.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<swagger.version>2.2.15</swagger.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-java-client</artifactId>
<version>${elasticsearch.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.3 application.yml配置(ES+MySQL+Swagger)
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/distributed_search?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
elasticsearch:
uris: http://localhost:9200
username: elastic
password: elastic
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.jam.demo.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
server:
port: 8080
4.4 MySQL表结构
CREATE DATABASE IF NOT EXISTS distributed_search DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE distributed_search;
CREATE TABLE `book` (
`id` varchar(64) NOT NULL COMMENT '图书ID',
`title` varchar(255) NOT NULL COMMENT '书名',
`author` varchar(100) NOT NULL COMMENT '作者',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`intro` text COMMENT '简介',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_author` (`author`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';
CREATE TABLE `local_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID',
`business_type` varchar(50) NOT NULL COMMENT '业务类型',
`business_id` varchar(64) NOT NULL COMMENT '业务ID',
`message` text NOT NULL COMMENT '消息内容',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未发送1已发送2已消费3失败',
`retry_count` int NOT NULL DEFAULT 0 COMMENT '重试次数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_business` (`business_type`,`business_id`),
KEY `idx_status` (`status`,`retry_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';
4.5 ES配置类
package com.jam.demo.config;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ES8.x配置类
* @author ken
*/
@Configuration
public class EsConfig {
@Value("${spring.elasticsearch.uris}")
private String esUris;
@Value("${spring.elasticsearch.username}")
private String username;
@Value("${spring.elasticsearch.password}")
private String password;
@Bean
public ElasticsearchClient elasticsearchClient() {
String[] uriArr = esUris.split(",");
HttpHost[] hosts = new HttpHost[uriArr.length];
for (int i = 0; i < uriArr.length; i++) {
String uri = uriArr[i].replace("http://", "").replace("https://", "");
String[] ipPort = uri.split(":");
hosts[i] = new HttpHost(ipPort[0], Integer.parseInt(ipPort[1]), "http");
}
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));
RestClient restClient = RestClient.builder(hosts).setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)).build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
4.6 实体类(Swagger3+Lombok)
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 图书实体
* @author ken
*/
@Data
@TableName("book")
@Schema(name = "Book", description = "图书信息")
public class Book {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "图书ID")
private String id;
@Schema(description = "书名")
private String title;
@Schema(description = "作者")
private String author;
@Schema(description = "价格")
private BigDecimal price;
@Schema(description = "简介")
private String intro;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "修改时间")
private LocalDateTime updateTime;
}
4.7 Mapper接口
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.Book;
import org.apache.ibatis.annotations.Mapper;
/**
* BookMapper
* @author ken
*/
@Mapper
public interface BookMapper extends BaseMapper<Book> {
}
4.8 Service层
package com.jam.demo.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.Book;
import org.springframework.transaction.TransactionStatus;
/**
* BookService
* @author ken
*/
public interface BookService extends IService<Book> {
/**
* 新增图书(MySQL+ES分布式事务)
* @param book 图书信息
* @return 结果
*/
boolean saveBook(Book book);
/**
* 全文检索图书
* @param keyword 关键词
* @return 结果
*/
Object searchBook(String keyword);
/**
* 编程式事务提交/回滚
* @param status 事务状态
* @param flag 成功标识
*/
void completeTransaction(TransactionStatus status, boolean flag);
}
4.9 Service实现类
package com.jam.demo.service.impl;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.IndexRequest;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.common.collect.Lists;
import com.jam.demo.entity.Book;
import com.jam.demo.mapper.BookMapper;
import com.jam.demo.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.List;
/**
* BookServiceImpl
* @author ken
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class BookServiceImpl extends ServiceImpl<BookMapper, Book> implements BookService {
private final ElasticsearchClient elasticsearchClient;
private final DataSourceTransactionManager transactionManager;
private static final String ES_INDEX = "book_index";
@Override
public boolean saveBook(Book book) {
if (ObjectUtils.isEmpty(book) || !StringUtils.hasText(book.getTitle()) || !StringUtils.hasText(book.getAuthor())) {
log.error("图书参数异常");
return false;
}
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(definition);
try {
boolean saveDb = this.save(book);
if (!saveDb) {
transactionManager.rollback(status);
return false;
}
IndexRequest<Book> request = IndexRequest.of(i -> i.index(ES_INDEX).id(book.getId()).document(book));
elasticsearchClient.index(request);
transactionManager.commit(status);
log.info("图书新增成功,ID:{}", book.getId());
return true;
} catch (Exception e) {
log.error("图书新增失败", e);
transactionManager.rollback(status);
return false;
}
}
@Override
public Object searchBook(String keyword) {
if (!StringUtils.hasText(keyword)) {
return Lists.newArrayList();
}
try {
SearchRequest request = SearchRequest.of(s -> s.index(ES_INDEX).query(q -> q.multiMatch(m -> m.fields("title", "intro", "author").query(keyword))));
SearchResponse<Book> response = elasticsearchClient.search(request, Book.class);
List<Hit<Book>> hits = response.hits().hits();
if (CollectionUtils.isEmpty(hits)) {
return Lists.newArrayList();
}
List<Book> result = Lists.newArrayList();
for (Hit<Book> hit : hits) {
result.add(hit.source());
}
log.info("检索关键词:{}, 结果数:{}", keyword, result.size());
return result;
} catch (IOException e) {
log.error("ES检索失败", e);
return Lists.newArrayList();
}
}
@Override
public void completeTransaction(TransactionStatus status, boolean flag) {
if (flag) {
if (!status.isCompleted()) {
transactionManager.commit(status);
}
} else {
if (!status.isCompleted()) {
transactionManager.rollback(status);
}
}
}
}
4.10 Controller层(Swagger3接口)
package com.jam.demo.controller;
import com.jam.demo.entity.Book;
import com.jam.demo.service.BookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* BookController
* @author ken
*/
@RestController
@RequestMapping("/book")
@RequiredArgsConstructor
@Tag(name = "图书检索接口", description = "分布式搜索引擎图书管理")
public class BookController {
private final BookService bookService;
@PostMapping("/save")
@Operation(summary = "新增图书", description = "新增图书并同步到ES")
public boolean saveBook(@RequestBody Book book) {
return bookService.saveBook(book);
}
@GetMapping("/search")
@Operation(summary = "全文检索图书", description = "基于ES全文检索")
public Object searchBook(@RequestParam String keyword) {
return bookService.searchBook(keyword);
}
}
4.11 启动类
package com.jam.demo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 启动类
* @author ken
*/
@SpringBootApplication
@MapperScan("com.jam.demo.mapper")
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
五、分布式搜索引擎性能优化(生产环境必备)
5.1 索引优化
- 合理设置分片数:3节点集群主分片数设6,副本数1,避免数据倾斜;
- 禁用不需要的字段:不需要检索的字段关闭索引,减少索引体积;
- 分段合并优化:ES后台自动合并分段,生产环境关闭自动合并,定时手动合并。
5.2 查询优化
- 精准限定字段:只查询需要的字段,禁止select *;
- 分页优化:深度分页用search_after,禁止使用from+size深度分页;
- 开启缓存:开启ES查询缓存,缓存热点查询结果。
5.3 集群优化
- 内存配置:ES堆内存设为机器内存的50%,最大不超过31GB;
- 节点分工:主节点只负责管理,数据节点负责存储,协调节点负责请求转发;
- 磁盘选型:生产环境必须使用SSD,机械硬盘无法支撑高并发检索。
六、常见问题及解决方案(避坑指南)
- ES脑裂问题:部署奇数个主节点,配置最小主节点数,避免脑裂;
- 数据不一致:使用本地消息表+最终一致性,确保MySQL与ES数据同步;
- 查询超时:优化查询语句,增加副本数,提升查询并发能力;
- 写入性能低:使用批量写入API,关闭实时刷新,异步同步副本。
七、总结
本文从底层逻辑出发,用通俗语言讲透分布式搜索引擎的倒排索引、分片、副本、集群架构、分布式事务、相关性排序核心原理,对比主流产品选型,覆盖生产环境优化与问题解决方案。分布式搜索引擎的核心是分而治之+最终一致性,掌握底层逻辑后,无论使用哪种搜索引擎,都能快速落地、高效调优、解决实际问题。