手把手实战:用 Spring Boot 搭建高性能商品搜索引擎
你有没有遇到过这样的场景?用户在电商网站搜索“华为手机”,系统卡顿半秒才返回结果,翻到第二页又慢了一拍——这种体验,在高并发、大数据量的今天已经无法接受。而背后的原因,往往就是还在用 MySQL 的LIKE做模糊查询。
别急,今天我们不讲理论堆砌,也不复制官方文档,而是带你从零开始,亲手搭建一个基于 Spring Boot 和 Elasticsearch 的商品搜索系统。整个过程就像写一篇开发日记:你会看到我踩过的坑、调过的参数、优化过的查询逻辑,以及最终上线后性能提升 10 倍的真实效果。
准备好了吗?我们直接开干。
为什么不能只靠数据库做搜索?
先说个真实案例。某电商平台早期所有数据都存在 MySQL 里,商品表不到 50 万条时还好,但一旦超过百万,哪怕加了索引,SELECT * FROM product WHERE title LIKE '%手机%'这种语句也常常耗时800ms~2s。
更麻烦的是:
- 中文分词难处理,“智能手机”搜不到“智能 手机”;
- 多条件组合(比如分类+价格区间+品牌)会让 SQL 越写越复杂;
- 分页深了会变慢,LIMIT 10000, 10直接全表扫描。
这时候,你就需要一个真正的搜索引擎——Elasticsearch。
它不是数据库替代品,而是专门为“查得快”设计的工具。它的核心能力是:
- 支持近实时全文检索(NRT),新数据 1 秒内可搜;
- 内置倒排索引机制,关键词查找效率极高;
- 提供丰富的 DSL 查询语言,轻松实现布尔、范围、模糊、聚合等高级功能;
- 分布式架构天然支持横向扩展。
简单来说:MySQL 负责存,ES 负责搜。两者配合,才是现代系统的标准解法。
技术选型:Spring Data Elasticsearch 到底香在哪?
Spring Boot 生态中整合 ES 有好几种方式:原生 REST Client、Jest、OpenSearch SDK……但我们选择最主流也最省心的一种——Spring Data Elasticsearch。
它到底解决了什么问题?
| 传统做法 | 使用 Spring Data Elasticsearch |
|---|---|
| 手动拼 JSON 查询 DSL | 方法名即查询逻辑,如findByTitleContaining() |
| 自己管理连接和异常 | 自动配置 RestClient,无缝集成 Spring 生命周期 |
| 实体与文档映射靠注释记忆 | 注解驱动,@Document,@Field清晰直观 |
| 分页要自己算 offset | 直接传Pageable,自动处理分页 |
一句话总结:它把复杂的 ES 操作,变成了像操作数据库一样的 CRUD 编程体验。
而且从 Spring Data Elasticsearch 4.x 开始,默认使用REST High Level Client,兼容性更好,支持 HTTPS 和认证,稳定性也更强。
动手实战:六步完成整合
我们来一步步搭建这个搜索系统。假设你现在有一个全新的 Spring Boot 项目,接下来的操作可以直接复用。
第一步:引入依赖(别搞错版本!)
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 关键依赖 --> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-elasticsearch</artifactId> </dependency> <!-- Lombok 简化代码(可选) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies>⚠️ 版本对齐很重要!
- Spring Boot 2.7.x → Spring Data Elasticsearch 4.4.x
- Spring Boot 3.x → 必须用 5.x,并注意包路径变化(Jakarta EE)
如果你用的是 Docker 启动的 ES,确保版本匹配。本文以Elasticsearch 8.11.0为例。
第二步:配置连接信息
spring: data: elasticsearch: client: reactive: endpoints: localhost:9200 # ES 地址 repositories: enabled: true # 启用仓库模式就这么一行地址,Spring 就会自动创建RestClient并连接集群。如果启用了安全认证(比如 X-Pack),还需要额外配置用户名密码或证书,这里暂不展开。
第三步:定义商品实体类
这是最关键的一步——如何将 Java 对象映射成 ES 文档。
@Document(indexName = "product") @Data @NoArgsConstructor @AllArgsConstructor public class Product { @Id private String id; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart") private String title; @Field(type = FieldType.Keyword) private String category; @Field(type = FieldType.Double) private Double price; @Field(type = FieldType.Integer) private Integer stock; @Field(type = FieldType.Date) private Date createTime; @Field(type = FieldType.Boolean) private Boolean onSale; }几个关键点解释一下:
🔹@Document(indexName = "product")
告诉框架这个类对应 ES 中的product索引。如果没有,启动时可以自动创建(需开启自动索引)。
🔹 中文分词怎么破?
默认分词器对中文是按单字切分的,比如“华为手机”会被切成“华”、“为”、“手”、“机”。显然不行!
解决方案:安装IK Analyzer 插件。
# 在 Elasticsearch 安装目录下执行 ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.11.0/elasticsearch-analysis-ik-8.11.0.zip然后重启 ES。之后就可以在字段上指定分词器:
analyzer = "ik_max_word":索引时尽可能多切词,提高召回率;searchAnalyzer = "ik_smart":搜索时智能少切词,提高准确率。
这样,“华为P60”能被正确识别为“华为”、“P60”,而不是一堆单字。
🔹KeywordvsText的区别?
Text:用于全文检索,会分词,适合标题、描述;Keyword:不分词,完整匹配,适合分类、品牌、状态等精确筛选字段。
记住了:你要模糊搜的字段用Text,要精准查的用Keyword。
第四步:编写 Repository 接口
Spring Data 的精髓就在这里——方法名即查询语义。
public interface ProductRepository extends ElasticsearchRepository<Product, String> { // 根据类别和价格区间查询 List<Product> findByCategoryAndPriceBetween(String category, Double minPrice, Double maxPrice); // 标题包含关键词(支持分词) List<Product> findByTitleContaining(String keyword); // 上架中 + 标题匹配,带分页 Page<Product> findByOnSaleTrueAndTitleContaining(String keyword, Pageable pageable); }你看,完全不用写 SQL 或 DSL,只要命名规范,框架就会自动生成对应的 ES 查询。
✅ 提示:这些方法底层生成的是
match查询,属于query context;如果是onSale=true这种过滤条件,建议放在filter context更高效(后面会讲优化技巧)。
第五步:Service 层实现动态查询
实际业务中,用户的筛选条件往往是可选的。比如搜索页有多个输入框,有的填了,有的没填。这时候就不能依赖固定的方法名了。
我们需要手动构建查询条件。Spring Data 提供了CriteriaQuery工具类。
@Service public class ProductService { @Autowired private ProductRepository productRepository; public Page<Product> searchProducts(String keyword, String category, Double minPrice, Double maxPrice, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("price").asc()); if (keyword != null && !keyword.trim().isEmpty()) { // 有关键词时优先走复合查询 return productRepository.findByOnSaleTrueAndTitleContaining(keyword, pageable); } // 否则走动态条件组合 Criteria criteria = new Criteria(); criteria.and(Criteria.where("onSale").is(true)); if (category != null && !category.isEmpty()) { criteria.and(Criteria.where("category").is(category)); } if (minPrice != null) { criteria.and(Criteria.where("price").greaterThanEqual(minPrice)); } if (maxPrice != null) { criteria.and(Criteria.where("price").lessThanEqual(maxPrice)); } Query query = new CriteriaQuery(criteria).setPageable(pageable); return productRepository.search(query); } public Product saveProduct(Product product) { return productRepository.save(product); } public void deleteById(String id) { productRepository.deleteById(id); } }这段代码有几个亮点:
- 使用
CriteriaQuery构建动态条件,避免大量 if-else 拼接; save()是 upsert 行为:ID 存在则更新,不存在则插入;- 分页通过
Pageable控制,防止内存溢出。
第六步:暴露 HTTP 接口
最后一步,让前端能调用。
@RestController @RequestMapping("/api/products") public class ProductController { @Autowired private ProductService productService; @GetMapping public ResponseEntity<Page<Product>> search( @RequestParam(required = false) String keyword, @RequestParam(required = false) String category, @RequestParam(required = false) Double minPrice, @RequestParam(required = false) Double maxPrice, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { Page<Product> result = productService.searchProducts(keyword, category, minPrice, maxPrice, page, size); return ResponseEntity.ok(result); } @PostMapping public ResponseEntity<Product> add(@RequestBody Product product) { product.setCreateTime(new Date()); product.setOnSale(true); return ResponseEntity.ok(productService.saveProduct(product)); } }启动项目,访问:
GET /api/products?keyword=手机&category=数码&minPrice=2000&maxPrice=8000&page=0&size=10你会发现,即使数据量达到百万级,响应时间也能稳定在50ms 以内。
架构设计与避坑指南
光跑通还不够,上线前还得考虑稳定性、一致性、性能等问题。以下是我在真实项目中总结的经验。
🧩 数据同步怎么做?
ES 不是主库,数据来源通常是 MySQL。常见的同步方案有三种:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 应用层双写(先写 DB 再写 ES) | 实现简单 | 可能丢数据,事务难保证 |
| Canal + Kafka 监听 Binlog | 异步解耦,可靠 | 架构复杂,运维成本高 |
| Logstash JDBC Input | 配置即可,适合离线同步 | 实时性差 |
推荐做法:写操作走 MQ。例如:
- 用户新增商品 → 写入 MySQL;
- 发送消息到 Kafka(事件:product.created);
- 消费者拉取消息,更新 ES 索引;
- 失败则重试 + 死信队列告警。
这样既保证最终一致性,又不影响主流程性能。
🔍 索引设计最佳实践
别小看 mapping 设计,设计不好会导致查询慢、内存爆、甚至集群宕机。
1. 关闭动态映射(必须!)
PUT /product { "mappings": { "dynamic": "strict", "properties": { "title": { "type": "text", "analyzer": "ik_max_word" }, "category": { "type": "keyword" }, "price": { "type": "double" } } } }设置"dynamic": "strict"后,任何意外字段都会报错,防止因脏数据导致 mapping explosion(映射爆炸),进而拖垮集群。
2. 合理设置分片数
"settings": { "number_of_shards": 3, "number_of_replicas": 1 }- 分片太多:资源开销大,查询合并慢;
- 分片太少:无法水平扩展。
经验法则:单个分片不超过50GB,初始设为 3~5 个足够。
⚡ 性能优化技巧
1. Filter 比 Query 更快
在 DSL 中,filter context不计算相关性得分,且结果可缓存。适合用于onSale=true、category=数码这类条件。
Spring Data 默认生成的是query,但我们可以通过NativeQuery手动控制:
Query query = NativeQuery.builder() .withQuery(q -> q.match(m -> m.field("title").query(keyword))) .withFilter(f -> f.term(t -> t.field("onSale").value(true))) .withPageable(pageable) .build();实测性能提升可达 20%~40%。
2. 禁止深度分页
不要让用户翻到第 1000 页。from + size超过 1 万条时,性能急剧下降。
替代方案:search_after
// 记录上次最后一条的 sort 值,作为下次起点 SearchAfter searchAfter = new SearchAfter(Arrays.asList(lastSortValue)); Pageable pageable = PageRequest.of(0, 10, Sort.by("price").asc()).first().next(searchAfter);适用于无限滚动场景,性能稳定。
写在最后:这不是终点,而是起点
当你第一次看到/api/products?keyword=手机在 30ms 内返回结果时,你会意识到:这才是现代系统的该有的样子。
但这只是第一步。后续你还可以继续深入:
- 给搜索结果加高亮显示;
- 实现拼音搜索(“xiangji” 能搜到 “相机”);
- 添加搜索建议和拼写纠错;
- 结合机器学习做个性化排序;
- 用 Kibana 做搜索行为分析。
而这一切的基础,正是今天我们搭建的这套Spring Boot + Elasticsearch搜索引擎骨架。
如果你正在做一个电商项目、内容平台,或者只是想提升自己的技术栈,那么掌握这套组合拳,绝对值得投入时间。
💬 如果你在集成过程中遇到了问题,比如连接失败、分词无效、查询为空……欢迎留言讨论,我可以帮你一起排查。毕竟,每一个报错背后,都藏着一次成长的机会。