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

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

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

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