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
);
(二)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 了,试试它们的组合,说不定会有惊喜哦!