本文已收錄到1.1K Star數(shù)開源學(xué)習(xí)指南——《大廠面試指北》,如果想要了解更多大廠面試相關(guān)的內(nèi)容及獲取《大廠面試指北》離線PDF版羡亩,請掃描下方二維碼碼關(guān)注公眾號“大廠面試”丹禀,謝謝大家了状勤!
《大廠面試指北》最佳閱讀地址:
http://notfound9.github.io/interviewGuide/
《大廠面試指北》項(xiàng)目地址:
https://github.com/NotFound9/interviewGuide
獲取《大廠面試指北》離線PDF版,請掃描下方二維碼關(guān)注公眾號“大廠面試”
《大廠面試指北》項(xiàng)目截圖:
文章說明
這篇文章主要是記錄自己最近在真實(shí)工作中遇到的慢查詢的案例湃崩,然后進(jìn)行調(diào)優(yōu)分析的過程荧降,歡迎大家一起討論調(diào)優(yōu)經(jīng)驗(yàn)。(以下出現(xiàn)的表名攒读,列名都是化名朵诫,實(shí)際數(shù)據(jù)也進(jìn)行過一點(diǎn)微調(diào)。)
一.復(fù)雜的深分頁問題優(yōu)化
背景
有一個(gè)article表薄扁,用于存儲文章的基本信息的剪返,有文章id废累,作者id等一些屬性,有一個(gè)content表脱盲,主要用于存儲文章的內(nèi)容邑滨,主鍵是article_id,需求需要將一些滿足條件的作者發(fā)布的文章導(dǎo)入到另外一個(gè)庫钱反,所以我同事就在項(xiàng)目中先查詢出了符合條件的作者id掖看,然后開啟了多個(gè)線程,每個(gè)線程每次取一個(gè)作者id面哥,執(zhí)行查詢和導(dǎo)入工作哎壳。
查詢出作者id是1111,名下的所有文章信息尚卫,文章內(nèi)容相關(guān)的信息的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
因?yàn)椴樵兊倪@個(gè)數(shù)據(jù)庫是機(jī)械硬盤的归榕,在offset查詢到20萬時(shí),查詢時(shí)間已經(jīng)特別長了吱涉,運(yùn)維同事那邊直接收到報(bào)警刹泄,說這個(gè)庫已經(jīng)IO阻塞了,已經(jīng)多次進(jìn)行主從切換了怎爵,我們就去navicat里面試著執(zhí)行了一下這個(gè)語句特石,也是一直在等待, 然后對數(shù)據(jù)庫執(zhí)行show proceesslist 命令查看了一下疙咸,發(fā)現(xiàn)每個(gè)查詢都是處于Writing to net的狀態(tài)县匠,沒辦法只能先把導(dǎo)入的項(xiàng)目暫時(shí)下線,然后執(zhí)行kill命令將當(dāng)前的查詢都?xì)⑺肋M(jìn)程(因?yàn)橹皇强蛻舳薙top的話撒轮,MySQL服務(wù)端會繼續(xù)查詢)。
然后我們開始分析這條命令執(zhí)行慢的原因:
是否是聯(lián)合索引的問題
當(dāng)前是索引情況如下:
article表的主鍵是id贼穆,author_id是一個(gè)普通索引
content表的主鍵是article_id
所以認(rèn)為當(dāng)前是執(zhí)行流程是先去article表的普通索引author_id里面找到1111的所有文章id题山,然后根據(jù)這些文章id去article表的聚集索引中找到所有的文章,然后拿每個(gè)文章id去content表中找文章內(nèi)容等信息故痊,然后判斷create_time是否滿足要求顶瞳,進(jìn)行過濾,最終找到offset為20000后的100條數(shù)據(jù)愕秫。
所以我們就將article的author_id索引改成了聯(lián)合索引(author_id,create_time),這樣聯(lián)合索引(author_id,create_time)中的B+樹就是先安裝author_id排序慨菱,再按照create_time排序,這樣一開始在聯(lián)合(author_id,create_time)查詢出來的文章id就是滿足create_time < '2020-04-29 00:00:00'條件的戴甩,后面就不用進(jìn)行過濾了符喝,就不會就是符合就不用對create_time過濾。
流程確實(shí)是這個(gè)流程甜孤,但是去查詢時(shí)协饲,如果limit還是210000, 100時(shí)畏腕,還是查不出數(shù)據(jù),幾分鐘都沒有數(shù)據(jù)茉稠,一直到navica提示超時(shí)描馅,使用Explain看的話,確實(shí)命中索引了而线,如果將offset調(diào)小铭污,調(diào)成6000, 100,勉強(qiáng)可以查出數(shù)據(jù)膀篮,但是需要46s况凉,所以瓶頸不在這里。
真實(shí)原因如下:
先看關(guān)于深分頁的兩個(gè)查詢各拷,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;
這兩個(gè)查詢的結(jié)果都是查詢出offset是30000后的5條數(shù)據(jù),區(qū)別在于第一個(gè)查詢需要先去普通索引val中查詢出300005個(gè)id烤黍,然后去聚集索引下讀取300005個(gè)數(shù)據(jù)頁知市,然后拋棄前面的300000個(gè)結(jié)果,只返回最后5個(gè)結(jié)果速蕊,過程中會產(chǎn)生了大量的隨機(jī)I/O嫂丙。第二個(gè)查詢一開始在普通索引val下就只會讀取后5個(gè)id,然后去聚集索引下讀取5個(gè)數(shù)據(jù)頁规哲。
同理我們業(yè)務(wù)中那條查詢其實(shí)是更加復(fù)雜的情況跟啤,因?yàn)槲覀儤I(yè)務(wù)的那條SQL不僅會讀取article表中的210100條結(jié)果,而且會每條結(jié)果去content表中查詢文章相關(guān)內(nèi)容唉锌,而這張表有幾個(gè)TEXT類型的字段隅肥,我們使用show table status命令查看表相關(guān)的信息發(fā)現(xiàn)
Name | Engine | Row_format | Rows | Avg_Row_length |
---|---|---|---|---|
article | InnoDB | Compact | 2682682 | 266 |
content | InnoDB | Compact | 2824768 | 16847 |
發(fā)現(xiàn)兩個(gè)表的數(shù)據(jù)量都是200多萬的量級,article表的行平均長度是266袄简,content表的平均長度是16847腥放,簡單來說是當(dāng) InnoDB 使用 Compact 或者 Redundant 格式存儲極長的 VARCHAR 或者 BLOB 這類大對象時(shí),我們并不會直接將所有的內(nèi)容都存放在數(shù)據(jù)頁節(jié)點(diǎn)中绿语,而是將行數(shù)據(jù)中的前 768 個(gè)字節(jié)存儲在數(shù)據(jù)頁中秃症,后面會通過偏移量指向溢出頁。
(詳細(xì)了解可以看看這篇文章深度好文帶你讀懂MySQL和InnoDB)
這樣再從content表里面查詢連續(xù)的100行數(shù)據(jù)時(shí)吕粹,讀取每行數(shù)據(jù)時(shí)种柑,還需要去讀溢出頁的數(shù)據(jù),這樣就需要大量隨機(jī)IO匹耕,因?yàn)闄C(jī)械硬盤的硬件特性聚请,隨機(jī)IO會比順序IO慢很多。所以我們后來又進(jìn)行了測試泌神,
只是從article表里面查詢limit 200000良漱,100的數(shù)據(jù)舞虱,發(fā)現(xiàn)即便存在深分頁的問題,查詢時(shí)間只是0.5s母市,因?yàn)閍rticle表的平均列長度是266矾兜,所有數(shù)據(jù)都存在數(shù)據(jù)頁節(jié)點(diǎn)中,不存在頁溢出患久,所以都是順序IO椅寺,所以比較快。
//查詢時(shí)間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個(gè)article_id去content表里面查詢數(shù)據(jù)返帕,發(fā)現(xiàn)比較慢,第一次查詢時(shí)需要3s左右(也就是這些id的文章內(nèi)容相關(guān)的信息都沒有過篙挽,沒有緩存的情況)荆萤,第二次查詢時(shí)因?yàn)檫@些溢出頁數(shù)據(jù)已經(jīng)加載到buffer pool,所以大概0.04s铣卡。
SELECT SQL_NO_CACHE c.*
FROM article_content c
WHERE c.article_id in(100個(gè)article_id)
解決方案
所以針對這個(gè)問題的解決方案主要有兩種:
先查出主鍵id再inner join
非連續(xù)查詢的情況下链韭,也就是我們在查第100頁的數(shù)據(jù)時(shí),不一定查了第99頁煮落,也就是允許跳頁查詢的情況敞峭,那么就是使用先查主鍵再join這種方法對我們的業(yè)務(wù)SQL進(jìn)行改寫成下面這樣,下查詢出210000, 100時(shí)主鍵id蝉仇,作為臨時(shí)表temp_table旋讹,將article表與temp_table表進(jìn)行inner join,查詢出中文章相關(guān)的信息轿衔,并且去left Join content表查詢文章內(nèi)容相關(guān)的信息沉迹。 第一次查詢大概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
優(yōu)化結(jié)果
優(yōu)化前呀枢,offset達(dá)到20萬的量級時(shí)胚股,查詢時(shí)間過長,一直到超時(shí)裙秋。
優(yōu)化后,offset達(dá)到20萬的量級時(shí)缨伊,查詢時(shí)間為1.11s摘刑。
利用范圍查詢條件來限制取出的數(shù)據(jù)
這種方法的大致思路如下,假設(shè)要查詢test_table中offset為10000的后100條數(shù)據(jù)刻坊,假設(shè)我們事先已知第10000條數(shù)據(jù)的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條數(shù)據(jù)即可徐块,這種處理方式的offset值便成為0了未玻,但此種方式有限制,必須知道offset對應(yīng)id胡控,然后作為min_id_value扳剿,增加id > min_id_value的條件來進(jìn)行過濾,如果是用于分頁查找的話昼激,也就是必須知道上一頁的最大的id庇绽,所以只能一頁一頁得查,不能跳頁橙困,但是因?yàn)槲覀兊臉I(yè)務(wù)需求就是每次100條數(shù)據(jù)瞧掺,進(jìn)行分批導(dǎo)數(shù)據(jù),所以我們這種場景是可以使用凡傅。針對這種方法辟狈,我們的業(yè)務(wù)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'
//然后每次循環(huán)查找
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條數(shù)據(jù)導(dǎo)入完畢后,將100條數(shù)據(jù)數(shù)據(jù)中最大的id賦值給min_id夏跷,以便導(dǎo)入下100條數(shù)據(jù)
}
優(yōu)化結(jié)果
優(yōu)化前哼转,offset達(dá)到20萬的量級時(shí),查詢時(shí)間過長拓春,一直到超時(shí)释簿。
優(yōu)化后,offset達(dá)到20萬的量級時(shí)硼莽,由于知道第20萬條數(shù)據(jù)的id庶溶,查詢時(shí)間為0.34s。
二.聯(lián)合索引問題優(yōu)化
聯(lián)合索引其實(shí)有兩個(gè)作用:
1.充分利用where條件懂鸵,縮小范圍
例如我們需要查詢以下語句:
SELECT * FROM test WHERE a = 1 AND b = 2
如果對字段a建立單列索引偏螺,對b建立單列索引,那么在查詢時(shí)匆光,只能選擇走索引a套像,查詢所有a=1的主鍵id,然后進(jìn)行回表终息,在回表的過程中夺巩,在聚集索引中讀取每一行數(shù)據(jù),然后過濾出b = 2結(jié)果集周崭,或者走索引b柳譬,也是這樣的過程。
如果對a续镇,b建立了聯(lián)合索引(a,b),那么在查詢時(shí)美澳,直接在聯(lián)合索引中先查到a=1的節(jié)點(diǎn),然后根據(jù)b=2繼續(xù)往下查,查出符合條件的結(jié)果集制跟,進(jìn)行回表舅桩。
2.避免回表(此時(shí)也叫覆蓋索引)
這種情況就是假如我們只查詢某幾個(gè)常用字段,例如查詢a和b如下:
SELECT a,b FROM test WHERE a = 1 AND b = 2
對字段a建立單列索引雨膨,對b建立單列索引就需要像上面所說的擂涛,查到符合條件的主鍵id集合后需要去聚集索引下回表查詢,但是如果我們要查詢的字段本身在聯(lián)合索引中就都包含了哥放,那么就不用回表了歼指。
3.減少需要回表的數(shù)據(jù)的行數(shù)
這種情況就是假如我們需要查詢a>1并且b=2的數(shù)據(jù)
SELECT * FROM test WHERE a > 1 AND b = 2
如果建立的是單列索引a,那么在查詢時(shí)會在單列索引a中把a(bǔ)>1的主鍵id全部查找出來然后進(jìn)行回表甥雕。
如果建立的是聯(lián)合索引(a,b),基于最左前綴匹配原則踩身,因?yàn)閍的查詢條件是一個(gè)范圍查找(=或者in之外的查詢條件都是范圍查找),這樣雖然在聯(lián)合索引中查詢時(shí)只能命中索引a的部分社露,b的部分命中不了挟阻,只能根據(jù)a>1進(jìn)行查詢,但是由于聯(lián)合索引中每個(gè)葉子節(jié)點(diǎn)包含b的信息峭弟,在查詢出所有a>1的主鍵id時(shí)附鸽,也會對b=2進(jìn)行篩選,這樣需要回表的主鍵id就只有a>1并且b=2這部分了瞒瘸,所以回表的數(shù)據(jù)量會變小坷备。
我們業(yè)務(wù)中碰到的就是第3種情況,我們的業(yè)務(wù)SQL本來更加復(fù)雜情臭,還會join其他表省撑,但是由于優(yōu)化的瓶頸在于建立聯(lián)合索引,所以進(jìn)行了一些簡化俯在,下面是簡化后的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
我們的需求其實(shí)就是從article表中查詢出最近一個(gè)月竟秫,status為1的文章,我們本來就是針對create_time建了單列索引跷乐,結(jié)果在慢查詢?nèi)罩局邪l(fā)現(xiàn)了這條語句肥败,查詢時(shí)間需要0.91s左右,所以開始嘗試著進(jìn)行優(yōu)化愕提。
為了便于測試馒稍,我們在表中分別對create_time建立了單列索引create_time,對(create_time,status)建立聯(lián)合索引idx_createTime_status浅侨。
強(qiáng)制使用idx_createTime進(jìn)行查詢
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
強(qiáng)制使用idx_createTime_status進(jìn)行查詢(即使不強(qiáng)制也是會選擇這個(gè)索引)
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
優(yōu)化結(jié)果:
優(yōu)化前使用idx_createTime單列索引筷黔,查詢時(shí)間為0.91s
優(yōu)化前使用idx_createTime_status聯(lián)合索引,查詢時(shí)間為0.21s
EXPLAIN的結(jié)果如下:
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
當(dāng)Query 中包含 ORDER BY 操作仗颈,而且無法利用索引完成排序操作的時(shí)候,MySQL Query Optimizer 不得不選擇相應(yīng)的排序算法來實(shí)現(xiàn)。數(shù)據(jù)較少時(shí)從內(nèi)存排序挨决,否則從磁盤排序请祖。Explain不會顯示的告訴客戶端用哪種排序。
Using index
僅使用索引樹中的信息從表中檢索列信息脖祈,而不需要進(jìn)行附加搜索來讀取實(shí)際行(使用二級覆蓋索引即可獲取數(shù)據(jù))肆捕。 當(dāng)查詢僅使用作為單個(gè)索引的一部分的列時(shí),可以使用此策略盖高。
Using temporary
要解決查詢慎陵,MySQL需要創(chuàng)建一個(gè)臨時(shí)表來保存結(jié)果。 如果查詢包含不同列的GROUP BY和ORDER BY子句喻奥,則通常會發(fā)生這種情況席纽。官方解釋:”為了解決查詢,MySQL需要創(chuàng)建一個(gè)臨時(shí)表來容納結(jié)果撞蚕。典型情況如查詢包含可以按不同情況列出列的GROUP BY和ORDER BY子句時(shí)润梯。很明顯就是通過where條件一次性檢索出來的結(jié)果集太大了,內(nèi)存放不下了纺铭,只能通過加臨時(shí)表來輔助處理。
Using where
表示當(dāng)where過濾條件中的字段無索引時(shí)刀疙,MySQL Sever層接收到存儲引擎(例如innodb)的結(jié)果集后谦秧,根據(jù)where條件中的條件進(jìn)行過濾竟纳。
Using index condition
Using index condition 會先條件過濾索引,過濾完索引后找到所有符合索引條件的數(shù)據(jù)行油够,隨后用 WHERE 子句中的其他條件去過濾這些數(shù)據(jù)行蚁袭;
我們的實(shí)際案例中,其實(shí)就是走單個(gè)索引idx_createTime時(shí)石咬,只能從索引中查出 滿足a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003'
條件的主鍵id揩悄,然后進(jìn)行回表,因?yàn)閕dx_createTime索引中沒有status的信息鬼悠,只能回表后查出所有的主鍵id對應(yīng)的行删性。然后innodb將結(jié)果集返回給MySQL Sever,MySQL Sever根據(jù)status字段進(jìn)行過濾焕窝,篩選出status為1的字段蹬挺,所以第一個(gè)查詢的Explain結(jié)果中的Extra才會顯示Using where。
filtered字段表示存儲引擎返回的數(shù)據(jù)在server層過濾后它掂,剩下多少滿足查詢的記錄數(shù)量的比例巴帮,這個(gè)是預(yù)估值溯泣,因?yàn)閟tatus取值是null,1榕茧,2垃沦,3,4用押,所以這里給的25%肢簿。
所以第二個(gè)查詢與第一個(gè)查詢的區(qū)別主要在于一開始去idx_createTime_status查到的結(jié)果集就是滿足status是1的id,所以去聚集索引下進(jìn)行回表查詢時(shí)蜻拨,掃描的行數(shù)會少很多(大概是2.7萬行與15萬行的區(qū)別)池充,之后innodb返回給MySQL Server的數(shù)據(jù)就是滿足條件status是1的結(jié)果集(2.7萬行),不用再進(jìn)行篩選了缎讼,所以第二個(gè)查詢才會快這么多收夸,時(shí)間是優(yōu)化前的23%。(兩種查詢方式的EXPLAIN預(yù)估掃描行數(shù)都是30萬行左右是因?yàn)閕dx_createTime_status只命中了createTime休涤,因?yàn)閏reateTime不是查單個(gè)值咱圆,查的是范圍)
//查詢結(jié)果行數(shù)是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'
//查詢結(jié)果行數(shù)是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
發(fā)散思考:如果將聯(lián)合索引(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
根據(jù)最左匹配的原則,因?yàn)槲覀兊膚here查詢條件是這樣捷凄,如果是(createTime忱详,status)那么索引就只能用到createTime,如果是(status跺涤,createTime)匈睁,因?yàn)閟tatus是查詢單個(gè)值,所以status桶错,createTime都可以命中航唆,在(status,createTime)索引中掃描行數(shù)會減少院刁,但是由于(createTime糯钙,status)這個(gè)索引本身值包含createTime,status退腥,id三個(gè)字段的信息任岸,數(shù)據(jù)量比較小,而一個(gè)數(shù)據(jù)頁是16k狡刘,可以存儲1000個(gè)以上的索引數(shù)據(jù)節(jié)點(diǎn)享潜,而且是查詢到createTime后,進(jìn)行的順序IO嗅蔬,所以讀取比較快剑按,總得的查詢時(shí)間兩者基本是一致疾就。下面是測試結(jié)果:
首先創(chuàng)建了(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
查詢時(shí)間是0.21虐译,跟第二種方式(createTime,status)索引的查詢時(shí)間基本一致吴趴。
Explain結(jié)果對比:
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 |
掃描行數(shù)確實(shí)會少一些,因?yàn)樵趇dx_status_createTime的索引中侮攀,一開始根據(jù)status = 1排除掉了status取值為其他值的情況锣枝。