作者:IT王小二
博客:https://itwxe.com
MySQL是怎么解决并发事务所产生的问题呢?又借助了哪些锁的思想呢?这篇小二给小伙伴们继续唠一唠MySQL的那些事。
一、事务是什么
事务是由一组SQL语句组成的逻辑处理单元,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。通常有下面4个特性,也就是咱程序猿俗称的ACID属性。
- 原子性(Atomicity):事务是一个原子操作单元,其对数据的修改,要么全部执行,要么全部不执行。举个例子:情人节小二给女朋友转账520块,那么SQL中一共两个操作,小二的账户余额
-520 块,女朋友账户余额+520 块,这一组转账操作,不能只执行小二的账户-520 块,或者只执行女朋友账户余额+520 块,只能都执行,或者都不执行。 - 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。一致性和原子性息息相关,即一组转账操作下来小二账户余额应该
-520 块,女朋友账户余额+520 块,两人转账操作之后账户总金额是一致的。 - 隔离性(Isolation):一个事务的执行不能被其他事务干扰,即每个事务都是独立的,不受其他事务的影响。简单来说就是一个事务(T1)处理过程的中间状态对其他事务(T2, T3…)是不可见的;同理,T2同样对T1和T3的中间状态不可见的。即只要小二的转账操作没完成,那么女朋友不管怎么查询结果都是原来的余额,而不会查询多出
520 块。 - 持久性(Durable):事务完成之后,对数据的修改是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。即只要小二转账成功了,数据就保存磁盘了。
二、并发事务所产生的问题
脏写或者更新丢失(Lost Update)
当两个或多个事务同一时间修改同一行,然后基于最初选定的值进行业务操作更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题。
简单来说就是最后的更新覆盖了由其他事务所做的更新。
脏读(Dirty Reads)
一个事务读到了其他事务已经修改但是未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不一定会存到数据库中,所以读出来的数据是无效的。
不可重复读(Non-Repeatable Reads)
一个事务在读取某些数据后的某个时间点,再次读取以前读过的那批数据,却发现其读出的数据已经发生了改变,可能会受到其他事务的影响,比如其他事务改了这批数据并提交了。通常针对数据更新(UPDATE)操作或者删除(DELETE)操作。
简单来说就是在一个事务中,相同查询语句在不同时刻查询出来的结果不一致,可能是结果字段值不一致,也可能是少了行数据。
幻读(Phantom Reads)
对比不可重复读,幻读是针对数据新增(INSERT)来说的,在同一事务下,在读取某些数据后的某个时间点,再次读取以前读过的那批数据,第二次的SQL语句返回了之前不存在的行。
简单来说就是事务A读取到了其他事务提交的新增数据。
再来区分一下经常搞混的不可重复读和幻读。
- 不可重复读:说的是原来存在的记录A,记录A从A变成了记录B。
- 幻读:出现了原来不存在的记录。
当然还是区分不明显的小伙伴可以结合五、行锁与事务隔离级别案例分析理解理解,理论结合实践,nice~
三、事务隔离级别
并发事务产生的脏读、不可重复读、幻读都是数据库读取的一致性,问题,所以数据库提供了一定的事务隔离级别来解决产生的问题。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|
读未提交(read-uncommitted) | 是 | 是 | 是 | 不可重复读(read-committed) | 否 | 是 | 是 | 可重复读(repeatable-read) | 否 | 否 | 是 | 串行化(serializable) | 否 | 否 | 否 |
MySQL数据库默认的隔离级别为可重复读(repeatable-read),也就是我们常说的RR级别。
查看事务隔离级别:show variables like 'tx_isolation';
设置事务隔离级别,例如设置隔离级别为读未提交:
- 仅对当前会话生效,立即生效:
set session transaction isolation level read uncommitted; 或者set tx_isolation = 'read-uncommitted'; - 对全局会话生效,需要退出会话后生效:
set global transaction isolation level read uncommitted;
用Spring开发程序时,如果不设置隔离级别默认用MySQL设置的隔离级别,如果Spring设置了就用设置的隔离级别。
四、锁分类
锁是计算机协调多个进程或者线程并发访问同一资源的机制,而对于数据库来说数据就是一种需要用户共享的资源,怎么保证数据的并发访问一致性和高效性是数据库需要解决的问题。
按不同分类有以下分类。
- 从性能上来分,分为乐观锁和悲观锁。
- 乐观锁比较乐观,乐观锁假设数据一般情况不会造成冲突,多线程同时对同一行数据修改的时候,在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。
- 悲观锁比较悲观,多线程同时对同一行数据修改的时候,最终只有一个线程修改成功。
- 从对数据库操作的类型分,分为读锁和写锁(都属于悲观锁)。
- 读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响。
- 写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁。
- 从对数据操作的粒度分,分为表锁和行锁。
1. 表锁
每次操作锁住整张表。开销小,加锁快,不会出现死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低,一般用在整表数据迁移的场景。
基本操作
- 手动增加表锁:
lock table 表名称 read/write,表名称2 read/write; - 查看表上加过的锁:
show open tables; - 删除表锁:
unlock tables;
实操一下
CREATE TABLE `test_myisam_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE = MyISAM DEFAULT CHARSET = utf8;
INSERT INTO `blog_test`.`test_myisam_lock` (`id`, `name`, `age`) VALUES (1, 'itwxe', 18);
INSERT INTO `blog_test`.`test_myisam_lock` (`id`, `name`, `age`) VALUES (2, 'Lee Patel', 62);
INSERT INTO `blog_test`.`test_myisam_lock` (`id`, `name`, `age`) VALUES (3, 'Sakurai Mio', 55);
INSERT INTO `blog_test`.`test_myisam_lock` (`id`, `name`, `age`) VALUES (4, 'Tan Xiuying', 42);
INSERT INTO `blog_test`.`test_myisam_lock` (`id`, `name`, `age`) VALUES (5, 'Cheng Yunxi', 47);
1、加读锁
本文表格中序号代表两个session中SQL的执行顺序,同一行为从左往右执行的语句。图中已标注当前示例SQL执行顺序,后续不再截图说明。
序号 | session1 | session2 |
---|
1 | lock table test_myisam_lock read; | | 2 | select * from test_myisam_lock; – 可以查询 | select * from test_myisam_lock; – 可以查询 | 3 | insert into blog_test.test_myisam_lock ( name, age) values (‘xiaowu’, 18); – 插入失败,报错 | insert into blog_test.test_myisam_lock ( name, age) values (‘xiaowu’, 18); – 等待执行 | 4 | unlock tables; | – 释放锁后没有超时则执行插入操作 |
对MyISAM表的读操作(加读锁),不会阻塞其他进程对同一表的读请求;但会阻塞对同一表的写请求,当前线程无法执行写操作,其他线程进入等待状态,只有当读锁释放后,才会执行其它进程的写操作。
2、加写锁
序号 | session1 | session2 |
---|
1 | Lock table test_myisam_lock write; | | 2 | select * from test_myisam_lock; – 可以查询 | select * from test_myisam_lock; – 进入等待执行状态,手动取消(ctrl + c) | 3 | insert into blog_test.test_myisam_lock ( name, age) values (‘xiaoer’, 18); – 插入成功 | insert into blog_test.test_myisam_lock ( name, age) values (‘xiaoer’, 18); – 进入等待执行状态,手动取消(ctrl + c) | 4 | update blog_test.test_myisam_lock set name = ‘wangxiaoer’ where id = 8; – 更新成功 | update blog_test.test_myisam_lock set name = ‘wangxiaoer’ where id = 8; – 进入等待执行状态,手动取消(ctrl + c) | 5 | delete from blog_test.test_myisam_lock where id = 8; – 删除成功 | delete from blog_test.test_myisam_lock where id = 8; – 进入等待执行状态 | 6 | unlock tables; | – 释放锁后没有超时则进行删除操作 |
简单来说:对MylSAM表的写操作(加写锁),不会阻塞当前进程对表的读写操作;但是会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。
2. 行锁
每次操作锁住一行数据,开销大,加锁慢,会出现死锁,锁定粒度最小,发生锁冲突的概率最低,并发度最高。
同时InnoDB与MyISAM最大的两点不同就是支持事务和支持行级锁。
基本操作
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(20) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (1, 'itwxe', 1000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (2, 'Kenneth Adams', 2000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (3, 'Takada Daichi', 3000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (4, 'Anne Russell', 4000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (5, 'Jia Yuning', 5000);
默认的RR隔离级别下示例,即可重复读(repeatable-read)。
序号 | session1 | session2 |
---|
1 | begin; – 开启事务 | begin; – 开启事务 | 2 | update account set balance = balance + 1000 where id = 1; – 更新id = 1的余额 | select * from account where id = 1; – 查询不阻塞,正常查询 | 3 | | update account set balance = balance + 1000 where id = 1; – 更新操作阻塞,进入等待执行状态,最后会超时或者等待session1提交(回滚)事务后才会执行更新语句。 | 4 | commit; – 提交事务 | – session1提交(回滚)事务后,没有超时则进行更新操作 |
当然啦,小二这里写的只有两个会话,如果有很多个session同时更新一行数据也是一样进入等待状态。
简单来说:在非串行化事务隔离级别下,InnoDB在执行查询语句select时不会加锁,但是update、insert、delete操作会加行锁。
当然,当查询语句也需要加锁也是可以的,使用 select ... for update 。
select * from account where id = 1 for update;
此时当前会话对于id = 1 的行可以进行增删改查操作,其它会话不能对id = 1 行进行操作。只有等待当前会话释放锁(提交/回滚事务)后才能够操作记录id = 1 的行。
五、行锁与事务隔离级别案例分析
行锁支持事务所以当然就得结合事务隔离级别来分析实操一下。
1. 读未提交
首先将示例账户余额还原到1000,update account set balance = 1000 where id = 1; 。
序号 | session1 | session2 |
---|
1 | set tx_isolation = ‘read-uncommitted’; – 设置当前回话事务隔离级别为读未提交 | set tx_isolation = ‘read-uncommitted’; – 设置当前回话事务隔离级别为读未提交 | 2 | begin; – 开启事务 | | 3 | select * from account where id = 1; – 第一次查询余额为1000 | begin; – 开启事务 | 4 | | update account set balance = balance + 1000 where id = 1; – 更新id = 1的余额 | 5 | select * from account where id = 1; – 第二次查询余额为2000,这里就是脏读了,读到了没有提交的数据 | | 6 | | rollback; – 回滚 | 7 | select * from account where id = 1; – 第三次查询余额为1000 | | 8 | update account set balance = balance - 2000 where id = 1; – 例如序号5中的余额在Java代码中判断可以购买后执行了扣减余额 | | 9 | commit; – 提交事务 | |
可以看到,读未提交隔离级别下,第5行session读取到的账户余额就是2000了,这个时候如果Java应用程序使用2000作为判断了某些业务操作,例如判断是否可以购买一个2000的商品,此时session又还没有扣减余额,那么当session2因为某些原因回滚了,此时id为1的用户余额就是 -1000 块了,这是明显不合理的;并且实际开发中一定不要使用Java程序计算后直接将值更新到数据库,这样会造成脏写,例如Java程序计算值后账户余额为零,那么执行update account set balance = 0 where id = 1; 的话问题就更大了。
为了解决这个问题就要用到读已提交的隔离级别了。
2. 读已提交
首先将示例账户余额还原到1000,update account set balance = 1000 where id = 1; 。
序号 | session1 | session2 |
---|
1 | set tx_isolation = ‘read-committed’; – 设置当前回话事务隔离级别为读已提交 | set tx_isolation = ‘read-committed’; – 设置当前回话事务隔离级别为读已提交 | 2 | begin; – 开启事务 | | 3 | select * from account where id = 1; – 第一次查询余额为1000 | begin; – 开启事务 | 4 | | update account set balance = balance + 1000 where id = 1; – 更新id = 1的余额 | 5 | select * from account where id = 1; – 第二次查询余额为1000 | | 6 | | commit; – 提交事务 | 7 | select * from account where id = 1; – 第三次查询余额为2000 | | 8 | commit; – 提交事务 | |
可以看到已经解决了脏读的问题,但是在session2提交后出现了不可重复读的问题,不可重复读会让我们在编写Java应用代码的时候很难编写,而不可重复读就得靠可重复读的隔离级别来解决了。
3. 可重复读
首先将示例账户余额还原到1000,update account set balance = 1000 where id = 1; 。
序号 | session1 | session2 |
---|
1 | set tx_isolation=‘repeatable-read’; – 设置当前回话事务隔离级别为可重复读 | set tx_isolation=‘repeatable-read’; – 设置当前回话事务隔离级别为可重复读 | 2 | begin; – 开启事务 | | 3 | select * from account; – 第一次查询id = 1账户余额为1000 | begin; – 开启事务 | 4 | | update account set balance = balance + 1000 where id = 1; – 更新id = 1的余额 | 5 | select * from account; – 第二次查询id = 1账户余额为1000 | | 6 | | commit; – 提交事务 | 7 | select * from account; – 第二次查询id = 1账户余额为1000,可以看到已经解决了不可重复读的问题 | | 8 | | begin; – 重新开启一个事务,验证幻读问题 | 9 | | insert into account (id, name, balance) values (6, ‘xiaoer’, 6000); – 插入一行数据 commit; – 提交事务 | 10 | select * from account; – 结果和序号7中展示的结果一致,只有id = 1~5的记录 | | 11 | update account set balance = 6666 where id = 6; – 更新新增的id = 6记录 | | 12 | select * from account; – 再次发现查询到的记录多了id = 6的记录,且id=6的账户余额为6666 | | 13 | commit; | |
所以,可以看到其实MySQL并没有完全解决幻读的问题,在RR级别下当MySQL有可能会出现幻读问题。
当然我们实际开发通常也不会这样去操作,因为id = 6 这个对当前session是不可见的,所以通常不会直接修改到其他session插入的记录。
所以MySQL默认的事务隔离级别就是RR级别,并发高且能解决99.99%的业务场景。
4. 串行化
首先将示例账户余额还原到1000,update account set balance = 1000 where id = 1; 。
序号 | session1 | session2 |
---|
1 | set tx_isolation=‘serializable’; – 设置当前回话事务隔离级别为串行化 | set tx_isolation=‘serializable’; – 设置当前回话事务隔离级别为串行化 | 2 | begin; – 开启事务 | begin; – 开启事务 | 3 | select * from account where id = 1; – 查询id = 1信息,账户余额为1000 | select * from account where id = 2; – id = 2的记录可以查询 | 4 | | select * from account where id = 1; – id = 1账户余额为1000 | 5 | | select * from account where id = 2; – 进入等待执行状态,最后会超时或者等待session1提交事务或者回滚后才会执行查询。 | 6 | commit; | – 如果没有超时则执行查询id = 2的记录信息 | 7 | | commit; |
可以看到串行化隔离级别下,使用的是排它锁,锁定记录的增删改查都会收到影响进入等待执行状态。
所以,串行化隔离级别下不会有幻读的问题,当然效率也最低,通常实际项目开发中都不会使用。
六、行锁详解
1. 记录锁、间隙锁、临键锁
行锁有三个类型,分别是记录锁、间隙锁、临键锁,记录锁只锁定一行记录,而间隙锁和临键锁都是锁定一个范围的记录行。
记录锁(Record Locks)
记录锁锁定一行记录的已经在锁分类中演示过了 id = 1 的场景,不唠叨了。
间隙锁(Gap Locks)
间隙锁指的是锁的是两个值之间的间隙,可以解决RR级别下一些情景的幻读问题。
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (13, 'itwxe.com', 13000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (18, 'zhangsan', 18000);
INSERT INTO `blog_test`.`account` (`id`, `name`, `balance`) VALUES (25, 'lisi', 25000);
插入数据后id就产生了(6, 13), (13, 18), (18, 25), (25, 正无穷) 这四个空间间隙。
下面使用RR事务隔离级别,set tx_isolation='repeatable-read'; 。
序号 | session1 | session2 |
---|
1 | begin; – 开启事务 | begin; – 开启事务 | 2 | update account set balance = balance + 1000 where id > 8 and id < 13; – 查询制造间隙(8, 13) | insert into account values(6, ‘jianxi’, 6000); – 可以正常执行响应结果,但是主键已存在,插入失败 | 3 | | insert into account values(7, ‘jianxi’, 7000); – 插入id = 10的记录会发现插入不了,进入等待执行状态 | 4 | | insert into account values(10, ‘jianxi’, 8000); – 插入id = 10的记录会发现插入不了,进入等待执行状态,手动取消(ctrl + c) | 5 | | insert into account values(13, ‘jianxi’, 8000); – 插入id = 13同样会阻塞,进入等待执行状态 | 6 | | insert into account values(14, ‘jianxi’, 8000); – 可以正常插入 | 7 | rollback; – 回滚事务 | rollback; – 回滚事务 |
可以看到测试中产生锁定的范围为(6, 13] ,也就是说间隙锁锁定的范围是小范围(id=8)前一条行记录(id=6)的开区间到大范围(id=13)的后一条记录,左开右闭区间。
可以看到间隙锁在减少了某些情况下幻读的发生,通过对间隙加锁让间隙内在session1提交(回滚)事务之前其他session都无法进行插入或者修改任何数据。
间隙锁是在可重复读隔离级别下才会生效。
临键锁(Next-key Locks)
临键锁可以说是特殊的间隙锁,即行锁和间隙锁的结合,可以理解为间隙锁中包含记录行。
目前存在(6, 13), (13, 18), (18, 25), (25, 正无穷) 这四个空间间隙。
下面使用RR事务隔离级别,set tx_isolation='repeatable-read'; 。
序号 | session1 | session2 |
---|
1 | begin; – 开启事务 | begin; – 开启事务 | 2 | update account set balance = balance + 1000 where id > 15 and id < 28; – 查询制造间隙(8, 13) | insert into account values(13, ‘linjian’, 13000); – 可以正常执行响应结果,但是主键已存在,插入失败 | 3 | | insert into account values(17, ‘linjian’, 17000); – 插入id = 17的记录会发现插入不了,进入等待执行状态 | 4 | | insert into account values(18, ‘linjian’, 18000); – 插入id = 18的记录会发现插入不了,进入等待执行状态,这就是包含的行锁。手动取消(ctrl + c) | 5 | | insert into account values(25, ‘linjian’, 25000); – 插入id = 25同样会阻塞,进入等待执行状态 | 6 | | insert into account values(29, ‘linjian’, 29000); – 插入id = 29同样会阻塞,进入等待执行状态 | 7 | rollback; – 回滚事务 | | 8 | | – 如果这个时候还没有超时则执行插入id = 29的记录 | 9 | | rollback; – 回滚事务 |
可以看到临键锁锁定的范围是(13,正无穷] 。同样是左开右闭,取不到13而取得到正无穷。
2. 无索引行锁升级表锁
序号 | session1 | session2 |
---|
1 | begin; – 开启事务 | begin; – 开启事务 | 2 | update account set balance = balance + 1000 where name = ‘itwxe’; – 进行更新操作,name列没有索引 | select * from account where id = 2; – 升级表锁可以查询 | 3 | | insert into account values(14, ‘linjian’, 13000); – 升级表锁无法插入,进入等待状态 | 4 | rollback; – 回滚事务,当然提交也可以 | | 5 | | – 如果这个时候还没有超时则执行插入id = 14的记录 | 6 | | rollback; – 回滚事务,当然提交也可以 |
看到这里都是耐心的小伙伴啦,InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则会从行锁升级为表锁。
所以,如果对非索引字段更新,行锁会升级成表锁,升级成表锁后任意行的写操作都会阻塞,直到表锁被释放。
3. 行锁分析
可以通过检查innodb_row_lock 状态变量来分析MySQL行锁的情况。
show status like 'innodb_row_lock%'
- Innodb_row_lock_current_waits:当前正在等待锁定的数量
- Innodb_row_lock_time:从MySQL启动到现在锁定总时间长度
- Innodb_row_lock_time_avg:每次等待的平均时间
- Innodb_row_lock_time_max:MySQL启动到现在最长的一次等待时间
- Innodb_row_lock_waits:MySQL启动后到现在总共等待的次数
当等待次数很多,并且每次等待时长也很长时,就要分析系统中为什么会有这么多的等待,然后根据分析结果着手制定优化计划。
4. 系统库与锁相关的表
MySQL系统库中几个与锁相关的表,有时开发中debug调试碰到一直卡着SQL语句不执行,这时候完全有可能是其他小伙伴也在调试这一行数据,然后一直锁定了那行数据所以你就调试不下去了,那么咋办嘞?
MySQL给我们提供了表来查询,只要kill 掉其他小伙伴的事务自然你就可以调试了,不过轻易不要尝试,因为你kill 掉那个事务后其他小伙伴如果没调试完那么就白调试了,所以被揍了小二概不负责😂
‐‐ 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
‐‐ 查看锁
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
‐‐ 查看锁等待
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
‐‐ 释放锁,trx_mysql_thread_id是INNODB_TRX表中查询的信息的值,例如`kill 394`,kill掉的事务无法提交也无法回滚,会包ERROR错误
kill trx_mysql_thread_id;
‐‐ 查看锁等待详细信息
show engine innodb status\G;
5. 死锁
序号 | session1 | session2 |
---|
1 | set tx_isolation=‘repeatable-read’; – 设置当前回话事务隔离级别为可重复读 | set tx_isolation=‘repeatable-read’; – 设置当前回话事务隔离级别为可重复读 | 2 | begin; – 开启事务 | begin; – 开启事务 | 3 | select * from account where id = 1 for update; – 正常查询 | select * from account where id = 2 for update; – 正常查询 | 4 | select * from account where id = 2 for update; – 进入等待状态 | select * from account where id = 1 for update; – 报错,检测到死锁,重新启动事务 – ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | 5 | – 因为session事务被重启,所以id = 2的行锁被释放,所以没有超时的情况下执行查询 | rollback; | 6 | rollback; | |
大多数情况下MySQL可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况MySQL没法自动检测死锁。
6. 锁优化建议
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁。
- 尽可能减少检索条件范围,避免间隙锁。
- 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行。
|