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

兄弟们,在咱们程序员的世界里,搜索功能那可是相当常见的需求。就说电商网站吧,用户想找 “白色运动鞋”,可能输入 “白鞋”“运动鞋白”,甚至拼写错误输成 “白运鞋”,这时候就需要模糊搜索来大显身手了。而在众多实现模糊搜索的技术中,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 );1.2.3.4.5.6.7.8.9.

(二)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" } } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

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

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

复制
<dependency> <groupId>com.alibaba.otter</groupId> <artifactId>canal.client</artifactId> <version>1.1.5</version> </dependency>1.2.3.4.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<CanalEntry.Column> columnsList = rowData.getAfterColumnsList(); Map<String, Object> 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<CanalEntry.Column> 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<String, Object> 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(); } } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.

(四)搜索接口实现

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

复制
public class SearchService { public List<Product> search(String keyword, double minPrice, double maxPrice, int minStock) { RestHighLevelClient client = EsClientFactory.getClient(); List<Product> 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<String, Object> 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; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.

五、踩坑指南

(一)分词器选择不当

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

(二)数据同步延迟

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

(三)ES 集群配置不合理

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

(四)MySQL 索引优化不足

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

六、总结

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

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

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

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

阅读剩余
THE END