線上遇到的慢查詢的案例,MySQL慢查詢到底該如何「優化」?

複雜的深分頁問題優化

有一個article表,用於存儲文章的基本信息的,有文章id,做者id等一些屬性,有一個content表,主要用於存儲文章的內容,主鍵是article_id,需求須要將一些知足條件的做者發佈的文章導入到另一個庫。java

因此我同事就在項目中先查詢出了符合條件的做者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萬時,查詢時間已經特別長了。spring

運維同事那邊直接收到報警,說這個庫已經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命令查看錶相關的信息發現

file

發現兩個表的數據量都是200多萬的量級,article表的行平均長度是266,content表的平均長度是16847。

簡單來講是當 InnoDB 使用 Compact 或者 Redundant 格式存儲極長的 VARCHAR 或者 BLOB 這類大對象時,咱們並不會直接將全部的內容都存放在數據頁節點中,而是將行數據中的前 768 個字節存儲在數據頁中,後面會經過偏移量指向溢出頁。

file

這樣再從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)

四、解決方案

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

(1)先查出主鍵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。

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

這種方法的大體思路以下,假設要查詢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。

聯合索引問題優化

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

一、充分利用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繼續往下查,查出符合條件的結果集,進行回表。

二、避免回表(此時也叫覆蓋索引)

這種狀況就是假如咱們只查詢某幾個經常使用字段,例如查詢a和b以下:

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

對字段a創建單列索引,對b創建單列索引就須要像上面所說的,查到符合條件的主鍵id集合後須要去彙集索引下回表查詢,可是若是咱們要查詢的字段自己在聯合索引中就都包含了,那麼就不用回表了。

三、減小須要回表的數據的行數

這種狀況就是假如咱們須要查詢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的結果以下:

idtypekeykey_lenrowsfilteredExtra1rangeidx_createTime431160825.00Using index condition; Using where2rangeidx_createTime_status6310812100.00Using 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結果對比:

file

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

文源網絡,僅供學習之用,若有侵權,聯繫刪除。

我將面試題和答案都整理成了PDF文檔,還有一套學習資料,涵蓋Java虛擬機、spring框架、Java線程、數據結構、設計模式等等,但不只限於此。

關注公衆號【java圈子】獲取資料,還有優質文章每日送達。

file

相關文章
相關標籤/搜索