最近在處理一個用戶端搜索速度慢的問題,表數據100w+, 需要搜索產品名稱和主要信息欄位, 之前的業務中直接使用了LIKE,導致查詢時間有時能達到10s+。那最簡單的處理手段就是使用mysql自帶的全文索引來實現基礎的查詢(如果要使用ES等花費代價太大)。
0x00 概念
全文索引(FULL TEXT INDEX)與其他索引類似, 只是它會拆分指定列內容裏面的詞語, 並分析出現的頻率。
0x01 版本支持
開始之前,先說一下全文索引的版本、存儲引擎、數據類型的支持情況:
MySQL 5.6 以前的版本,只有 MyISAM 存儲引擎支持全文索引;
MySQL 5.6 及以後的版本,MyISAM 和 InnoDB 存儲引擎均支持全文索引;
只有字段的數據類型爲 char、varchar、text 及其系列纔可以建全文索引。
測試或使用全文索引時,要先看一下自己的 MySQL 版本、存儲引擎和數據類型是否支持全文索引。
0x02 全文索引的配置
執行SHOW VARIABLES LIKE '%ft_%'
:
上圖中下你是了與全文索引有關的2個分詞長度(最大/小搜索長度)設置:
MyISAM: ft_max_word_len, ft_min_word_len
InnoDB: inodb_ft_max_token_size, innodb_ft_min_token_size
有關min的配置, 是分詞最小長度. 對於英文來說, 是直接按空格來拆分單詞, 中文分詞就需要用到組件ngram
. 對於拆分出來的詞語長度不小於最小配置的詞才進行索引(統計)。與分詞有關的"停止詞"(stopword)請自行度娘。
innodb引擎默認只對單詞長度 >= 3的進行索引,我們先設置爲1試試。
在mysql配置文件的mysqld節點下添加設置:
[mysqld]
innodb_ft_min_token_size = 1
然後重啓服務。
0x03 全文索引操作
-- 建表
CREATE TABLE `test` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`content` text NOT NULL,
PRIMARY KEY(id)
) ENGINE=InnoDB;
-- 建索引
ALTER TABLE `test` ADD FULLTEXT INDEX `FIX_content`(`content`) WITH PARSER ngram; /*這裏必須指定中文分詞器*/
-- 插入數據
INSERT INTO `test` (`content`) VALUES ('我'),('你'),('他')
,('我們'),('你們'),('他們')
,('我們的'),('你們的'),('他們的')
,('我們的愛'),('你們的貓'),('他們的狗')
,('我們的愛永久'),('你們的貓很可愛'),('他們的狗很兇猛')
,('我們的愛永久不變'),('你們的貓很可愛的樣子'),('他們的狗很兇猛的樣子');
如果修改了單詞長度限制參數, 則建議重建索引, 先執行 ALTER TABLE test DROP INDEX FIX_content
然後再add即可。
和普通索引一樣, 全文索引頁支持對多列操作:
ALTER TABLE tbname ADD FULLTEXT INDEX ix_name(col1, col2, ...)
0x04 全文索引的查詢
語法是:
MATCH(col [,col...]) AGAINST('keyword')
那我們來查詢看看:
SELECT *, MATCH(content) AGAINST('我們') AS rel FROM `test` WHERE MATCH(content) AGAINST('我們');
執行結果:
這個查詢結果比較正常,是我們所期望的。
再看一下mysql自動分詞的查詢:
SELECT *, MATCH(content) AGAINST('我們的愛') AS rel FROM `test` WHERE MATCH(content) AGAINST('我們的愛');
執行結果:
默認是按相關度進行排序的, 但是後面那幾個數據,相關度太低, 就不是我們想要的了。這個是否與我們前面設置的最小分詞數等於1有關呢?
我們現在去設置 innodb_ft_min_token_size = 2:
[mysqld]]
innodb_ft_min_token_size = 2
重啓mysql, 重建全文索引:
ALTER TABLE `test` DROP INDEX `FIX_content`;
ALTER TABLE `test` ADD FULLTEXT INDEX `FIX_content`(`content`) WITH PARSER ngram; /*這裏必須指定中文分詞器*/
通過上圖的參數查詢結果能看到, 設置已經生效. 然後重新執行查詢:
SELECT *, MATCH(content) AGAINST('我們的愛') AS rel FROM `test` WHERE MATCH(content) AGAINST('我們的愛');
結果還是那樣, oh my god! 從測試來看,類似“的”這種助詞,mysql8.0 默認的全文索引查詢仍然沒有處理好。
我們換個關鍵詞:
SELECT *, MATCH(content) AGAINST('我們不變') AS rel FROM `test` WHERE MATCH(content) AGAINST('我們不變');
上面我們查詢"我們不變", 系統拆分並自動匹配了"我們" 和"不變"。
我們現在配置的最小搜索長度是2, 那我們搜索一個字行不行呢?
sorry, 搜索詞長度 < 全文索引設置的最小搜索長度 是沒有結果的。
所以這個最小搜索長度, 無法限制mysql查詢時分詞的長度.
0x05 兩種全文索引
自然語言的全文索引 (默認)
默認情況下,或者使用 IN NATURAL LANGUAGE MODE 修飾符時,match() 函數對文本集合執行自然語言搜索,上面的例子都是自然語言的全文索引。
自然語言搜索引擎將計算每一個文檔對象和查詢的相關度。這裏,相關度是基於匹配的關鍵詞的個數,以及關鍵詞在文檔中出現的次數。在整個索引中出現次數越少的詞語,匹配時的相關度就越高。相反,非常常見的單詞將不會被搜索,如果一個詞語在超過 50% 的記錄中都出現了,那麼自然語言的搜索將不會搜索這類詞語。
這個機制也比較好理解,比如說,一個數據表存儲的是一篇篇的文章,文章中的常見詞、語氣詞等等,出現的肯定比較多,搜索這些詞語就沒什麼意義了,需要搜索的是那些文章中有特殊意義的詞,這樣才能把文章區分開。
這種模式下, 會進行關鍵詞的拆分, 比如"洗衣機", 會匹配"洗衣機","洗衣","洗","衣","機"等。
自然語言搜索模式的特點:
1.忽略停詞(stopword),英語中頻繁出現的and/or/to等詞被認爲是沒有實際搜索的意義,搜索這些不會獲得任何結果。
2.如果某個詞在數據集中頻繁出現的機率超過了50%,也會被認爲是停詞,所以如果數據庫中只有一行數據,不管你怎麼全文搜索都不能獲得結果。
3.搜索結果都具有一個相關度的數據,返回結果自動按相關度由高到低排列。
4.只針對獨立的單詞進行檢索,而不考慮單詞的局部匹配,如搜索box時,就不會將boxing作爲檢索目標。
布爾全文索引
使用IN BOOLEAN MODE
。這種查找方式的特點是沒有自然查找模式中的50%規則,即便有詞語在數據集中頻繁出現的機率超過50%,也會被作爲搜索目標進行檢索並返回結果,而且檢索時單詞的局部匹配也會被作爲目標進行檢索。
但是當關鍵詞長度爲1且出現頻率很高時,卻無法匹配。例如:
INSERT INTO `test` (`content`) VALUES('1.天嬌美奴女士揹包雙肩揹包雙肩包女帆布小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('2.天嬌美奴女士揹包雙肩揹包雙肩包女尼龍小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('3.天嬌美奴女士揹包雙肩揹包雙肩包女尼龍小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('4.天嬌美奴女士揹包雙肩揹包雙肩包女帆布小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('5.天嬌美奴女士揹包雙肩揹包雙肩包女尼龍小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('6.天嬌美奴女士揹包雙肩揹包雙肩包女尼龍小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('7.天嬌美奴女士揹包雙肩揹包雙肩包女尼龍小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('8.天嬌美奴女士揹包雙肩揹包雙肩包女帆布小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('9.天嬌美奴女士揹包雙肩揹包雙肩包女小包書包女士真皮雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('10.天嬌美奴女士揹包雙肩揹包雙肩包女小包書包女士真皮雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('11.天嬌美奴牛皮女士揹包雙肩揹包雙肩包女小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女'),
('12.天嬌美奴牛皮女士揹包雙肩揹包雙肩包女小包書包女士雙肩包女韓版女揹包雙肩包百搭旅行揹包女');
執行查詢"包","揹包":
SELECT * FROM test WHERE MATCH(content) AGAINST('包'); -- 無結果
SELECT * FROM test WHERE MATCH(content) AGAINST('包' IN BOOLEAN MODE);-- 無結果
SELECT * FROM test WHERE MATCH(content) AGAINST('揹包'); -- 有結果
SELECT * FROM test WHERE MATCH(content) AGAINST('揹包' IN BOOLEAN MODE); -- 有結果
搜索一個字時無法得到結果。但是我們可以使用修飾符來解決:
SELECT * FROM test WHERE MATCH(content) AGAINST('包*' IN BOOLEAN MODE);
上面的標識: 尋找包含"包"或者以"包"開頭的記錄。
在布爾搜索中,我們可以在查詢中自定義某個被搜索的詞語的相關性,當編寫一個布爾搜索查詢時,可以通過一些前綴修飾符來定製搜索。
MySQL 內置的修飾符,上面查詢最小搜索長度時,搜索結果 ft_boolean_syntax 變量的值就是內置的修飾符,下面簡單解釋幾個,更多修飾符的作用可以查手冊
- + 必須包含該詞
- - 必須不包含該詞
- > 提高該詞的相關性,查詢的結果靠前
- < 降低該詞的相關性,查詢的結果靠後
- (*) 星號 通配符,只能接在詞後面
- " 精確匹配輸入的單詞
我們再插入些數據:
INSERT INTO `test` (`content`) VALUES ('可愛的貓'),('貓的主人是我們');
目前中文版的布爾查詢有些問題:
SELECT *, MATCH(content) AGAINST('-(我們)' IN BOOLEAN MODE) AS rel FROM `test` WHERE MATCH(content) AGAINST('-(我們)' IN BOOLEAN MODE); -- 查詢不到結果
SELECT *, MATCH(content) AGAINST('+(我們)' IN BOOLEAN MODE) AS rel FROM `test` WHERE MATCH(content) AGAINST('+(我們)' IN BOOLEAN MODE);-- 正常
如果要精確查找, 而不讓mysql自動分詞, 可以使用雙引號(")包含關鍵詞, 比如:
我們再插入幾筆數據:
INSERT INTO `test` (`content`)VALUES('日用口罩 '),('求購醫用柺杖'),('醫用外科口罩 防病毒肺炎傳染');
執行3種查詢
SELECT * FROM test WHERE MATCH(content) AGAINST('醫用口罩');
SELECT * FROM test WHERE MATCH(content) AGAINST('醫用口罩' IN BOOLEAN MODE);
SELECT * FROM test WHERE MATCH(content) AGAINST('"正規公司"' IN BOOLEAN MODE);
在 InnoDB引擎"BOOLEAN MODE"下查詢不到數據.
那我們再來試下MyISAM引擎:
-- 建表 用MyISAM引擎
CREATE TABLE `test2` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`content` text NOT NULL,
PRIMARY KEY(id)
) ENGINE=MyISAM;
-- 建索引
ALTER TABLE `test2` ADD FULLTEXT INDEX `FIX_content`(`content`) WITH PARSER ngram; /*這裏必須指定中文分詞器*/
-- 插入數據
INSERT INTO `test2` (`content`)VALUES('日用口罩 '),('求購醫用柺杖'),('醫用外科口罩 防病毒肺炎傳染');
那我們現在執行查詢並比較結果:
SELECT COUNT(*) AS count, 'InnoDB: 醫用口罩' AS 'cat' FROM test WHERE MATCH(content) AGAINST('醫用口罩') -- InnoDB
UNION ALL
SELECT COUNT(*) AS count, 'MyISAM: 醫用口罩' AS 'cat' FROM test2 WHERE MATCH(content) AGAINST('醫用口罩') -- MyISAM
UNION ALL
SELECT COUNT(*) AS count, 'InnoDB: 醫用口罩 IN BOOLEAN MODE' AS 'cat' FROM test WHERE MATCH(content) AGAINST('醫用口罩' IN BOOLEAN MODE) -- InnoDB
UNION ALL
SELECT COUNT(*) AS count, 'MyISAM: 醫用口罩 IN BOOLEAN MODE' AS 'cat' FROM test2 WHERE MATCH(content) AGAINST('醫用口罩' IN BOOLEAN MODE) -- MyISAM
UNION ALL
SELECT COUNT(*) AS count, 'InnoDB: "醫用口罩" IN BOOLEAN MODE' AS 'cat' FROM test WHERE MATCH(content) AGAINST('"醫用口罩"' IN BOOLEAN MODE) -- InnoDB
UNION ALL
SELECT COUNT(*) AS count, 'MyISAM: "醫用口罩" IN BOOLEAN MODE' AS 'cat' FROM test2 WHERE MATCH(content) AGAINST('"醫用口罩"' IN BOOLEAN MODE) -- MyISAM
;
在MyISAM引擎下, 只有最後一種雙引號的纔是正確的結果.
英文相關的資料參考 https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html
注: InnoDB的停止詞:
SELECT group_concat(value) FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD;
a,about,an,are,as,at,be,by,com,de,en,for,from,how,i,in,is,it,la,of,on,or,that,the,this,to,was,what,when,where,who,will,with,und,the,www
就那麼幾個英文單詞, 這maybe就是導致前面"的"問題出現的原因.
今天不做深入研究.
注意
- 全文索引查詢比LIKE模糊匹配速度快幾十倍, 但是精度問題不好控制(需要區分InnoDB與MyISAM).
- 帶索引的大表, 在恢復時建議先刪除或停止索引, 待數據恢復後在恢復或重建索引, 以提高數據恢復速度。
- 默認全文搜索不區分大小寫。若要區分大小寫,可以更改成對應字符集的二進制排序方式。如Latin1編碼下則選擇latin1_bin作爲二進制排序方式
補充
在Mysql5.7版本+表數據100w的情況下,如果只是查詢指定的全文索引的欄位, 速度確實很快, 但是如果再加上其他欄位的查詢, 與LIKE相比速度提升也很有限, 即使這些欄位已經添加了普通索引。
參考資料: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html