大表分頁優化千萬級——cpu還沒反應過來我已經搜索出來了

前言

書接上文 震驚 原來5.5k月薪的你也能優化千萬級mysql
本文糾正、確認下來前文的幾點問題,並詳解分頁機制的優化操作

■ 任務

慢sql優化改造,大表數據分頁機制性能改造 ,提升大數據查詢速度

解疑前文

下文皆爲innodb的btree索引

■ 1.查詢優化器什麼時候選擇不走範圍索引?

前文表述

不走索引更快時,查詢優化器選擇不走索引。時間範圍就是一個例子,有說選擇性小於17%時就不走了,具體未驗證,我是有試過時間範圍大到一定時不走索引,但我強制讓它走索引時,速度還會快挺多,所以我這個表述也不是很準確。

糾正、解答:
mysql查詢優化器會根據查詢成本模型估算它認爲的最優執行計劃。這部分比較複雜,會有很多神操作,但可以確定的是——查詢優化器選擇的是估算成本方案中最優的執行計劃(它不一定會比較完所有方案,所以有時候不會是最優解),而不是選擇查詢速度最快的方案,更不是單純多大範圍不走。
看下面的一個例子:
在這裏插入圖片描述
上圖中的t_d_c索引有區別的,我稱之爲索引串味,後面會講。可以通過key_len來區分,204的那個纔是真正的t_d_c索引,它的最後一位是時間範圍索引。在sql不干擾索引使用的情況下,通過調整時間範圍,由廣不斷縮小範圍可以發現,範圍索引不生效->生效->不生效->生效
所以很多廣爲流傳的說法都是不對的。

選擇性小於17%不走。這個是某個人由特定sql得出的結論,帖子被爬蟲不斷複製,然後看得人多了,再不斷傳播,從而有了一定影響,錯錯錯。

範圍大到一定範圍它不會走範圍索引。這個說法就謹慎很多,他不講出特定值,不過還是錯。
在抖音上有看到一個直播面試,面的是高級Java開發,問:範圍索引都會生效嗎?面試者反應也很快,回答:這個不一定會生效,範圍索引大到一定範圍就就不走索引了,這個值有可能是40%這樣,超過了就不走了。 然後面試官也很滿意。呃~。。。現在我可以告訴你這個說法是錯誤的,當然範圍的大小肯定是有和這個優化器的選擇掛鉤的,但絕不是唯一因素,

所以,mysql查詢優化器是根據查詢成本模型估算它認爲的最優執行計劃,判斷範圍索引走不走是一個複雜的操作。如果你操作得當,就是100年的時間範圍,它都會走索引。

■ 2.explain測出來的key有可能不是真實走的索引

前文我說到這種情況:explain測出來的key有可能不是真實走的索引,那它走的是嘛玩意呢?我之前稱之爲多個索引互相串味。多個索引相互串味,嗯?這是不是和索引合併有點類似。

索引合併:
1、索引合併是把幾個索引的範圍掃描合併成一個索引。
2、索引合併的時候,會對索引進行並集,交集或者先交集再並集操作,以便合併成一個索引。
3、這些需要合併的索引只能是一個表的。不能對多表進行索引合併。
如果使用了索引合併,那麼在輸出內容的type列會顯示 index_merge,key列會顯示出所有使用的索引

呃。。我隨便搜了個介紹,非常官方。說白了就是索引建的不合理,mysql對多個單列索引進行了合併使用,就叫索引合併,並且執行計劃是可以看出index_merge。

索引合併是針對單列索引的,打破腦袋也是想不到mysql會對組合索引下手,並且執行計劃不會告訴你它到底幹了啥,喪心病狂啊。前一點的圖片可以看到索引串味的情況,可以看到的是,它的key列都是同一個索引。

辨別的方法,看key_len列就可以進行區分,長度是不一樣的。然後通過在sql 表名後面加 ignore index(索引a,索引b…)操作就可以找出究竟是哪個索引影響了你考清華。很快就能找到,再通過force index(索引)強制走那個索引,你有可能會發現索引串味時走的也並非是這個索引,所以我才稱之爲索引串味,而不是索引變味,變成某一個。

解決方案:
來自《高性能mysql第三版》的一段話,我並不認可
在這裏插入圖片描述
如果優化器走錯了路導致我們的查詢卡着不動肯定是不行的,也是必須解決的,不能因爲未知的麻煩而害怕優化。

①刪除掉無用的索引。

②又或者sql語句使用ignore index、force index、use index,再通過mybatis標籤指定哪些索引可以供它選擇,這個方法是相當靈活的,不影響舊索引,只針對具體sql,具體條件。
force和use的區別 :force 是隻要有索引字段能對應的上查詢字段,它就肯定走。use 是查詢優化器認爲全表掃描更快,他就不會走)

■ 3.強調一遍索引最左前綴原則

這是一件很有意思的事件。博主認證爲博客專家,寫的是一篇mysql的索引介紹,博主通過面試官提問爲引子,講解了索引,廣受好評,收藏量將近1000,而下圖中的勇士是一個博客新人。

在這裏插入圖片描述
在這裏插入圖片描述
最左原則都不明白的話,幾乎可以認定你沒有真實的調優經驗(複合索引),但是博主有博客專家認證加成,看些理論書,總結些套話,不明白索引的人就不會去懷疑他的說法,帶來的影響力是非常可怕的。僅僅收藏量就將近1000,這種錯誤的觀點流傳開來不可想象。因爲我以前對索引最左原則的認知就和博主一樣,所以對這個錯誤觀點比較上心,但是我不懂不會去傳播,也沒有能力去傳播(這是重點),O(∩_∩)O哈哈~。

博客專家的反駁很有可能會讓一個新人懷疑人生,放棄自己的觀點,這裏是幸運的,我肯定了勇士的說法,勇士也以實例再次反駁博主,博主也很快糾正了錯誤,避免誤導更多的人。

計算機發展迅速,很多東西也纔出現幾年,誰都有可能是誰的老師,有時候更多是需要去肯定自己。

那麼,什麼是最左原則?
引用前文

索引最左原則指的是組合索引定義(sex,age,time)按從左到右順序走,而不是sql語句where條件的先後順序。網上經常說到怎麼怎麼樣,索引不生效,走全表掃描,例如上例:並不是說time不生效就完全不走索引了,它還是走(sex,age)的,這是一句很有歧義的表達,應該表達爲走全表掃描或全索引掃描。

那什麼是最左原則,見下一點——

■ 4.我對索引的認知

網上談innodb索引都是給出一張B+樹圖,對於很多人來說並不是很好理解,我去掉索引樹這個結構,將索引如何縮小範圍查詢更加地形象化,看下面這張我給出的圖,基本就能理解了索引走不走的問題。

例圖爲我自創的582w條記錄的門禁記錄表的複合索引(公司,職務,性別,時間)的形象化結構圖。索引順序在圖中從上到下就相當於最左原則的從左到右。每一塊被切割的長方體的數字表示那一塊索引數量大小,不斷地一層一層進行分類、排序。
在這裏插入圖片描述
接着,我給出實例來——
兩個sql 查詢
sql2 查詢 2019年某中等公司全部女經理的門禁記錄
sql1 查詢 2019-11-10~2019-11-17某大型公司男員工 limit 10000,10
在這裏插入圖片描述
去雜色版 ——
在這裏插入圖片描述

■ 5.儘量避免in的使用

前文說過in是走索引的,in雖然explain爲rang,但它不算是範圍查詢,所以組合索引前面字段用了in,後面的字段也是走索引的。也給出了一個例子,
回顧前文——

③ 用in可以巧妙的少建立索引。例如你有一個搜索條件性別 ,男爲1,女爲2,全部爲不查,有的人可能會建立這麼兩個索引(…,sex,…)和(…,…)來應對有性別搜索條件,和無性別搜索條件。然後你非要給性別設索引的話,可以只建(…,sex,…)索引,然後修改sql語句,當不過濾男女的時候把男女全列出來也就是
select … from a where … and sex in(1,2)and…
這樣就可以巧妙的少建立索引。

這也是《高性能mysql第三版》給出的方案,距如今已經7、8年有餘。
現在我要告訴你,這個方案是使用場景有侷限性。如果組合索引in字段後面的字段在sql中進行了排序,那麼查詢連接估摸着會直接無響應。

例子:sql3 查詢 某中等和大公司的男經理和男員工
如圖——
在這裏插入圖片描述
看下此圖便清楚了in是怎麼走的索引,在不需要排序的情況的這個索引是不錯的,而你會發現limit前900條的記錄都是中等公司的男經理的記錄。
也就是說如果查詢全部,最後查出來的結果是4個獨立的連續時間的區間塊再合併在一起,每一個塊自身是有序的,一旦你排序了,它就會對這4塊進行filesort操作重組排序。(每個塊自身有序,多塊組合自然要重排)in用的越多,笛卡爾乘積的組合排序也就越多,性能自然也就急劇下降。

所以,後續字段用到了排序,前面字段儘可能的避免使用in操作,只有不關心順序的查詢才能用這種方法。

優化進行中

以下爲一個5.5k的碼畜 對mysql(innodb引擎)近千萬級大表的分頁優化操作

■ 效果展示

我曾被百度貼吧震驚了,單單一個李毅吧1500w個主題,頁數隨便跳轉都能保證秒查,不愧是大廠,後來才發現它只給定位到201頁,後面的都是假分頁。。。好吧,索性也不指望能找到一個好的現成解決方案,便自己研究起來。

以下是一個count爲一百多w的分頁效果展示——
在這裏插入圖片描述
在保證索引最優效果的前提下,進行大表分頁改造。在count緩存好的情況下,大數據量分頁前幾萬條和後幾萬條的查詢效果良好,在1w條以內甚至只剩下網絡傳輸所消耗的時間,越往中間查詢性能越差,不過中間數據的查看價值不高,首尾的數據還是最有價值的,可以限制不讓用戶跳轉太遠,保障查詢性能。

■ 查詢流程大綱

這圖畫的我自己都懵逼了,後面會以文字表述來講解。不過畫圖也能理清點思路,發現之前沒發現的bug——異步查詢緩存爲Loading,查出數據後,再查次緩存有count了,被我刪除了緩存。加個限制——這個緩存是你當前接口請求所更新的纔去刪除。
在這裏插入圖片描述

■ 詳解查詢流程

#準備階段

① 回顧前文所說的動態追加下一頁法

例子:移動端需要第3頁,5條數據。傳統我們是去除limit先查一遍count,有可能是100w,然後查詢具體數據limit 10,5,查詢count耗時好幾秒,limit具體數據是秒查,這樣如何優化呢?
我稱之爲——動態追加下一頁法:
limit 10,6 爲 resultList ;返回count=(3-1)*5+resultList.size ;如果siz爲6返回的resultList romove掉最後一條數據
如果resultList.size返回1~5,說明這個查詢就是最後一頁了,這麼計算的count和傳統count一致,如果resultList.size返回6,說明查詢還可以有下一頁,移動端也能判斷是否有下一頁

簡單而言就是多查一條數據用以判斷是否有下一頁,然後再經過後期處理將數據整理成正常查一樣的效果,從而起到可以判斷是否有下一頁的效果。

② 不可避免犧牲count實時性

CAP原則的背後道理是通用,很多時候想要一些東西就得犧牲一些東西。在這按日查一般需要即時性,而且數據量又小,count不作緩存性能也不錯,而越大的count緩存的時間也越長,就需要犧牲count的實時性去提高查詢性能。想要秒出mysql大表查詢的實時count是不可能的,即便是MyIsam也不支持帶條件查詢的秒查count。

③ 倒序查法

例如limit 100000, 10; 之前講過,mysql肯定是要掃描那前10w條的,然後再丟棄掉。這跟走不走索引毫無關係,不會因爲你建了索引它就不掃描,這是必然的過程。那麼我們可以這麼做:如果你知道count爲10w,現在要查最後10條,實際上是不是把順序顛倒過來的前10條呢?
如下——

public void reverse() {
		this.firstResultReverse = (int)count - this.firstResult - pageSize;
		if(this.firstResultReverse < 0) {
			this.pageSizeReverse = this.firstResultReverse + this.pageSize;
			this.firstResultReverse = 0;
		} else {
			this.pageSizeReverse = this.pageSize;
		}
	}

例子:
總數爲9995; order by DESC limit 9980, 10 ; 結果爲倒數第2頁,10條數據;
可得參數:count=9995; firstResult=9980; pageSize=10;
代入上面方法可得:

	firstResultReverse=5;
 =>  判斷大於等於0
	pageSizeReverse=10;

重組sql——order by ASC limit 5, 10 order by DESC;

總數爲9995; order by desc limit 9990, 10 ; 結果爲最後一頁,5條數據;
可得參數:count=9995; firstResult=9990; pageSize=10;
代入上面方法可得:

	firstResultReverse=-5;
 =>  判斷小於0
	pageSizeReverse=5;
	firstResultReverse=0

重組sql——order by desc limit 0, 5 order by DESC;

這樣即可做到查最後的一些頁數和最前面的一頁頁數一樣的神速,當然往中間查會越來越慢。這個做法與追加一頁法有點衝突,之前採用追加下一頁法不斷的查詢最後一頁,我會不斷的進行更新count緩存,而用了倒序法,最後一頁就是最後一頁,它肯定最後面的數據,但是count就不會更新了,所以需要設定一個額外的機制去更新count緩存,保證count不會一直不變。

1.按日查詢

按日查詢被我歸爲低數據量查詢,單用戶單日數據量最多也就小几萬,所以此處多線程同時查詢count和具體數據,再整合返回。

2.無條件查詢

無條件查詢被我歸爲移動端查詢,移動端查詢是最輕鬆的,不需要查詢count,使用動態追加下一頁法輕鬆解決。又或者採用最後一條的時間戳爲遊標帶到下一個查詢的方式,這個方式比動態追加下一頁要更好,只要索引生效,無論劃到天涯海角都沒有性能問題,唯一要解決的就是同一時間戳的數據的定位問題。

3.其它條件查詢

其它條件查詢——幾乎卡到爆的查詢,也就是我要講的重點了,絕大多數的操作都用在此處。
①緩存count
大表分頁查詢最大的難題是什麼呢?毫無疑問總count,又想要即時性又想要速度,可以說是不可能(在條件單一的情況下可以單獨維護count,在這講的是查詢條件複雜的情況),當然你可以採用只給開放附近幾條的分頁方式,我這裏講解的是傳統顯示count的分頁查詢。
對查詢參數整理(去除一些不參與排序的參數,將查詢參數排序,生成條件唯一值)——

// 獲取升序參數map
	public static Map getSortParmMap(ServletRequest request) {
		Enumeration<?> pNames = request.getParameterNames();
		Map<String, String> params = new HashMap<>();
		while (pNames.hasMoreElements()) {
			String pName = (String) pNames.nextElement();
			if ("pageSize".equals(pName) || "pageNo".equals(pName) || "token".equals(pName) || "sign".equals(pName) || "appSecret".equals(pName))) {
				continue;
			}
			String pValue = request.getParameter(pName).replace(":","-");
			if (StringUtils.isNotBlank(pValue)) {
				params.put(pName, pValue);
			}
		}
		return params;
	}

生成redis的key值(小技巧:使用冒號: 在redis可視化管理工具裏,會進行歸檔,方便管理)。不必擔心key太長導致性能下降,這麼點長度對於redis來說小菜一碟。

Map<String, Object> params = LargePage.getSortParmMap(request);
String paramStr = "face:" + rcd.getCurrentUser().getTenantId()+ ":" + params;

② 在主線程數據已經查詢出來的情況下,count還未緩存完畢,數據永遠比count重要,不等待,保證數據優先,直接使用動態追加下一頁法。
舉個例子我查詢數據耗時200ms,數據結果已經出來,主線程再次查詢緩存得知count還是Loading,那就不要等待了,直接返回這個結果,並且告知前端有下一頁。
在這裏插入圖片描述
如圖,並告知前端當前採用的動態追加下一頁法,不要顯示動態追加下一頁法所返回的count,而是顯示一個加載狀態,表示count還在查詢之中,返回的假count僅用於分頁插件的顯示頁數計算。

③ 假如數據查詢出來時,再次查詢得到了count緩存,這時候得到count就可以拿來用了,如果這個count緩存是你當前查詢所做的,你就可以把這個緩存清除掉,因爲數據出來了,count也查出來了,說明這個count查詢很快,緩存是沒必要的,甚至可以設定該條件緩存爲small一定的時間,表示後面查詢這個條件不必做緩存,每次都去查詢數據庫,以做到小count的實時性。

#總結下效果

按日正常查
其它類型異步查count,count先查出來直接賦值,保證實時
count 1000(可設定)以下不緩存
count還在查詢,前端count顯示轉圈圈 ,但是肯定知道知否有下一頁

count小於5w(可設定) 後面的頁數常規limit 會稍微慢點,不過會判斷有沒有下一頁
count大於5w(可設定) 後面的頁數實際上就是將順序倒過來查前幾頁,頭尾都很快,中間慢

我的count緩存機制是,越大的count緩存越久,然後做一個count的刷新機制,一個大表分頁機制就完成了。

最後

路漫漫其修遠兮,mysql優化和分頁的優化還有很長的路要走,我還在路上。

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