1. 程式人生 > >資料倉庫中的sql效能優化(MySQL篇)

資料倉庫中的sql效能優化(MySQL篇)

做資料倉庫的頭兩年,使用高配置單機 + MySQL的方式來實現所有的計算(包括資料的ETL,以及報表計算。沒有OLAP)。用過MySQL自帶的MYISAM和列儲存引擎Infobright。這篇文章總結了自己和團隊在那段時間碰到的一些常見效能問題和解決方案。

P.S.如果沒有特別指出,下面說的mysql都是指用MYISAM做儲存引擎。

利用已有資料,避免重複計算

業務需求中往往有計算一週/一個月的某某資料,比如計算最近一週某個特定頁面的PV/UV。這裡出現的問題就是實現的時候直接取整週的日誌資料,然後進行計算。這樣其實就出現了重複計算,某一天的資料在不同的日子裡被重複計算了7次。

解決辦法非常之簡單,就是把計算進行切分,如果是算PV,做法就是每天算好當天的PV,那麼一週的PV就把算好的7天的PV相加。如果是算UV,那麼每天從日誌資料取出相應的訪客資料,把最近七天的訪客資料單獨儲存在一個表裡面,計算周UV的時候直接用這個表做計算,而不需要從原始日誌資料中抓上一大把資料來算了。

這是一個非常簡單的問題,甚至不需要多少SQL的知識,但是在開發過程中往往被視而不見。這就是隻實現業務而忽略效能的表現。從小規模資料倉庫做起的工程師,如果缺乏這方面的意識和做事規範,就容易出現這種問題,等到資料倉庫的資料量變得比較大的時候,才會發現。

case when關鍵字的使用方法

case when這個關鍵字,在做聚合的時候,可以很方便的將一份資料在一個SQL語句中進行分類的統計。舉個例子,比如下面有一張成績表(表名定為scores):

name course score
小明 語文 90
小張 語文 94
小紅 語文 95
小明 數學 96
小張 數學 98
小紅 數學 94
小明 英語 99
小張 英語 96
小紅 英語 93

現在需要統計小張的平均成績,小明的平均成績和小明的語文成績。SQL實現如下:

1
2
3
4
5
6
select 
       avg (case when name ='小張' then score end) as xz_avg_score,
       avg (case when name ='小明' then score end) as xm_avg_score,
       avg (case when name ='小明' and course = '語文' then score end) 
as xm_yuwen_score 
from
scores;

如果現在這個成績表有1200萬條的資料,包含了400萬的名字 * 3個科目,上面的計算需要多長時間?我做了一個簡單的測試,答案是5.5秒。

而如果我們把sql改成下面的寫法:

1
2
3
4
5
6
select 
       avg (case when name ='小張' then score end) as xz_avg_score,
       avg (case when name ='小明' then score end) as xm_avg_score,
       avg (case when name ='小明' and course = '語文' then score end) 
as xm_yuwen_score 
from scores where name in ('小張', '小明');

這樣的話,只需要3.3秒就能完成。

之所以後面一種寫法總是比前面一種寫法快,不同之處就在於是否先在where裡面把資料過濾掉。前一種寫法掃描了三遍全表的資料(做一個case when掃一遍),後面的寫法掃描一遍全表,把資料過濾了之後,case when就不用過這麼多資料量了。

跟進一步說,如果在name欄位上有索引,那麼後一種寫法將會更快,測試結果只用0.05秒,而前面一種情況,sql優化器是判斷不出來能用索引的,時間依然是5.5秒。

在實際工作中,開發經常只是為了實現功能邏輯,而習慣了在case when中限制條件取資料。這樣在出現類似例子中的需求時,沒有把應該限制的條件寫到where裡面。這是在實際程式碼中發現最多的一類問題。

分頁取數方式

在資料倉庫中有一個重要的基礎步驟,就是對資料進行清洗。比如資料來源的資料如果以JSON方式儲存,在mysql的資料倉庫就必須將json中需要的欄位提取出來,做成單獨的表字段。這個步驟用sql直接處理很麻煩,所以可以用主流程式語言(比如java)的json庫進行解析。解析的時候需要讀取資料,一次性讀取進來是不可能的,所以要分批讀取(相當於分頁了)。

最初的實現方式就是標記住每次取資料的偏移量,然後一批批讀取:

1
2
3
4
5
select json_obj from t limit 10000,10000;
select json_obj from t limit 20000,10000;
select json_obj from t limit 30000,10000;
/*略去很多行……*/
select json_obj from t limit 990000,10000;

這樣的程式碼,在開始幾句sql的時候執行速度還行,但是到後面會越來越慢,因為每次要讀取大量資料再丟棄,其實是一種浪費。

高效的實現方式,可以是用表中的主鍵進行分頁。如果資料是按照主鍵排序的,那麼可以是這樣(這麼做是要求主鍵的取值序列是連續的。假設主鍵的取值序列我們比較清楚,是從10001-1000000的連續值):

1
2
3
4
5
select json_obj from t where t.id > 10000 limit 10000;
select json_obj from t where t.id > 20000 limit 10000;
select json_obj from t where t.id > 30000 limit 10000;
/*略去很多行……*/
select json_obj from t where t.id > 990000 limit 10000;

就算資料不是按主鍵排序的,也可以通過限制主鍵的範圍來分頁。這樣處理的話,主鍵的取值序列不連續也沒有太大問題,就是每次拿到的資料會比理想中的少一些,反正是用在資料處理,不影響正確性:

1
2
3
4
5
select json_obj from t where t.id > 10000 and t.id <= 20000;
select json_obj from t where t.id > 20000 and t.id <= 30000;
select json_obj from t where t.id > 30000 and t.id <= 40000;
/*略去很多行……*/
select json_obj from t where t.id > 990000 and t.id <= 1000000;

這樣的話,由於主鍵上面有索引,取資料速度就不會受到資料的具體位置的影響了。

索引使用

索引的使用是關係資料庫的SQL優化中一個非常重要的主題,也是一個常識性的東西。但是工程師在實際開發中往往是加完索引就覺得萬事大吉了,也不去檢查索引是否被正確的使用了,所以會經常出一些瞎貓碰到死耗子或者是似是而非的情況。

索引調整

前面說到開發人員在對索引的瞭解似是而非的時候只知道要加索引,而不知道為什麼加。

比如現在有一個數據集,對應非常常見的網站統計場景。這個資料集有兩個表組成,其中一個是流量表item_visits,每條記錄表示某一個商品(item)被訪問了一次,包括訪問者的一些資訊,比如使用者id,使用者名稱等等,有將近800萬條資料。示例如下:

item_id visitor_id visitor_name visitor_city url ……
1 55 使用者001 1 …… ……
10 245 使用者002 2 …… ……
3 2 使用者003 1 …… ……
10 148 使用者004 3 …… ……
3 75 使用者005 4 …… ……
7 422 使用者006 4 …… ……
3 10 使用者007 …… …… ……
…… …… …… …… …… ……

另一個表是商品表items,包含1200多種商品,欄位有商品名字和所屬種類:

item_id item_name item_type
1 毛巾 生活用品
2 臉盆 生活用品
…… …… ……

現在有一個需求,計算每個商品種類(item_type)被訪問的次數。sql的實現不難:

1
2
3
4
select item_type, count(*) as visit_num
from items a
join item_visit b using (item_id)
group by item_type;

開發人員知道,在join的時候,其中的一個表的join key要加索引,然後他發現visit表在item_id欄位上已經有索引了,所以就打完收工了。到這裡為止一切都沒有問題。但是後來這個需求有改動,需要限制使用者的城市是某個固定城市,比如visitor_city = 1,那麼顯然sql變成了:

1
2
3
4
5
select item_type, count(*) as visit_num
from items a
join item_visit b using (item_id)
where visitor_city = 1
group by item_type;

開發人員按照需求修改sql之後對於索引的調整無動於衷,因為他覺得,我已經用上索引了呀。而實際上很明顯的,只需要在visitor_cityitems表的item_id上都加上索引,就能極大的減少時間。原因就在於開發人員“感覺”能用上索引,而且開發階段試執行時間沒問題就OK,並不關心是不是有更好的索引使用方式,甚至不確認是否用上了索引。在這樣的一個真實案例中,原有的sql在後期出現了執行緩慢的現象,才逐漸被髮掘出問題。

覆蓋索引

針對上面那個需求(不限制city),假設現在兩個表的的item_id欄位都有索引,而且把count(*)換成count(visitor_city),還是會得到完全一樣的結果,但是會對執行時間有什麼影響?

1
2
3
4
select item_type, count(visitor_city) as visit_num
from items a
join item_visit b using (item_id)
group by item_type;

測試結果表明,count(*)版本用時57秒,count(visitor_city)版本用時70秒。原因在哪裡?主要就在於count(*)版本中item_visits表只需要用到item_id,所以可以直接用索引來代替資料訪問,這就是覆蓋索引。
在實際開發中,一方面要創造使用覆蓋索引的機會,不要無謂的增加不需要的欄位到查詢語句中,上面的案例就是反面例子,實際開發中就有這樣的情況發生。另一方面,根據具體的查詢也要在成本允許的情況下構造覆蓋索引,這樣比普通的索引有更少的IO,自然有更快的訪問速度。

強制索引

有時候mysql的執行計劃會不恰當的使用索引,這個時候就要求開發人員有一定的排查能力,並且根據實際情況調整索引。當然,這種情況還是比較少見的。Mysql用錯索引的主要原因在於mysql是根據IO和CPU的代價來估算是否用索引,或者用哪種索引,而這個估算基於的統計資訊有可能不準確。

強制使用索引主要在兩種場景下碰到。第一種是針對where過濾條件的索引,這個時候的語法是force index (index_name)。比如在ETL中常見的資料抽取,利用時間戳增量抽取當天新增或者更新的資料:

1
select * from user where update_timestamp >= curdate() – 1;

一般在更新時間戳上會有索引,但是有時候mysql會判斷出某一天的更新量特別大,比如超過了20%,那麼根據資料的選擇性,mysql決定不用索引。但實際上這個判斷有可能是不準確的,如果表比較大而且在線上服務時間較長,還是有可能發生的,這個時候可以通過強制使用索引保證抽取的穩定性(當然,這要基於你對業務的瞭解,保證抽取量能維持在一個穩定的水平,不會發生超大更新量的情況):

1
2
select * from user force index (update_timestamp) 
where update_timestamp >= curdate() – 1;

強制使用索引的第二種情況,是join時對索引的選擇。資料倉庫中有時候會出現一種計算場景,對一個按日統計的報表中某一天的小部分資料進行更新。比如有一個按日統計的使用者pv表(user_pv_byday,一天約50萬用戶,表中有1個月資料,共1500萬):

user_id pv stat_date
1 10 2013-01-01
2 15 2013-01-01
…… …… ……
1 14 2013-01-02
2 19 2013-01-02
…… …… ……

另一個表是當天小部分使用者的pv表(user_pv_to_update,1000條資料):

user_id pv
1 18
2 20
…… ……

兩個表的索引情況是,user_pv_byday表的user_idstat_date欄位有索引,user_pv_to_update表的user_id欄位有索引。
現在要把user_pv_to_update的pv資料更新到user_pv_byday當天的資料中:

1
2
3
4
update user_pv_byday a 
join user_pv_to_update b on a.user_id = b.user_id 
set a.pv = b.pv
where a.stat_date = curdate() – 1;

經過一段時間的線上執行之後,發現這個步驟越來越慢了。查了一下執行計劃,發現mysql選擇了user_pv_byday表的user_id做索引。於是,決定強制用上stat_date的索引。這樣一來join的時候需要用到的就是user_pv_to_update上的user_id欄位。這個時候就需要指定順序,強制user_pv_byday表作為外層驅動表(user_pv_to_update則是nest loop的內層巢狀):

1
2
3
4
update user_pv_byday a 
straight_join user_pv_to_update b on a.user_id = b.user_id 
set a.pv = b.pv
where a.stat_date = curdate() – 1;

然後來看一下兩種寫法的執行計劃。

原有寫法:

…… table type …… key …… ref rows extra
…… b ALL …… …… 1000
…… a ref …… user_id …… b.user_id 368 Using where

強制join順序的寫法:

…… table type …… key …… ref rows extra
…… a ref …… stat_date …… const 485228 Using where
…… b ref …… user_id …… a.user_id 1 Using where

根據執行計劃中的資料,可以說mysql的選擇沒有錯。原有寫法需要讀取的資料大致是1000 * 368約合37萬,修改寫法則是48萬,修改寫法讀取的代價更大。但實際執行情況則是修改寫法快過原來寫法數倍。

不過,這個情況卻不能隨時重現。真要把這兩個表寫入空表並且重建索引再來查詢,會發現straight_join的結果確實會更慢,也就是說在初始狀態下,mysql的判斷是對的。所以這樣的問題只在日常運營中才會發生,無法重現,卻是真實存在的,而且碰到類似這一類的應用場景,則必然發生。

究其原因,在這種情況下,由於資料不是一次性建成,而是按天陸續寫入,所以user_pv_bydayuser_id索引會進行反覆的修改,造成索引碎片,極端嚴重的情況下還會導致索引完全失效。而stat_date上的索引由於是每天遞增,所以完全沒有碎片問題,而且讀取資料是還是順序讀取,效率自然要高不少。另外,根據實際情況的觀察,隨著資料的積累,上面執行計劃中368這個值還會變得更小,也就是統計資訊會越來越不準確。

總之,mysql的執行計劃在大部分情況下是沒問題的,但是隨著資料的不斷積累修改,會逐漸出現mysql所不瞭解的細節,影響優化器的正常判斷。這個時候如果能對錶做一下重建也能讓事情回到正軌,但是很多時候沒有這個許可權或者條件去做(比如你不是DBA,沒有這種操作許可權;或者表太大,沒有完整的時間段可以操作)。那麼強制索引使用就成了開發人員一個低成本的解決方案。

過多的join

在mysql中,需要join的表如果太多,會對效能造成很顯著的下降。同樣,舉例說明。

首先生成一個表(表名test),這個表只有60條記錄,6個欄位,其中第一個欄位為主鍵:

pk c1 c2 c3 c4 c5
1 11 21 31 41 51
2 12 22 32 42 52
3 13 23 33 43 53
4 14 24 34 44 54
…… …… …… …… …… ……

然後做一個查詢:

1
2
3
select count(*) 
from test a1
join test a2 using (pk)

也就是說讓test表跟自己關聯。計算的結果顯然是60,而且幾乎不費時間。

但是如果是這樣的查詢(十個test表關聯),會花費多少時間?

1
2
3
4
5
6
7
8
9
10
11
select count(*)
from test a1
join test a2 using (pk)
join test a3 using (pk)
join test a4 using (pk)
join test a5 using (pk)
join test a6 using (pk)
join test a7 using (pk)
join test a8 using (pk)
join test a9 using (pk)
join test a10 using (pk)

答案是:肯定超過5分鐘。因為做了實際測試,5分鐘還沒有出結果。

那麼mysql到底在幹什麼呢?用show processlist去看一下執行時情況:

ID …… COMMAND TIME STATE INFO
121 …… QUERY 302 statistics select count(*) from test a1 ……

原來是處在statistics的狀態。這個狀態,根據mysql的解釋是在根據統計資訊去生成執行計劃。當然這個解釋肯定是沒有追根溯源。實際上mysql在生成執行計劃的時候,其中有一個步驟,是確定表的join順序。預設情況下,mysql會把所有join順序全部排列出來,依次計算各個join順序的執行代價並且取最優的那個。這樣一來,n個表join會有n!種情況。十個表join就是10!,大概300萬,所以難怪mysql要分析半天了。

而在實際開發過程中,曾經出現過30多個表關聯的情況(有10^32種join順序)。一旦出現,花費在statistics狀態的時間往往是在1個小時以上,這還只是在表資料量都非常小,需要做順序分析的點比較少的情況下。至於出現這種情況的原因,無外乎我們需要計算的彙總報表的欄位太多,需要從各種各樣的地方計算出來資料,然後再把資料拼接起來,報表在維護過程中不斷新增欄位,又由於種種原因沒有去掉已經廢棄的欄位,這樣欄位必定會越來愈多,實現這些欄位計算就需要用更多的臨時計算結果表去關聯到一起,結果需要關聯的表也越來越多,成了mysql無法承受之重。

這個問題的解決方法有兩個。從開發角度來說,可以控制join的表個數。如果需要join的表太多,可以根據業務上的分類,先做一輪join,把表的數量控制在一定範圍內,然後拿到第一輪的join結果,再做第二輪全域性join,這樣就不會有問題了。從運維角度來說,可以設定optimizer_search_depth這個引數。它能夠控制join順序遍歷的深度,進行貪婪搜尋得到區域性最優的順序。一般有好多個表join的情況,都是上面說的相同維度的資料需要拼接成一張大表,對於join順序基本上沒什麼要求。所以適當的把這個值調低,對於效能應該說沒有影響。

列儲存引擎Infobright

Infobright是基於mysql的儲存引擎,具有列儲存/列壓縮和知識網格等特性,比較適合資料倉庫的計算。使用起來也不需要考慮索引之類的問題,非常方便。不過經過一段時間的運用,也發現了個別需要注意的問題。

一個問題和myisam類似,不要取不需要的資料。這裡說的不需要的資料,包括不需要的列(Infobright的使用常識。當然行儲存也要注意,只不過影響相對比較小,所以沒有專門提到),和不需要的行(行數是可以擴充套件的,行儲存一行基本上都能存在一個儲存單元中,但是列儲存一列明顯不可能存在一個儲存單元中)。

第二個問題,就是Infobright在長字元檢索的時候並不給力。一般來說,網站的訪問日誌中會有URL欄位用來標識訪問的具體地址。這樣就有查詢特定URL的需求。比如我要在一個日誌表中查詢某種型別的url的訪問次數:

1
select count(*) from log where url like '%mysql%';

類似這樣在一個長字串裡面檢索子串的需求,Infobright的執行時間測試下來是myisam的1.5-3倍。
至於速度慢的原因,這裡給出一個簡要的解釋:Infobright作為列式資料庫使用了列儲存的常用特性,就是壓縮(列式資料庫的壓縮率一般要能做到10%以內,Infobright也不例外)。另外為了加快查詢速度,它還使用了一種叫知識網格檢索方式,一般情況下能夠極大的減少需要讀取的資料量。關於知識網格的原理已經超出了本篇文章的討論篇幅,可以看這裡瞭解。但是在查詢url的時候,知識網格的優點無法體現出來,但是使用知識網格本身帶來的檢索代價和解壓長字串的代價卻仍然存在,而且比查詢一般的數字類欄位要來的大的多。

解決辦法有幾種,比如官方的方案是把長字串MD5成一個數字,查詢的時候加上數字作為補充查詢條件。而這條微博給出的方法是進行分詞然後再整數化。這些方案相對來說比較複雜,而我嘗試過一種簡單的解決方案(不過也有相當的侷限性),就是根據這個長欄位排序後再匯入。這樣一來按照該欄位查詢時,通過知識網格就能夠遮蔽掉比較多的“資料包”(Infobright的資料壓縮單元),而未排序的情況下符合條件的資料散佈在各個“資料包”中,其解壓工作量就大得多了。使用這個方法進行查詢,測試下來其執行時間就只有mysql的0.5倍左右了。

轉自: