多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot集成Elasticsearch实战:版本选型、索引建模与搜索优化

SpringBoot集成Elasticsearch实战:版本选型、索引建模与搜索优化 SpringBoot 项目里接入 Elasticsearch 这件事说难不难说简单也真不简单。我从第一次在 Windows 上把 ES 跑起来到后来在 SpringBoot 里用 Spring Data Elasticsearch 做商品搜索、日志检索中间踩过的坑可以绕公司工位一圈。这篇文章我就把整个集成过程从头到尾捋一遍包括环境选型、依赖配置、索引建模、DSL 查询组装以及部署上线时的注意事项全部用实际项目经验说话希望能帮你少走几个月的弯路。很多刚接触的人容易把 ES 当成一个“数据库”用它来存业务主数据这是个很要命的理解偏差。ES 本身是个分布式搜索与分析引擎它的强项是“检索”和“聚合”不是数据一致性。我在项目里一贯的原则是MySQL 负责存储事实ES 负责检索和统计两边通过 MQ 或者定时任务做数据同步。这么干的好处是职责清晰坏处是需要多写一套同步逻辑但长期看非常值得。你要是刚开始做集成也建议先把这个架构边界想清楚再动手写代码。1. 先从业务说起为什么放着 MySQL 不用非要上 ES1.1 拿 MySQL 硬扛搜索的痛点到底在哪很多小型项目一开始都靠 MySQL 的 LIKE 查询撑场面比如商品列表页的关键词搜索SELECT * FROM product WHERE name LIKE %手机%;数据量小的时候没啥感觉一旦表里有个几百万行加上 “%关键词%” 这种无法走索引的查询数据库瞬间就待不住了。更难受的是LIKE 查询不支持分词用户搜“华为手机”的时候你没法命中“华为 手机”这种由两个词组合出来的结果排序也基本靠猜。另一个经常被忽视的问题是MySQL 很难做“相关性排序”。搜索引擎的核心价值在于把最匹配用户意图的文档排在最前面这涉及词频、逆文档频率、向量空间模型等一堆算法这些是数据库的 LIKE 做不了的。ES 的 BM25 相关性算法开箱即用排序效果比 LIKE 高出一个维度。1.2 ES 真正擅长的事以及不该用它干的事ES 适合做的场景有这么几类一是站内搜索电商商品、博客文章、企业知识库二是日志与指标分析配合 Logstash 或 Filebeat 做集中式日志检索三是推荐系统里的“基于标签或向量的相似度召回”四是大量数据的聚合统计比如按天、按类目统计 PV/UV。但 ES 不适合做事务型操作不适合做强一致性的业务数据存储。数据写入 ES 后需要一小段时间才能被搜到这个叫“近实时”。你要是在 ES 里存订单金额然后拿它做财务对账那纯粹是给自己找麻烦。另外ES 的更新本质上是“先删后写”频繁更新压力很大。所以我在项目里只把 ES 当索引库用业务增删改走 MySQL再异步同步到 ES。2. 版本匹配SpringBoot 与 ES 的“婚姻法”2.1 版本配不对连启动都是奢望如果你去搜索引擎搜 SpringBoot 集成 ES 的教程十有八九会遇到版本混乱的问题。这事的根源在于 ES 官方客户端和服务端之间有严格的版本兼容性要求而 Spring Data Elasticsearch 对 ES 版本的适配又慢半拍。举个例子SpringBoot 3.x 默认引入的 Spring Data Elasticsearch 5.x 对应的是 ES 8.x你要是把 ES 服务端装成 7.17客户端就报版本错误。反过来SpringBoot 2.7.x 用的是 Spring Data Elasticsearch 4.4.x它适配的是 ES 7.17。这个错位经常让新手一头雾水明明代码对着教程敲的一跑就报NoNodeAvailableException。我在项目里一般这么定版本基线SpringBoot 版本Spring Data Elasticsearch适配 ES 服务端备注2.7.x4.4.x7.17.x最稳定的组合生产环境主力2.6.x4.2.x7.15.x旧项目维护期在使用3.0.x~3.2.x5.x8.x新项目推荐API 有变化2.2 一个简明的版本选择策略如果你项目刚起步我建议直接上 SpringBoot 3.2 ES 8.11这套组合生命周期长技术栈新。如果你还在用一个存量 SpringBoot 2.x 项目就选 2.7 7.17性价比最高踩坑资料也最多。这里有个重要的经验ES 服务端版本一旦定了尽量不要随便升级。ES 小版本升级倒是还好大版本升级7 到 8意味着 API 命名空间变化、RestHighLevelClient 被淘汰、新版 Java API Client 的使用方式不同这相当于把数据访问层重写一遍。所以上线前就把版本锁死后面少受罪。3. 工程搭建与基础配置3.1 用 Maven 添加依赖注意排除冲突项在 SpringBoot 项目里引入 ES 依赖其实很简单不需要额外指定版本号由 SpringBoot 父工程统一管理。以 SpringBoot 2.7.x 为例pom.xml 里加这么一段dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-elasticsearch/artifactId /dependency这个 starter 会帮你把 Spring Data Elasticsearch 和底层传输依赖都带进来。不过我遇到过一个问题如果你的项目里同时有spring-boot-starter-web和 ES 的依赖个别情况下会引入重复的jakarta.json相关类跑起来报错。解决办法是在 ES 依赖里做 exclusions把 json 相关的 jar 剔除这个坑不算常见但遇到一次能卡掉半天时间。如果你的项目用的是 Gradle那就在 build.gradle 里加一行implementation org.springframework.boot:spring-boot-starter-data-elasticsearchGradle 项目里尤其要注意依赖冲突建议装上 Gradle 的 dependency insight 插件排查冲突非常方便。3.2 application.yml 配置到底该写哪些东西配置文件是很多人容易写错的地方。Spring Data Elasticsearch 在 SpringBoot 2.x 和 3.x 里的配置前缀和字段名有差异。我以 2.7 为例spring: elasticsearch: uris: http://localhost:9200 username: elastic password: 你的密码早期版本用的是spring.elasticsearch.rest.uris后来版本改成了spring.elasticsearch.uris。如果你照着旧教程写配置新版会直接启动失败日志提示配置无法绑定。需要注意Spring Boot 2.7 里 Elasticsearch 的配置项并不统一管理。如果你用 Spring Data Elasticsearch 的ElasticsearchRestTemplate它读的是spring.elasticsearch.rest.uris如果你用原生的 RestHighLevelClient就需要自己写一个配置类来注入连接。我在项目里的习惯是统一走ElasticsearchRestTemplate少写一套配置类。如果是新版本 SpringBoot 3.x配置风格又变了官方推荐使用 Java Client 的ElasticsearchClient配置类也需要显式声明Configuration public class ElasticsearchConfig { Bean public ElasticsearchClient elasticsearchClient(RestClient restClient) { return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper())); } }3.3 生产环境连接配置的加分项连接池和超时时间这些参数新手一般不会去动它但实际一上线就会遇到问题。ES 客户端默认的连接池大小、socket 超时都不太适合高并发场景我一般会显式配置一下RestClientBuilder builder RestClient.builder( new HttpHost(localhost, 9200, http) ).setRequestConfigCallback(requestConfigBuilder - requestConfigBuilder .setConnectTimeout(5000) .setSocketTimeout(60000) ).setMaxRetryTimeoutMillis(30000);socket 超时为什么调大因为 ES 在聚合大结果集或做深度分页时响应时间可能远超默认值。尤其是在一次查询涉及大量分片的时候协调节点需要合并各分片结果稍有不慎就把客户端超时干爆了。4. 数据建模与索引操作实战4.1 用 Document 注解建立实体与索引的映射Spring Data Elasticsearch 最核心的就是实体类加注解让它自动映射到 ES 索引。我这里用一个商品搜索的例子来说请记住ES 里的“索引”约等于 MySQL 里的“表”“文档”约等于“一行记录”“字段”约等于“列”。Data Document(indexName product_index, createIndex true) public class Product { Id private String id; Field(type FieldType.Keyword) private String skuCode; Field(type FieldType.Text, analyzer ik_max_word, searchAnalyzer ik_max_word) private String name; Field(type FieldType.Text, analyzer ik_max_word) private String description; Field(type FieldType.Double) private BigDecimal price; Field(type FieldType.Keyword) private String category; Field(type FieldType.Date, format DateFormat.date_time) private LocalDateTime createTime; }Document里的createIndex属性比较贴心启动时如果没有索引会自动创建。但别掉以轻心生产环境我是不让你自动创建的因为自动创建的 mapping 往往不符合真实业务需求。比如你要自定义分词器、要配 dynamic 策略、要设置字段 copy_to这些都得通过手动执行 mapping 文件来搞定。Field里的FieldType选择是个技术活。字符串字段有两种Text和Keyword。Text会被分词器拆分成多个词项用于全文检索Keyword不会分词用于精确匹配、排序、聚合。这两个用错了检索效果天差地别。比如商品名称必须用Text才能支持搜索“华为手机”但商品编码 skuCode就只能用Keyword否则你搜一条精确的 sku 会被拆成好几段。4.2 Repository 层与基础增删改查Spring Data Elasticsearch 和 Spring Data JPA 长得非常像你只要定义好接口CRUD 方法和简单查询方法都能自动生成public interface ProductRepository extends ElasticsearchRepositoryProduct, String { ListProduct findByName(String name); PageProduct findByCategory(String category, Pageable pageable); }写几个单元测试验证一下增删改查Autowired private ProductRepository productRepository; Test public void testInsert() { Product product new Product(); product.setId(P1001); product.setSkuCode(SKU-1001); product.setName(华为 Mate 60 Pro 手机); product.setDescription(高端旗舰手机支持卫星通话); product.setPrice(new BigDecimal(6999.00)); product.setCategory(数码产品); product.setCreateTime(LocalDateTime.now()); productRepository.save(product); } Test public void testUpdate() { // save 方法在文档存在时执行的是更新 OptionalProduct product productRepository.findById(P1001); product.ifPresent(p - { p.setPrice(new BigDecimal(6499.00)); productRepository.save(p); }); }这里有个坑我要特别提醒save方法在 ES 里是 upsert 语义你传入的文档不带 id 字段时ES 会自动生成一个随机 id。如果你更新时没把完整对象传进去原本的字段会被新文档覆盖掉导致数据丢失。所以更新操作尽量先查出来再改不要 new 一个部分字段的对象直接 save。4.3 手动管理索引的几种姿势实体注解自动建索引虽然方便但改动 mapping 后它不会自动同步。我一个项目里就吃过亏改了字段的 analyzer结果索引里还是旧的分词配置搜索空结果。后续我基本都走手动管理推荐两种方式一种是在项目启动时写一个 ApplicationRunner检测索引是否存在不存在就执行 mapping 文件创建索引。mapping 文件放在 resources 下内容大致这样{ mappings: { properties: { skuCode: { type: keyword }, name: { type: text, analyzer: ik_max_word, search_analyzer: ik_max_word } } } }另一种是用ElasticsearchRestTemplate的 API 来建索引。你在项目里封装一个索引管理服务统一负责 createIndex、updateMapping、deleteIndex 这些操作。这个方案适合有专门搜索工程团队维护的场景架构清晰。5. 查询 DSL 的 Java 构建艺术5.1 BoolQueryBuilder 三大金刚must、should、filterES 的查询 DSL 是 JSON 格式的好在 Spring Data Elasticsearch 提供了 Java 构建器类型安全且易读。最有用的就是BoolQueryBuilder它把多个子查询组合到一起逻辑上就是 AND、OR、NOT。我用一个实际场景来演示商品搜索页用户搜索“手机”要求品牌是“华为”价格区间 3000 到 8000有库存的商品。Autowired private ElasticsearchRestTemplate elasticsearchRestTemplate; public PageProduct searchProducts(String keyword, String brand, double minPrice, double maxPrice, int page, int size) { BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); // 关键词匹配 name 和 description 字段 boolQuery.must(QueryBuilders.multiMatchQuery(keyword, name, description)); // 品牌精确过滤 boolQuery.filter(QueryBuilders.termQuery(brand, brand)); // 价格区间过滤 boolQuery.filter(QueryBuilders.rangeQuery(price).gte(minPrice).lte(maxPrice)); // 库存大于0 boolQuery.filter(QueryBuilders.rangeQuery(stock).gt(0)); NativeSearchQuery searchQuery new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(PageRequest.of(page, size)) .build(); SearchHitsProduct searchHits elasticsearchRestTemplate.search(searchQuery, Product.class); return new PageImpl(searchHits.stream().map(SearchHit::getContent).toList(), PageRequest.of(page, size), searchHits.getTotalHits()); }这里区分must和filter非常重要。must会影响相关性评分filter只是过滤、不影响分数。像价格、库存、品牌这些业务过滤条件放 filter 里性能更好因为 ES 会缓存这些过滤结果下次同样的条件直接走缓存。你的查询要是把这些都塞 must 里不但慢了相关性排序还会被无关条件干扰。5.2 match 和 term 的区别一定要吃透matchQuery会把输入的文本分词然后逐个词项去匹配termQuery则是把整个输入当成一个词项精确匹配。我见过很多人把这两个混用查出来的结果莫名其妙。举个例子搜索字段是 Text 类型的 name内容为“华为 Mate 60 Pro 手机”。如果你用termQuery(name, 华为手机)ES 会拿“华为手机”这个完整词去倒排索引里找而倒排索引里存的可能是“华为”“手机”这些分词后的词项结果就是什么都匹配不到。正确做法是用matchQuery或者multiMatchQuery让 ES 先把搜索词分词再匹配。反过来如果你对 Keyword 类型字段做全文搜索也搜不到完整匹配之外的结果。所以查询类型要和字段类型配合这个意识要建立起来Text 配 matchKeyword 配 term。5.3 高亮、排序、分页的组合拳搜索列表页基本都要做关键词高亮把命中的词用红色标记。Spring Data Elasticsearch 里高亮也要在查询时指定HighlightBuilder.Field highlightField new HighlightBuilder.Field(name) .preTags(span classhighlight) .postTags(/span); NativeSearchQuery searchQuery new NativeSearchQueryBuilder() .withQuery(QueryBuilders.multiMatchQuery(keyword, name)) .withHighlightFields(highlightField) .build(); SearchHitsProduct searchHits elasticsearchRestTemplate.search(searchQuery, Product.class); for (SearchHitProduct hit : searchHits) { ListString highlightFields hit.getHighlightFields().get(name); if (highlightFields ! null !highlightFields.isEmpty()) { // 用高亮片段替换原始内容 product.setName(highlightFields.get(0)); } }排序这里要注意ES 默认按相关性分数_score降序这也是搜索引擎和普通数据库查询的基本差异。如果你想按价格排序需要单独设置NativeSearchQueryBuilder queryBuilder new NativeSearchQueryBuilder() .withSort(SortBuilders.fieldSort(price).order(SortOrder.DESC));分页的话默认 max_result_window 是 10000超过这个限制的深分页直接报错。如果业务上确实要查很多页优先考虑用search_after或 scroll而不是简单加大 max_result_window。这个参数调整起来很危险会导致 OOM。6. 中文分词方案IK 分词器与自定义词典6.1 Windows 上安装 IK 分词器其实就两步很多国内项目的痛点是 ES 默认的分词器对中文支持很差它会把“中华人民共和国”拆成一个个单字。要解决这个问题我推荐用 IK 分词器它是目前使用最广泛的中文分词插件。安装方法很简单去 GitHub 下载和你的 ES 版本完全一致的 IK 插件包比如elasticsearch-analysis-ik-7.17.0.zip。解压后把目录整个复制到 ES 安装目录的plugins/ik文件夹下然后重启 ES。在 Windows 上启动 ES 时你需要用命令窗口切到 ES 安装目录下的 bin 文件夹运行elasticsearch.bat。如果你下载的是 zip 包首次启动如果报权限或内存错误可以去 config 里的 jvm.options 检查内存设置Windows 上很容易因为默认堆内存设置过大导致启动失败。用text字段设置ik_max_word分词粒度后再用下面这个接口测试效果curl -X POST localhost:9200/_analyze -H Content-Type: application/json -d {\analyzer\:\ik_max_word\,\text\:\华为Mate60Pro手机\}正常情况下会拆出“华为”“Mate60”“Pro”“手机”等词项。这样你的中文搜索体验才算合格。6.2 在索引 mapping 里配置 IK 分词器的正确姿势把插件装好不代表自动生效你必须在索引的 mapping 里显式指定 analyzer。我改造一下前面的映射{ settings: { analysis: { analyzer: { custom_analyzer: { type: custom, tokenizer: ik_max_word, filter: [lowercase] } } } }, mappings: { properties: { name: { type: text, analyzer: custom_analyzer, search_analyzer: custom_analyzer } } } }索引创建后再改 analyzer 是不生效的必须重建索引然后重新灌数据。这又是一个我在项目里花过大代价换来的教训。如果你是在已经创建好的索引上加 IK只能先删索引再按新 mapping 建期间会有短暂的搜索不可用。6.3 project 里用 HanLP 做更复杂分词的经验如果项目涉及领域专有名词特别多比如医疗、法律、装备制造IK 的默认词典可能不够用。除了给 IK 扩展自定义词典外你也可以考虑接 HanLP。HanLP 是一个功能强大的 NLP 工具包里面有很多预训练模型我在一个装备制造项目里用它做细粒度分词和关键词抽取效果比 IK 好不少。在 SpringBoot 里接 HanLP 主要是在搜索前把查询词先经过 HanLP 分词再把分词结果拼成 query。我在那个项目里就是这么做的用户输入“液压缸密封圈更换”HanLP 拆出“液压缸”“密封圈”“更换”然后再用 multiMatchQuery 对这几个词加权匹配。但注意HanLP 本身是个较大的工具包启动时加载模型会占用内存部署环境内存不够的小机器慎用。7. 部署与日常运维那些事7.1 Windows 本地启动 ES 的常见尴尬之前提到你需要在命令行里启动 ES 而不是双击 bat 文件否则日志一闪而过看不到问题。启动后测试连通性curl http://localhost:9200正常情况下会返回一个 JSON 对象里面包含 cluster_name、version.number 等信息。看到 version 你就心里有数客户端版本要匹配。Windows 上新手遇到最多的问题是“内存不足无法启动”。因为 ES 默认堆内存是 4G如果你的笔记本只有 8G 内存直接启动就炸。解决方式打开config/jvm.options把-Xms4g -Xmx4g改成-Xms1g -Xmx1g另外一个冷门问题Windows 上文件路径和权限不对会报java.nio.file.AccessDeniedException。如果你把 ES 解压到了有权限限制的目录比如 Program Files建议换个纯英文且无空格目录。7.2 Docker 部署 ES 与 SpringBoot 的联动生产环境我一般推荐用 Docker 部署 ES干净且便于管理。一个最小可用的部署命令长这样docker run -d \ --name elasticsearch \ -p 9200:9200 -p 9300:9300 \ -e discovery.typesingle-node \ -e ES_JAVA_OPTS-Xms2g -Xmx2g \ -v es_data:/usr/share/elasticsearch/data \ docker.elastic.co/elasticsearch/elasticsearch:7.17.0SpringBoot 服务如果也在 Docker 里那你的 application.yml 中 ES 地址就不能写 localhost得写容器名或服务名。用 docker-compose 编排时SpringBoot 容器和 ES 容器放在同一网络直接写elasticsearch:9200就行。我还遇到过在宝塔面板的 Docker 管理里部署 SpringBoot 和 ES 的情况注意一定要把 ES 的 9200 端口映射出来同时关闭宿主机的防火墙限制。宝塔面板自带的防火墙界面里要放行 9200 端口否则外网访问不到。7.3 生产环境必调的三个参数第一个是堆内存大小建议设置为物理内存的一半但不能超过 32G。ES 的 JVM 堆内存设置既要够用也不能太大因为堆外内存用于文件缓存和堆内存要平衡。第二个是bootstrap.memory_lock: true这个配置锁定内存防止交换到磁盘对性能影响明显。第三个是thread_pool.search.queue_size如果业务上搜索请求突增适当调大搜索线程池的队列能减少EsRejectedExecutionException错误。8. 常见问题与排查技巧实录混合这几年踩的坑我把最典型的几个整理成了表格方便你快速定位错误现象根本原因解决方案NoNodeAvailableException: no nodes available客户端地址配错或 ES 没起来检查 application.yml 的 uriscurl 一下 9200 端口version mismatch 错误客户端版本和服务端版本不一致统一 Spring Data Elasticsearch 与 ES 版本mapper_parsing_exception索引 mapping 类型和写入数据类型冲突删索引、修正 mapping 后重建再重新写入max_result_window is exceeded分页太深超过默认 10000 限制改用 search_after 或 scroll API400 illegal_argument_exception fielddata对 Text 字段执行聚合或排序在 mapping 里对字段设置 keyword 子字段用于聚合中文搜不出来完整词没装分词插件或 analyzer 配置错误安装 IK检查索引的 analyzer 配置查询结果排序不符合预期match 和 term 混用或 filter 影响排序sure正确使用 must/filter检查类型数据同步延迟导致搜不到刚写入的数据ES 近实时特性默认 refresh 间隔 1s可以根据需求调整 refresh_interval但别太频繁排错的基本思路先从服务端日志入手ES 的日志在logs/目录下然后用_cat/indices接口看索引状态再用_search接口直接调试 DSL排除 Java 层的问题。很多时候你以为是代码问题最后发现是索引里的数据压根没同步过来这个问题属于集成过程中的“真气运行不畅”。所谓数据同步我提一句不管用 MQ 也好、定时任务全量重建也好、还是用 Logstash 数据管道也好关键是同步链路要有失败重试和日志告警。否则线上搜索数据和数据库对不上排查起来特别被动。9. 几个值得长期养成的工程习惯最后分享几个我在实际项目中反复验证过的习惯。第一ES 相关的代码要尽量封装成独立的 service 层不要散落在 controller 里。我习惯建一个search/包里面放实体类、Repository、DSL 构建专门类。搜索逻辑本身已经很复杂了如果再和业务逻辑混在一起后续维护会让你怀疑人生。第二所有的 mapping 变更要走版本控制。mapping 文件放到项目 resources 目录下和代码一起走 Git标签版本对齐。我见过团队直接在笔记本上删索引改 mapping结果生产环境和开发环境不一致搜索行为截然不同。第三要用好中文分词器千万不要只停留在默认配置。IK 的自定义词典实践很值得做把业务上的专有名词、竞品品牌、用户高频搜索词维护进字典往往能直接提升搜索结果的命中率。这个优化往往比你调整评分公式来得快。第四一定要盯住 ES 的慢查询和错误日志。ES 的index.search.slowlog阈值可以调低到几百毫秒把超过阈值的查询全部打点。你才知道哪些 DSL 是真正的性能杀手。聚合类查询经常会出现“看似很快实际很重”的情况没有日志很难发现。第五集成 ES 只是开始设置好索引的副本数、分片数规划也早做。分片数在索引创建后就不能改了副本数还可以动态调整。分片策略的基础原则是单分片控制在 30-50G 以内主分片数尽量和节点数成倍数关系。没有人一开始就规划得完美至少在你创建索引之前先算一次未来的数据体量。SpringBoot 集成 Elasticsearch 这事说到底不是几行依赖配置的事。版本匹配、索引建模、查询语法、分词策略、部署参数每一环都决定你线上搜索的质量和稳定性。我自己经历过“搜不出来”“查得慢”“同步丢数据”的三连击才知道提前把原理搞清楚有多重要。这篇内容基本把我入行以来积攒的核心经验都摊开讲完了希望你集成道路上能少流几次冷汗。
返回列表