分布式搜索引擎:底层逻辑 + 实战

简介: 本文深入剖析分布式搜索引擎核心原理,涵盖倒排索引、分片机制、副本高可用、集群架构、分布式事务及相关性排序,结合ES 8.14+SpringBoot3实战,兼顾底层逻辑与生产优化,助开发者从“会用”进阶到“精通”。

在分布式系统横行的今天,“数据查询”早已不是简单的数据库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的问题很明显:

  1. 模糊查询(%开头)无法使用索引,只能全表扫描,1000万条数据扫描一次可能需要几秒甚至几十秒,用户体验极差;
  2. 无法实现“相关性排序”(比如优先显示书名完全匹配的,再显示简介包含的);
  3. 无法支持复杂查询(比如“Java编程 AND 2024年出版 AND 价格<100元”“Java编程 OR Python编程,排除入门级”);
  4. 当数据量涨到1亿条、10亿条,MySQL完全扛不住,即使分库分表,查询性能也会急剧下降。

场景2:用单体搜索引擎(如Lucene)

Lucene是一个“全文检索工具包”,核心能力是“构建倒排索引”(后面会详细讲),能实现快速全文检索、相关性排序。但它有一个致命缺陷:只能单机运行

也就是说,你只能把1000万条图书数据都存在一台服务器上,用Lucene构建索引、处理查询。这会带来两个问题:

  1. 存储瓶颈:一台服务器的硬盘、内存有限,无法存储PB级数据(比如10亿条图书数据,每条数据1KB,就需要100GB存储,加上索引,可能需要几百GB);
  2. 高可用风险:一旦这台服务器宕机,整个检索功能就瘫痪了;
  3. 性能瓶颈:单台服务器的CPU、内存有限,无法支撑万级并发查询(比如双11期间,大量用户同时查询图书)。

场景3:用分布式搜索引擎(如Elasticsearch)

分布式搜索引擎的核心思路是“分而治之+集群协作”:

  1. 分片存储:把1000万条图书数据,分成10个“分片”(每个分片100万条数据),分别存储在10台不同的服务器上;
  2. 并行查询:用户查询“Java编程”时,10台服务器同时查询自己分片上的数据,最后汇总结果,原本单台需要10秒的查询,现在1秒内就能完成;
  3. 副本备份:每个分片再备份1个“副本”,存储在其他服务器上,比如分片1存在服务器A,副本1存在服务器B,一旦服务器A宕机,服务器B能立即接管,保证检索功能不中断;
  4. 集群管理:有专门的“集群管理节点”,负责监控所有服务器(节点)的状态、分片的分配、请求的路由,确保整个集群正常运行。

简单来说:分布式搜索引擎 = 多个单体搜索引擎(如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件事:
  1. 去停用词:过滤掉无意义的词(比如“的、是、和、与、有”),这些词对检索无帮助,还会增加索引体积;
  2. 大小写统一:英文词条统一转小写(比如“Java”转“java”),避免“Java”和“java”被当作两个不同的词条;
  3. 去重:同一字段中,相同的词条只保留一个(比如文档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列表”的映射。
  • 构建结果(核心部分):
  1. 词典(部分):java、编程、思想、核心、技术、python、入门、领域、经典...
  2. 倒排列表(部分):
  • 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次)]
  1. 字段存储:存储3个文档的原始title、author、intro内容,用于查询结果展示。
步骤5:索引优化(Index Optimization)
  • 作用:优化倒排索引的结构,提升检索效率,核心做2件事:
  1. 词典压缩:对词典中的词条进行压缩(比如用前缀压缩,“java编程”和“java核心”,前缀“java”只存储一次),减少内存占用;
  2. 倒排列表压缩:对倒排列表中的文档ID、词频等信息进行压缩(比如用差值编码,文档ID为1、2、3,差值为1、1,存储差值即可),减少磁盘占用。
步骤6:索引存储(Index Storage)
  • 作用:将优化后的倒排索引,存储在磁盘(持久化)和内存(缓存)中——内存中存储词典和热点倒排列表(提升查询速度),磁盘中存储完整的倒排索引和字段存储(保证数据不丢失)。

2.1.4 倒排索引的更新机制(避免全量重建)

如果每次新增/修改/删除文档,都重新构建整个倒排索引,效率会极低(比如10亿条数据,全量重建可能需要几小时)。因此,分布式搜索引擎采用“增量更新”机制,核心是“分段索引(Segment)”:

  1. 分段索引:将整个倒排索引,分成多个“小的分段索引”(比如每个分段存储100万条文档的索引),每个分段都是一个独立的倒排索引,可单独查询、更新、删除;
  2. 新增文档:新增文档时,不会修改已有的分段索引,而是新建一个“临时分段”,将新增文档的索引存储在临时分段中,然后定期将临时分段合并到已有的分段中(后台异步合并,不影响查询);
  3. 修改文档:分布式搜索引擎中,“修改文档”本质是“删除旧文档+新增新文档”——先给旧文档打一个“删除标记”(不会立即删除旧分段中的索引),然后将修改后的文档新增到临时分段中,合并分段时,再彻底删除带有删除标记的文档索引;
  4. 删除文档:不会立即删除分段中的索引,只是给文档打一个“删除标记”,查询时会过滤掉带有删除标记的文档,合并分段时再彻底删除。

这种机制的优势:无需全量重建索引,新增/修改/删除文档的效率极高,同时不影响查询性能(查询时只需遍历所有分段,合并结果)。

2.2 数据分片:分布式存储的“核心手段”

分布式搜索引擎要支撑PB级数据存储,核心是“分片(Shard)”——将海量数据拆分成多个小的“数据分片”,每个分片存储一部分数据,分布在不同的节点(服务器)上,实现“分而治之”。

很多开发者会把“分片”和“副本”搞混,这里先明确:分片负责“数据拆分”(解决存储瓶颈),副本负责“数据备份”(解决高可用瓶颈) ,两者分工明确,缺一不可。

2.2.1 分片的核心原理

用架构图展示分片的分布逻辑,清晰明了:

核心说明:
  1. 主分片(Primary Shard):核心分片,负责“数据的写入、修改、删除”,每个文档只能存储在一个主分片中,主分片的数量在集群创建时指定,后续无法修改(因为主分片数量决定了数据的拆分规则);
  2. 副本分片(Replica Shard):主分片的备份,负责“数据的查询”和“主分片故障时的容错”,每个主分片可以有多个副本(默认1个),副本的数量可以后续动态调整;
  3. 分片分布规则:主分片和其副本分片,不会存储在同一个节点上(避免单节点宕机,主分片和副本同时丢失),比如主分片1在节点1,副本1就在节点3;
  4. 数据分配规则:文档写入时,会根据“文档ID的哈希值”计算出该文档应该存储在哪个主分片上(哈希值%主分片数量),确保数据均匀分布在各个主分片上,避免某个分片数据过多(数据倾斜)。

2.2.2 分片的数量选择

很多开发者在搭建分布式搜索引擎集群时,不知道“主分片数量设多少合适”,这里给出明确的实战建议(基于ES实战经验,有理有据):

  1. 核心原则:主分片数量 = 集群中“可用于存储数据的节点数” × 每个节点可承载的主分片数(一般每个节点最多承载3-5个主分片,避免单个节点压力过大);
  2. 具体建议:
  • 小型集群(3-5个节点):主分片数量设为6-10个(每个节点承载2-3个主分片);
  • 中型集群(10-20个节点):主分片数量设为20-30个(每个节点承载2-3个主分片);
  • 大型集群(20个以上节点):主分片数量设为50-100个(每个节点承载3-5个主分片);
  1. 禁忌:
  • 主分片数量不要太少(比如1-2个):无法实现数据均匀分布,单个分片数据量过大,查询和写入性能都会下降;
  • 主分片数量不要太多(比如100个以上):集群管理成本增加,查询时需要遍历更多分片,合并结果的效率下降;
  1. 关键提醒:主分片数量一旦确定,后续无法修改(除非重建集群),因此在搭建集群时,要结合业务未来1-2年的数据增长情况,合理规划主分片数量。

2.2.3 分片路由机制(查询/写入如何找到对应分片?)

分布式搜索引擎中,无论是“写入文档”还是“查询文档”,都需要先找到“文档对应的分片”,这个过程叫“分片路由”,核心是“确定文档属于哪个主分片”。

路由流程(流程图):

详细说明(结合实例):

假设集群中主分片数量为3(分片ID:0、1、2),文档ID为“java_book_123”,路由过程如下:

  1. 客户端发起“写入文档”请求,携带文档ID“java_book_123”;
  2. 集群节点获取文档ID,对其进行哈希计算(比如用ES的默认哈希算法:MurmurHash3),得到哈希值(假设为123456);
  3. 用哈希值对主分片数量(3)取模,123456 % 3 = 0,得到分片ID为0;
  4. 找到分片ID为0的主分片(假设在节点1上),将文档写入该主分片;
  5. 主分片写入成功后,同步数据到其副本分片(假设在节点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宕机,副本1在节点3,节点3上的副本1升级为主分片,继续处理请求);
  2. 提升查询性能:副本分片可以处理查询请求,主分片只负责写入/修改/删除操作,这样查询请求可以分散到多个副本分片上,提升集群的查询吞吐量(比如万级并发查询,主分片处理不过来,副本分片可以分担压力)。

2.3.2 副本的分布规则(实战重点)

副本的分布规则,直接影响集群的高可用和性能,核心有3条规则(必须遵守):

  1. 主分片和其副本分片,不能存储在同一个节点上(避免单节点宕机,主分片和副本同时丢失);
  2. 同一个主分片的多个副本分片,不能存储在同一个节点上(避免单节点宕机,多个副本同时丢失);
  3. 副本分片的数量,不能超过集群节点的数量-1(比如集群有3个节点,副本数量最多为2,因为每个副本需要存储在不同的节点上)。

2.3.3 故障转移流程(主分片宕机后,如何恢复?)

当主分片所在的节点宕机时,集群会自动触发“故障转移”流程,确保服务不中断,流程如下(流程图+详细说明):

详细说明(结合实例):

假设集群节点1(主分片0、副本2)、节点2(主分片1、副本0)、节点3(主分片2、副本1),主分片0所在的节点1宕机,故障转移流程如下:

  1. 节点1宕机,集群的“主节点”(后面会讲主节点和数据节点的区别)通过心跳检测(默认每1秒检测一次),发现节点1失联,标记节点1为“不可用”;
  2. 主节点发现主分片0(在节点1上)不可用,查看该主分片的副本分片(副本0在节点2上);
  3. 主节点选举节点2上的副本0,升级为主分片0,负责处理所有针对分片0的请求;
  4. 新主分片0(节点2上)接管请求后,主节点重新分配副本分片——在节点3上新建一个副本0(主分片0的副本),确保主分片0有一个副本;
  5. 节点1恢复后,会自动加入集群,主节点会将其上面的旧主分片0,降级为副本分片,同步新主分片0的数据,集群恢复正常。

2.3.4 副本数量的选择(实战建议)

副本数量不是越多越好,要结合集群节点数量和业务需求,给出明确的实战建议:

  1. 核心原则:副本数量 = 集群节点数量 - 1(最多),一般建议副本数量为1-2个;
  2. 具体建议:
  • 小型集群(3个节点):副本数量设为1(每个主分片1个副本,总共3主3副,6个分片,每个节点承载2个分片);
  • 中型集群(10个节点):副本数量设为1-2个(根据查询并发量调整,并发高则设为2);
  • 大型集群(20个以上节点):副本数量设为2(确保主分片宕机后,有多个副本可以选举,提升容错能力);
  1. 禁忌:
  • 副本数量不要为0:没有副本,主分片宕机后,数据丢失,服务中断;
  • 副本数量不要过多(比如超过3个):副本过多会增加数据同步的压力(主分片写入后,需要同步到多个副本),占用更多的磁盘和内存,降低写入性能。

2.4 集群架构:分布式协作的“大脑”

分布式搜索引擎的集群,不是“多个节点简单叠加”,而是有明确的节点分工,核心分为3类节点(主节点、数据节点、协调节点),每个节点各司其职,确保集群正常运行。

2.4.1 集群节点分工

用架构图展示集群节点的分工:

1. 主节点(Master Node):集群的“管理者”
  • 核心职责(只负责管理,不负责数据存储和查询执行):
  1. 集群管理:维护集群状态(节点状态、分片状态),管理集群配置(比如主分片数量、副本数量);
  2. 节点监控:通过心跳检测,监控所有节点的状态,发现节点宕机时,触发故障转移;
  3. 分片分配:负责将分片(主分片、副本分片)分配到各个数据节点上,确保数据均匀分布;
  4. 故障转移:主分片宕机时,选举副本分片升级为主分片,重新分配副本分片。
  • 实战建议:
  • 主节点不要存储数据(避免管理压力和数据压力叠加),单独部署1-3个主节点(奇数个,用于主节点选举,防止脑裂);
  • 小型集群(3个节点):可以让数据节点同时充当主节点(节省服务器资源),但生产环境不推荐;
  • 生产环境:单独部署3个主节点(仅负责管理),确保集群管理的高可用(一个主节点宕机,另外两个可以正常工作,选举新的主节点)。
2. 数据节点(Data Node):集群的“存储和计算节点”
  • 核心职责(负责数据存储和查询/写入执行,是集群的核心工作节点):
  1. 存储分片:存储主分片和副本分片的数据(倒排索引、字段存储);
  2. 索引构建:对写入的文档,构建倒排索引(分段索引);
  3. 执行操作:执行客户端发起的查询、写入、修改、删除请求;
  4. 数据同步:主分片的数据,同步到副本分片。
  • 实战建议:
  • 数据节点的数量,根据数据量和并发量调整(小型集群3-5个,中型10-20个,大型20个以上);
  • 数据节点的硬件配置:重点提升CPU(索引构建、查询计算)、内存(缓存词典和热点索引)、磁盘(存储数据和索引,推荐用SSD,提升读写速度)。
3. 协调节点(Coordinating Node):集群的“网关”
  • 核心职责(只负责请求的路由和结果合并,不负责数据存储和管理):
  1. 接收客户端请求:接收客户端发起的所有查询、写入请求;
  2. 请求路由:将写入请求路由到对应的主分片,将查询请求路由到对应的主分片/副本分片;
  3. 结果合并:查询请求时,收集所有分片返回的结果,进行合并、排序、过滤,得到最终结果;
  4. 返回结果:将最终结果返回给客户端。
  • 实战建议:
  • 小型集群:可以让数据节点同时充当协调节点(节省服务器资源);
  • 大型集群(高并发场景):单独部署协调节点(3-5个),专门处理请求的路由和结果合并,提升集群的并发处理能力。

2.4.2 主节点选举机制(避免脑裂,实战重点)

主节点是集群的“管理者”,一旦主节点宕机,需要选举新的主节点,确保集群正常运行。主节点选举的核心是“分布式一致性协议”(比如ES用的是“Bully算法”,简化版的Raft协议)。

核心选举流程:

关键说明(避免脑裂):
  1. 选举条件:只有“有资格成为主节点”的节点(配置文件中node.master: true),才能参与选举;
  2. 投票规则:每个节点只能投一票,投票给“优先级最高”的节点(优先级由配置文件中的node.master.priority决定,默认都是1);
  3. 脑裂预防:
  • 部署奇数个主节点(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。

运行流程:

  1. 客户端(Java程序)发起“查询title包含Java编程的图书”请求,发送到协调节点node-coord-1;
  2. 协调节点node-coord-1解析请求,确定需要查询所有主分片/副本分片(因为查询条件是全文检索,需要遍历所有分片);
  3. 协调节点根据分片分布情况,将查询请求路由到各个数据节点的分片上(比如分片0的主节点在node-data-1,副本在node-data-2;分片1的主节点在node-data-3,副本在node-data-4;分片2的主节点在node-data-5,副本在node-data-1);
  4. 各个分片执行查询操作(基于倒排索引,快速匹配文档),将查询结果(文档ID、相关性得分)返回给协调节点node-coord-1;
  5. 协调节点node-coord-1收集所有分片的查询结果,进行“结果合并”(去重、按相关性得分排序),得到最终结果;
  6. 协调节点根据文档ID,从字段存储中获取文档的原始内容(title、author、price等),组装成完整的查询结果;
  7. 协调节点将查询结果返回给客户端,客户端展示给用户。

2.4 分布式事务:数据一致性的“保障”

分布式搜索引擎的“数据一致性”,核心是“确保主分片和副本分片的数据同步一致”,以及“多个分片之间的数据操作一致性”。但由于分布式系统的特性(网络延迟、节点宕机),无法实现“强一致性”(比如MySQL的事务ACID),只能实现“最终一致性”。

2.4.1 最终一致性的通俗理解

最终一致性:客户端写入数据后,主分片立即写入成功,但副本分片可能需要一定时间(毫秒级)才能同步到数据,在同步完成前,查询副本分片可能会得到旧数据,但最终(同步完成后),主分片和副本分片的数据会保持一致。

比如:客户端写入文档A到主分片0,主分片0写入成功后,立即返回“写入成功”给客户端,但副本分片0还未同步到文档A,此时客户端查询副本分片0,会查不到文档A,但10毫秒后,副本分片0同步完成,再查询就能查到文档A,这就是“最终一致性”。

2.4.2 数据同步机制(主分片→副本分片)

主分片和副本分片的数据同步,采用“异步同步+确认机制”,流程如下:

关键说明(确保数据不丢失):
  1. 主分片写入数据时,会先写入“内存缓冲区”和“事务日志(Translog)”,然后返回“写入成功”给客户端——事务日志是持久化的,即使主分片宕机,重启后也能从事务日志中恢复数据,避免数据丢失;
  2. 主分片写入成功后,异步将数据同步到副本分片,副本分片写入数据时,同样会写入“内存缓冲区”和“事务日志”;
  3. 副本分片同步成功后,返回“同步成功”给主分片,主分片记录该副本的同步状态;
  4. 如果主分片宕机,而副本分片还未同步数据,新主分片(原副本分片)会从主分片的事务日志中,恢复未同步的数据,确保数据不丢失。

2.4.3 分布式事务的实战场景(结合Java代码)

在实际业务中,经常会遇到“数据库和分布式搜索引擎的数据一致性”问题(比如“新增图书”,既要写入MySQL,也要写入ES,确保两者数据一致),此时需要实现“分布式事务”,核心方案是“本地消息表+最终一致性”(最常用、最可靠的方案)。

方案流程(流程图):

详细说明(结合实例):

假设业务场景:新增图书时,先写入MySQL,再写入ES,确保两者数据一致,采用“本地消息表+RabbitMQ”实现分布式事务,步骤如下:

  1. 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='本地消息表';

  1. 客户端发起“新增图书”请求,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算法,分布式搜索引擎还会结合以下因素,调整相关性得分,让排序更合理:

  1. 关键词位置:关键词出现在标题中的得分,高于出现在简介中的得分(比如用户查询“Java编程思想”,书名完全匹配的文档,得分更高);
  2. 关键词密度:关键词在文档中出现的密度(均匀分布 vs 集中出现),均匀分布的得分更高;
  3. 文档新鲜度:对于时间敏感的场景(比如新闻检索),最新的文档得分更高;
  4. 文档权重:手动给重要的文档设置更高的权重(比如电商场景中,销量高的商品,得分更高)。

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 选型结论

  1. 90%的互联网/分布式场景:首选Elasticsearch 8.x,生态完善、高可用、高并发、支持向量检索,适配所有主流分布式架构;
  2. 传统企业静态文档检索:可选Solr,无需高并发,静态数据检索性能略优;
  3. 阿里技术栈+分布式数据库场景:首选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 索引优化

  1. 合理设置分片数:3节点集群主分片数设6,副本数1,避免数据倾斜;
  2. 禁用不需要的字段:不需要检索的字段关闭索引,减少索引体积;
  3. 分段合并优化:ES后台自动合并分段,生产环境关闭自动合并,定时手动合并。

5.2 查询优化

  1. 精准限定字段:只查询需要的字段,禁止select *;
  2. 分页优化:深度分页用search_after,禁止使用from+size深度分页;
  3. 开启缓存:开启ES查询缓存,缓存热点查询结果。

5.3 集群优化

  1. 内存配置:ES堆内存设为机器内存的50%,最大不超过31GB;
  2. 节点分工:主节点只负责管理,数据节点负责存储,协调节点负责请求转发;
  3. 磁盘选型:生产环境必须使用SSD,机械硬盘无法支撑高并发检索。

六、常见问题及解决方案(避坑指南)

  1. ES脑裂问题:部署奇数个主节点,配置最小主节点数,避免脑裂;
  2. 数据不一致:使用本地消息表+最终一致性,确保MySQL与ES数据同步;
  3. 查询超时:优化查询语句,增加副本数,提升查询并发能力;
  4. 写入性能低:使用批量写入API,关闭实时刷新,异步同步副本。

七、总结

本文从底层逻辑出发,用通俗语言讲透分布式搜索引擎的倒排索引、分片、副本、集群架构、分布式事务、相关性排序核心原理,对比主流产品选型,覆盖生产环境优化与问题解决方案。分布式搜索引擎的核心是分而治之+最终一致性,掌握底层逻辑后,无论使用哪种搜索引擎,都能快速落地、高效调优、解决实际问题。

目录
相关文章
|
1天前
|
消息中间件 NoSQL Java
百万消息积压 4 小时,我靠这套方案快速止血
本文针对分布式系统中百万级消息积压问题,提出了一套完整的解决方案。首先分析了消息积压的本质是生产速度超过消费速度,并阐述了积压的危害。随后详细介绍了&quot;紧急止血→根源排查→彻底解决→复盘优化&quot;的四步处理流程:通过暂停非核心生产者、扩容消费者、消息分流和跳过无效消息快速缓解积压;从消费端、生产端和队列配置三个维度排查根本原因;从架构、配置和代码层面提出长期优化方案;最后强调建立监控预警体系的重要性。文章提供了大量生产环境验证的代码示例和技术方案,帮助开发者系统性地解决消息积压问题。
28 5
|
存储 安全 算法
一文搞懂PKI/CA
一文搞懂PKI/CA
3303 0
一文搞懂PKI/CA
|
存储 缓存 NoSQL
阿里云 Tair KVCache 仿真分析:高精度的计算和缓存模拟设计与实现
阿里云 Tair 推出 KVCache-HiSim,首个高保真 LLM 推理仿真工具。在 CPU 上实现<5%误差的性能预测,成本仅为真实集群的1/39万,支持多级缓存建模与 SLO 约束下的配置优化,助力大模型高效部署。
|
9天前
|
JSON Java 数据格式
Feign 复杂对象参数传递避坑指南:从报错到优雅落地
本文深入剖析了SpringCloud Feign在复杂对象参数传递中的常见问题及解决方案。文章首先分析了GET请求传递复杂对象失败的底层原因,包括HTTP规范约束和Feign参数解析逻辑。针对GET场景,提供了四种解决方案:@SpringQueryMap(首选)、手动拆分属性+@RequestParam、MultiValueMap封装和自定义FeignEncoder,详细比较了各方案的优缺点和适用场景。对于POST场景,推荐使用@RequestBody注解传递JSON请求体。
133 5
postman 传入不同组参数循环调用接口
postman 传入不同组参数循环调用接口
1978 0
postman 传入不同组参数循环调用接口
|
2月前
|
缓存 NoSQL Java
多级缓存架构实战指南
本文详解如何利用装饰器模式实现多级缓存架构,通过Caffeine、Redis与MySQL三级联动,兼顾高性能与数据一致性。采用SpringBoot实战,代码可落地,有效解决高并发场景下的缓存穿透、击穿、雪崩问题,提升系统稳定性与扩展性。
130 1
|
2月前
|
网络协议 Java 数据安全/隐私保护
吃透OSI七层模型:从底层逻辑到实战落地,一文打通网络通信任督二脉
本文从“底层逻辑拆解+权威标准解读+可落地实战示例”三个维度,用通俗的语言讲透OSI七层模型的每一个细节。所有内容均参考ISO/IEC 7498-1官方标准(OSI模型的权威定义),核心论点100%有据可依;实战示例基于Java语言实现,确保可直接编译运行;同时针对易混淆技术点进行明确区分,帮你真正做到“知其然,更知其所以然”。
566 2
|
2月前
|
存储 缓存 Cloud Native
云原生数据库驱动企业架构革新:从架构设计到落地实践全指南
本文系统阐述基于云原生数据库的企业信息系统架构设计,涵盖核心认知、四大设计原则、主流组件选型及分层架构落地。结合可运行代码实例,深入解析从环境配置到高可用保障的全流程,并提供避坑指南与进阶优化方向,助力企业实现高性能、高可用、低成本的数字化架构升级。
217 2
|
机器学习/深度学习 人工智能 API
大模型推理服务全景图
国内大模型推理需求激增,性能提升的主战场将从训练转移到推理。
1492 97
|
3天前
|
Prometheus 监控 Cloud Native
Prometheus+Grafana:一站式搞定监控告警全链路
本文详解Prometheus+Grafana监控体系:从核心原理(时序数据、4类指标、Pull采集、PromQL)到完整实战,涵盖服务器、Spring Boot应用监控搭建、告警配置与生产优化,助你构建实时、可视化、可告警的分布式系统“生命线”。
74 4