• ES+MySQL 搞模糊搜索能多秀?这套操作直接看呆!

ES+MySQL 搞模糊搜索能多秀?这套操作直接看呆!

2025-04-30 10:00:03 栏目:宝塔面板 47 阅读

兄弟们,在咱们程序员的世界里,搜索功能那可是相当常见的需求。就说电商网站吧,用户想找 “白色运动鞋”,可能输入 “白鞋”“运动鞋白”,甚至拼写错误输成 “白运鞋”,这时候就需要模糊搜索来大显身手了。而在众多实现模糊搜索的技术中,Elasticsearch(以下简称 ES)和 MySQL 是比较常用的,它们搭配起来能玩出什么花活呢?咱们今天就好好唠唠。

一、先聊聊 MySQL 的模糊搜索

咱先从大家熟悉的 MySQL 说起。MySQL 里实现模糊搜索,最常用的就是 LIKE 关键字了。比如说,我们有一个 products 表,里面有个 name 字段,想搜索名字里包含 “手机” 的产品,就可以用 SELECT * FROM products WHERE name LIKE '%手机%'。这看起来挺简单的,对吧?

但是呢,LIKE 操作在使用的时候可是有不少讲究的。如果我们用 LIKE '关键词%',也就是前缀匹配,这时候 MySQL 是可以利用索引的,因为索引是按照字符顺序存储的,前缀匹配可以快速定位到以关键词开头的记录。但如果是 LIKE '%关键词%',也就是全模糊匹配,这时候索引就大概率失效了,会进行全表扫描。要是表的数据量小,全表扫描还能接受,可要是数据量达到百万级、千万级,那查询速度可就惨不忍睹了,可能得好几秒甚至更长时间才能返回结果,用户体验那是相当差。

而且,MySQL 的模糊搜索还有一个问题,就是对中文的分词支持不太友好。比如说 “智能手机”,用户搜索 “智能” 或者 “手机”,用 LIKE '%智能%' 或者 LIKE '%手机%' 能找到,但如果用户搜索 “智能手”,就找不到了,因为 MySQL 不会把 “智能手机” 拆分成 “智能”“手机”“智能手” 等词汇,它只是简单地进行字符串匹配。

二、再看看 ES 的模糊搜索魔法

那 ES 为啥在模糊搜索方面表现出色呢?这就得从它的底层原理说起了。ES 是基于 Lucene 实现的,而 Lucene 使用的是倒排索引。倒排索引和我们平时用的字典很像,字典是根据字的顺序来查找对应的解释,倒排索引则是根据关键词来查找包含这个关键词的文档。

ES 在处理文本的时候,会先对文本进行分词。分词器就像是一个文本切割机,把一段文本切成一个个的词(术语)。比如对于 “智能手机是一种智能的移动设备” 这句话,分词器可能会切成 “智能”“手机”“是”“一种”“智能”“的”“移动”“设备” 等词。然后,ES 会把这些词和对应的文档 ID 存储到倒排索引中。当我们进行模糊搜索时,ES 会根据输入的关键词,在倒排索引中找到所有相关的词,然后找到对应的文档。

ES 支持多种模糊搜索的方式,比如 fuzzy 查询、match 查询、query_string 查询等。fuzzy 查询可以允许关键词有一定的拼写错误,比如搜索 “phne”,它可能会匹配到 “phone”。match 查询则会对输入的关键词进行分词,然后在倒排索引中查找匹配的词。而且 ES 还支持分词器的自定义,我们可以根据不同的语言、不同的业务需求,选择合适的分词器,比如中文分词器有 ik 分词器、jieba 分词器等,ik 分词器还支持自定义词典,我们可以把一些专业术语、品牌名称等添加到词典中,让分词更准确。

三、ES + MySQL 双剑合璧

既然 MySQL 和 ES 各有优缺点,那咱们能不能把它们结合起来,让模糊搜索既高效又准确呢?答案是肯定的。

(一)适用场景划分

一般来说,对于数据量较小、实时性要求不高、对搜索精度要求不是特别高的场景,我们可以直接使用 MySQL 的模糊搜索。比如一些小型的企业官网,产品数量不多,用户搜索频率也不高,这时候用 MySQL 就足够了。

而对于数据量大、搜索频率高、对搜索功能要求比较复杂(比如支持分词、拼写纠错、相关性排序等)的场景,比如电商平台、搜索引擎、新闻网站等,ES 就派上大用场了。但是呢,我们的业务系统往往不会只使用 ES 或者只使用 MySQL,而是两者结合,MySQL 作为数据源,存储完整的业务数据,ES 作为搜索引擎,提供高效的搜索服务。

(二)数据同步

既然要结合使用,那数据同步就是一个关键的问题了。我们需要把 MySQL 中的数据实时或者定时同步到 ES 中。数据同步的方式有很多种,比如通过应用层同步、通过数据库触发器同步、通过中间件同步等。这里咱们重点说一下通过 Canal 中间件来实现数据同步。

Canal 是阿里巴巴开源的一个分布式数据库同步工具,它模拟 MySQL 主从复制的原理,监听 MySQL 的 binlog 文件,获取数据的变更事件,然后将这些事件发送给消费者,消费者再将数据同步到 ES 中。

具体的实现步骤大概是这样的:首先,我们需要在 MySQL 中开启 binlog 功能,并且配置好主从复制。然后,安装 Canal 服务端,配置好要监听的 MySQL 实例信息。接着,开发 Canal 客户端,也就是消费者,用来接收 Canal 服务端发送过来的数据变更事件。在客户端中,我们需要解析这些事件,判断是插入、更新还是删除操作,然后根据操作类型对 ES 中的数据进行相应的处理。

比如说,当 MySQL 中有一条新的产品数据插入时,Canal 会捕获到这个插入事件,客户端接收到后,会从事件中获取到新插入的数据,然后将这条数据按照 ES 的文档格式,插入到 ES 的索引中。当 MySQL 中的数据发生更新时,客户端会根据更新后的数据,更新 ES 中对应的文档。当数据被删除时,客户端会删除 ES 中对应的文档。

(三)搜索实现

在搜索的时候,我们的应用程序会先向 ES 发送搜索请求,ES 处理搜索请求,返回相关的文档 ID 和排序结果等信息。然后,应用程序再根据这些文档 ID 到 MySQL 中查询完整的业务数据,这样就可以避免在 ES 中存储过多的非搜索相关的数据,减轻 ES 的存储压力。

比如说,用户在电商平台搜索 “笔记本电脑”,应用程序会向 ES 发送搜索请求,ES 根据分词器将 “笔记本电脑” 拆分成 “笔记本”“电脑” 等词,然后在倒排索引中找到包含这些词的文档 ID,并且根据相关性算法对这些文档进行排序,返回给应用程序。应用程序拿到这些文档 ID 后,再到 MySQL 中查询对应的产品详情、价格、库存等完整数据,展示给用户。

四、实战案例走一波

咱们假设现在要开发一个电商平台的搜索模块,商品表 products 中有 id、name、description、price、stock 等字段。我们需要实现对商品名称和描述的模糊搜索,同时支持价格和库存的过滤,还要根据相关性对搜索结果进行排序。

(一)MySQL 表结构设计

CREATE TABLE products (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    description TEXT,
    price DECIMAL(10, 2) NOT NULL,
    stock INT NOT NULL,
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

(二)ES 索引设计

我们创建一个名为 products 的索引,设置合适的映射(Mapping)。对于 name 和 description 字段,我们使用 text 类型,并指定分词器为 ik 分词器,同时为了支持精确查询,再添加一个 keyword 子字段。

{
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "name": {
        "type": "text",
        "analyzer": "ik_max_word",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "stock": { "type": "integer" },
      "create_time": { "type": "date" },
      "update_time": { "type": "date" }
    }
  }
}

(三)数据同步代码(以 Java 为例)

这里使用 Canal 的 Java 客户端来实现数据同步。首先引入 Canal 客户端的依赖:


    com.alibaba.otter
    canal.client
    1.1.5

然后编写 Canal 客户端代码:

public class CanalClient {
    private static final String SERVER_IP = "127.0.0.1";
    private static final int PORT = 11111;
    private static final String DESTINATION = "example";
    public static void main(String[] args) {
        CanalConnector connector = CanalConnectors.newClusterConnector(SERVER_IP, PORT, DESTINATION, "", "");
        connector.connect();
        connector.subscribe(".*..*");
        connector.rollback();
        while (true) {
            Message message = connector.get(100);
            if (message.getEntries().isEmpty()) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                continue;
            }
            for (CanalEntry.Entry entry : message.getEntries()) {
                if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN || entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
                    continue;
                }
                CanalEntry.RowChange rowChange;
                try {
                    rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                } catch (Exception e) {
                    throw new RuntimeException("解析 rowChange 失败", e);
                }
                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    if (rowChange.getEventType() == CanalEntry.EventType.INSERT || rowChange.getEventType() == CanalEntry.EventType.UPDATE || rowChange.getEventType() == CanalEntry.EventType.DELETE) {
                        // 处理数据变更
                        handleRowData(rowData, rowChange.getEventType());
                    }
                }
            }
        }
    }
    private static void handleRowData(CanalEntry.RowData rowData, CanalEntry.EventType eventType) {
        // 获取表名
        String tableName = rowData.getTable();
        if (!"products".equals(tableName)) {
            return;
        }
        // 根据事件类型处理数据
        if (eventType == CanalEntry.EventType.INSERT || eventType == CanalEntry.EventType.UPDATE) {
            // 插入或更新数据,获取新数据
            List columnsList = rowData.getAfterColumnsList();
            Map data = new HashMap<>();
            for (CanalEntry.Column column : columnsList) {
                data.put(column.getName(), column.getValue());
            }
            // 将数据同步到 ES
            syncToES(data, eventType == CanalEntry.EventType.UPDATE);
        } else if (eventType == CanalEntry.EventType.DELETE) {
            // 删除数据,获取旧数据中的 id
            List columnsList = rowData.getBeforeColumnsList();
            String id = null;
            for (CanalEntry.Column column : columnsList) {
                if ("id".equals(column.getName())) {
                    id = column.getValue();
                    break;
                }
            }
            if (id != null) {
                // 从 ES 中删除对应文档
                deleteFromES(id);
            }
        }
    }
    private static void syncToES(Map data, boolean isUpdate) {
        // 这里编写将数据同步到 ES 的代码,使用 Elasticsearch Java 客户端
        // 例如,构建一个 Document,设置 ID 为 data 中的 id
        // 如果是更新,使用 update 方法;如果是插入,使用 index 方法
        // 这里只是一个示例,实际代码需要根据具体的 ES 客户端版本和配置来编写
        String id = data.get("id").toString();
        // 创建客户端连接 ES
        RestHighLevelClient client = EsClientFactory.getClient();
        try {
            IndexRequest request = new IndexRequest("products");
            request.id(id);
            // 将 data 转换为 JSON 对象
            JSONObject jsonObject = new JSONObject(data);
            request.source(jsonObject.toJSONString(), XContentType.JSON);
            if (isUpdate) {
                // 更新操作,这里其实应该用 UpdateRequest,但为了简单示例,先这样写,实际需要根据业务调整
                client.index(request, RequestOptions.DEFAULT);
            } else {
                client.index(request, RequestOptions.DEFAULT);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 关闭客户端,这里简化处理,实际应使用连接池等方式管理客户端
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    private static void deleteFromES(String id) {
        // 编写从 ES 中删除文档的代码
        RestHighLevelClient client = EsClientFactory.getClient();
        try {
            DeleteRequest request = new DeleteRequest("products", id);
            client.delete(request, RequestOptions.DEFAULT);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

(四)搜索接口实现

在应用程序中,我们编写一个搜索接口,接收用户的搜索关键词、价格范围、库存状态等参数,然后构建 ES 查询请求。

public class SearchService {
    public List search(String keyword, double minPrice, double maxPrice, int minStock) {
        RestHighLevelClient client = EsClientFactory.getClient();
        List products = new ArrayList<>();
        try {
            // 构建布尔查询
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
            // 模糊搜索名称和描述
            MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(keyword, "name", "description")
               .field("name", 2) // 给名称字段更高的权重
               .analyzer("ik_max_word")
               .minimumShouldMatch("75%"); // 至少匹配 75% 的分词
            boolQuery.must(multiMatchQuery);
            // 价格过滤
            if (minPrice > 0 || maxPrice > 0) {
                RangeQueryBuilder priceRangeQuery = QueryBuilders.rangeQuery("price")
                   .from(minPrice).to(maxPrice);
                boolQuery.filter(priceRangeQuery);
            }
            // 库存过滤
            if (minStock > 0) {
                RangeQueryBuilder stockRangeQuery = QueryBuilders.rangeQuery("stock")
                   .from(minStock).to(Integer.MAX_VALUE);
                boolQuery.filter(stockRangeQuery);
            }
            // 构建搜索请求
            SearchRequest searchRequest = new SearchRequest("products");
            searchRequest.source(new SearchSourceBuilder()
               .query(boolQuery)
               .sort("score", SortOrder.DESC) // 根据相关性得分排序
               .size(100)); // 返回前 100 条结果
            SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
            // 解析搜索结果
            for (SearchHit hit : searchResponse.getHits().getHits()) {
                String id = hit.getId();
                Map sourceAsMap = hit.getSourceAsMap();
                Product product = new Product();
                product.setId(Long.parseLong(id));
                product.setName((String) sourceAsMap.get("name"));
                product.setDescription((String) sourceAsMap.get("description"));
                product.setPrice((Double) sourceAsMap.get("price"));
                product.setStock((Integer) sourceAsMap.get("stock"));
                products.add(product);
            }
            // 根据文档 ID 到 MySQL 中查询完整数据,这里简化处理,假设 ES 中已经存储了完整数据,实际应根据业务需求调整
            // 这里只是示例,实际项目中可能需要根据 ID 到 MySQL 中查询更详细的信息,比如库存是否有变化等
            // 但为了搜索效率,通常会在 ES 中存储需要展示的基本信息,完整数据在需要详情时再从 MySQL 中查询
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return products;
    }
}


五、踩坑指南

(一)分词器选择不当

在 ES 中,分词器的选择非常重要。如果选择了不适合中文的分词器,比如默认的标准分词器,对中文的分词效果就会很差,可能会把 “智能手机” 分成 “智能”“手机”,也可能分成 “智”“能”“手”“机”,这就会影响搜索的准确性。所以一定要根据业务需求选择合适的分词器,比如 ik 分词器,并且可以自定义词典,把一些品牌名、专业术语等添加进去,让分词更准确。

(二)数据同步延迟

使用 Canal 进行数据同步时,可能会存在一定的延迟。比如 MySQL 中数据已经更新了,但 ES 中还没有同步过来,这时候用户搜索可能就会找不到最新的数据。为了解决这个问题,我们可以在业务允许的范围内,设置合适的同步延迟容忍时间,或者在一些对实时性要求非常高的场景,采用双写的方式,即在更新 MySQL 数据的同时,同步更新 ES 数据,但双写要注意事务的一致性,避免出现数据不一致的问题。

(三)ES 集群配置不合理

如果 ES 集群的节点数量、分片数、副本数等配置不合理,可能会导致搜索性能下降、集群不稳定等问题。比如分片数设置过多,会增加集群的管理成本和资源消耗;分片数设置过少,会影响搜索的并发处理能力。所以需要根据数据量、查询并发量等因素,合理配置 ES 集群的参数。

(四)MySQL 索引优化不足

虽然我们在 ES 中进行模糊搜索,但 MySQL 作为数据源,在查询完整数据时,如果表的索引设计不合理,也会影响查询速度。比如在根据文档 ID 到 MySQL 中查询数据时,如果 ID 字段没有建立索引,或者其他常用查询字段没有建立索引,就会导致查询缓慢。所以要对 MySQL 的表进行合理的索引优化,确保常用的查询操作能够利用索引快速执行。

六、总结

ES 和 MySQL 结合起来搞模糊搜索,那可真是强强联手。MySQL 负责存储完整的业务数据,保证数据的完整性和一致性;ES 作为专业的搜索引擎,提供高效的模糊搜索、分词、拼写纠错、相关性排序等功能,让搜索体验更上一层楼。

当然,在实际应用中,我们需要根据业务场景的特点,合理划分两者的使用场景,处理好数据同步、搜索实现等关键问题,同时也要注意各种可能出现的坑,做好优化和监控。

通过这样的组合,我们可以实现一个既高效又准确的模糊搜索系统,让用户在搜索时能够快速找到想要的内容,提升用户体验。而且,随着业务的发展和数据量的增长,这种架构也具有一定的扩展性和灵活性,可以方便地进行集群扩展和功能升级。

所以,下次再遇到模糊搜索的需求,别再纠结只用 MySQL 还是只用 ES 了,试试它们的组合,说不定会有惊喜哦!

本文地址:https://www.yitenyun.com/176.html

搜索文章

Tags

数据库 API FastAPI Calcite 电商系统 MySQL 数据同步 ACK 双主架构 循环复制 Web 应用 异步数据库 序列 核心机制 生命周期 Deepseek 宝塔面板 Linux宝塔 Docker JumpServer JumpServer安装 堡垒机安装 Linux安装JumpServer esxi esxi6 root密码不对 无法登录 web无法登录 Windows Windows server net3.5 .NET 安装出错 宝塔面板打不开 宝塔面板无法访问 SSL 堡垒机 跳板机 HTTPS Windows宝塔 Mysql重置密码 无法访问宝塔面板 HTTPS加密 查看硬件 Linux查看硬件 Linux查看CPU Linux查看内存 ES 协同 修改DNS Centos7如何修改DNS scp Linux的scp怎么用 scp上传 scp下载 scp命令 防火墙 服务器 黑客 Serverless 无服务器 语言 存储 Spring SQL 动态查询 Oracle 处理机制 Linux 安全 网络架构 工具 网络配置 加密 场景 Canal MySQL 9.3 开源 PostgreSQL 存储引擎 RocketMQ 长轮询 配置 HexHub Rsync 架构 InnoDB 缓存方案 缓存架构 缓存穿透 信息化 智能运维 响应模型 日志文件 MIXED 3 线上 库存 预扣 索引 数据 业务 监控 单点故障 聚簇 非聚簇 B+Tree ID 字段 AI 助手 数据库锁 分库 分表 云原生 GreatSQL Hash 字段 DBMS 管理系统 SpringAI Redis 自定义序列化 优化 万能公式 Redis 8.0 SQLite Redka ​Redis 机器学习 推荐模型 SVM Embedding PostGIS 系统 SQLark openHalo OB 单机版 数据集成工具 sqlmock 虚拟服务器 虚拟机 内存 分页查询 Netstat Linux 服务器 端口 prometheus Alert SQLite-Web 数据库管理工具 自动重启 运维 同城 双活 缓存 sftp 服务器 参数 共享锁 • 索引 • 数据库 RDB AOF 排行榜 排序 Testcloud 云端自动化 查询 EasyExcel MySQL8 向量数据库 大模型 不宕机 容器化 分布式架构 分布式锁​ 聚簇索引 非聚簇索引 OAuth2 Token Entity 开发 技术 IT 数据备份 Postgres OTel Iceberg MongoDB 容器 数据类型 StarRocks 数据仓库 Doris SeaTunnel 人工智能 推荐系统 分页 数据结构 连接控制 机制 AIOPS Python Web LRU Milvus IT运维 Caffeine CP 部署 崖山 新版本 高可用 向量库 悲观锁 乐观锁 Ftp 池化技术 连接池 磁盘架构 MCP mini-redis INCR指令 redo log 重做日志 单线程 线程 MVCC 事务隔离 流量 开放协议 Web 接口 字典 电商 微软 SQL Server AI功能 速度 服务器中毒 数据脱敏 加密算法 R2DBC QPS 高并发 原子性 对象 窗口 函数 双引擎 RAG HelixDB 频繁 Codis Crash 代码 主库 Order SSH 引擎 性能 网络 dbt 数据转换工具 1 PG DBA ZODB List 类型 Pottery 工具链 优化器 意向锁 记录锁 InfluxDB 模型 事务同步 网络故障 传统数据库 向量化 发件箱模式 UUIDv7 主键 仪表盘 Redisson 锁芯 LLM 线程安全 INSERT COMPACT Undo Log 连接数 订单 JOIN