Elasticsearch实战指南:构建实时全文检索系统

在数据爆炸的时代,如何让用户在海量信息中毫秒级找到目标内容,是衡量系统体验的关键指标。传统的数据库 LIKE 查询在面对大数据量时往往力不从心,不仅性能低下,还难以支持分词、模糊匹配等高级功能。本文将带你从零开始,基于 Elasticsearch (ES) 构建一个高可用、实时的全文检索系统。我们将采用 Canal 监听数据库变更,通过 Kafka 进行消息削峰填谷,最终实现数据从 MySQL 到 ES 的实时同步。

1. Elasticsearch 概述

Elasticsearch 是一个基于 Lucene 构建的开源、分布式、RESTful 风格的搜索和数据分析引擎。它能以毫秒级的速度存储、搜索和分析海量数据。

Elasticsearch 基础内容可参考:Elasticsearch从入门到实战:核心概念与全平台部署及开发详解

2. 全文检索

Elasticsearch 是一个基于 Lucene 构建的开源、分布式、RESTful 风格的搜索和数据分析引擎。它能以毫秒级的速度存储、搜索和分析海量数据。

2.1. 什么是全文检索

全文检索是指计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置。当用户查询时,检索程序根据用户输入的关键词在索引中查找,并将结果返回。

2.2. 与传统查询的区别

传统查询,类似于字典的部首查字,必须精确匹配(如 SQL 中的 = 或 LIKE),无法处理语义模糊的情况。

全文检索,类似于书籍的目录索引(倒排索引),支持分词、同义词、权重打分,能够理解"什么是相关的"。

2.3. 常见场景

  • 电商搜索: 商品名称、描述的模糊搜索,支持高亮、聚合筛选。
  • 日志分析: 也就是著名的 ELK 技术栈,用于运维监控和故障排查。
  • 内容平台: 博客文章、新闻资讯的关键词检索。
  • 代码托管: 如 GitHub 的代码搜索功能。

3. 全文检索实现方式

实现全文检索通过有以下几种主流方式。

3.1. 基于数据库的模糊查询

当前方式主要是使用 SQL 中的LIKE '%keyword%'

缺点主要是全表扫描,性能极差,无法利用索引,不支持相关性排序。

仅适用于数据量极小的场景。

3.2. 基于Lucene的直接开发

当前方式主要是直接使用 Lucene 库编写代码。

缺点主要是 Lucene 只是一个核心库,缺乏分布式支持,扩展性和容错性需要自己实现,开发成本高。

3.3. 基于ES的成熟中间件

当前方式则是使用成熟的搜索引擎中间件。

其优点就是开箱即用,支持分布式集群、高可用、实时索引、丰富的分词器和 RESTful API。

这是目前企业级开发的首选方案。

4. 实时数据同步与全文检索实战

4.1. 技术栈

前端:这里暂不考虑。

后端:SpringBoot

数据库:MySQL

数据库变更捕获:Canal

消息中间件:Kafka

搜索引擎:Elasticsearch

4.2. 技术链路

数据异步写入ES:

  1. 数据持久化:前端请求,调用后端接口,由后端接收、处理,最后持久化到 MySQL 数据库。

  2. 数据监听与同步Kafka:由 Canal 伪装成数据库的 Slave 节点,并实时监听 Binlog 文件,当 Canal 监听到INSERTUPDATEDELETE操作时,将数据推送到 Kafka 指定的 Topic 中。

  3. 消息消费与同步ES:另一个后端服务,作为 Kafka 的消费者,监听对应的 Topic,接收 Canal 发送的 JSON 消息,解析出数据内容。

    • 如果是新增/修改,调用 ES 的IndexRequest,将数据索引到 ES 中。

    • 如果是删除,调用 ES 的DeleteRequest,根据主键删除文档。

全文检索:

  1. 前端搜索:用户可以在搜索栏中输入关键词进行搜索,调用后端接口;

  2. 后端构建查询:由后端根据关键词,构建查询条件,根据需求使用matchterm,或者bool等,并将结果中的关键字设置为高亮,将 ES 查询结果响应给前端展示。

4.3. 技术实现

以下只展示核心代码片段。

4.3.1. 需求说明

这里还是以一个书籍为例,可以根据书籍名称(nameText)和摘要(commentText)分词检索,也可以根据作者精确查询。

4.3.2. MySQL表结构

SQL 复制代码
CREATE TABLE `books` (
  `id` VARCHAR(64) NOT NULL COMMENT '主键ID',
  `name` VARCHAR(255) NOT NULL COMMENT '书名',
  `author` VARCHAR(255) DEFAULT NULL COMMENT '作者',
  `score` VARCHAR(4) DEFAULT NULL COMMENT '评分',
  `comment` VARCHAR(2000) DEFAULT NULL COMMENT '摘要',
  `publishTime` DATETIME DEFAULT NULL COMMENT '发布时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书籍信息表';

4.3.3. ES 文档结构

bash 复制代码
PUT /book_index
{
  "settings": {
    "number_of_replicas": 1,
    "number_of_shards": 2
  },
  "mappings": {
    "properties": {
      "nameText": {
        "search_analyzer": "ik_smart",
        "analyzer": "ik_max_word",
        "type": "text"
      },
      "author": {
        "type":"text",
        "fields":{
            "keyword":{
                "ignore_above":256,
                "type":"keyword"
            }
        }
      },
      "score":{
          "type":"float"
      },
      "commentText": {
        "search_analyzer": "ik_smart",
        "analyzer": "ik_max_word",
        "type": "text"
      },
      "publishTime":{
          "type":"date",
          "format": "yyyy-MM-d HH:mm:ss"
      }
    }
  }
}

4.3.4. Canal 逻辑

由 Canal 监听 MySQL 的 Binlog 文件,将对应操作捕获,并发送到 Kafka 指定 Topic 中。

Canal 部署的话,后期单独写一篇文章,这里只把 Canal 抓取的数据结构示例给出。

  1. 新增数据
JSON 复制代码
{
  "data": [
    {
      "id": "book_001",
      "name": "深入理解计算机系统",
      "author": "Randal E. Bryant",
      "score": "9.8",
      "comment": "被誉为计算机科学的圣经,内容全面且深入。",
      "publishTime": "2026-02-18 10:00:00"
    }
  ],
  "database": "testdb",
  "es": 1698372000000,
  "id": 12345,
  "isDdl": false,
  "mysqlType": {
    "id": "VARCHAR(64)",
    "name": "VARCHAR(255)",
    "author": "VARCHAR(255)",
    "score": "VARCHAR(4)",
    "comment": "VARCHAR(2000)",
    "publishTime": "DATETIME"
  },
  "old": [],
  "pkNames": [
    "id"
  ],
  "sql": "",
  "sqlType": {
    "id": -9,
    "name": 12,
    "author": 12,
    "score": 12,
    "comment": 12,
    "publishTime": 93
  },
  "table": "books",
  "ts": 1771383060000,
  "type": "INSERT"
}
  1. 修改数据。
JSON 复制代码
{
  "data": [
    {
      "id": "book_001",
      "name": "深入理解计算机系统",
      "author": "Randal E. Bryant",
      "score": "9.9",
      "comment": "被誉为计算机科学的圣经,内容全面且深入,强烈推荐。",
      "publishTime": "2026-02-18 10:00:00"
    }
  ],
  "database": "testdb",
  "es": 1698372100000,
  "id": 12346,
  "isDdl": false,
  "mysqlType": {
    "id": "VARCHAR(64)",
    "name": "VARCHAR(255)",
    "author": "VARCHAR(255)",
    "score": "VARCHAR(4)",
    "comment": "VARCHAR(2000)",
    "publishTime": "DATETIME"
  },
  "old": [
    {
      "score": "9.8",
      "comment": "被誉为计算机科学的圣经,内容全面且深入。"
    }
  ],
  "pkNames": [
    "id"
  ],
  "sql": "",
  "sqlType": {
    "id": -9,
    "name": 12,
    "author": 12,
    "score": 12,
    "comment": 12,
    "publishTime": 93
  },
  "table": "books",
  "ts": 1771383060000,
  "type": "UPDATE"
}
  1. 删除数据。
JSON 复制代码
{
  "data": [
    {
      "id": "book_001",
      "name": "深入理解计算机系统",
      "author": "Randal E. Bryant",
      "score": "9.9",
      "comment": "被誉为计算机科学的圣经,内容全面且深入,强烈推荐。",
      "publishTime": "2026-02-18 10:00:00"
    }
  ],
  "database": "testdb",
  "es": 1698372200000,
  "id": 12347,
  "isDdl": false,
  "mysqlType": {
    "id": "VARCHAR(64)",
    "name": "VARCHAR(255)",
    "author": "VARCHAR(255)",
    "score": "VARCHAR(4)",
    "comment": "VARCHAR(2000)",
    "publishTime": "DATETIME"
  },
  "old": [],
  "pkNames": [
    "id"
  ],
  "sql": "",
  "sqlType": {
    "id": -9,
    "name": 12,
    "author": 12,
    "score": 12,
    "comment": 12,
    "publishTime": 93
  },
  "table": "books",
  "ts": 1771383060000,
  "type": "DELETE"
}

参数说明:

字段名 描述
type 变更操作的类型,值为 INSERTUPDATEDELETE
data 包含变更后的行数据。对于 INSERTUPDATE,是新值;对于 DELETE,是被删除的旧值。
old 仅在 UPDATE 操作中存在,包含发生变更的字段及其旧值。
database 发生数据变更的数据库名称。
table 发生数据变更的表名。
es 数据变更事件在 MySQL 中发生的时间戳(毫秒)。
ts Canal 将该消息发送到 Kafka 的时间戳(毫秒)。
pkNames 表的主键字段名列表。

4.3.5. Kafka 消费者逻辑

监听指定 Topic,接收消息并处理,根据操作不同,将数据更新到ES,或者删除ES的文档。

  1. 核心依赖。
XML 复制代码
<dependencies>

  <!-- Kafka -->
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>

    <!-- Elasticsearch -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>
    
    <!-- JSON 处理 (如 Jackson 或 Hutool,这里以 Jackson 为例) -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>
  1. 创建 ES 对应实体类
Java 复制代码
package com.example.demo.model;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.DateFormat;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

@Data
@Document(indexName = "book_index")
public class Book {
    @Id
    private String id; // 对应 MySQL 的 id

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String nameText; // 对应 MySQL 的 name

    @Field(type = FieldType.Text)
    private String author;

    @Field(type = FieldType.Float)
    private Float score;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String commentText; // 对应 MySQL 的 comment

    @Field(type = FieldType.Date, format = DateFormat.custom, pattern = "yyyy-MM-dd HH:mm:ss")
    private String publishTime;
}
  1. 创建接收 Canal 数据的实体类。
Java 复制代码
package com.example.demo.model;

import lombok.Data;
import java.util.List;

@Data
public class CanalMessageDTO {
    // INSERT, UPDATE, DELETE
    private String type;
    private String database;
    private String table;
    // 变更后的数据
    private List<Map<String, Object>> data;
    // 变更前的数据(仅 UPDATE 时有)
    private List<Map<String, Object>> old;
    // 事件发生时间
    private Long es;
    // Canal 处理时间
    private Long ts;
}
  1. Kafka 消费者实现。
Java 复制代码
package com.example.demo.consumer;

import com.example.demo.model.Book;
import com.example.demo.model.CanalMessageDTO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.IndexOperations;
import org.springframework.data.elasticsearch.core.document.Document;
import org.springframework.data.elasticsearch.core.query.Criteria;
import org.springframework.data.elasticsearch.core.query.CriteriaQuery;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class BookSyncConsumer {

    @Autowired
    private ElasticsearchRestTemplate elasticsearchRestTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    // 替换为你的 Kafka Topic 名称
    private static final String TOPIC_NAME = "canal_topic";

    @KafkaListener(topics = TOPIC_NAME, groupId = "es-sync-group")
    public void consume(String message) {
        log.info("接收到 Kafka 消息: {}", message);
        
        try {
            // 1. 解析 Canal JSON 消息
            CanalMessageDTO canalMessage = objectMapper.readValue(message, CanalMessageDTO.class);
            
            // 简单校验,确保是 books 表的数据
            if (!"books".equals(canalMessage.getTable())) {
                return;
            }

            // 2. 根据操作类型处理
            String type = canalMessage.getType();
            List<Map<String, Object>> dataList = canalMessage.getData();

            if (dataList == null || dataList.isEmpty()) {
                log.warn("数据列表为空,忽略消息");
                return;
            }

            // 获取主键 ID (假设 Canal 消息中 data 的第一条数据的 id 即为文档 ID)
            // 注意:Canal flatMessage=true 时,data 是一个列表,通常包含一行
            Map<String, Object> rowData = dataList.get(0);
            String docId = String.valueOf(rowData.get("id"));

            switch (type) {
                case "INSERT":
                    handleInsert(rowData, docId);
                    break;
                case "UPDATE":
                    handleUpdate(rowData, docId);
                    break;
                case "DELETE":
                    handleDelete(docId);
                    break;
                default:
                    log.info("未知操作类型: {}", type);
            }

        } catch (JsonProcessingException e) {
            log.error("JSON 解析失败: {}", e.getMessage(), e);
        } catch (Exception e) {
            log.error("同步 ES 失败: {}", e.getMessage(), e);
        }
    }

    /**
     * 1. 新增数据:直接保存到 ES
     */
    private void handleInsert(Map<String, Object> rowData, String docId) {
        log.info("执行 ES 新增操作,ID: {}", docId);
        Book book = convertToBook(rowData, docId);
        elasticsearchRestTemplate.save(book);
    }

    /**
     * 2. 更新数据:根据 id 删除原有文档,然后新增
     */
    private void handleUpdate(Map<String, Object> rowData, String docId) {
        log.info("执行 ES 更新操作(先删后增),ID: {}", docId);
        
        // 步骤 A: 删除旧文档
        // 使用 delete 方法,如果文档不存在也不会报错(根据配置可能抛异常,这里简单处理)
        try {
            elasticsearchRestTemplate.delete(Book.class, docId);
        } catch (Exception e) {
            log.warn("删除旧文档时未找到或发生异常,继续执行新增: {}", e.getMessage());
        }

        // 步骤 B: 新增新文档
        Book book = convertToBook(rowData, docId);
        elasticsearchRestTemplate.save(book);
    }

    /**
     * 3. 删除数据:根据 id 删除文档
     */
    private void handleDelete(String docId) {
        log.info("执行 ES 删除操作,ID: {}", docId);
        try {
            elasticsearchRestTemplate.delete(Book.class, docId);
        } catch (Exception e) {
            log.error("删除 ES 文档失败: {}", e.getMessage());
        }
    }

    /**
     * 将 Canal 的 Map 数据转换为 Book 实体
     */
    private Book convertToBook(Map<String, Object> data, String id) {
        Book book = new Book();
        book.setId(id);
        
        // 字段映射:MySQL 字段名 -> Book 属性名
        // 注意:Canal 获取的数据通常是 Object 或 String,需要确保类型匹配
        book.setNameText((String) data.get("name"));
        book.setAuthor((String) data.get("author"));
        
        // 处理数值类型转换
        Object scoreObj = data.get("score");
        if (scoreObj != null) {
            book.setScore(Float.valueOf(scoreObj.toString()));
        }
        
        book.setCommentText((String) data.get("comment"));
        book.setPublishTime((String) data.get("publishTime"));
        
        return book;
    }
}

4.3.6. 全文检索逻辑

前端发送查询请求,后端接收到请求后,构建SearchCotroller来处理请求。

  1. 数据传输对象。
Java 复制代码
package com.example.demo.dto;

import lombok.Data;
import java.io.Serializable;

@Data
public class BookSearchDTO implements Serializable {
    // 1. 作者(精准查询)
    private String author;

    // 2. 关键词(用于匹配书名、摘要,分词查询)
    private String keyword;

    // 3. 发布时间范围开始 (格式: yyyy-MM-dd HH:mm:ss)
    private String publishTimeStart;

    // 4. 发布时间范围结束 (格式: yyyy-MM-dd HH:mm:ss)
    private String publishTimeEnd;

    // 分页参数
    private Integer pageNum = 1;
    private Integer pageSize = 10;
}
  1. 核心查询逻辑实现。
Java 复制代码
package com.example.demo.service;

import com.example.demo.dto.BookSearchDTO;
import com.example.demo.model.Book;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.MatchQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Service
public class BookService {

    @Autowired
    private ElasticsearchRestTemplate elasticsearchRestTemplate;

    /**
     * 综合搜索接口
     */
    public List<Map<String, Object>> searchBooks(BookSearchDTO searchDTO) {
        List<Map<String, Object>> resultList = new ArrayList<>();

        // 1. 构建查询条件
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // === 条件 A: 作者精准查询 ===
        if (searchDTO.getAuthor() != null && !searchDTO.getAuthor().trim().isEmpty()) {
            // 注意:author 是 text 类型,精准查询必须使用 .keyword 字段
            boolQuery.filter(QueryBuilders.termQuery("author.keyword", searchDTO.getAuthor()));
        }

        // === 条件 B: 书名、摘要 分词查询 ===
        if (searchDTO.getKeyword() != null && !searchDTO.getKeyword().trim().isEmpty()) {
            MatchQueryBuilder matchQuery = QueryBuilders.matchQuery("nameText", searchDTO.getKeyword())
                    .analyzer("ik_max_word")
                    .minimumShouldMatch("75%"); // 可选:控制匹配度
            
            // 也可以同时查询 commentText,使用 multiMatchQuery
            // 这里为了演示简单,假设主要查书名
             boolQuery.must(QueryBuilders.multiMatchQuery(searchDTO.getKeyword(), "nameText", "commentText")
                     .analyzer("ik_max_word"));
        }

        // === 条件 C: 发布时间范围查询 ===
        if (searchDTO.getPublishTimeStart() != null || searchDTO.getPublishTimeEnd() != null) {
            boolQuery.filter(QueryBuilders.rangeQuery("publishTime")
                    .gte(searchDTO.getPublishTimeStart())
                    .lte(searchDTO.getPublishTimeEnd())
                    .format("yyyy-MM-dd HH:mm:ss")); // 指定日期格式
        }

        // 2. 构建高亮
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.preTags("<font color='red'>"); // 高亮前缀
        highlightBuilder.postTags("</font>");           // 高亮后缀
        highlightBuilder.field("nameText");             // 书名高亮
        highlightBuilder.field("commentText");          // 摘要高亮

        // 3. 组装查询对象
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withHighlightBuilder(highlightBuilder)
                .withPageable(PageRequest.of(searchDTO.getPageNum() - 1, searchDTO.getPageSize()))
                .build();

        // 4. 执行查询
        SearchHits<Book> searchHits = elasticsearchRestTemplate.search(searchQuery, Book.class);

        // 5. 处理结果(合并高亮字段到原始数据中)
        for (SearchHit<Book> hit : searchHits.getSearchHits()) {
            Book content = hit.getContent(); // 原始数据
            Map<String, List<String>> highlightFields = hit.getHighlightFields(); // 高亮数据

            // 将高亮结果覆盖原始字段,方便前端直接展示
            // 如果高亮字段存在,则替换
            if (highlightFields.containsKey("nameText")) {
                content.setNameText(highlightFields.get("nameText").get(0));
            }
            if (highlightFields.containsKey("commentText")) {
                content.setCommentText(highlightFields.get("commentText").get(0));
            }
            
            // 这里简单演示将对象转为 Map返回
            Map<String, Object> sourceMap = elasticsearchRestTemplate.getObjectMapper().convertValue(content, Map.class);
            resultList.add(sourceMap);
        }

        return resultList;
    }
}

5. 总结

通过上述技术架构,实现了业务数据库与搜索引擎的解耦。MySQL 负责事务性数据存储,ES 负责高性能检索,Kafka 充当缓冲和削峰角色,Canal 保证了数据的实时流动。该方案不仅能支撑亿级数据的毫秒级检索,还能有效应对高并发写入场景,是企业级全文检索系统的标准答案。

相关推荐
zhangzeyuaaa1 小时前
Kafka 核心原理超通俗详解|Offset、消费组、分区、持久化一次讲透
分布式·kafka
隔壁阿布都1 小时前
Kafka `acks` 参数取值全解
分布式·kafka
lvrongbao3 小时前
Kafka 场景化面试题top5: 事务与分布式一致性
分布式·kafka
Elastic 中国社区官方博客3 小时前
Elasticsearch ES|QL “读取时模式”:你的未映射字段一直都在那里
大数据·数据库·sql·elasticsearch·搜索引擎·全文检索
zhangzeyuaaa3 小时前
深入剖析Kafka:Offset机制的底层基石——消息有序性
分布式·kafka
Elastic 中国社区官方博客3 小时前
Elasticsearch 查询日志:每个查询一行协调器级别日志,适用于 ES|QL、DSL、SQL 和 EQL
大数据·数据库·sql·elasticsearch·搜索引擎·全文检索·可用性测试
SuperherRo3 小时前
服务攻防-处理平台安全&消息队列&ActiveMQ&RocketMQ&Kafka&Spring包&CVE复现
kafka·消息队列·rocketmq·activemq
隔壁阿布都3 小时前
Kafka 核心组件及其作用(全解)
分布式·kafka
可观测性用观测云16 小时前
观测云日志转发至 Kafka 最佳实践
kafka