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 A session 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;

猜你喜欢

转载自blog.csdn.net/u010520724/article/details/113776402