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 客戶端的依賴:
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.5</version>
</dependency>
然后編寫 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();
}
}
}
}
(四)搜索接口實現
在應用程序中,我們編寫一個搜索接口,接收用戶的搜索關鍵詞、價格范圍、庫存狀態等參數,然后構建 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;
}
}
五、踩坑指南
(一)分詞器選擇不當
在 ES 中,分詞器的選擇非常重要。如果選擇了不適合中文的分詞器,比如默認的標準分詞器,對中文的分詞效果就會很差,可能會把 “智能手機” 分成 “智能”“手機”,也可能分成 “智”“能”“手”“機”,這就會影響搜索的準確性。所以一定要根據業務需求選擇合適的分詞器,比如 ik 分詞器,并且可以自定義詞典,把一些品牌名、專業術語等添加進去,讓分詞更準確。
(二)數據同步延遲
使用 Canal 進行數據同步時,可能會存在一定的延遲。比如 MySQL 中數據已經更新了,但 ES 中還沒有同步過來,這時候用戶搜索可能就會找不到最新的數據。為了解決這個問題,我們可以在業務允許的范圍內,設置合適的同步延遲容忍時間,或者在一些對實時性要求非常高的場景,采用雙寫的方式,即在更新 MySQL 數據的同時,同步更新 ES 數據,但雙寫要注意事務的一致性,避免出現數據不一致的問題。
(三)ES 集群配置不合理
如果 ES 集群的節點數量、分片數、副本數等配置不合理,可能會導致搜索性能下降、集群不穩定等問題。比如分片數設置過多,會增加集群的管理成本和資源消耗;分片數設置過少,會影響搜索的并發處理能力。所以需要根據數據量、查詢并發量等因素,合理配置 ES 集群的參數。
(四)MySQL 索引優化不足
雖然我們在 ES 中進行模糊搜索,但 MySQL 作為數據源,在查詢完整數據時,如果表的索引設計不合理,也會影響查詢速度。比如在根據文檔 ID 到 MySQL 中查詢數據時,如果 ID 字段沒有建立索引,或者其他常用查詢字段沒有建立索引,就會導致查詢緩慢。所以要對 MySQL 的表進行合理的索引優化,確保常用的查詢操作能夠利用索引快速執行。
六、總結
ES 和 MySQL 結合起來搞模糊搜索,那可真是強強聯手。MySQL 負責存儲完整的業務數據,保證數據的完整性和一致性;ES 作為專業的搜索引擎,提供高效的模糊搜索、分詞、拼寫糾錯、相關性排序等功能,讓搜索體驗更上一層樓。
當然,在實際應用中,我們需要根據業務場景的特點,合理劃分兩者的使用場景,處理好數據同步、搜索實現等關鍵問題,同時也要注意各種可能出現的坑,做好優化和監控。
通過這樣的組合,我們可以實現一個既高效又準確的模糊搜索系統,讓用戶在搜索時能夠快速找到想要的內容,提升用戶體驗。而且,隨著業務的發展和數據量的增長,這種架構也具有一定的擴展性和靈活性,可以方便地進行集群擴展和功能升級。
所以,下次再遇到模糊搜索的需求,別再糾結只用 MySQL 還是只用 ES 了,試試它們的組合,說不定會有驚喜哦!