MySQL慢查詢優化(線上環境調優實踐)

PS:本文已收錄到1.3 K+ Star 數的開源項目《大廠面試指北》,如果想要領取《大廠面試指北》離線PDF版,請關注“大廠面試”公衆號領取,或者去點擊入羣二維碼掃碼進羣或者加我微信ruiwendelll,備註獲取資料
項目地址:https://github.com/NotFound9/interviewGuide

入羣二維碼圖片地址:http://notfound9.github.io/interviewGuide/static/49160c2basfdsf.jpeg)
項目截圖:
項目截圖
掃碼領取《大廠面試指北》PDF資料
在這裏插入圖片描述

文章說明

這篇文章主要是記錄自己最近在真實工作中遇到的慢查詢的案例,然後進行調優分析的過程,歡迎大家一起討論調優經驗。(以下出現的表名,列名都是化名,實際數據也進行過一點微調。)

本文已收錄到1.1k Star數的開源項目《面試指北》中,如果想要了解更多MySQL相關的技術總結,可以看一看,如果對大家有幫助,希望大家幫忙給一個star,謝謝大家了!

《面試指北》項目地址:https://github.com/NotFound9/interviewGuide


爲了方便技術交流,分享一些我覺得好的書和自己給大家,及便於大家領取《面試指北》的PDF版本,也建了一個技術交流羣,歡迎大家掃碼加入!
在這裏插入圖片描述

一.複雜的深分頁問題優化

背景

有一個article表,用於存儲文章的基本信息的,有文章id,作者id等一些屬性,有一個content表,主要用於存儲文章的內容,主鍵是article_id,需求需要將一些滿足條件的作者發佈的文章導入到另外一個庫,所以我同事就在項目中先查詢出了符合條件的作者id,然後開啓了多個線程,每個線程每次取一個作者id,執行查詢和導入工作。

查詢出作者id是1111,名下的所有文章信息,文章內容相關的信息的SQL如下:

SELECT
    a.*, c.*
FROM
    article a
LEFT JOIN content c ON a.id = c.article_id
WHERE
    a.author_id = 1111
AND a.create_time < '2020-04-29 00:00:00'
LIMIT 210000,100

因爲查詢的這個數據庫是機械硬盤的,在offset查詢到20萬時,查詢時間已經特別長了,運維同事那邊直接收到報警,說這個庫已經IO阻塞了,已經多次進行主從切換了,我們就去navicat裏面試着執行了一下這個語句,也是一直在等待, 然後對數據庫執行show proceesslist 命令查看了一下,發現每個查詢都是處於Writing to net的狀態,沒辦法只能先把導入的項目暫時下線,然後執行kill命令將當前的查詢都殺死進程(因爲只是客戶端Stop的話,MySQL服務端會繼續查詢)。

然後我們開始分析這條命令執行慢的原因:

是否是聯合索引的問題

當前是索引情況如下:

article表的主鍵是id,author_id是一個普通索引
content表的主鍵是article_id

所以認爲當前是執行流程是先去article表的普通索引author_id裏面找到1111的所有文章id,然後根據這些文章id去article表的聚集索引中找到所有的文章,然後拿每個文章id去content表中找文章內容等信息,然後判斷create_time是否滿足要求,進行過濾,最終找到offset爲20000後的100條數據。

所以我們就將article的author_id索引改成了聯合索引(author_id,create_time),這樣聯合索引(author_id,create_time)中的B+樹就是先安裝author_id排序,再按照create_time排序,這樣一開始在聯合(author_id,create_time)查詢出來的文章id就是滿足create_time < '2020-04-29 00:00:00’條件的,後面就不用進行過濾了,就不會就是符合就不用對create_time過濾。

流程確實是這個流程,但是去查詢時,如果limit還是210000, 100時,還是查不出數據,幾分鐘都沒有數據,一直到navica提示超時,使用Explain看的話,確實命中索引了,如果將offset調小,調成6000, 100,勉強可以查出數據,但是需要46s,所以瓶頸不在這裏。

真實原因如下:

先看關於深分頁的兩個查詢,id是主鍵,val是普通索引

直接查詢法

select * from test where val=4 limit 300000,5;

先查主鍵再join

select * from test a 
inner join
(select id from test where val=4 limit 300000,5) as b 
on a.id=b.id;

這兩個查詢的結果都是查詢出offset是30000後的5條數據,區別在於第一個查詢需要先去普通索引val中查詢出300005個id,然後去聚集索引下讀取300005個數據頁,然後拋棄前面的300000個結果,只返回最後5個結果,過程中會產生了大量的隨機I/O。第二個查詢一開始在普通索引val下就只會讀取後5個id,然後去聚集索引下讀取5個數據頁。

同理我們業務中那條查詢其實是更加複雜的情況,因爲我們業務的那條SQL不僅會讀取article表中的210100條結果,而且會每條結果去content表中查詢文章相關內容,而這張表有幾個TEXT類型的字段,我們使用show table status命令查看錶相關的信息發現

Name Engine Row_format Rows Avg_Row_length
article InnoDB Compact 2682682 266
content InnoDB Compact 2824768 16847

發現兩個表的數據量都是200多萬的量級,article表的行平均長度是266,content表的平均長度是16847,簡單來說是當 InnoDB 使用 Compact 或者 Redundant 格式存儲極長的 VARCHAR 或者 BLOB 這類大對象時,我們並不會直接將所有的內容都存放在數據頁節點中,而是將行數據中的前 768 個字節存儲在數據頁中,後面會通過偏移量指向溢出頁。

(詳細瞭解可以看看這篇文章深度好文帶你讀懂MySQL和InnoDB

img

這樣再從content表裏面查詢連續的100行數據時,讀取每行數據時,還需要去讀溢出頁的數據,這樣就需要大量隨機IO,因爲機械硬盤的硬件特性,隨機IO會比順序IO慢很多。所以我們後來又進行了測試,

只是從article表裏面查詢limit 200000,100的數據,發現即便存在深分頁的問題,查詢時間只是0.5s,因爲article表的平均列長度是266,所有數據都存在數據頁節點中,不存在頁溢出,所以都是順序IO,所以比較快。

//查詢時間0.51s
SELECT a.* FROM article a  
WHERE a.author_id = 1111  
AND a.create_time < '2020-04-29 00:00:00' 
LIMIT 200100, 100

相反的,我們直接先找出100個article_id去content表裏面查詢數據,發現比較慢,第一次查詢時需要3s左右(也就是這些id的文章內容相關的信息都沒有過,沒有緩存的情況),第二次查詢時因爲這些溢出頁數據已經加載到buffer pool,所以大概0.04s。

SELECT SQL_NO_CACHE c.* 
FROM article_content c 
WHERE c.article_id in(100個article_id)

解決方案

所以針對這個問題的解決方案主要有兩種:

先查出主鍵id再inner join

非連續查詢的情況下,也就是我們在查第100頁的數據時,不一定查了第99頁,也就是允許跳頁查詢的情況,那麼就是使用先查主鍵再join這種方法對我們的業務SQL進行改寫成下面這樣,下查詢出210000, 100時主鍵id,作爲臨時表temp_table,將article表與temp_table表進行inner join,查詢出中文章相關的信息,並且去left Join content表查詢文章內容相關的信息。 第一次查詢大概1.11s,後面每次查詢大概0.15s

SELECT
    a.*, c.*
FROM article a
INNER JOIN(
    SELECT    id FROM    article a
    WHERE    a.author_id = 1111
    AND a.create_time < '2020-04-29 00:00:00'
    LIMIT 210000 ,
    100
) as temp_table ON a.id = temp_table.id
LEFT JOIN content c ON a.id = c.article_id

優化結果

優化前,offset達到20萬的量級時,查詢時間過長,一直到超時。

優化後,offset達到20萬的量級時,查詢時間爲1.11s。

利用範圍查詢條件來限制取出的數據

這種方法的大致思路如下,假設要查詢test_table中offset爲10000的後100條數據,假設我們事先已知第10000條數據的id,值爲min_id_value

select * from test_table where id > min_id_value order by id limit 0, 100,就是即利用條件id > min_id_value在掃描索引是跳過10000條記錄,然後取100條數據即可,這種處理方式的offset值便成爲0了,但此種方式有限制,必須知道offset對應id,然後作爲min_id_value,增加id > min_id_value的條件來進行過濾,如果是用於分頁查找的話,也就是必須知道上一頁的最大的id,所以只能一頁一頁得查,不能跳頁,但是因爲我們的業務需求就是每次100條數據,進行分批導數據,所以我們這種場景是可以使用。針對這種方法,我們的業務SQL改寫如下:

//先查出最大和最小的id
SELECT min(a.id) as min_id , max(a.id) as max_id 
FROM article a 
WHERE a.author_id = 1111  
AND a.create_time < '2020-04-29 00:00:00' 
//然後每次循環查找
while(min_id<max_id) {
        SELECT a.*, c.* FROM article a LEFT JOIN content c ON a.id = c.article_id  WHERE a.author_id = 1111  AND a.id > min_id LIMIT 100
        //這100條數據導入完畢後,將100條數據數據中最大的id賦值給min_id,以便導入下100條數據
}

優化結果

優化前,offset達到20萬的量級時,查詢時間過長,一直到超時。

優化後,offset達到20萬的量級時,由於知道第20萬條數據的id,查詢時間爲0.34s。

二.聯合索引問題優化

聯合索引其實有兩個作用:

1.充分利用where條件,縮小範圍

例如我們需要查詢以下語句:

SELECT * FROM test WHERE a = 1 AND b = 2

如果對字段a建立單列索引,對b建立單列索引,那麼在查詢時,只能選擇走索引a,查詢所有a=1的主鍵id,然後進行回表,在回表的過程中,在聚集索引中讀取每一行數據,然後過濾出b = 2結果集,或者走索引b,也是這樣的過程。
如果對a,b建立了聯合索引(a,b),那麼在查詢時,直接在聯合索引中先查到a=1的節點,然後根據b=2繼續往下查,查出符合條件的結果集,進行回表。

2.避免回表(此時也叫覆蓋索引)

這種情況就是假如我們只查詢某幾個常用字段,例如查詢a和b如下:

SELECT a,b FROM test WHERE a = 1 AND b = 2

對字段a建立單列索引,對b建立單列索引就需要像上面所說的,查到符合條件的主鍵id集合後需要去聚集索引下回表查詢,但是如果我們要查詢的字段本身在聯合索引中就都包含了,那麼就不用回表了。

3.減少需要回表的數據的行數

這種情況就是假如我們需要查詢a>1並且b=2的數據

SELECT * FROM test WHERE a > 1 AND b = 2

如果建立的是單列索引a,那麼在查詢時會在單列索引a中把a>1的主鍵id全部查找出來然後進行回表。
如果建立的是聯合索引(a,b),基於最左前綴匹配原則,因爲a的查詢條件是一個範圍查找(=或者in之外的查詢條件都是範圍查找),這樣雖然在聯合索引中查詢時只能命中索引a的部分,b的部分命中不了,只能根據a>1進行查詢,但是由於聯合索引中每個葉子節點包含b的信息,在查詢出所有a>1的主鍵id時,也會對b=2進行篩選,這樣需要回表的主鍵id就只有a>1並且b=2這部分了,所以回表的數據量會變小。

我們業務中碰到的就是第3種情況,我們的業務SQL本來更加複雜,還會join其他表,但是由於優化的瓶頸在於建立聯合索引,所以進行了一些簡化,下面是簡化後的SQL:

SELECT
  a.id as article_id ,
  a.title as title ,
  a.author_id as author_id 
from
  article a
where
  a.create_time between '2020-03-29 03:00:00.003'
and '2020-04-29 03:00:00.003'
and a.status = 1

我們的需求其實就是從article表中查詢出最近一個月,status爲1的文章,我們本來就是針對create_time建了單列索引,結果在慢查詢日誌中發現了這條語句,查詢時間需要0.91s左右,所以開始嘗試着進行優化。

爲了便於測試,我們在表中分別對create_time建立了單列索引create_time,對(create_time,status)建立聯合索引idx_createTime_status。

強制使用idx_createTime進行查詢

SELECT
  a.id as article_id ,
  a.title as title ,
  a.author_id as author_id 
from
  article a  FORCE INDEX(idx_createTime)
where
  a.create_time between '2020-03-22 03:00:00.003'
and '2020-04-22 03:00:00.003'
and a.status = 1

強制使用idx_createTime_status進行查詢(即使不強制也是會選擇這個索引)

SELECT
  a.id as article_id ,
  a.title as title ,
  a.author_id as author_id 
from
  article a  FORCE INDEX(idx_createTime_status)
where
  a.create_time between '2020-03-22 03:00:00.003'
and '2020-04-22 03:00:00.003'
and a.status = 1

優化結果:

優化前使用idx_createTime單列索引,查詢時間爲0.91s

優化前使用idx_createTime_status聯合索引,查詢時間爲0.21s

EXPLAIN的結果如下:

id type key key_len rows filtered Extra
1 range idx_createTime 4 311608 25.00 Using index condition; Using where
2 range idx_createTime_status 6 310812 100.00 Using index condition

原理分析

先介紹一下EXPLAIN中Extra列的各種取值的含義

Using filesort

當Query 中包含 ORDER BY 操作,而且無法利用索引完成排序操作的時候,MySQL Query Optimizer 不得不選擇相應的排序算法來實現。數據較少時從內存排序,否則從磁盤排序。Explain不會顯示的告訴客戶端用哪種排序。

Using index

僅使用索引樹中的信息從表中檢索列信息,而不需要進行附加搜索來讀取實際行(使用二級覆蓋索引即可獲取數據)。 當查詢僅使用作爲單個索引的一部分的列時,可以使用此策略。

Using temporary

要解決查詢,MySQL需要創建一個臨時表來保存結果。 如果查詢包含不同列的GROUP BY和ORDER BY子句,則通常會發生這種情況。官方解釋:”爲了解決查詢,MySQL需要創建一個臨時表來容納結果。典型情況如查詢包含可以按不同情況列出列的GROUP BY和ORDER BY子句時。很明顯就是通過where條件一次性檢索出來的結果集太大了,內存放不下了,只能通過加臨時表來輔助處理。

Using where

表示當where過濾條件中的字段無索引時,MySQL Sever層接收到存儲引擎(例如innodb)的結果集後,根據where條件中的條件進行過濾。

Using index condition

Using index condition 會先條件過濾索引,過濾完索引後找到所有符合索引條件的數據行,隨後用 WHERE 子句中的其他條件去過濾這些數據行;

我們的實際案例中,其實就是走單個索引idx_createTime時,只能從索引中查出 滿足a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003'條件的主鍵id,然後進行回表,因爲idx_createTime索引中沒有status的信息,只能回表後查出所有的主鍵id對應的行。然後innodb將結果集返回給MySQL Sever,MySQL Sever根據status字段進行過濾,篩選出status爲1的字段,所以第一個查詢的Explain結果中的Extra纔會顯示Using where。

filtered字段表示存儲引擎返回的數據在server層過濾後,剩下多少滿足查詢的記錄數量的比例,這個是預估值,因爲status取值是null,1,2,3,4,所以這裏給的25%。

所以第二個查詢與第一個查詢的區別主要在於一開始去idx_createTime_status查到的結果集就是滿足status是1的id,所以去聚集索引下進行回表查詢時,掃描的行數會少很多(大概是2.7萬行與15萬行的區別),之後innodb返回給MySQL Server的數據就是滿足條件status是1的結果集(2.7萬行),不用再進行篩選了,所以第二個查詢纔會快這麼多,時間是優化前的23%。(兩種查詢方式的EXPLAIN預估掃描行數都是30萬行左右是因爲idx_createTime_status只命中了createTime,因爲createTime不是查單個值,查的是範圍)

//查詢結果行數是15萬行左右
SELECT count(*) from article a 
where a.post_time 
between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003'

//查詢結果行數是2萬6行左右
SELECT count(*) from article a 
where a.post_time 
between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' 
and a.audit_status = 1

發散思考:如果將聯合索引(createTime,status)改成(status,createTime)會怎麼樣?

where
  a.create_time between '2020-03-22 03:00:00.003'
and '2020-04-22 03:00:00.003'
and a.status = 1

根據最左匹配的原則,因爲我們的where查詢條件是這樣,如果是(createTime,status)那麼索引就只能用到createTime,如果是(status,createTime),因爲status是查詢單個值,所以status,createTime都可以命中,在(status,createTime)索引中掃描行數會減少,但是由於(createTime,status)這個索引本身值包含createTime,status,id三個字段的信息,數據量比較小,而一個數據頁是16k,可以存儲1000個以上的索引數據節點,而且是查詢到createTime後,進行的順序IO,所以讀取比較快,總得的查詢時間兩者基本是一致。下面是測試結果:

首先創建了(status,createTime)名叫idx_status_createTime,

SELECT
  a.id as article_id ,
  a.title as title ,
  a.author_id as author_id 
from
  article a  FORCE INDEX(idx_status_createTime)
where
  a.create_time between '2020-03-22 03:00:00.003'
and '2020-04-22 03:00:00.003'
and a.status = 1

查詢時間是0.21,跟第二種方式(createTime,status)索引的查詢時間基本一致。

Explain結果對比:

id type key key_len rows filtered Extra
2 range idx_createTime_status 6 310812 100.00 Using index condition
3 range idx_status_createTime 6 52542 100.00 Using index condition

掃描行數確實會少一些,因爲在idx_status_createTime的索引中,一開始根據status = 1排除掉了status取值爲其他值的情況。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章