SpringBoot 整合 Elasticsearch 实战——搜索引擎从入门到落地

当 MySQL 的 LIKE '%keyword%' 越来越慢,或者你需要做全文搜索、高亮展示、聚合统计时,Elasticsearch 就是标配方案了。

一、Elasticsearch 核心概念

ES 概念 类比 MySQL 说明
Index(索引) 数据库 存储同类文档的地方
Type(类型) 7.x 以后废弃,一个 Index 下面不再分 Type
Document(文档) 一行记录 JSON 格式的数据单元
Field(字段) 一列 文档中的字段
Mapping(映射) 表结构 定义字段类型和分析器
Shard(分片) 分表 数据水平拆分到多台机器

关键词: ES 的搜索快,靠的是倒排索引------把文档拆成词条,建立"词条→文档"的映射,查的时候直接定位,不走全表扫描。

二、SpringBoot 整合 ES

1. 引入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

2. 配置连接

yaml 复制代码
spring:
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 3s
    socket-timeout: 30s

3. 创建实体类

java 复制代码
@Data
@Document(indexName = "products")  // 对应 ES 索引名
public class ProductDocument {

    @Id
    private Long id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;       // 商品标题(中文分词)

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String description; // 商品描述

    @Field(type = FieldType.Keyword)
    private String brand;       // 品牌(不分词,精确匹配)

    @Field(type = FieldType.Double)
    private Double price;       // 价格

    @Field(type = FieldType.Integer)
    private Integer stock;      // 库存

    @Field(type = FieldType.Date)
    private Date createTime;
}

注意: analyzer = "ik_max_word" 是中文分词器,需要提前安装到 ES 中,否则用默认的 standard 分词器会把中文一个字一个字地分。

4. 编写 Repository

java 复制代码
@Repository
public interface ProductRepository
        extends ElasticsearchRepository<ProductDocument, Long> {

    // 按标题搜索
    List<ProductDocument> findByTitle(String title);

    // 按标题模糊搜索
    List<ProductDocument> findByTitleContaining(String keyword);

    // 组合条件:标题包含 + 价格区间
    List<ProductDocument> findByTitleContainingAndPriceBetween(
            String keyword, Double min, Double max);

    // 自定义查询(复杂查询用 @Query)
    @Query("{\"match\": {\"description\": \"?0\"}}")
    List<ProductDocument> searchByDescription(String keyword);
}

三、CRUD 实战

1. 批量导入数据(从 MySQL 同步到 ES)

java 复制代码
@Service
public class ProductIndexService {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ProductMapper productMapper;  // MyBatis-Plus 的 Mapper

    /**
     * 全量同步:把 MySQL 的商品数据导入到 ES
     */
    public void fullSync() {
        List<Product> products = productMapper.selectList(null);
        List<ProductDocument> docs = products.stream()
                .map(this::convertToDoc)
                .collect(Collectors.toList());

        productRepository.saveAll(docs);
        System.out.println("全量同步完成,共 " + docs.size() + " 条");
    }

    /**
     * 增量同步:单条新增或更新
     */
    public void syncById(Long productId) {
        Product product = productMapper.selectById(productId);
        if (product != null) {
            productRepository.save(convertToDoc(product));
        }
    }

    /**
     * 从 ES 删除
     */
    public void deleteFromIndex(Long productId) {
        productRepository.deleteById(productId);
    }

    private ProductDocument convertToDoc(Product product) {
        ProductDocument doc = new ProductDocument();
        BeanUtils.copyProperties(product, doc);
        return doc;
    }
}

2. 搜索接口

java 复制代码
@RestController
@RequestMapping("/search")
public class SearchController {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ElasticsearchRestTemplate elasticsearchTemplate;

    /**
     * 简单搜索(使用 Repository 自带方法)
     */
    @GetMapping("/simple")
    public List<ProductDocument> simpleSearch(String keyword) {
        return productRepository.findByTitleContaining(keyword);
    }

    /**
     * 高级搜索(分页 + 高亮 + 排序)
     */
    @GetMapping("/advanced")
    public Page<ProductDocument> advancedSearch(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {

        // 构建查询条件
        NativeQueryBuilder queryBuilder = new NativeQueryBuilder();

        // 多字段匹配:标题和描述都搜
        queryBuilder.withQuery(QueryBuilders
                .multiMatchQuery(keyword, "title", "description"));

        // 分页(ES 页码从 0 开始)
        queryBuilder.withPageable(PageRequest.of(page - 1, size));

        // 高亮设置
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.field("title").preTags("<em>").postTags("</em>");
        highlightBuilder.field("description").preTags("<em>").postTags("</em>");
        queryBuilder.withHighlightBuilder(highlightBuilder);

        // 排序(按价格升序)
        queryBuilder.withSort(Sort.by(Sort.Direction.ASC, "price"));

        NativeQuery query = queryBuilder.build();
        SearchHits<ProductDocument> searchHits =
                elasticsearchTemplate.search(query, ProductDocument.class);

        // 处理高亮结果
        List<ProductDocument> products = searchHits.stream().map(hit -> {
            ProductDocument doc = hit.getContent();
            Map<String, List<String>> highlights = hit.getHighlightFields();

            // 如果有高亮,替换原标题
            if (highlights.containsKey("title")) {
                doc.setTitle(highlights.get("title").get(0));
            }
            if (highlights.containsKey("description")) {
                doc.setDescription(highlights.get("description").get(0));
            }
            return doc;
        }).collect(Collectors.toList());

        return new PageImpl<>(products);
    }

    /**
     * 聚合搜索(按品牌统计)
     */
    @GetMapping("/aggregate")
    public List<BrandCount> aggregateByBrand() {
        NativeQuery query = NativeQuery.builder()
                .withAggregation("brands", AggregationBuilders
                        .terms("brands").field("brand").size(10))
                .build();

        SearchHits<ProductDocument> hits =
                elasticsearchTemplate.search(query, ProductDocument.class);

        // 解析聚合结果
        ElasticsearchAggregations aggregations =
                (ElasticsearchAggregations) hits.getAggregations();
        ParsedStringTerms terms =
                aggregations.get("brands");

        return terms.getBuckets().stream()
                .map(bucket -> new BrandCount(
                        bucket.getKey().toString(),
                        bucket.getDocCount()))
                .collect(Collectors.toList());
    }
}

四、ES 和 MySQL 的双写问题

ES 不是主数据库,它只是搜索引擎。正确的架构是:

复制代码
写入:MySQL(主) → 同步到 ES(从)
搜索:直接查 ES,速度快

同步方式有三种:

方式 优点 缺点
业务代码双写 简单 耦合高,容易漏
MQ 异步同步 👍 解耦,可靠 多一个 MQ 组件
logstash 定时同步 不写代码 有秒级延迟

推荐用 MQ:

java 复制代码
// 商品服务:修改商品后发送消息
@PostMapping("/product/update")
public ResultVO<?> updateProduct(@RequestBody Product product) {
    productService.updateById(product);

    // 发送消息通知 ES 同步
    rabbitTemplate.convertAndSend("product.exchange", "product.sync", product.getId());
    return ResultVO.success();
}

// 搜索服务:监听消息,同步到 ES
@RabbitListener(queues = "product.sync.queue")
public void handleProductSync(Long productId) {
    productIndexService.syncById(productId);
}

五、SpringBoot 版本适配

不同 SpringBoot 版本对应的 ES 客户端不一样:

SpringBoot 版本 推荐的 ES 客户端
2.3.x TransportClient(已弃用)
2.4.x - 2.7.x ElasticsearchRestTemplate
3.x 最新的 Java Client

你现在用 SpringBoot 2.7,就用 RestTemplate 方式,上面给的代码都是兼容的。

六、ES 实际开发注意事项

  1. ES 不是银弹------几十万条数据用 MySQL 的 LIKE 也够用,别什么都往 ES 里扔
  2. 中文搜索必须装 IK 分词器,否则一个字一个字地搜,体验极差
  3. ES 不擅长关联查询(比如"查订单 + 商品名 + 用户地址"),关联用 MySQL 做
  4. ES 写性能不如 MySQL,不适合高频写入场景(比如日志除外)
  5. 分页别太深from + size 超过 10000 会慢,深度分页用 search_after

💡 觉得有用的话,点赞 + 关注【张老师技术栈】吧!每周更新 Java/Python/爬虫 实战干货,不让你白来。