mysql索引設計的注意事項(大量示例,收藏再看)
目錄
- 一、索引的重要性
- 二、執行計划上的重要關注點
- (1).全表掃描,檢索行數
- (2).key,using index(覆蓋索引)
- (3).通過key_len確定究竟使用了復合索引的幾個索引字段
- (4) order by和Using filesort
- 三、索引設計的注意事項
- (1). 關於INNODB表PRIMARY KEY的建議
- (2). 什么列上適合建索引,什么列上不適合建索引
- (3). 索引一定是有益的嗎?
- (4). where條件中不要在索引字段側進行任何運算(包括隱式運算),否則會導致索引不可用,導致全表掃描
- (5). 不要使用%xxx%這種模糊匹配,會導致全表掃描/索引全掃描
- (6). 關於前綴索引和冗余索引
- (7). 關於索引定義中的字段順序
- (8). 關於排序查詢的優化
- (9). 關於單列索引和復合索引
- (10). 關於多表關聯
- 四、慢查詢日志的分析以及關注點
- (1). 使用pt-query-digest工具來統計
- (2). 對統計輸出進行分析
- 五、幾個優化案例
- 優化案例1
- 優化案例2
- 優化案例3
一、索引的重要性
索引對於MySQL數據庫的重要性是不言而喻的:
因為缺乏合適的索引,一個稍大的表全表掃描,稍微來些並發,就可能導致DB響應時間急劇飆升,甚至導致DB性能的雪崩;
現在大家普遍使用的Innodb引擎的鎖機制依賴於索引,缺乏適合的索引,會導致鎖范圍的擴大,甚至導致鎖表的效果,嚴重影響業務SQL的並行執行,影響業務的可伸縮性,只有在合適的索引條件下,才是行鎖的效果.
既然索引對MySQL數據庫這么重要,那么在索引的設計上有什么需要注意的事項嗎? 這篇文章就來聊聊這個.
二、執行計划上的重要關注點
既然涉及到索引,避免不了執行計划的對比,先簡單說一下執行計划上的重要關注點
(1).全表掃描,檢索行數
mysql> show create table novel_agg_info\G *************************** 1. row *************************** Table: novel_agg_info Create Table: CREATE TABLE `novel_agg_info` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `rid` bigint(20) unsigned NOT NULL, `book_name` varchar(128) NOT NULL, `tag` varchar(128) NOT NULL, `dir_id` bigint(20) unsigned NOT NULL DEFAULT '0', `dir_url` varchar(512) NOT NULL DEFAULT '', `public_status` int(2) NOT NULL DEFAULT '1', PRIMARY KEY (`id`), UNIQUE KEY `rid` (`rid`), KEY `book_name` (`book_name`) ) ENGINE=InnoDB AUTO_INCREMENT=12096483 DEFAULT CHARSET=utf8 1 row in set (0.00 sec) mysql> select count(1) from novel_agg_info; +----------+ | count(1) | +----------+ | 4298257 | +----------+ 1 row in set (0.00 sec) mysql> show table status like 'novel_agg_info'\G *************************** 1. row *************************** Name: novel_agg_info Engine: InnoDB Version: 10 Row_format: Compact Rows: 4321842 Avg_row_length: 130 Data_length: 565182464 Max_data_length: 0 Index_length: 374095872 Data_free: 35651584 Auto_increment: 12096483 Create_time: 2017-05-10 11:55:30 Update_time: NULL Check_time: NULL Collation: utf8_general_ci Checksum: NULL Create_options: Comment: 1 row in set (0.00 sec)
實際數據行數近430W,優化器估算Rows: 4321842 行記錄(這是一個估算值,來自於動態采樣,數量級沒有大的誤差即可,實際上多次執行show table status,得到的數據也是不同的)

where dir_id = 13301689388199959972 因為dir_id字段上沒有索引可用,導致了全表掃描(type:ALL),優化器估算檢索行數為(rows:4290581)
避免全表掃描 全表掃描(type:ALL),大的檢索行數(rows:N,為估算值),這些都是我們應該盡量避免的.
(2).key,using index(覆蓋索引)
再看下面的執行計划對比:
key:book_name代表執行使用了KEY book_name
(book_name
),檢索行數為1,這很好,是我們想要的效果.
為什么第1個執行計划中出現了Using index,而第2個執行計划中卻沒有呢?
因為:第1個SQL中只需要檢索id,book_name字段,這在KEY book_name
(book_name
)中都存在了(索引葉節點中都會存儲PRIMARY KEY字段ID),不需要回訪表去獲取其它字段了,Using index即代表這個含義;而第2個SQL中還需要檢索tag字段,這在KEY book_name
(book_name
)中並不存在,就需要回訪表會獲取這個字段內容,所以沒有出現Using index.
key,Using index
key: 代表使用的索引名稱
Extra部分的Using index,代表只使用了索引便完成了查詢,並沒有回訪表去獲取索引外的字段,也就是我們通常所說的使用了“覆蓋索引”;如果使用了key,但沒有出現Using index,說明索引並不能覆蓋檢索和核對的所有字段,需要回訪表去獲取其它字段內容,這相對於覆蓋索引增加了回訪表的成本,增加了隨機IO的成本
(3).通過key_len確定究竟使用了復合索引的幾個索引字段
對於復合索引INDEX(a,b,c) 我如何確定執行計划到底使用了幾個索引字段呢? 這個需要通過key_len去確定.
*************************** 1. row *************************** Table: operationMenuInfo Create Table: CREATE TABLE `operationMenuInfo` ( `id` int(50) NOT NULL AUTO_INCREMENT, `operationMenuName` varchar(200) NOT NULL, `createTime` int(50) DEFAULT NULL, `startTime` int(50) DEFAULT NULL, `endTime` int(50) DEFAULT NULL, `appId` int(50) NOT NULL, `status` int(50) NOT NULL, `fromPlat` varchar(200) DEFAULT NULL, `appName` varchar(200) DEFAULT NULL, `packageId` int(20) DEFAULT NULL, `menuType` smallint(5) NOT NULL DEFAULT '0' COMMENT 'type', `entityId` int(11) NOT NULL DEFAULT '0' COMMENT 'entityId', `productId` int(11) NOT NULL DEFAULT '0' COMMENT 'pid', PRIMARY KEY (`id`), KEY `time_appid` (`appId`,`createTime`), KEY `idx_startTime` (`startTime`), KEY `idx_endTime` (`endTime`), KEY `t_eId_pId` (`entityId`,`menuType`,`productId`), KEY `idx_appId_createTime_fromPlat` (`appId`,`createTime`,`fromPlat`) ) ENGINE=InnoDB AUTO_INCREMENT=4656258 DEFAULT CHARSET=utf8 1 row in set (0.00 sec)
對比下面這兩個SQL和它們的執行計划
where appId=927 and createTime=1494492062 按我們的理解,應該是使用KEY idx_appId_createTime_fromPlat
(appId
,createTime
,fromPlat
)的前2個字段.
where appId=927 and fromPlat='dataman' 按我們的理解,應該是使用KEY idx_appId_createTime_fromPlat
(appId
,createTime
,fromPlat
)的第1個字段.因為where條件中缺少createTime字段,所以只能使用索引的第1個字段來access.
其實key_len反映的就是這些信息,不過沒有那么直接(其實直接顯示使用哪些字段來access了會更好),要對應到字段上還需要一些換算:
key_len的計算
通過key_len可以知道復合索引都使用了哪些字段.key_len的計算上:
當字段定義可以為空時,需要額外的1個字節來記錄它是否為空,當字段定義為not null時,這額外的1個字節是不需要的.
當字段定義為變長數據類型(比如說varchar)時,需要額外的2個字節來記錄它的長度; 當字段定義為定長數據類型(比如說int,char,datetime等),這額外的2個字節是不需要的.
對於字符型數據,varchar(n),char(n), n都是定義的最大字符長度, gbk的話:2*n ,utf8的話:3*n
int 4個字節,bigint 8個字節,這些定長類型占用的字節數,這里只列舉這2個吧.
索引使用哪些字段,上述計算公式計算出的字節的和就是ken_len,就可以確定索引使用了哪些字段
第1個SQL,使用了索引的前2個字段,appId(4) + createTime(4+1 這個字段定義為可以為空,所以是4+1) =9 ,所以ken_len是9,標識索引使用了這2個字段.
第2個SQL,只使用了索引的第1個字段appId(4) =4,所以ken_len是4,標識索引只使用了第1個字段.
(4) order by和Using filesort
業務SQL經常會有order by,一般來說這需要真實的物理排序才能達到這個效果, 這就是我們所說的Using filesort,一般來說它需要檢索出所有的符合where條件的數據記錄,而后在內存/文件層面進行物理排序,所以一般是一個很耗時的操作,是我們極力想要避免的.
但其實對於MySQL來說,卻不一定非得物理排序才能達到order by的效果,也可以通過索引達到order by的效果,卻不需要物理排序.
因為索引通過葉節點上的雙向鏈表實現了邏輯有序性,比如說對於where a=? order by b limit 1; 可以直接使用index(a,b)來達到效果,不需要物理排序,從索引的根節點,走到葉節點,找到a=?的位置,因為這時b是有序的,只要順着鏈表向右走,掃描1個位置,就可以找到想要的1條記錄,這樣既達到了業務SQL的要求,也避免了物理的排序操作。這種情況下,執行計划的Extra部分就不會出現Using filesort,因為它只掃描了極少量的索引葉節點就返回了結果,所以一般而言,執行很快,資源消耗很少,是我們想要的效果.
因為存在KEY time_appid
(appId
,createTime
), 第1個SQL可以通過它快速的返回結果,因為沒有物理排序,所以執行計划的Extra部分沒有出現Using filesort.
而第2個SQL是無法通過任何索引達到上述效果的,必須掃描出所有的符合條件的記錄行后物理排序再返回TOP1的記錄,因為存在物理排序,所以執行計划的Extra部分出現了Using filesort.
執行時間上,第1個SQL瞬間返回結果,第2個SQL需要0.7秒左右才能返回結果(因為它要檢索出符合條件的40W記錄,而后還要排序,這2個操作導致了它執行時間偏長).
order by和Using filesort
索引本身是邏輯有序的,所以可以通過索引達到order by的效果要求,卻不需要真正的物理排序操作. 如果業務SQL中有order by,但執行計划的Extra部分中卻沒有出現Using filesort,說明通過索引避免了物理的排序操作,對於TOPN SQL而言,這往往意味着通過索引快速的返回了結果,是我們想要的.
如果執行計划的Extra部分中出現了Using filesort,說明無法通過索引達到效果,而使用了物理排序操作,對TOPN SQL而言,這意味着雖然只是返回極少的N條記錄,但需要檢索出符合where條件的所有記錄,而后物理排序,最終才能返回業務想要的N條記錄,如果符合where條件的記錄很多,這2個操作往往是很耗時的,是我們極力想要避免的.
三、索引設計的注意事項
關於索引的2個知識點 關於索引,首先說2個應該知道的事項(其實上面也已經提到了): 1.現在普遍使用的innodb存儲引擎中,索引的葉節點中除了存儲了索引定義中的字段外,還存儲了primary key,從而可以找到對應的行記錄,這樣才能訪問索引外的字段. 2.索引的葉節點通過雙向鏈表實現了邏輯上的有序性,使得索引是有序的.
(1). 關於INNODB表PRIMARY KEY的建議
表設計層面,我們一般建議使用自增ID做PRIMARY KEY,業務主鍵做UNIQUE KEY,原因如下:
1.如果業務主鍵做PRIMARY KEY,業務主鍵的插入順序比較隨機,這樣會導致插入時間偏長,而且聚簇索引葉節點分裂嚴重,導致碎片嚴重,浪費空間;而自增ID做PRIMARY KEY的情況下,順序插入,插入快,而且聚簇索引比較緊湊,空間浪費小。
2.一般表設計上除了PRIMARY KEY外,還會有幾個索引用來優化讀寫.而這些非PK索引葉節點中都要存儲PRIMARY KEY,以指向數據行,從而關聯非索引中的字段內容.這樣自增ID(定義為bigint才占用8個字節)和業務主鍵(通常字符串,多字段,空間占用大)相比,做PRIMARY KEY在索引空間層面的優勢也是很明顯的(同時也會轉換為時間成本層面的優勢),表定義中的索引越多,這種優勢越明顯。
綜上所述,我們一般建議使用自增ID做PRIMARY KEY,業務主鍵做UNIQUE KEY。
(2). 什么列上適合建索引,什么列上不適合建索引
這里涉及到一個重要的概念:字段的選擇性
select count(1)/count(distinct col) 這個結果越接近數據總行數,那么這個字段的選擇性越低; 越接近1,那么這個字段的選擇性越高. 簡單舉例說就是:身份證ID字段的選擇性很高,而性別字段的選擇性很低.
一般來說,高選擇性字段上是適合創建索引的,而低選擇性字段上是不適合創建索引的
一般來說,status,type這類枚舉值很少的字段,就是低選擇性字段(或者說低基數字段),是不適合單獨作為索引字段的.
例外的情況就是: 這類字段數據分布特別不均衡,而你經常要定位的是數據量極少的字段值,這種情況下,還是適合在這個字段上創建索引的.
mysql> show create table novel_agg_info\G *************************** 1. row *************************** Table: novel_agg_info Create Table: CREATE TABLE `novel_agg_info` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `rid` bigint(20) unsigned NOT NULL, `book_name` varchar(128) NOT NULL, `tag` varchar(128) NOT NULL, `dir_id` bigint(20) unsigned NOT NULL DEFAULT '0', `dir_url` varchar(512) NOT NULL DEFAULT '', `public_status` int(2) NOT NULL DEFAULT '1', PRIMARY KEY (`id`), UNIQUE KEY `rid` (`rid`), KEY `book_name` (`book_name`), KEY `idx_public_status` (`public_status`) ) ENGINE=InnoDB AUTO_INCREMENT=12096483 DEFAULT CHARSET=utf8 1 row in set (0.00 sec) mysql> select public_status,count(1) from novel_agg_info group by public_status; +---------------+----------+ | public_status | count(1) | +---------------+----------+ | 0 | 3511945 | | 1 | 367234 | | 2 | 419062 | | 12 | 16 | +---------------+----------+ 4 rows in set (1.35 sec) mysql> explain select * from novel_agg_info where public_status = 12; +----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+ | 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 15 | NULL | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+------+-------+ 1 row in set (0.00 sec) mysql> explain select * from novel_agg_info where public_status = 0; +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ | 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 1955112 | NULL | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ 1 row in set (0.00 sec) mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 12 ) tmp; +----------+ | count(1) | +----------+ | 16 | +----------+ 1 row in set (0.00 sec) mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 0 ) tmp; +----------+ | count(1) | +----------+ | 3511945 | +----------+ 1 row in set (11.60 sec)
可以看到狀態值為12的數據量極少,所以where public_status = 12 使用索引,快速的返回了結果. 但where public_status = 0 完全是另外一種情況了.
其實下面可以看到 where public_status = 0 不使用索引,使用全表掃描會更好些,但這里也依然是選擇了使用索引的執行計划. 優化器應該基於數據分布的統計信息,對於不同的輸入值,使用更合理的執行計划,而不是使用一個統一的執行計划,這也是優化器層面需要繼續智能化,提升的地方.
它的一個典型的應用場景,就是任務處理表:
不斷有新任務插入進來,任務狀態初始化為"未處理",后台不斷的掃描出"未處理"的任務,進行調度處理,完成后,更新任務狀態為"已處理",任務數據仍然保留下來.
這里任務狀態字段就是這種情況,不同值很少,但頻繁查詢的"未處理"狀態極少,絕大部分為"已處理"狀態,它們又基本上不會被查詢,這種情況下,就適合在任務狀態字段上創建索引.
為什么低選擇性字段上不適合創建索引呢? 其實也涉及到另一個問題: 使用索引一定比全表掃描要好嗎? 答案是否定的.
繼續進行測試:
mysql> explain select * from novel_agg_info where public_status = 0; ---默認走索引idx_public_status +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ | 1 | SIMPLE | novel_agg_info | ref | idx_public_status | idx_public_status | 4 | const | 1955112 | NULL | +----+-------------+----------------+------+-------------------+-------------------+---------+-------+---------+-------+ 1 row in set (0.00 sec) mysql> explain select * from novel_agg_info ignore index(idx_public_status) where public_status = 0; ---強制忽略索引idx_public_status,走全表掃描. +----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+ | 1 | SIMPLE | novel_agg_info | ALL | NULL | NULL | NULL | NULL | 3910225 | Using where | +----+-------------+----------------+------+---------------+------+---------+------+---------+-------------+ 1 row in set (0.00 sec) mysql> select sql_no_cache count(1) from (select * from novel_agg_info where public_status = 0) tmp; +----------+ | count(1) | +----------+ | 3511945 | +----------+ 1 row in set (11.59 sec) mysql> select sql_no_cache count(1) from (select * from novel_agg_info ignore index(idx_public_status) where public_status = 0) tmp; +----------+ | count(1) | +----------+ | 3511945 | +----------+ 1 row in set (8.46 sec)
上面2個SQL的執行時間均取多次執行的平均執行時間,可以忽略BUFFER POOL的影響.
為什么全表掃描反而快了,使用索引反而慢了呢?
一定程度上是因為回訪表的操作,使用索引,但提取了索引字段外的數據,所以需要回訪表數據,這里符合條件的數據量特別大,所以導致了大量的回表操作,帶來了大量的隨機IO; 而全表掃描的話,雖然說表空間比索引空間大,但可以使用多塊讀特性,一定程度上使用順序讀; 此消彼長,導致全表掃描反而比使用索引還要快了.
這也解釋了低選擇性字段(低基數字段)為什么不適合創建索引(當然,使用覆蓋索引,不需要回訪表是另外一種情況了).
(3). 索引一定是有益的嗎?
答案是否定的,因為索引是有代價的:
每次的寫操作,都要維護索引,相應的調整索引數據,會在一定程度上降低寫操作的速度.所以大量的索引必然會降低寫性能,索引的創建要從整體考慮,在讀寫性能之間找到一個好的平衡點,在主要矛盾和次要矛盾之間找到平衡點.
所以說,索引並不是越多越好,無用的索引要刪除,冗余的索引(這在后面會提到)要刪除,因為它們只有維護上的開銷,卻沒有益處,所以在業務邏輯,SQL,索引結構變更的時候,要及時刪除無用/冗余的索引.
索引使用不合理的情況下,使用索引也不一定會比全表掃描快,上面也提到了.
總結說,索引不是萬能的,要合理的創建索引.
(4). where條件中不要在索引字段側進行任何運算(包括隱式運算),否則會導致索引不可用,導致全表掃描
select * from tab where id + 1 = 1000; 會導致全表掃描,應該修改為select * from tab where id = 1000 -1; 才可以高效返回.
select * from tab where from_unixtime(addtime) = '2017-05-11 00:00:00' 會導致index(addtime)不可用
應該調整為select * from tab where addtime = unix_timestamp('2017-05-11 00:00:00') 這樣才可以使用index(addtime)
再比如說:
SELECT COUNT(*) FROM message WHERE (token = 'bed21e35b19fe40e71b3ba2ad080b10a') AND (date(create_time) = curdate());
會導致create_time上的索引不可用, 為了使得create_time上的索引可用,應轉化為如下的等效形式:
SELECT COUNT(*) FROM message WHERE (token = 'bed21e35b19fe40e71b3ba2ad080b10a') AND create_time>=curdate() and create_time<adddate(curdate(),1)
這里的運算也包括隱式的運算,比如說隱式的類型轉換..業務上經常有類型不匹配導致隱式的類型轉換的情況.這里經常出現的情況是字符串和整型比較.
比如說表定義字段類型為BIGINT,但業務上傳進來一個字符串的; 或者是表定義字段類型為varchar,但業務上傳進來一個整型的.這個字段上存在索引時,索引也許是不可用的.
為什么說也許呢?這取決於這種隱式的類型轉換發生在了哪側?是表字段側,還是業務傳入數據側?
整型和字符串比較,DB中和許多程序語言中的處理方式是一樣的,都是字符串轉換為整型后和整型比較.
所以表定義字段類型為BIGINT,但業務上傳進來一個字符串,字段上的索引依然可用,因為隱式的類型轉換發生在業務傳入數據側(這只能說是索引依然可用,沒有大的性能影響,但隱式的類型轉換照樣是有性能損耗的,所以還是一致的好)。
表定義字段類型為varchar,但業務上傳進來一個整型,會導致索引不可用,全表掃描.因為隱式的類型轉換發生在表字段側。
建議可以使用INT/BIGINT存儲的,盡量定義為INT/BIGINT,這樣相對於長的純數字字符串的VARCHAR定義,INT/BIGINT不僅更節省空間(INT 4個字節,BIGINT 8個字節),性能更好;而且即使類型不匹配了,也不會導致索引不可用的問題.
還有表關聯,關聯字段上類型不一致,這種情況下,索引是否可用,是否存在嚴重的性能問題,取決於哪個表是驅動表,哪個表是被驅動表.這里不細論這個問題了.關聯字段類型定義一致了,什么問題都沒有.這也是表設計階段需要注意的.
總結起來還是一句話,類型一致了,什么問題都沒有,否則可能存在嚴重的性能問題.
索引字段類型定義改變時的調整順序
這里單獨的說一下這個,因為業務上確實存在字段類型調整的情況,存在int/bigint和varchar定義轉換的情況,如果這個字段上還存在着高效索引的話,一定要注意是業務代碼側先調整,還是DB側先調整,如果順序弄反了,會導致這里提到的全表掃描問題的:
原定義為int/bigint,要修改為varchar的: 業務代碼側先調整,傳入數據都按字符串處理,確認都調整完畢后,DB端再修改表定義.
原定義為varchar,要修改為 int/bigint的: DB端先修改表定義,DB端調整完畢,且確認從庫也同步完畢之后,業務代碼再調整,傳入數據都按整型處理
再說一下區分大小寫的字段比較
mysql的字符串比較默認是不區分大小寫的.所以有些業務上為了嚴格匹配,區分大小寫,在SQL中使用了binary,確實達到了區分大小寫的目的,但導致索引不可用了(因為在字段側進行了運算)
表定義中存在合適的索引 KEY `idx_app_name_status` (`appname`,`status`)
mysql> explain select * from tbl_rtlc_conf where binary appname='LbsPCommon' and status = 1; +----+-------------+---------------+------+---------------+------+---------+------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+---------------+------+---------------+------+---------+------+------+-------------+ | 1 | SIMPLE | tbl_rtlc_conf | ALL | NULL | NULL | NULL | NULL | 9156 | Using where | +----+-------------+---------------+------+---------------+------+---------+------+------+-------------+ 1 row in set (0.00 sec)
但因為binary的使用,導致了全表掃描.
那如何達到目的,又能高效呢?
mysql的字符串比較默認不區分大小寫,是因為它們默認的collation是不區分大小寫的
mysql> pager egrep -i "utf8|gbk|Default collation" PAGER set to 'egrep -i "utf8|gbk|Default collation"' mysql> show character set; | Charset | Description | Default collation | Maxlen | | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 |
gbk,utf8 字符集默認的collation分別為gbk_chinese_ci,utf8_general_ci, caseignore 它們都是忽略大小寫的,導致字符串比較默認不區分大小寫了.
區分大小寫,且索引可用
解決的方案就是修改特定表/字段的collation,表collation的修改會影響到這個表的所有字段,所以一般都是只修改特定目標字段的collation
表字符集為utf8的話:appname
varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT 'appname'
表字符集為gbk的話:appname
varchar(255) CHARACTER SET gbk COLLATE gbk_bin NOT NULL COMMENT 'appname'
同時保證SQL中沒有包含binary, 這樣既達到了嚴格匹配的目的(utf8_bin ,gbk_bin這2個collation都是嚴格匹配的),也保證了索引的可用性.
(5). 不要使用%xxx%這種模糊匹配,會導致全表掃描/索引全掃描
where name like '%zhao%'
這種前后統配的模糊查詢,會導致索引不可用,全表掃描,稍好的情況是,能使用覆蓋索引的話,是索引全掃描,但也高效不了.
如果確實存在這樣高頻執行的模糊匹配的業務需求,建議走全文檢索系統,不要使用MySQL來做這個事情.
但其實很多業務,使用模糊匹配是帶有很大的隨意性的,完全可以改為精確匹配,從而使用字段上的索引快速定位數據的.
另外where name like 'xxx%'
這種,不前統配,只后統配的,確實是可以使用索引的.
但它其實是一個范圍匹配,下文會提到,這種范圍匹配(非等值匹配)會導致后面的索引字段不能(高效)使用,會導致索引不能用於避免物理排序等問題.
所以還是要謹慎使用,如果可以改為精確匹配的話,還是建議使用精確匹配的好.
(6). 關於前綴索引和冗余索引
index(a,b,c) 能同時優化下面幾類查詢:
where a=? and b=? and c=?
where a=? and b=?
where a=?
也能優化如下的排序查詢:
where a=? order by b[,c] limit
where a=? and b=? order by c limit
但不能優化 where b=? and c=? 因為索引定義index(a,b,c) 的前綴列a沒有出現在where條件中.
更不能優化where c=?
對於where a=? and c=? 查詢,它只能使用index(a,b,c)的第1個索引字段a.
所以,如果業務查詢為如下2類:
where b=? and c=?
where c=?
**那么就應該定義索引為index(c,b),它能同時優化上面2類查詢 **,而不應該定義索引index(b,c)的,因為索引index(b,c)優化不了where c=? 因為這個索引的前綴列b沒有出現在where條件中.
也不建議創建2個索引: index(b,c) 和index(c) 因為前面提到了索引越少越好,可以用一個index(c,b) 來完成的,就不要創建2個索引來完成.
在存在索引index(a,b,c)的情況下,絕大多數情況下,下面的這些索引就冗余了,可以DROP掉的:
index(a)
index(a,b)
上面提到了,這2個索引能優化的查詢,index(a,b,c)絕大多數情況下也都能優化,所以它們就冗余了,本着索引越少越好的原則,都可以DROP掉的.
上面提到了絕大多數情況下,冗余了,可以DROP了,但也存在例外的情況,它們的存在還是必要的:
那就是存在下面的查詢:
where a=? order by id limit
這里index(a) ( 實際為index(a,id) ) 可以優化上面的查詢,通過使用這個索引,避免物理排序而達到排序的實際效果.
但index(a,b,c) ( 實際為index(a,b,c,id) ) 和index(a,b) (實際為index(a,b,id)) 卻達不到這樣的效果.
這種情況下,存在index(a,b,c)的情況下,index(a) 是不冗余的,是需要保留的.
如果不存在這種情況,存在index(a,b,c)的情況下,index(a) ,index(a,b) 都是冗余的,建議drop掉.
但如果where a=? 后返回的數據行已經很少,也就是說對很少的數據進行order by id排序的話,也是可以使用index(a,b)或者index(a,b,c) 來過濾行的,只不過還需要進行物理排序,但代價已經很小了,是否還需要創建一個index(a)需要業務折中考慮了.
(7). 關於索引定義中的字段順序
建議where條件中等值匹配的字段放到索引定義的前部,范圍匹配的字段(> < between in等為范圍匹配)放到索引定義的后面.
因為前綴索引字段使用了范圍匹配后,會導致后續的索引字段不能高效的用於優化查詢.
來看一個例子:
mysql> show create table opLog\G *************************** 1. row *************************** Table: opLog Create Table: CREATE TABLE `opLog` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `listId` int(11) unsigned NOT NULL COMMENT '對應id', `listType` varchar(255) NOT NULL COMMENT '對應類型', `opName` varchar(255) NOT NULL COMMENT '操作人id', `operation` varchar(255) NOT NULL COMMENT '具體操作', `content` varchar(255) NOT NULL COMMENT '內容', `createTime` int(10) NOT NULL COMMENT '時間', PRIMARY KEY (`id`), KEY `idx_opName_createTime` (`opName`,`createTime`), KEY `idx_createTime_opName` (`createTime`,`opName`) ) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作記錄表'
查詢2017-04-23到2017-05-23 這一個月內某個op發起的操作數量:
select sql_no_cache count(1) from opLog where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
+----------+
| count(1) |
+----------+
| 0 |
+----------+
這1個月內共有2.2W次的操作記錄,對應2.2W行記錄.
mysql> select count(1) from opLog where createTime between 1492876800 and 1495468800;
+----------+
| count(1) |
+----------+
| 22211 |
+----------+
我下面使用force index的hint強制走某個索引:
# Query_time: 0.009124 Lock_time: 0.000093 Rows_sent: 1 Rows_examined: 22211
select sql_no_cache count(1) from opLog force index(idx_createTime_opName) where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
# Query_time: 0.000220 Lock_time: 0.000077 Rows_sent: 1 Rows_examined: 0
select sql_no_cache count(1) from opLog force index(idx_opName_createTime) where opName='zhangyu21' and createTime between 1492876800 and 1495468800;
可以看到第1個SQL,強制走KEY `idx_createTime_opName`(`createTime`,`opName`)時,檢索的行數是22211行,這個行數剛好是這個時間段內的總行數.為什么是這樣呢?
因為在前綴索引字段createTime上使用了范圍匹配,所以導致索引定義中后面的字段opName不能作為高效的檢索字段(Access),只能作為低效的過濾字段(Filter)了.
(在5.6推出ICP之前,這一點都很難滿足,導致范圍匹配后的索引字段基本是無用的)
說白了,就是說索引上定位到createTime的起止,對期間的索引條目一行行的檢查是否滿足opName='zhangyu21'的條件,滿足的返回.
而第2個SQL,強制走KEY `idx_opName_createTime` (`opName`,`createTime`)時,這2個索引字段都是可以作為高效的Access條件的.
通過索引定位到opName='zhangyu21',createTime =1492876800 條目,向后掃描,直至opName='zhangyu21',createTime>1495468800或者opName!='zhangyu21'為止.
它是相當高效的,掃描的條目就是返回的條目.
沒有帶force index這類hint的話,mysql優化器會默認使用idx_opName_createTime這個索引.
(8). 關於排序查詢的優化
前面提到了index(a,b) 邏輯上是有序的,所以可以用於優化where a=? order by b [asc/desc] [limit n] 特別是對這種topN操作的優化效果非常好.
mysql> show create table opLog\G *************************** 1. row *************************** Table: opLog Create Table: CREATE TABLE `opLog` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `listId` int(11) unsigned NOT NULL COMMENT '對應id', `listType` varchar(255) NOT NULL COMMENT '對應類型', `opName` varchar(255) NOT NULL COMMENT '操作人id', `operation` varchar(255) NOT NULL COMMENT '具體操作', `content` varchar(255) NOT NULL COMMENT '內容', `createTime` int(10) NOT NULL COMMENT '時間', PRIMARY KEY (`id`), KEY `idx_opName_createTime` (`opName`,`createTime`) ) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作記錄表' mysql> select count(1) from opLog where opName=''; +----------+ | count(1) | +----------+ | 2511443 | +----------+ 1 row in set (1.08 sec)
一共有251W的匿名用戶,要查找他們最近的5個操作記錄:
# Query_time: 0.001566 Lock_time: 0.000084 Rows_sent: 5 Rows_examined: 5
select * from opLog where opName='' order by createTime desc limit 5;
從實際執行的統計信息看,它並沒有掃描出251W的記錄,排序,最終輸出5條記錄,而是只掃描了5條記錄,就直接輸出了,執行時間很短的.
看一下執行計划:
mysql> explain select * from opLog where opName='' order by createTime desc limit 5;
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
| 1 | SIMPLE | opLog | ref | idx_opName_createTime | idx_opName_createTime | 767 | const | 1252639 | Using where |
+----+-------------+-------+------+-----------------------+-----------------------+---------+-------+---------+-------------+
1 row in set (0.01 sec)
sql中有order by,但執行計划的Extra部分並沒有出現Using filesort,說明通過KEY `idx_opName_createTime` (`opName`,`createTime`)這個索引達到了排序的效果,但避免了物理排序的操作.(rows部分的估算值可以忽略呀)
如果沒有這個索引,就真的需要檢索出251W記錄(如何檢索出這些記錄,取決於其他的索引,如果沒有合適的索引,可能需要全表掃描),對他們進行物理排序,並輸出需要的5行記錄.執行代價很大,執行時間很長.
但這里通過索引,利用索引本身的邏輯有序性,避免了物理排序操作,快速的返回了topN行記錄.
mysql> show create table opLog\G *************************** 1. row *************************** Table: opLog Create Table: CREATE TABLE `opLog` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `listId` int(11) unsigned NOT NULL COMMENT '對應id', `listType` varchar(255) NOT NULL COMMENT '對應類型', `opName` varchar(255) NOT NULL COMMENT '操作人id', `operation` varchar(255) NOT NULL COMMENT '具體操作', `content` varchar(255) NOT NULL COMMENT '內容', `createTime` int(10) NOT NULL COMMENT '時間', PRIMARY KEY (`id`), KEY `idx_opName_listType_createTime` (`opName`,`listType`,`createTime`) ) ENGINE=InnoDB AUTO_INCREMENT=2515923 DEFAULT CHARSET=utf8 COMMENT='操作記錄表' 還是上面的表數據,我修改了一下表的索引結構. mysql> select count(1) from opLog where opName=''; +----------+ | count(1) | +----------+ | 2511443 | +----------+ 1 row in set (0.91 sec)
# Query_time: 3.188810 Lock_time: 0.000088 Rows_sent: 1 Rows_examined: 2511444 select * from opLog where opName='' and listType in ('cronJob','cronJobNew') order by createTime desc limit 1;
從執行統計信息看,這個查詢並沒有通過索引快速的返回結果.
mysql> explain select * from opLog where opName='' and listType in ('cronJob','cronJobNew') order by createTime desc limit 1; +----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+ | 1 | SIMPLE | opLog | ref | idx_opName_listType_createTime | idx_opName_listType_createTime | 767 | const | 1252640 | Using index condition; Using where; Using filesort | +----+-------------+-------+------+--------------------------------+--------------------------------+---------+-------+---------+----------------------------------------------------+
執行計划來看,還是有Using filesort,還是需要物理排序的. 為什么不能通過這個索引避免物理排序,快速的返回結果呢?
原因就在於listType in ('cronJob','cronJobNew') 在這個索引字段上使用了范圍匹配,從而導致索引層面上整體不再有序了.
在排序字段前的所有索引字段上都必須是等值匹配,才能通過索引保證有序性,才能通過索引避免物理排序,快速的返回結果.
所以上面的查詢必須改造為等效的等值匹配才可以通過索引快速的返回結果的:
mysql> select * -> from -> ( -> select * from opLog where opName='' and listType = 'cronJob' order by createTime desc limit 1 -> union all -> select * from opLog where opName='' and listType = 'cronJobNew' order by createTime desc limit 1 -> ) tmp -> order by createTime desc limit 1; ERROR 1221 (HY000): Incorrect usage of UNION and ORDER BY
這樣還不行,必須再嵌套個外層,使用臨時表才可以的:
select * from ( select * from ( select * from opLog where opName='' and listType = 'cronJob' order by createTime desc limit 1 ) tmp_1 union all select * from ( select * from opLog where opName='' and listType = 'cronJobNew' order by createTime desc limit 1 ) tmp_2 ) tmp order by createTime desc limit 1;
這樣就可以了.
改造后的SQL對應的執行統計信息如下:
# Query_time: 0.000765 Lock_time: 0.000332 Rows_sent: 1 Rows_examined: 4
經過改造為等效的等值匹配,使用索引避免了大的物理排序操作,快速的返回了結果.
說到通過索引優化排序查詢,特別是TOPN操作,必須說一下MySQL在優化器層面的一個問題:
就是說在遇到order by時,myql會優先選擇一個可以避免物理排序的索引來優化這個查詢,有時候,這種優先選擇是不合理的,會導致性能很差.
(特別在涉及到order by id limit N, 這里id是primary key,優化器選擇使用PRIMARY KEY來避免物理排序時尤其要注意是否合理了)
mysql> show create table layer\G *************************** 1. row *************************** Table: layer Create Table: CREATE TABLE `layer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'layer的id', `uuid` varchar(255) NOT NULL COMMENT 'layer的唯一標識', `type` tinyint(4) NOT NULL COMMENT 'layer的類型', `status` tinyint(4) NOT NULL COMMENT 'layer的狀態', `app_id` bigint(20) NOT NULL COMMENT 'layer所屬的app id', `src` varchar(1024) NOT NULL COMMENT 'layer的源地址', `oais_src` varchar(1024) NOT NULL DEFAULT '' COMMENT 'layer存在於oais的地址', `cmd` varchar(1024) NOT NULL DEFAULT '' COMMENT 'layer執行的命令', `skip_download` tinyint(1) NOT NULL DEFAULT '0' COMMENT '默認為0,不跳過中轉', `extra` text NOT NULL COMMENT 'layer的額外信息', `create_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer創建時間戳', `last_update_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer更新時間戳', `finish_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'layer完成時間戳', `merge_latest_layer_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '該baseLayer最新merge的layerid', PRIMARY KEY (`id`), KEY `idx_uuid` (`uuid`), KEY `idx_app_id` (`app_id`) ) ENGINE=InnoDB AUTO_INCREMENT=2866980 DEFAULT CHARSET=utf8 COMMENT='layer表' # Query_time: 2.586674 Lock_time: 0.000084 Rows_sent: 1 Rows_examined: 1986479 SELECT * FROM `layer` WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1; 輸出的ID:1998941 # Query_time: 1.442171 Lock_time: 0.000071 Rows_sent: 1 Rows_examined: 1095035 SELECT * FROM `layer` WHERE (app_id = 139) ORDER BY `layer`.`id` ASC LIMIT 1; 輸出的ID: 1107497 # Query_time: 0.597380 Lock_time: 0.000077 Rows_sent: 1 Rows_examined: 464929 SELECT * FROM `layer` WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1; 輸出的ID:465532 mysql> explain SELECT sql_no_cache* FROM `layer` WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1; +----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+ | 1 | SIMPLE | layer | index | idx_app_id | PRIMARY | 8 | NULL | 151 | Using where | +----+-------------+-------+-------+-----------------------------+---------+---------+------+------+-------------+ 1 row in set (0.00 sec)
可以看到掃描的數據行數是大不同的.為什么呢? 源於它的執行計划,使用了PRIMARY KEY (`id`)來避免物理排序操作.
說白了,就是順着PRIMARY KEY (`id`)的索引鏈表,從小往大掃描,找到第1條滿足app_id = ?的記錄就返回了.
所以執行的時間長短,掃描的記錄行數的多少,完全取決於app_id = ? 的總體數據量,數據分布情況.如果查找1個不存在的app_id最終的結果是掃描了整個表的數據行,也沒有找到數據,返回0行記錄,執行時間肯定長.
下面也可以驗證這1點:
mysql> SELECT count(1) FROM `layer` WHERE (app_id = 2183) and id<1998941; +----------+ | count(1) | +----------+ | 0 | +----------+ 1 row in set (0.01 sec) mysql> SELECT count(1) FROM `layer` WHERE id<=1998941; +----------+ | count(1) | +----------+ | 1986479 | +----------+ 1 row in set (0.79 sec) 就是檢索的數據行數 mysql> SELECT count(1) FROM `layer` WHERE (app_id = 139) and id<1107497; +----------+ | count(1) | +----------+ | 0 | +----------+ 1 row in set (0.00 sec) mysql> SELECT count(1) FROM `layer` WHERE id<= 1107497; +----------+ | count(1) | +----------+ | 1095035 | +----------+ 1 row in set (0.43 sec) 就是檢索的數據行數 mysql> SELECT count(1) FROM `layer` WHERE (app_id = 1241) and id<465532; +----------+ | count(1) | +----------+ | 0 | +----------+ 1 row in set (0.00 sec) mysql> SELECT count(1) FROM `layer` WHERE id<= 465532; +----------+ | count(1) | +----------+ | 464929 | +----------+ 1 row in set (0.18 sec) 就是檢索的數據行數
這里雖然通過索引避免了物理排序,但掃描的行數很大,實際執行時間很長,執行效果很差.
那這個SQL應該如何優化呢?
KEY idx_app_id
(app_id
) 等價於index(app_id,id) 完全可以通過它來高效的返回前N行記錄呀.但因為MySQL默認不選擇它,只能使用force index這個hint來強制mysql選擇這個索引了.
mysql> explain SELECT * FROM `layer` force index(idx_app_id) WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1; +----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+ | 1 | SIMPLE | layer | ref | idx_app_id | idx_app_id | 8 | const | 111142 | Using where | +----+-------------+-------+------+---------------+------------+---------+-------+--------+-------------+ 1 row in set (0.00 sec) 表名后跟 force index(idx_app_id) 提示mysql強制選擇這個索引. 通過這個索引也是可以避免物理排序的,而且真的可以快速的返回結果(即使這個app_id不存在,也會快速返回結果) # Query_time: 0.000213 Lock_time: 0.000082 Rows_sent: 1 Rows_examined: 1 SELECT * FROM `layer` force index(idx_app_id) WHERE (app_id = 2183) ORDER BY `layer`.`id` ASC LIMIT 1; # Query_time: 0.000202 Lock_time: 0.000075 Rows_sent: 1 Rows_examined: 1 SELECT * FROM `layer`force index(idx_app_id) WHERE (app_id = 139) ORDER BY `layer`.`id` ASC LIMIT 1; # Query_time: 0.000222 Lock_time: 0.000075 Rows_sent: 1 Rows_examined: 1 SELECT * FROM `layer`force index(idx_app_id) WHERE (app_id = 1241) ORDER BY `layer`.`id` ASC LIMIT 1;
使用force index 這個hint強制走某個索引后,真的高效返回了.
在遇到MYSQL蒙圈,選擇錯誤的執行計划時,需要使用一些hint給mysql一些提示,使用頻率較高的hint有:
force index(index_name) 強制走某個索引
ignore index(index_name) 建議忽略某個索引不使用
但一般不建議使用這種hint,原因如下:
hint是和索引名稱而不是索引字段綁定的,以后存在着很大的風險,把索引改名了,會導致提示無效的.
業務存在拼接SQL的情況下,代碼考慮不周全,會導致一些不應該使用這種HINT的SQL也使用了這種HINT,導致它們的執行計划變差.
隨着版本的升級,優化器的提升,數據量,數據分布特點的變化,MYSQL本可以選擇更好的執行計划,但因為HINT導致MYSQL不能選擇更好的執行計划.
所以使用這些提示前,請先和DBA溝通,也要進行詳盡的測試,確認HINT的引入只帶來了益處,沒有帶來壞處.
(9). 關於單列索引和復合索引
有時候會看到業務SQL是where a=? and b=? and c=?
但3個列上分別創建了一個單列索引:
index(a) index(b) index(c)
這種創建是否合理呢?
前面提到高選擇性字段上適合創建索引,低選擇性字段上不適合創建單列索引(但可以考慮作為復合索引定義的一部分)
**如果a字段上的選擇性足夠高,b,c的選擇性低,完全可以只創建索引index(a) **, 這種情況下,當然也可以只創建index(a,b) 或者只創建index(a,b,c). (不要創建index(b), index(c) 這2個低選擇性字段上的單列索引了).
需要考慮到index(a,b) index(a,b,c) 相對於index(a),提升的收益並不大,但可能空間占用卻大出不少去,需要業務在時空的矛盾中做出平衡,看創建哪個索引更合適.
如果實際情況是a,b,c單獨的選擇性一般,都不是很高,但3個組合到一起的選擇性很高的話,那就建議創建index(a,b,c)的組合索引,不要3個字段上都創建一個單列索引.
為什么呢? mysql確實可以使用index merge來使用多個索引,但很多時候是否比得上復合索引效率高呢?
簡化一下: where a=? and b=?
a=? 返回1W行記錄, b=? 返回1W行記錄, where a=? and b=? 返回100行記錄.
如果是兩個單列索引: index(a) index(b) 的情況下,index_merge會是一個什么樣的執行計划呢?
針對a=? 通過使用index(a) 返回1W行記錄,帶PRMIARY KEY
針對b=? 通過使用index(b) 返回1W行記錄,帶PRMIARY KEY
然后對primary key 取交集,不管是排序后取交集也好,還是通過嵌套循環,關聯的方式取交集也好.都會是一個耗時耗費資源的操作.
綜合來說,掃描各自的索引返回1W行記錄,而后對這2W行記錄取交集,肯定是一個耗時耗費資源的操作了.
但如果存在復合索引index(a,b) 通過索引的掃描定位,可以快速的返回這100行記錄的.
所以針對這種情況,建議創建復合索引,不要創建多個單列索引.
補充說一下:
where a=? or b=? 這種查詢, a列,b列上的選擇性都很高,這時候需要index(a) index(b),缺少一個,都會導致全表掃描的.
(10). 關於多表關聯
ORACLE中有三種主要的表關聯方式:NESTED LOOP , HASH JOIN 和 SORT MERGE JOIN
其中最常用的還是前兩種,ORACLE的優化器會根據統計得到的表行數,數據分布情況等信息,對各種關聯方式,關聯順序下的多個執行計划進行評估,分別計算它們的cost,最后選擇一個cost最低(優化器認為的最優)執行計划作為最終的執行計划去執行.
但至少到mysql官方的5.6版本,依然只有NESTED LOOP(嵌套循環)這樣一種關聯方式.
NESTED LOOP說白了就是FOR循環實現:
比如說針對下面的關聯查詢: select a.*, b * from EMP a,DEPT b where a.DEPTNO = b.DEPTNO; 它的嵌套循環的偽代碼大意是這樣的: declare begin for outer_table in (select * from dept) loop for inner_table in (select * from emp where DEPTNO = outer_table.DEPTNO) loop dbms_output.put_line(inner_table.*, outer_table.*); end loop; end loop; end;
NESTED LOOP的適用場景是什么?
外表(驅動表)經過過濾后返回較少的數據行(最好也可以通過索引快遞的定位這些數據行,和表本身的數據行多少無關,只要求經過條件的過濾后返回較少的數據行),而內表(被驅動表)在表的關聯字段上存在着高效的索引可用.
因為這種情況下,FOR循環的代價是小的,是適用NESTED LOOP的.
其它情況,使用NESTED LOOP都不合適,比如內外表經過過濾后都返回上萬行甚至數十萬,百萬的記錄,這種情況下,FOR循環的成本太高了(其實這種情況下,HASH JION是適用的)
因為這個原因(當然還有其它原因了,比如說mysql沒有bitmap index等),mysql不適合做OLAP系統,不適合做復雜的多表關聯:
多表關聯,關聯的表越多,返回的行數越多,他們作為外表,FOR循環的成本會越來越高,執行時間越來越長,很容易就超過業務設置的讀超時時間,或者超過DB端設置的超時時間,稍微來點兒並發,就可能會耗盡DB的資源,會導致雪崩,DB響應不了任何的業務請求.
所以不建議在MySQL上進行復雜的多表關聯查詢,低頻,基本無並發的查詢,可以在線下庫進行;執行頻率稍高,存在並發的,就必須到hadoop,hbase等環境進行了.
因為mysql的表關聯實現就是for循環,所以簡單的表關聯,業務也可以自己for循環實現.
四、慢查詢日志的分析以及關注點
(1). 使用pt-query-digest工具來統計
可以使用percona公司的開源工具pt-query-digest來進行統計,它可以支持多種類型日志文件的分析,包括binlog,genlog,slowlog,tcpdump的輸出進行統計.默認就是對slowlog進行分析的.
它也支持多種過濾條件,比如說執行時間,檢索行數等的過濾輸出,也支持過濾后裸數據的輸出,支持多種聚合排序輸出.
一般使用最簡單的調用形式即可,都使用默認定義:
/usr/local/bin/pt-query-digest slow.log > slow.log.fenxi
slow.log 是待分析的慢查詢日志文件,將分析的結果重定向到文件slow.log.fenxi中.
它是去除字面值后對SQL進行分類匯總,然后按照每類SQL總的執行時間降序排序輸出的.並且每類SQL都給出了一個字面值SQL(期間執行時間最長的SQL).
(2). 對統計輸出進行分析
我們一般重點分析執行時間占比大的SQL,也就是前排的一些SQL,它們的執行時間長,系統資源消耗大,對業務的影響也大.
以一個輸出為例:
# Profile # Rank Query ID Response time Calls R/Call V/M Item # ==== ================== =============== ===== ======= ===== ============ # 1 0x426D0452190D3B9C 9629.1622 55.8% 5204 1.8503 0.01 SELECT queue_count # 2 0x52A6A31F2F3F0692 2989.7074 17.3% 2224 1.3443 0.03 SELECT server_info # 3 0x959209F179E16B2A 819.3819 4.8% 759 1.0796 0.00 SELECT server_info
第1類SQL總共耗時9629s,總的執行時間占日志中所有SQL執行時間的55.8%,在慢查詢日志中出現了5204次,平均每次執行耗時為1.85s
下面有這類SQL的詳盡信息,顯示的字面值SQL是其中執行時間最長的SQL
# Query 1: 0.11 QPS, 0.20x concurrency, ID 0x426D0452190D3B9C at byte 4615533 # This item is included in the report because it matches --limit. # Scores: V/M = 0.01 # Time range: 2017-05-24 23:56:03 to 2017-05-25 13:37:15 # Attribute pct total min max avg 95% stddev median # ============ === ======= ======= ======= ======= ======= ======= ======= # Count 52 5204 # Exec time 55 9629s 2s 3s 2s 2s 110ms 2s # Lock time 23 185ms 20us 23ms 35us 40us 331us 25us # Rows sent 0 5.65k 0 2 1.11 1.96 0.45 0.99 # Rows examine 83 18.54G 3.65M 3.65M 3.65M 3.50M 0 3.50M # Query size 20 665.75k 131 131 131 131 0 131 # String: # Databases queue_center # Hosts 10.36.31.52 (696/13%), 10.36.31.31 (694/13%)... 6 more # Users queue_center_w # Query_time distribution # 1us # 10us # 100us # 1ms # 10ms # 100ms # 1s ################################################################ # 10s+ # Tables # SHOW TABLE STATUS FROM `queue_center` LIKE 'queue_count'\G # SHOW CREATE TABLE `queue_center`.`queue_count`\G # EXPLAIN /*!50100 PARTITIONS*/ select * from `queue_count` where `app_id` = '1' and `created_at` > '2017-05-25 11:42:01' and `created_at` <= '2017-05-25 11:43:01'\G
我們重點關注avg,95分位的 #Rows examine 和 # Rows sent
Rows examine / Rows sent 對非聚合SQL而言,代表返回1行數據所要檢索的數據行數, 1 是想要的效果.
Rows examine檢索行數偏大的,如果同時Rows sent返回的數據行數很少(聚合函數除外),一般是可以通過索引優化的。
對於update/delete類的寫操作,慢查詢日志中Rows_examined還是SQL執行過程中檢索的行數,Rows_sent: 0 沒有意義,慢查詢日志中沒有體現出來匹配/影響的行數來。
如果寫操作Rows_examined很大,同時匹配/影響的行數極少,一般是全表掃描,寫操作過程中持有表鎖,影響並發的,而且執行時間長,容易導致同步延遲。但其實是可以通過索引優化這類寫操作的。
如果Rows examin,Rows sent都很小,但總體執行時間長的話,特別是讀取操作,很可能是受其它慢查詢影響的,可以暫時先不管,把其它慢查詢優化完畢之后,這類慢查詢很可能也就消失了。
像上面這個SQL,3.65M/1.11 = 3.29M,也就是說平均需要掃描329W行數據才能返回1行記錄,太低效了.
表結構中除了主鍵ID外沒有任何的索引,其實業務都是查詢最近1分鍾內的數據,確實可以通過index(app_id,created_at)或者index(created_at)來優化這類查詢。
五、幾個優化案例
優化案例1
mysql> show create table lc_day_channel_version\G *************************** 1. row *************************** Table: lc_day_channel_version Create Table: CREATE TABLE `lc_day_channel_version` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', `prodline` varchar(50) NOT NULL DEFAULT '' COMMENT '產品線標識', `os` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '平台類型,1:Android_Phone 2:Android_Pad 3:IPhone', `original_type` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '母包類型,1主線 3非主線', `dtime` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'date time,yyyymmdd', `version_name` varchar(50) NOT NULL DEFAULT '' COMMENT '來源版本號', `channel` varchar(50) NOT NULL DEFAULT '' COMMENT '渠道號', `request_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '請求量', `request_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '請求用戶量', `response_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '請求成功量', `response_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '請求成功用戶量', `download_pv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下載量', `download_uv` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '下載用戶量', PRIMARY KEY (`id`), UNIQUE KEY `UNIQUE_poouvc` (`prodline`,`os`,`original_type`,`dtime`,`version_name`,`channel`), KEY `INDEX_d` (`dtime`) ) ENGINE=InnoDB AUTO_INCREMENT=135293125 DEFAULT CHARSET=utf8 COMMENT='升級版本渠道匯總信息' 1 row in set (0.00 sec) mysql> explain select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime>=20170504 and dtime<=20170510 group by version_name order by request_pv desc; +----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+ | 1 | SIMPLE | lc_day_channel_version | range | UNIQUE_poouvc,INDEX_d | INDEX_d | 4 | NULL | 2779470 | Using index condition; Using temporary; Using filesort | +----+-------------+------------------------+-------+-----------------------+---------+---------+------+---------+--------------------------------------------------------+ 1 row in set (0.00 sec)
業務反饋執行上面的SQL,有索引可用呀,為什么還這么慢呢?
問題在於:
mysql> select count(1) from lc_day_channel_version where dtime>=20170504 and dtime<=20170510; +----------+ | count(1) | +----------+ | 1462991 | +----------+ 1 row in set (0.58 sec)
對應146W記錄,使用index(dtime),需要回訪表獲取version_name,request_pv字段,這樣要對應146W的隨機IO + 掃描的索引塊數量的隨機IO,
而后還要對這146W的結果集 group by version_name order by request_pv desc,代價還是很高的. 多次測試執行4.7s左右.
一種優化方案就是走覆蓋索引,避免回訪表:alter table lc_day_channel_version add key idx_dtime_version_name_request_pv(dtime,version_name,request_pv);
再看執行計划:
mysql> explain select sql_no_cache version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime>=20170504 and dtime<=20170510 group by version_name order by request_pv desc; +----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+ | 1 | SIMPLE | lc_day_channel_version | range | UNIQUE_poouvc,INDEX_d,idx_dtime_version_name_request_pv | idx_dtime_version_name_request_pv | 4 | NULL | 2681154 | Using where; Using index; Using temporary; Using filesort | +----+-------------+------------------------+-------+---------------------------------------------------------+-----------------------------------+---------+------+---------+-----------------------------------------------------------+ 1 row in set (0.00 sec)
Using index 已經不需要回訪表了,整體的執行時間也降低了一半,平均執行時間為2.35s左右.
再繼續優化下去,能否避免對這么大量的數據(146W行記錄)進行排序操作呀? 能利用索引避免排序操作嗎? index(dtime,version_name,request_pv)為什么不能避免物理排序操作呢?(Using filesort顯示確實存在物理排序動作)
原因就在於dtime上使用了范圍匹配,使得索引數據整體上不再有序了. 那我改成等值匹配看看呢?
mysql> explain select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170504 group by version_name; +----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+ | 1 | SIMPLE | lc_day_channel_version | ref | UNIQUE_poouvc,INDEX_d,idx_dtime_version_name_request_pv | idx_dtime_version_name_request_pv | 4 | const | 402616 | Using where; Using index | +----+-------------+------------------------+------+---------------------------------------------------------+-----------------------------------+---------+-------+--------+--------------------------+ 1 row in set (0.00 sec)
確實沒有出現Using filesort,說明通過這個索引能避免物理排序操作.
當然,業務邏輯還是不能變的,最終最初的SQL可以修改為如下等效的SQL:
select version_name,sum(request_pv) as request_pv from ( select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170504 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170505 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170506 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170507 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170508 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170509 group by version_name union all select version_name,sum(request_pv) as request_pv from lc_day_channel_version where dtime=20170510 group by version_name ) tmp group by version_name order by request_pv desc;
每天對應21W左右的記錄,每天的記錄group by后對應2500行左右的記錄,這樣就將原來對146W記錄排序,變成了對2500*7 行記錄排序,排序量大幅下降,所以執行時間也有了提升,現在執行時間已經變為1.01s左右了
單純從SQL的角度優化,似乎只能優化到這個地步了.而實際的業務SQL要比上面的還要復雜多變,比如說:
select version_name, sum(request_uv) as request_uv from `lc_day_channel_version` where `dtime` >= 20170505 and `dtime` <= 20170511 group by version_name order by request_uv desc; select sql_calc_found_rows version_name, channel, sum(request_pv) as request_pv, sum(request_uv) as request_uv, sum(response_pv) as response_pv, sum(response_uv) as response_uv, sum(download_pv) as download_pv, sum(download_uv) as download_uv from `lc_day_channel_version` where `dtime` >= 20170505 and `dtime` <= 20170511 group by version_name, channel order by request_uv desc limit 0, 15 select version_name, sum(response_pv) as response_pv from `lc_day_channel_version` where `dtime` >= 20170505 and `dtime` <= 20170511 group by version_name order by response_pv desc select sql_calc_found_rows version_name, channel, sum(request_pv) as request_pv, sum(request_uv) as request_uv, sum(response_pv) as response_pv, sum(response_uv) as response_uv, sum(download_pv) as download_pv, sum(download_uv) as download_uv from `lc_day_channel_version` where `dtime` >= 20170505 and `dtime` <= 20170511 group by version_name, channel order by response_pv desc limit 0, 15
針對每類SQL都添加一個對應的索引? 那索引太多了,會嚴重影響寫入性能的.
index(dtime,version_name,所有的統計項字段)
index(dtime,version_name,channel,所有的統計項字段)
這樣全家桶式的索引,包含了所有的統計項字段,問題是每個索引太大了.
不要光想着SQL優化,其實最大的殺手鐧: 業務優化還沒有考慮呢。 那業務層面是否有優化的空間呢? 當然是有的,而且優化空間還不小.
每天的統計數據在插入后,基本就不再變動了.每天插入21W左右的記錄,每天的數據統計后也就是2000多行的記錄,這樣在每天凌晨對前1天的數據進行異步統計,
統計結果放到一個中間表中去,每天的這種統計報表,不再掃描原始數據表,而掃描這類中間表,每天掃描的記錄行數可以減少到1/100的數量級,再配以SQL層面的優化才是王道呀!
優化案例2
mysql> show create table mc_state\G *************************** 1. row *************************** Table: mc_state Create Table: CREATE TABLE `mc_state` ( `state_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '機器的狀態ID', `transaction_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '維修周期ID', `ip` int(10) unsigned NOT NULL COMMENT '機器的IP', `state_name` varchar(255) NOT NULL COMMENT '狀態名稱', `start_time` datetime NOT NULL COMMENT '狀態開始時間', `update_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '狀態信息的更新時間', `end_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '狀態結束時間', `create_ip` int(10) unsigned NOT NULL COMMENT '創建該狀態的IP', `error_status` text NOT NULL COMMENT '機器當前的狀態錯誤信息(JSON)', PRIMARY KEY (`state_id`), KEY `idx_name` (`state_name`), KEY `idx_time` (`start_time`), KEY `idx_transaction_id` (`transaction_id`), KEY `idx_ip` (`ip`), KEY `idx_end_time` (`end_time`) ) ENGINE=InnoDB AUTO_INCREMENT=7614257 DEFAULT CHARSET=utf8 COMMENT='機器維修狀態表' mysql> show create table mc_machine\G *************************** 1. row *************************** Table: mc_machine Create Table: CREATE TABLE `mc_machine` ( `ip` int(10) unsigned NOT NULL COMMENT '機器的IP', `pool` varchar(255) NOT NULL COMMENT '機器所屬維修策略', `params` varchar(2047) NOT NULL DEFAULT '' COMMENT '其他PER機器的維修參數信息', `create_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '機器信息的創建時間', `update_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '機器信息的更新時間', PRIMARY KEY (`ip`), KEY `idx_pool` (`pool`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='機器信息表'
每天存在如下的慢查詢:
# Query_time: 6.799199 Lock_time: 0.000124 Rows_sent: 148 Rows_examined: 8394700 SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status FROM mc_state INNER JOIN (SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1 ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip WHERE mc_machine.pool IN ('hadoop-repair-quick-repair') LIMIT 0, 999999999999; # Query_time: 6.826629 Lock_time: 0.000125 Rows_sent: 98 Rows_examined: 8394700 SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status FROM mc_state INNER JOIN (SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1 ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip WHERE mc_machine.pool IN ('kuorong_beehive') LIMIT 0, 999999999999; # Query_time: 7.824977 Lock_time: 0.000139 Rows_sent: 148 Rows_examined: 8394700 SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status FROM mc_state INNER JOIN (SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1 ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip LIMIT 0, 999999999999; # Query_time: 7.899820 Lock_time: 0.000095 Rows_sent: 98 Rows_examined: 8394700 SELECT mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_stat e.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_stat e.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status FROM mc_state INNER JOIN (SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip) AS anon_1 ON mc_state.state_id = anon_1.state_id INNER JOIN mc_machine ON mc_state.ip = mc_machine.ip WHERE mc_machine.pool IN ('kuorong_beehive') LIMIT 0, 999999999999;
業務的邏輯是什么?獲取每個池中,每台機器最新的狀態數據.
其實最原始的業務需求是每天獲得每台機器最新的狀態數據,但一個SQL執行時間太長了,經常超時報錯,所以最后修改為這樣,按池獲取.
但其實這樣,每次執行都要執行SELECT max(mc_state.state_id) AS state_id FROM mc_state GROUP BY mc_state.ip
大量這這個pool不相關的數據也要獲取一遍,其實存在着明顯的資源浪費的.
其實業務邏輯可以下面這樣實現:
$last_ip = 0; $result1 = $dbconn->prepare("select ip from mc_machine where pool='ps_diaoyan' and ip>? order by ip limit 1000"); $result2 = $dbconn->prepare("select mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, mc_state.ip AS mc_state_ip, mc_state.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, mc_state.update_time AS mc_state_update_time, mc_state.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, mc_state.error_status AS mc_state_error_status from mc_state where ip=? order by state_id desc limit 1"); $v_file = fopen("matrix_mc1.result","w+"); $result1->bindParam(1,$last_ip,PDO::PARAM_INT); $result2->bindParam(1,$this_ip,PDO::PARAM_INT); while (true) { $result1->execute(); $iplist = $result1->fetchAll(PDO::FETCH_ASSOC); foreach ( $iplist as $row ) { $this_ip = intval($row["ip"]); $result2->execute(); foreach ( $result2->fetchAll(PDO::FETCH_NUM) as $row2 ) { $v_str = implode(",",$row2).PHP_EOL; fwrite($v_file,$v_str); } } if ( count($iplist) < 1000) { break; } $last_ip = intval($row["ip"]); } $result1 = null; $result2 = null; $dbconn = NULL; fclose($v_file);
原始SQL的執行計划不在這里展示了.
優化后的方案只涉及到2類SQL,都是很簡單的SQL,都可以通過高效的索引快速的返回結果:
mysql> explain select ip from mc_machine where ip>169524751 order by ip limit 1000; +----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+ | 1 | SIMPLE | mc_machine | range | PRIMARY | PRIMARY | 4 | NULL | 103043 | Using where; Using index | +----+-------------+------------+-------+---------------+---------+---------+------+--------+--------------------------+ 1 row in set (0.00 sec) mysql> explain select mc_state.state_id AS mc_state_state_id, mc_state.transaction_id AS mc_state_transaction_id, -> mc_state.ip AS mc_state_ip,mc_state.state_name AS mc_state_state_name, mc_state.start_time AS mc_state_start_time, -> mc_state.update_time AS mc_state_update_time, mc_state.end_time AS mc_state_end_time, mc_state.create_ip AS mc_state_create_ip, -> mc_state.error_status AS mc_state_error_status -> from mc_state where ip=169524751 order by state_id desc limit 1; +----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+ | 1 | SIMPLE | mc_state | ref | idx_ip | idx_ip | 4 | const | 1 | Using where | +----+-------------+----------+------+---------------+--------+---------+-------+------+-------------+ 1 row in set (0.00 sec)
這樣通過SQL的拆分,通過循環的方式,將原來的一個復雜的自關聯查詢,變成2類簡單的SQL循環執行,從而達到了優化的目的.
針對由此帶來的應用端和DB端網絡交互太多帶來的時間成本,可以考慮使用multiquery一次發送執行多條SQL來減少頻繁網絡交互帶來的影響(具體一次發送執行多少個SQL合適,需要業務層面進行測試確定).
當然,業務最終沒有使用這里的方案,而是根據業務邏輯,變為簡單的讀取兩個表的記錄,而后代碼層進行關聯,也成功的消除了業務的讀取壓力問題.
這也說明了業務層面的優化是很重要的.
優化案例3
手百的夏逗活動, 是手百為了提升用戶黏度推出的一個活動,鼓勵用戶通過手百搜索一些奇葩的問題,並獎勵給用戶一定的豆幣,最終排名前1500名的用戶,可以瓜分一筆現金大獎.
表結構如下:
mysql> show create table xiadou_user\G *************************** 1. row *************************** Table: xiadou_user Create Table: CREATE TABLE `xiadou_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用戶 id', `uid` bigint(20) NOT NULL COMMENT '百度賬號 uid', `uname` varchar(128) NOT NULL DEFAULT '' COMMENT '百度賬號名稱', `displayname` varchar(128) NOT NULL DEFAULT '' COMMENT '百度賬號顯示名稱', `securemobil` varchar(50) NOT NULL DEFAULT '' COMMENT '綁定的手機號', `score` int(10) NOT NULL DEFAULT '0' COMMENT '用戶當前的豆子數', `money` float NOT NULL DEFAULT '0' COMMENT '累積抽獎賺取的金額', `last_sign_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '最近一次簽到時間', `sign_continue_days` tinyint(3) NOT NULL DEFAULT '0' COMMENT '簽到連續天數', `create_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '創建時間', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間', `last_add_score_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '最近一次加豆子的時間', `cuid` varchar(255) NOT NULL DEFAULT '' COMMENT '用戶的 cuid 信息,可能包含多個,逗號分隔,最多存3個', `win` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否是最后大獎中獎用戶', `awarded` tinyint(1) NOT NULL DEFAULT '0' COMMENT '表示是否領取過最后大獎', `issafe` tinyint(2) NOT NULL DEFAULT '1' COMMENT '安全狀態,1 正常,0高危', `appos` varchar(100) NOT NULL DEFAULT '' COMMENT 'appos', PRIMARY KEY (`id`), UNIQUE KEY `uid_UNIQUE` (`uid`), KEY `SCORE_TIME_INDEX` (`score`,`last_add_score_time`), KEY `WIN_INDEX` (`win`), KEY `idx_appos` (`appos`) ) ENGINE=InnoDB AUTO_INCREMENT=9891815 DEFAULT CHARSET=utf8 COMMENT='用戶信息表' 1 row in set (0.00 sec)
具體的排名規則是: ORDER BY score DESC, last_add_score_time ASC 優先按照分數降序排名, 分數相同的, 早獲得這個分數的用戶排名靠前.
用戶參與活動,搜索了奇葩問題后,很可能想查看自己目前的積分,距離瓜分大獎的資格還差多少分.
所以業務提供了這樣一個功能:
SELECT score FROM xiadou_user ORDER BY score DESC, last_add_score_time ASC LIMIT 1500, 1;
查詢目前第1500名(其實應該是limit 1499,1 的) 的分數,然后顯示目前用戶的分數距離它還差多少分數.
這個查詢應用端是有CACHE的,但每次只要用戶積分有變化,排名就可能發生變化,所以業務會del相關的CACHE,所以對於這個查詢而言,CACHE是沒有用的,白天時段,讀取基本上還都是要實時的走DB的.
但是很快DB端CPU就打滿了,DB端都是上面這個查詢,為什么呢?
mysql> explain SELECT score FROM xiadou_user ORDER BY score DESC, last_add_score_time ASC LIMIT 1500, 1; +----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+ | 1 | SIMPLE | xiadou_user | index | NULL | SCORE_TIME_INDEX | 8 | NULL | 9255660 | Using index; Using filesort | +----+-------------+-------------+-------+---------------+------------------+---------+------+---------+-----------------------------+ 1 row in set (0.00 sec)
存在KEY SCORE_TIME_INDEX
(score
,last_add_score_time
) 但是它只能優化這2個字段的同向排序,都升序或者都降序都可以通過這個索引避免物理排序,快速的返回TOPN記錄.
因為MYSQL本身只有升序索引,沒有降序索引,但索引葉節點是通過雙向鏈表來保證邏輯有序的,所以SQL層面兩個排序字段都升序或者都降序,都是可以通過索引來優化的,就是正向掃描和逆向掃描索引而已.
但對於2個排序字段排序方向不同的情況,是無法通過索引優化的,只能進行物理排序了,所以執行計划中出現了Using filesort ,也就是說讀取出幾百W的記錄,而后物理排序,最后輸出第1500個記錄,所以SQL性能很差的.
大並發的情況,情況進一步惡化,從而導致DB主機CPU打滿的情況(隨着數據的持續增加,情況只會是進一步的惡化).
優化方案:
因為排序時優先按分數排序,分數相同的,再按照時間排序,這里並不是要獲得確切的第1500名的用戶信息,而只是要獲得第1500名的分數而已,所以上面的SQL在業務邏輯層其實等價於下面的SQL:
SELECT score FROM xiadou_user ORDER BY score DESC LIMIT 1500, 1; mysql> explain SELECT score FROM xiadou_user ORDER BY score DESC LIMIT 1500, 1; +----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+ | 1 | SIMPLE | xiadou_user | index | NULL | SCORE_TIME_INDEX | 8 | NULL | 1501 | Using index | +----+-------------+-------------+-------+---------------+------------------+---------+------+------+-------------+ 1 row in set (0.00 sec)
這個SQL是可以使用KEY SCORE_TIME_INDEX
(score
,last_add_score_time
) 來優化的,是不需要物理排序的.
業務改寫為這個SQL,上線后,DB主機CPU恢復正常,而且業務響應時間大幅提升.
當然,最終的用戶排名還是要調用上面的2個字段的排序SQL的.
不過業務21點結束活動,22點公布排名,這中間完全可以執行SQL,把結果插入到一個結果表去,而后只是讀取這個結果表就可以了.而且這樣也方便業務干預最終的排名結果.
當然,應用端使用cache緩存上面2個字段的排序SQL的執行結果,也是完全可行的,因為數據不再變動,完全可以通過cache擋住全部的讀取流量.
總結:
原本並不等價的2個SQL,但在業務層面是完全等價的,通過SQL的改寫,達到了優化的目的.