同事亂用分頁(yè) MySQL 卡爆,我真是醉了...
背景
一天晚上10點(diǎn)半,下班后愉快的坐在在回家的地鐵上,心里想著周末的生活怎么安排。
突然電話響了起來,一看是我們的一個(gè)開發(fā)同學(xué),頓時(shí)緊張了起來,本周的版本已經(jīng)發(fā)布過了,這時(shí)候打電話一般來說是線上出問題了。
果然,溝通的情況是線上的一個(gè)查詢數(shù)據(jù)的接口被瘋狂的失去理智般的調(diào)用,這個(gè)操作直接導(dǎo)致線上的MySql集群被拖慢了。
好吧,這問題算是嚴(yán)重了,下了地鐵匆匆趕到家,開電腦,跟同事把Pinpoint上的慢查詢?nèi)罩緭瞥鰜???吹揭粋€(gè)很奇怪的查詢,如下
- 1 POST domain/v1.0/module/method?order=condition&orderType=desc&offset=1800000&limit=500
domain、module 和 method 都是化名,代表接口的域、模塊和實(shí)例方法名,后面的offset和limit代表分頁(yè)操作的偏移量和每頁(yè)的數(shù)量,也就是說該同學(xué)是在 翻第(1800000/500+1=3601)頁(yè)。初步撈了一下日志,發(fā)現(xiàn) 有8000多次這樣調(diào)用。
這太神奇了,而且我們頁(yè)面上的分頁(yè)單頁(yè)數(shù)量也不是500,而是 25條每頁(yè),這個(gè)絕對(duì)不是人為的在功能頁(yè)面上進(jìn)行一頁(yè)一頁(yè)的翻頁(yè)操作,而是數(shù)據(jù)被刷了(說明下,我們生產(chǎn)環(huán)境數(shù)據(jù)有1億+)。詳細(xì)對(duì)比日志發(fā)現(xiàn),很多分頁(yè)的時(shí)間是重疊的,對(duì)方應(yīng)該是多線程調(diào)用。
通過對(duì)鑒權(quán)的Token的分析,基本定位了請(qǐng)求是來自一個(gè)叫做ApiAutotest的客戶端程序在做這個(gè)操作,也定位了生成鑒權(quán)Token的賬號(hào)來自一個(gè)QA的同學(xué)。立馬打電話給同學(xué),進(jìn)行了溝通和處理。
分析
其實(shí)對(duì)于我們的MySQL查詢語句來說,整體效率還是可以的,該有的聯(lián)表查詢優(yōu)化都有,該簡(jiǎn)略的查詢內(nèi)容也有,關(guān)鍵條件字段和排序字段該有的索引也都在,問題在于他一頁(yè)一頁(yè)的分頁(yè)去查詢,查到越后面的頁(yè)數(shù),掃描到的數(shù)據(jù)越多,也就越慢。
我們?cè)诓榭辞皫醉?yè)的時(shí)候,發(fā)現(xiàn)速度非???,比如 limit 200,25,瞬間就出來了。但是越往后,速度就越慢,特別是百萬條之后,卡到不行,那這個(gè)是什么原理呢。先看一下我們翻頁(yè)翻到后面時(shí),查詢的sql是怎樣的:
- 1 select * from t_name where c_name1='xxx' order by c_name2 limit 2000000,25;
這種查詢的慢,其實(shí)是因?yàn)閘imit后面的偏移量太大導(dǎo)致的。比如像上面的 limit 2000000,25 ,這個(gè)等同于數(shù)據(jù)庫(kù)要掃描出 2000025條數(shù)據(jù),然后再丟棄前面的 20000000條數(shù)據(jù),返回剩下25條數(shù)據(jù)給用戶,這種取法明顯不合理。
大家翻看《高性能MySQL》第六章:查詢性能優(yōu)化,對(duì)這個(gè)問題有過說明:
分頁(yè)操作通常會(huì)使用limit加上偏移量的辦法實(shí)現(xiàn),同時(shí)再加上合適的order by子句。但這會(huì)出現(xiàn)一個(gè)常見問題:當(dāng)偏移量非常大的時(shí)候,它會(huì)導(dǎo)致MySQL掃描大量不需要的行然后再拋棄掉。
數(shù)據(jù)模擬
那好,了解了問題的原理,那就要試著解決它了。涉及數(shù)據(jù)敏感性,我們這邊模擬一下這種情況,構(gòu)造一些數(shù)據(jù)來做測(cè)試。
1、創(chuàng)建兩個(gè)表:?jiǎn)T工表和部門表
- 1 /*部門表,存在則進(jìn)行刪除 */
- 2 drop table if EXISTS dep;
- 3 create table dep(
- 4 id int unsigned primary key auto_increment,
- 5 depno mediumint unsigned not null default 0,
- 6 depname varchar(20) not null default "",
- 7 memo varchar(200) not null default ""
- 8 );
- 9
- 10 /*員工表,存在則進(jìn)行刪除*/
- 11 drop table if EXISTS emp;
- 12 create table emp(
- 13 id int unsigned primary key auto_increment,
- 14 empno mediumint unsigned not null default 0,
- 15 empname varchar(20) not null default "",
- 16 job varchar(9) not null default "",
- 17 mgr mediumint unsigned not null default 0,
- 18 hiredate datetime not null,
- 19 sal decimal(7,2) not null,
- 20 comn decimal(7,2) not null,
- 21 depno mediumint unsigned not null default 0
- 22 );
2、創(chuàng)建兩個(gè)函數(shù):生成隨機(jī)字符串和隨機(jī)編號(hào)
- 1 /* 產(chǎn)生隨機(jī)字符串的函數(shù)*/
- 2 DELIMITER $
- 3 drop FUNCTION if EXISTS rand_string;
- 4 CREATE FUNCTION rand_string(n INT) RETURNS VARCHAR(255)
- 5 BEGIN
- 6 DECLARE chars_str VARCHAR(100) DEFAULT 'abcdefghijklmlopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
- 7 DECLARE return_str VARCHAR(255) DEFAULT '';
- 8 DECLARE i INT DEFAULT 0;
- 9 WHILE i < n DO
- 10 SET return_str = CONCAT(return_str,SUBSTRING(chars_str,FLOOR(1+RAND()*52),1));
- 11 SET ii = i+1;
- 12 END WHILE;
- 13 RETURN return_str;
- 14 END $
- 15 DELIMITER;
- 16
- 17
- 18 /*產(chǎn)生隨機(jī)部門編號(hào)的函數(shù)*/
- 19 DELIMITER $
- 20 drop FUNCTION if EXISTS rand_num;
- 21 CREATE FUNCTION rand_num() RETURNS INT(5)
- 22 BEGIN
- 23 DECLARE i INT DEFAULT 0;
- 24 SET i = FLOOR(100+RAND()*10);
- 25 RETURN i;
- 26 END $
- 27 DELIMITER;
3、編寫存儲(chǔ)過程,模擬500W的員工數(shù)據(jù)
- 1 /*建立存儲(chǔ)過程:往emp表中插入數(shù)據(jù)*/
- 2 DELIMITER $
- 3 drop PROCEDURE if EXISTS insert_emp;
- 4 CREATE PROCEDURE insert_emp(IN START INT(10),IN max_num INT(10))
- 5 BEGIN
- 6 DECLARE i INT DEFAULT 0;
- 7 /*set autocommit =0 把a(bǔ)utocommit設(shè)置成0,把默認(rèn)提交關(guān)閉*/
- 8 SET autocommit = 0;
- 9 REPEAT
- 10 SET ii = i + 1;
- 11 INSERT INTO emp(empno,empname,job,mgr,hiredate,sal,comn,depno) VALUES ((START+i),rand_string(6),'SALEMAN',0001,now(),2000,400,rand_num());
- 12 UNTIL i = max_num
- 13 END REPEAT;
- 14 COMMIT;
- 15 END $
- 16 DELIMITER;
- 17 /*插入500W條數(shù)據(jù)*/
- 18 call insert_emp(0,5000000);
4、編寫存儲(chǔ)過程,模擬120的部門數(shù)據(jù)
- 1 /*建立存儲(chǔ)過程:往dep表中插入數(shù)據(jù)*/
- 2 DELIMITER $
- 3 drop PROCEDURE if EXISTS insert_dept;
- 4 CREATE PROCEDURE insert_dept(IN START INT(10),IN max_num INT(10))
- 5 BEGIN
- 6 DECLARE i INT DEFAULT 0;
- 7 SET autocommit = 0;
- 8 REPEAT
- 9 SET ii = i+1;
- 10 INSERT INTO dep( depno,depname,memo) VALUES((START+i),rand_string(10),rand_string(8));
- 11 UNTIL i = max_num
- 12 END REPEAT;
- 13 COMMIT;
- 14 END $
- 15 DELIMITER;
- 16 /*插入120條數(shù)據(jù)*/
- 17 call insert_dept(1,120);
5、建立關(guān)鍵字段的索引,這邊是跑完數(shù)據(jù)之后再建索引,會(huì)導(dǎo)致建索引耗時(shí)長(zhǎng),但是跑數(shù)據(jù)就會(huì)快一些。
- 1 /*建立關(guān)鍵字段的索引:排序、條件*/
- 2 CREATE INDEX idx_emp_id ON emp(id);
- 3 CREATE INDEX idx_emp_depno ON emp(depno);
- 4 CREATE INDEX idx_dep_depno ON dep(depno);
測(cè)試
測(cè)試數(shù)據(jù)
- 1 /*偏移量為100,取25*/
- 2 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno order by a.id desc limit 100,25;
- 4 /*偏移量為4800000,取25*/
- 5 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 6 from emp a left join dep b on a.depno = b.depno order by a.id desc limit 4800000,25;
執(zhí)行結(jié)果
- 1 [SQL]
- 2 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno order by a.id desc limit 100,25;
- 4 受影響的行: 0
- 5 時(shí)間: 0.001s
- 6 [SQL]
- 7 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 8 from emp a left join dep b on a.depno = b.depno order by a.id desc limit 4800000,25;
- 9 受影響的行: 0
- 0 時(shí)間: 12.275s
因?yàn)閽呙璧臄?shù)據(jù)多,所以這個(gè)明顯不是一個(gè)量級(jí)上的耗時(shí)。另外,MySQL 系列面試題和答案全部整理好了,微信搜索Java技術(shù)棧,在后臺(tái)發(fā)送:面試,可以在線閱讀。
解決方案
1、使用索引覆蓋+子查詢優(yōu)化
因?yàn)槲覀冇兄麈Iid,并且在上面建了索引,所以可以先在索引樹中找到開始位置的 id值,再根據(jù)找到的id值查詢行數(shù)據(jù)。
- 1 /*子查詢獲取偏移100條的位置的id,在這個(gè)位置上往后取25*/
- 2 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno
- 4 where a.id >= (select id from emp order by id limit 100,1)
- 5 order by a.id limit 25;
- 6
- 7 /*子查詢獲取偏移4800000條的位置的id,在這個(gè)位置上往后取25*/
- 8 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 9 from emp a left join dep b on a.depno = b.depno
- 10 where a.id >= (select id from emp order by id limit 4800000,1)
- 11 order by a.id limit 25;
執(zhí)行結(jié)果
執(zhí)行效率相比之前有大幅的提升:
- 1 [SQL]
- 2 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno
- 4 where a.id >= (select id from emp order by id limit 100,1)
- 5 order by a.id limit 25;
- 6 受影響的行: 0
- 7 時(shí)間: 0.106s
- 8
- 9 [SQL]
- 10 SELECT a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 11 from emp a left join dep b on a.depno = b.depno
- 12 where a.id >= (select id from emp order by id limit 4800000,1)
- 13 order by a.id limit 25;
- 14 受影響的行: 0
- 15 時(shí)間: 1.541s
2、起始位置重定義
記住上次查找結(jié)果的主鍵位置,避免使用偏移量 offset
- 1 /*記住了上次的分頁(yè)的最后一條數(shù)據(jù)的id是100,這邊就直接跳過100,從101開始掃描表*/
- 2 SELECT a.id,a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno
- 4 where a.id > 100 order by a.id limit 25;
- 5
- 6 /*記住了上次的分頁(yè)的最后一條數(shù)據(jù)的id是4800000,這邊就直接跳過4800000,從4800001開始掃描表*/
- 7 SELECT a.id,a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 8 from emp a left join dep b on a.depno = b.depno
- 9 where a.id > 4800000
- 10 order by a.id limit 25;
執(zhí)行結(jié)果
- 1 [SQL]
- 2 SELECT a.id,a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 3 from emp a left join dep b on a.depno = b.depno
- 4 where a.id > 100 order by a.id limit 25;
- 5 受影響的行: 0
- 6 時(shí)間: 0.001s
- 7
- 8 [SQL]
- 9 SELECT a.id,a.empno,a.empname,a.job,a.sal,b.depno,b.depname
- 10 from emp a left join dep b on a.depno = b.depno
- 11 where a.id > 4800000
- 12 order by a.id limit 25;
- 13 受影響的行: 0
- 14 時(shí)間: 0.000s
這個(gè)效率是最好的,無論怎么分頁(yè),耗時(shí)基本都是一致的,因?yàn)樗麍?zhí)行完條件之后,都只掃描了25條數(shù)據(jù)。
但是有個(gè)問題,只適合一頁(yè)一頁(yè)的分頁(yè),這樣才能記住前一個(gè)分頁(yè)的最后Id。如果用戶跳著分頁(yè)就有問題了,比如剛剛刷完第25頁(yè),馬上跳到35頁(yè),數(shù)據(jù)就會(huì)不對(duì)。《MySQL 開發(fā) 36 條軍規(guī)》有必要看下。
這種的適合場(chǎng)景是類似百度搜索或者騰訊新聞那種滾輪往下拉,不斷拉取不斷加載的情況。這種延遲加載會(huì)保證數(shù)據(jù)不會(huì)跳躍著獲取。
3、降級(jí)策略
看了網(wǎng)上一個(gè)阿里的dba同學(xué)分享的方案:配置limit的偏移量和獲取數(shù)一個(gè)最大值,超過這個(gè)最大值,就返回空數(shù)據(jù)。
因?yàn)樗X得超過這個(gè)值你已經(jīng)不是在分頁(yè)了,而是在刷數(shù)據(jù)了,如果確認(rèn)要找數(shù)據(jù),應(yīng)該輸入合適條件來縮小范圍,而不是一頁(yè)一頁(yè)分頁(yè)。
這個(gè)跟我同事的想法大致一樣:request的時(shí)候 如果offset大于某個(gè)數(shù)值就先返回一個(gè)4xx的錯(cuò)誤。
小結(jié)
當(dāng)晚我們應(yīng)用上述第三個(gè)方案,對(duì)offset做一下限流,超過某個(gè)值,就返回空值。第二天使用第一種和第二種配合使用的方案對(duì)程序和數(shù)據(jù)庫(kù)腳本進(jìn)一步做了優(yōu)化。
合理來說做任何功能都應(yīng)該考慮極端情況,設(shè)計(jì)容量都應(yīng)該涵蓋極端邊界測(cè)試。另外,關(guān)注公眾號(hào)Java技術(shù)棧,在后臺(tái)回復(fù):面試,可以獲取我整理的 Java 系列面試題和答案,非常齊全。
另外,該有的限流、降級(jí)也應(yīng)該考慮進(jìn)去。比如工具多線程調(diào)用,在短時(shí)間頻率內(nèi)8000次調(diào)用,可以使用計(jì)數(shù)服務(wù)判斷并反饋用戶調(diào)用過于頻繁,直接給予斷掉。
哎,大意了啊,搞了半夜,QA同學(xué)不講武德。
不過這是很美好的經(jīng)歷了。