1. 程式人生 > 其它 >MySQL 鎖相關的優化案例

MySQL 鎖相關的優化案例

技術標籤:MySQL從小工到專家之路# MySQL案例mysql重複讀優化併發

備註:
MySQL 5.5

測試資料:
基於資訊保安考慮,我自己建立的測試表,來模擬實際應用場景。

create table test_202101
(id int(11) not null auto_increment,
 name varchar(200),
 age int,
 qq varchar(200),
 email varchar(200),
 status1 int(11),
 status2 int(11),
 status3 int(11),
 status4 int(11),
 status5 int(11),
 status6 int(11),
 isread  int not null DEFAULT 0,
 PRIMARY KEY (id) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1;

 insert into test_202101(id,name,age,status1,isread) values (1,'a',22,0,0);
 insert into test_202101(id,name,age,status1,isread) values (2,'b',33,0,0);
 insert into test_202101(id,name,age,status1,isread) values (3,'c',29,0,0);
 insert into test_202101(id,name,age,status1,isread) values (4,'d',21,0,0);
 insert into test_202101(id,name,age,status1,isread) values (5,'e',28,0,0);
 insert into test_202101(id,name,age,status1,isread) values (6,'f',32,0,0);
 insert into test_202101(id,name,age,status1,isread) values (7,'g',41,0,0);
 insert into test_202101(id,name,age,status1,isread) values (8,'h',54,0,0);
 insert into test_202101(id,name,age,status1,isread) values (9,'i',26,0,0);
 insert into test_202101(id,name,age,status1,isread) values (10,'j',31,0,0);

一.問題描述

最近有人通過我寫的MySQL相關的部落格,詢問是否可以接私活,幫忙解決一個重複讀的問題。

突然發現寫部落格居然可以在網上接私活
image.png

業務場景描述:
表資料在幾十萬級別,有status1到status6不等,代表不同的類別,還有一個isread,0-未讀,1-已讀。
現在業務要求,找到不同類別(status1-6)下id值最小的一條記錄,然後將status值和isread值都更新為1。

最開始的sql如下:

-- 待更新的資料要展示給前段使用者
select id,name,age
   from test_202101
 where status1 = 0
     and isread = 0
  order by id limit 1

-- update資料
update test_202101 t1
inner join ( select id from test_202101 where  status1 = 0 and isread = 0 order by id limit 1) t2
set t1.status1 = 1,t2.isread = 1 
where t1.id = t2.id;  

當併發開到200的時候,發現很多執行緒查詢出來的id值是一樣的,也就是存在大量的重複讀

二.問題分析

最開始想到的是調整表結構,將status1-status11(原需求很多個status列)改為兩列,一列type,一列value,這樣列轉行,通過type定位到指定status,可以過濾掉大部分資料無需處理的資料。

但是諮詢問題的哥們專案經驗較淺,對mysql不熟悉,無奈只能放棄這個方法。
image.png

2.1 開啟事務並加鎖

MySQL預設的隔離級別是可重複讀,如果有新的資料錄入到表中,極端的情況下,上述select和update語句操作的可能不是同一條資料,這樣會給應用帶來諸多麻煩,於是調整如下:

begin
-- 待更新的資料要展示給前段使用者
select id,name,age
   from test_202101
 where status1 = 0
     and isread = 0
  order by id limit 1 ;

-- update資料
update test_202101 t1
inner join ( select id from test_202101 where  status1 = 0 and isread = 0 order by id limit 1) t2
set t1.status1 = 1,t2.isread = 1 
where t1.id = t2.id;  
commit;

但是上述更改可以保證select和update操作的是同一條,並不能避免重複讀

要解決重複讀問題,需要給資料加鎖
MySQL 8.0開始才支援 select * from tab for update nowait
所以只能考慮使用select * from tab for update (預設預設為wait)
修改如下:

begin
-- 待更新的資料要展示給前段使用者,並鎖住這條記錄
select id,name,age
   from test_202101
 where status1 = 0
     and isread = 0
  order by id limit 1 for update;

-- update資料
update test_202101 t1
inner join ( select id from test_202101 where  status1 = 0 and isread = 0 order by id limit 1) t2
set t1.status1 = 1,t2.isread = 1 
where t1.id = t2.id;  
commit;

如上的修改,給最小的id加鎖了,那麼如果前面更改最小id的事務沒有結束,又發起了一個事務會出現什麼情況呢?

session Asession B描述
begin會話A開啟事務
select id,name,age
from test_202101
where status1 = 0
and isread = 0
order by id limit 1 for update;
begin會話A給status1為0 且isread為0的最小id加鎖
select id,name,age
from test_202101
where status1 = 0
and isread = 0
order by id limit 1 for update;
會話B給status1為0 且isread為0的最小id加鎖,但是由於該行已經被會話A加鎖,故處於等待狀態
update test_202101 t1
inner join ( select id from test_202101 where status1 = 0 and isread = 0 order by id limit 1) t2
set t1.status1 = 1,t2.isread = 1
where t1.id = t2.id;
會話A更改資料
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction會話B等待超時,報錯
commit;會話A提交事務

會話B的加鎖會被會話A給阻塞,會話B會有一個等待超時的時間,由innodb的innodb_lock_wait_timeout引數來控制,預設值為50,代表會等待50s。
如果50s內會話A依舊沒有釋放行鎖,就會報錯。
如果50s內會話A釋放了行鎖,會話B開啟查詢,查詢最新的status1為0 且isread為0的最小id,然後進行更新。

2.2 加索引

我們從表結構可以看到,整個表只有一個主鍵id有索引,其它列都沒有索引。
其實status1-7可增加索引,不然select id,name,age from test_202101
where status1 = 0 and isread = 0 order by id limit 1 for update;這個語句就是鎖全表的資料。

如果在status1列上增加了索引,那麼此時可以快速定位到status1的值,此時加鎖的只是部分資料,在一定程度上增加了併發。

程式碼如下:

create index idx1 on test_202101(status1);
create index idx2 on test_202101(status2);
create index idx3 on test_202101(status3);
create index idx4 on test_202101(status4);
create index idx5 on test_202101(status5);
create index idx6 on test_202101(status6);

2.3 調優sql

本想調優下sql,或者在程式端通過變數的方式來減少重複查詢的,結果鬧了個烏龍。

比較group by與 order by limit 1的效能
從type一個range一個ALL就能判斷出來,order by limit1的效能會由於group by。

因為是innodb的表,status1建立索引,儲存的其實就是status1和id兩列,這個時候可以通過這個小索引範圍掃描,快速定位到id值最小的一行,然後通過id回表找到這一條記錄,所以可以理解執行計劃中的range索引範圍掃描。

但是group by為什麼要走全索引掃描而不走範圍掃描呢?MySQL的這個執行計劃,真的讓我很無語了。

mysql> explain
    -> select id,name,age
    ->    from test_202101
    ->  where status1 = 0
    ->      and isread = 0
    ->   order by id limit 1;
+----+-------------+-------------+-------+---------------+------+---------+------+------+------------------------------------+
| id | select_type | table       | type  | possible_keys | key  | key_len | ref  | rows | Extra                              |
+----+-------------+-------------+-------+---------------+------+---------+------+------+------------------------------------+
|  1 | SIMPLE      | test_202101 | range | idx1          | idx1 | 5       | NULL |   10 | Using index condition; Using where |
+----+-------------+-------------+-------+---------------+------+---------+------+------+------------------------------------+
1 row in set (0.00 sec)

mysql> explain
    -> select min(id)
    ->   from test_202101
    ->  where status1 = 0
    ->      and isread = 0
    ->    group by status1;
+----+-------------+-------------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table       | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+----+-------------+-------------+------+---------------+------+---------+------+------+-------------+
|  1 | SIMPLE      | test_202101 | ALL  | idx1          | NULL | NULL    | NULL |   10 | Using where |
+----+-------------+-------------+------+---------------+------+---------+------+------+-------------+
1 row in set (0.00 sec)

上面的測試結果,直接讓我打消了將sql修改為如下的想法了
而且也得知諮詢我的那個哥們無法改程式碼,只能通過sql實現功能,沒辦法使用變數,除非我這邊在儲存過程中來使用變數,但是輸出也比較麻煩。
於是放棄瞭如下的想法

begin

-- 查詢出最小id
select min(id)
  from test_202101
 where status1 = 0
     and isread = 0
   group by status1 ;

-- 通過上一步的id來查詢,通過主鍵id來查詢是最快的
select id,name,age
  from test_202101
  where id = last_id for update;

update test_202101
  set  status1 = 1,isread = 1
 where id = last_id;

commit;

2.3 最終的sql

於是有了如下的最終sql的版本

-- 加索引(一次性操作)
create index idx1 on test_202101(status1);
create index idx2 on test_202101(status2);
create index idx3 on test_202101(status3);
create index idx4 on test_202101(status4);
create index idx5 on test_202101(status5);
create index idx6 on test_202101(status6);

begin
-- 待更新的資料要展示給前段使用者,並鎖住這條記錄
select id,name,age
   from test_202101
 where status1 = 0
     and isread = 0
  order by id limit 1 for update;

-- update資料
update test_202101 t1
inner join ( select id from test_202101 where  status1 = 0 and isread = 0 order by id limit 1) t2
set t1.status1 = 1,t2.isread = 1 
where t1.id = t2.id;  
commit;