ES+MySQL 搞模糊搜索能多秀?這套操作直接看呆!
兄弟們,在咱們程序員的世界里,搜索功能那可是相當(dāng)常見的需求。就說電商網(wǎng)站吧,用戶想找 “白色運動鞋”,可能輸入 “白鞋”“運動鞋白”,甚至拼寫錯誤輸成 “白運鞋”,這時候就需要模糊搜索來大顯身手了。而在眾多實現(xiàn)模糊搜索的技術(shù)中,Elasticsearch(以下簡稱 ES)和 MySQL 是比較常用的,它們搭配起來能玩出什么花活呢?咱們今天就好好嘮嘮。
一、先聊聊 MySQL 的模糊搜索
咱先從大家熟悉的 MySQL 說起。MySQL 里實現(xiàn)模糊搜索,最常用的就是 LIKE 關(guān)鍵字了。比如說,我們有一個 products 表,里面有個 name 字段,想搜索名字里包含 “手機” 的產(chǎn)品,就可以用 SELECT * FROM products WHERE name LIKE '%手機%'。這看起來挺簡單的,對吧?
但是呢,LIKE 操作在使用的時候可是有不少講究的。如果我們用 LIKE '關(guān)鍵詞%',也就是前綴匹配,這時候 MySQL 是可以利用索引的,因為索引是按照字符順序存儲的,前綴匹配可以快速定位到以關(guān)鍵詞開頭的記錄。但如果是 LIKE '%關(guān)鍵詞%',也就是全模糊匹配,這時候索引就大概率失效了,會進行全表掃描。要是表的數(shù)據(jù)量小,全表掃描還能接受,可要是數(shù)據(jù)量達到百萬級、千萬級,那查詢速度可就慘不忍睹了,可能得好幾秒甚至更長時間才能返回結(jié)果,用戶體驗?zāi)鞘窍喈?dāng)差。
而且,MySQL 的模糊搜索還有一個問題,就是對中文的分詞支持不太友好。比如說 “智能手機”,用戶搜索 “智能” 或者 “手機”,用 LIKE '%智能%' 或者 LIKE '%手機%' 能找到,但如果用戶搜索 “智能手”,就找不到了,因為 MySQL 不會把 “智能手機” 拆分成 “智能”“手機”“智能手” 等詞匯,它只是簡單地進行字符串匹配。
二、再看看 ES 的模糊搜索魔法
那 ES 為啥在模糊搜索方面表現(xiàn)出色呢?這就得從它的底層原理說起了。ES 是基于 Lucene 實現(xiàn)的,而 Lucene 使用的是倒排索引。倒排索引和我們平時用的字典很像,字典是根據(jù)字的順序來查找對應(yīng)的解釋,倒排索引則是根據(jù)關(guān)鍵詞來查找包含這個關(guān)鍵詞的文檔。
ES 在處理文本的時候,會先對文本進行分詞。分詞器就像是一個文本切割機,把一段文本切成一個個的詞(術(shù)語)。比如對于 “智能手機是一種智能的移動設(shè)備” 這句話,分詞器可能會切成 “智能”“手機”“是”“一種”“智能”“的”“移動”“設(shè)備” 等詞。然后,ES 會把這些詞和對應(yīng)的文檔 ID 存儲到倒排索引中。當(dāng)我們進行模糊搜索時,ES 會根據(jù)輸入的關(guān)鍵詞,在倒排索引中找到所有相關(guān)的詞,然后找到對應(yīng)的文檔。
ES 支持多種模糊搜索的方式,比如 fuzzy 查詢、match 查詢、query_string 查詢等。fuzzy 查詢可以允許關(guān)鍵詞有一定的拼寫錯誤,比如搜索 “phne”,它可能會匹配到 “phone”。match 查詢則會對輸入的關(guān)鍵詞進行分詞,然后在倒排索引中查找匹配的詞。而且 ES 還支持分詞器的自定義,我們可以根據(jù)不同的語言、不同的業(yè)務(wù)需求,選擇合適的分詞器,比如中文分詞器有 ik 分詞器、jieba 分詞器等,ik 分詞器還支持自定義詞典,我們可以把一些專業(yè)術(shù)語、品牌名稱等添加到詞典中,讓分詞更準(zhǔn)確。
三、ES + MySQL 雙劍合璧
既然 MySQL 和 ES 各有優(yōu)缺點,那咱們能不能把它們結(jié)合起來,讓模糊搜索既高效又準(zhǔn)確呢?答案是肯定的。
(一)適用場景劃分
一般來說,對于數(shù)據(jù)量較小、實時性要求不高、對搜索精度要求不是特別高的場景,我們可以直接使用 MySQL 的模糊搜索。比如一些小型的企業(yè)官網(wǎng),產(chǎn)品數(shù)量不多,用戶搜索頻率也不高,這時候用 MySQL 就足夠了。
而對于數(shù)據(jù)量大、搜索頻率高、對搜索功能要求比較復(fù)雜(比如支持分詞、拼寫糾錯、相關(guān)性排序等)的場景,比如電商平臺、搜索引擎、新聞網(wǎng)站等,ES 就派上大用場了。但是呢,我們的業(yè)務(wù)系統(tǒng)往往不會只使用 ES 或者只使用 MySQL,而是兩者結(jié)合,MySQL 作為數(shù)據(jù)源,存儲完整的業(yè)務(wù)數(shù)據(jù),ES 作為搜索引擎,提供高效的搜索服務(wù)。
(二)數(shù)據(jù)同步
既然要結(jié)合使用,那數(shù)據(jù)同步就是一個關(guān)鍵的問題了。我們需要把 MySQL 中的數(shù)據(jù)實時或者定時同步到 ES 中。數(shù)據(jù)同步的方式有很多種,比如通過應(yīng)用層同步、通過數(shù)據(jù)庫觸發(fā)器同步、通過中間件同步等。這里咱們重點說一下通過 Canal 中間件來實現(xiàn)數(shù)據(jù)同步。
Canal 是阿里巴巴開源的一個分布式數(shù)據(jù)庫同步工具,它模擬 MySQL 主從復(fù)制的原理,監(jiān)聽 MySQL 的 binlog 文件,獲取數(shù)據(jù)的變更事件,然后將這些事件發(fā)送給消費者,消費者再將數(shù)據(jù)同步到 ES 中。
具體的實現(xiàn)步驟大概是這樣的:首先,我們需要在 MySQL 中開啟 binlog 功能,并且配置好主從復(fù)制。然后,安裝 Canal 服務(wù)端,配置好要監(jiān)聽的 MySQL 實例信息。接著,開發(fā) Canal 客戶端,也就是消費者,用來接收 Canal 服務(wù)端發(fā)送過來的數(shù)據(jù)變更事件。在客戶端中,我們需要解析這些事件,判斷是插入、更新還是刪除操作,然后根據(jù)操作類型對 ES 中的數(shù)據(jù)進行相應(yīng)的處理。
比如說,當(dāng) MySQL 中有一條新的產(chǎn)品數(shù)據(jù)插入時,Canal 會捕獲到這個插入事件,客戶端接收到后,會從事件中獲取到新插入的數(shù)據(jù),然后將這條數(shù)據(jù)按照 ES 的文檔格式,插入到 ES 的索引中。當(dāng) MySQL 中的數(shù)據(jù)發(fā)生更新時,客戶端會根據(jù)更新后的數(shù)據(jù),更新 ES 中對應(yīng)的文檔。當(dāng)數(shù)據(jù)被刪除時,客戶端會刪除 ES 中對應(yīng)的文檔。
(三)搜索實現(xiàn)
在搜索的時候,我們的應(yīng)用程序會先向 ES 發(fā)送搜索請求,ES 處理搜索請求,返回相關(guān)的文檔 ID 和排序結(jié)果等信息。然后,應(yīng)用程序再根據(jù)這些文檔 ID 到 MySQL 中查詢完整的業(yè)務(wù)數(shù)據(jù),這樣就可以避免在 ES 中存儲過多的非搜索相關(guān)的數(shù)據(jù),減輕 ES 的存儲壓力。
比如說,用戶在電商平臺搜索 “筆記本電腦”,應(yīng)用程序會向 ES 發(fā)送搜索請求,ES 根據(jù)分詞器將 “筆記本電腦” 拆分成 “筆記本”“電腦” 等詞,然后在倒排索引中找到包含這些詞的文檔 ID,并且根據(jù)相關(guān)性算法對這些文檔進行排序,返回給應(yīng)用程序。應(yīng)用程序拿到這些文檔 ID 后,再到 MySQL 中查詢對應(yīng)的產(chǎn)品詳情、價格、庫存等完整數(shù)據(jù),展示給用戶。
四、實戰(zhàn)案例走一波
咱們假設(shè)現(xiàn)在要開發(fā)一個電商平臺的搜索模塊,商品表 products 中有 id、name、description、price、stock 等字段。我們需要實現(xiàn)對商品名稱和描述的模糊搜索,同時支持價格和庫存的過濾,還要根據(jù)相關(guān)性對搜索結(jié)果進行排序。
(一)MySQL 表結(jié)構(gòu)設(shè)計
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 索引設(shè)計
我們創(chuàng)建一個名為 products 的索引,設(shè)置合適的映射(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" }
}
}
}
(三)數(shù)據(jù)同步代碼(以 Java 為例)
這里使用 Canal 的 Java 客戶端來實現(xiàn)數(shù)據(jù)同步。首先引入 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) {
// 處理數(shù)據(jù)變更
handleRowData(rowData, rowChange.getEventType());
}
}
}
}
}
private static void handleRowData(CanalEntry.RowData rowData, CanalEntry.EventType eventType) {
// 獲取表名
String tableName = rowData.getTable();
if (!"products".equals(tableName)) {
return;
}
// 根據(jù)事件類型處理數(shù)據(jù)
if (eventType == CanalEntry.EventType.INSERT || eventType == CanalEntry.EventType.UPDATE) {
// 插入或更新數(shù)據(jù),獲取新數(shù)據(jù)
List<CanalEntry.Column> columnsList = rowData.getAfterColumnsList();
Map<String, Object> data = new HashMap<>();
for (CanalEntry.Column column : columnsList) {
data.put(column.getName(), column.getValue());
}
// 將數(shù)據(jù)同步到 ES
syncToES(data, eventType == CanalEntry.EventType.UPDATE);
} else if (eventType == CanalEntry.EventType.DELETE) {
// 刪除數(shù)據(jù),獲取舊數(shù)據(jù)中的 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 中刪除對應(yīng)文檔
deleteFromES(id);
}
}
}
private static void syncToES(Map<String, Object> data, boolean isUpdate) {
// 這里編寫將數(shù)據(jù)同步到 ES 的代碼,使用 Elasticsearch Java 客戶端
// 例如,構(gòu)建一個 Document,設(shè)置 ID 為 data 中的 id
// 如果是更新,使用 update 方法;如果是插入,使用 index 方法
// 這里只是一個示例,實際代碼需要根據(jù)具體的 ES 客戶端版本和配置來編寫
String id = data.get("id").toString();
// 創(chuàng)建客戶端連接 ES
RestHighLevelClient client = EsClientFactory.getClient();
try {
IndexRequest request = new IndexRequest("products");
request.id(id);
// 將 data 轉(zhuǎn)換為 JSON 對象
JSONObject jsonObject = new JSONObject(data);
request.source(jsonObject.toJSONString(), XContentType.JSON);
if (isUpdate) {
// 更新操作,這里其實應(yīng)該用 UpdateRequest,但為了簡單示例,先這樣寫,實際需要根據(jù)業(yè)務(wù)調(diào)整
client.index(request, RequestOptions.DEFAULT);
} else {
client.index(request, RequestOptions.DEFAULT);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 關(guān)閉客戶端,這里簡化處理,實際應(yīng)使用連接池等方式管理客戶端
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();
}
}
}
}
(四)搜索接口實現(xiàn)
在應(yīng)用程序中,我們編寫一個搜索接口,接收用戶的搜索關(guān)鍵詞、價格范圍、庫存狀態(tài)等參數(shù),然后構(gòu)建 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 {
// 構(gòu)建布爾查詢
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 模糊搜索名稱和描述
MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(keyword, "name", "description")
.field("name", 2) // 給名稱字段更高的權(quán)重
.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);
}
// 構(gòu)建搜索請求
SearchRequest searchRequest = new SearchRequest("products");
searchRequest.source(new SearchSourceBuilder()
.query(boolQuery)
.sort("score", SortOrder.DESC) // 根據(jù)相關(guān)性得分排序
.size(100)); // 返回前 100 條結(jié)果
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
// 解析搜索結(jié)果
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);
}
// 根據(jù)文檔 ID 到 MySQL 中查詢完整數(shù)據(jù),這里簡化處理,假設(shè) ES 中已經(jīng)存儲了完整數(shù)據(jù),實際應(yīng)根據(jù)業(yè)務(wù)需求調(diào)整
// 這里只是示例,實際項目中可能需要根據(jù) ID 到 MySQL 中查詢更詳細(xì)的信息,比如庫存是否有變化等
// 但為了搜索效率,通常會在 ES 中存儲需要展示的基本信息,完整數(shù)據(jù)在需要詳情時再從 MySQL 中查詢
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return products;
}
}
五、踩坑指南
(一)分詞器選擇不當(dāng)
在 ES 中,分詞器的選擇非常重要。如果選擇了不適合中文的分詞器,比如默認(rèn)的標(biāo)準(zhǔn)分詞器,對中文的分詞效果就會很差,可能會把 “智能手機” 分成 “智能”“手機”,也可能分成 “智”“能”“手”“機”,這就會影響搜索的準(zhǔn)確性。所以一定要根據(jù)業(yè)務(wù)需求選擇合適的分詞器,比如 ik 分詞器,并且可以自定義詞典,把一些品牌名、專業(yè)術(shù)語等添加進去,讓分詞更準(zhǔn)確。
(二)數(shù)據(jù)同步延遲
使用 Canal 進行數(shù)據(jù)同步時,可能會存在一定的延遲。比如 MySQL 中數(shù)據(jù)已經(jīng)更新了,但 ES 中還沒有同步過來,這時候用戶搜索可能就會找不到最新的數(shù)據(jù)。為了解決這個問題,我們可以在業(yè)務(wù)允許的范圍內(nèi),設(shè)置合適的同步延遲容忍時間,或者在一些對實時性要求非常高的場景,采用雙寫的方式,即在更新 MySQL 數(shù)據(jù)的同時,同步更新 ES 數(shù)據(jù),但雙寫要注意事務(wù)的一致性,避免出現(xiàn)數(shù)據(jù)不一致的問題。
(三)ES 集群配置不合理
如果 ES 集群的節(jié)點數(shù)量、分片數(shù)、副本數(shù)等配置不合理,可能會導(dǎo)致搜索性能下降、集群不穩(wěn)定等問題。比如分片數(shù)設(shè)置過多,會增加集群的管理成本和資源消耗;分片數(shù)設(shè)置過少,會影響搜索的并發(fā)處理能力。所以需要根據(jù)數(shù)據(jù)量、查詢并發(fā)量等因素,合理配置 ES 集群的參數(shù)。
(四)MySQL 索引優(yōu)化不足
雖然我們在 ES 中進行模糊搜索,但 MySQL 作為數(shù)據(jù)源,在查詢完整數(shù)據(jù)時,如果表的索引設(shè)計不合理,也會影響查詢速度。比如在根據(jù)文檔 ID 到 MySQL 中查詢數(shù)據(jù)時,如果 ID 字段沒有建立索引,或者其他常用查詢字段沒有建立索引,就會導(dǎo)致查詢緩慢。所以要對 MySQL 的表進行合理的索引優(yōu)化,確保常用的查詢操作能夠利用索引快速執(zhí)行。
六、總結(jié)
ES 和 MySQL 結(jié)合起來搞模糊搜索,那可真是強強聯(lián)手。MySQL 負(fù)責(zé)存儲完整的業(yè)務(wù)數(shù)據(jù),保證數(shù)據(jù)的完整性和一致性;ES 作為專業(yè)的搜索引擎,提供高效的模糊搜索、分詞、拼寫糾錯、相關(guān)性排序等功能,讓搜索體驗更上一層樓。
當(dāng)然,在實際應(yīng)用中,我們需要根據(jù)業(yè)務(wù)場景的特點,合理劃分兩者的使用場景,處理好數(shù)據(jù)同步、搜索實現(xiàn)等關(guān)鍵問題,同時也要注意各種可能出現(xiàn)的坑,做好優(yōu)化和監(jiān)控。
通過這樣的組合,我們可以實現(xiàn)一個既高效又準(zhǔn)確的模糊搜索系統(tǒng),讓用戶在搜索時能夠快速找到想要的內(nèi)容,提升用戶體驗。而且,隨著業(yè)務(wù)的發(fā)展和數(shù)據(jù)量的增長,這種架構(gòu)也具有一定的擴展性和靈活性,可以方便地進行集群擴展和功能升級。
所以,下次再遇到模糊搜索的需求,別再糾結(jié)只用 MySQL 還是只用 ES 了,試試它們的組合,說不定會有驚喜哦!