MySQL之事务隔离级别与锁机制浅析
文章摘要: MySQL通过锁机制、事务机制和日志机制保证数据ACID特性。锁机制分为乐观锁和悲观锁,按粒度有表锁、行锁等,其中行锁支持间隙锁和临键锁解决幻读问题。事务机制通过隔离级别(RU/RC/RR/SERIALIZABLE)处理并发问题,MVCC机制结合undo log实现读写不冲突。InnoDB特有的事务特性由undo log(原子性)、锁/MVCC(隔离性)和redo log(持久性)共同
1、概述
MySQL既能保证sql并发执行,又能保证数据ACID
(原子性、一致性、隔离性、持久性)。其中依靠了3个重要的机制:锁机制
、事务机制
、日志机制
。
说明
1、本文基于MySQL5.7版本、InnoDB存储引擎。
2、本文仅记录了锁机制与事务机制。
2、锁机制
2.1、锁分类
1、按照性能划分为乐观锁
与悲观锁
。
1> 乐观锁适用于读多写少的场景,使用CAS或者版本比对实现。(如果写也多的情况,会导致频繁CAS自旋,浪费性能)
2> 悲观锁适用于读多写也多的场景,强制排队,顺序执行。
2、按照数据操作粒度分为表锁
、行锁
、页锁(InnoDB无页锁)
。
3、按照对数据库的操作类型分为读锁
、写锁
(都属于悲观锁)。还有用于辅助快速获取表锁的意向锁
1> 读锁示例:select * from T where id=1 lock in share mode
2> 写锁示例:select * from T where id=1 for update (insert、update、delete自动加写锁)
tips:读 读能并行,但是读 写不能并行。
3> 意向锁(又称I锁
):主要是MySQL为了提高加表锁的速度而单独提供了一个锁机制,当表中的某一行数据被加上读写锁之前,都会先给表加上一个标识(这个标识就是意向锁)代表已经有表行被加上锁了,当需要加表锁时就不用逐行去扫描是否有行锁了。
意向锁又进一步分为了意向共享锁(IS)
和意向排它锁(IX)
:顾名思义,当要给表加共享锁(读锁)时需要获取到意向共享锁。当要给表加排他锁时需要获取一下排它锁。
2.1.1、表锁
每次操作,都是锁整个表,因为有意向锁帮助,加表锁开销小,加锁也快。但是锁的粒度很大,冲突率是最高的。
一般在在表迁移等场景使用:
‐‐手动增加表锁
lock table 表名称 read(write),表名称2 read(write);
‐‐查看表上加过的锁
show open tables;
‐‐删除表锁
unlock tables;
2.1.2、页锁
仅BDB存储引擎
支持,如果要查询一行数据,就加页锁,并发度没有行锁高。InnoDB的行锁粒度更细。
2.1.3、行锁
行锁是对表中的一行加锁,本质上是对索引上锁
。因此锁粒度小
、并发度最大
,但是开销大
、加锁慢
。
特别说明
1> InnoDB存储引擎与MyISAM存储引擎最大的区别:仅InnoDB支持事务和行锁。
2.1.4、间隙锁(Gap Lock)
间隙锁,锁的就是两个行之间的空隙,间隙锁是在可重复读(RR)隔离级别下才会生效
。目的是为了解决大部分场景下的幻读。
假设表中仅有id为1、2、3、10、20,那么间隙就有 id 为 (3,10),(10,20),(20,正无穷) 这三个区间。
where id = 18 for update; -- 这是就把(10,20)这个区间全部锁住了,注意,左右是开区间
2.1.5、临键锁(Next-key Locks)
行锁与间隙锁的组合。例如(10, 20]: where id > 10 and id <= 20;
2.1.6、锁等待分析
能拿到锁的状态,参数可以有个映象
show status like 'innodb_row_lock%'; 2
-- 对各个状态量的说明如下:
Innodb_row_lock_current_waits: 当前正在等待锁定的数量
Innodb_row_lock_time: 从系统启动到现在锁定总时间长度
Innodb_row_lock_time_avg: 每次等待所花平均时间
Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间
Innodb_row_lock_waits: 系统启动后到现在总共等待的次数
对于这5个状态变量,比较重要的主要是:
Innodb_row_lock_time_avg (等待平均时长) 12 Innodb_row_lock_waits (等待总次数)
Innodb_row_lock_time(等待总时长)
查看事务的相关表
‐‐ 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
‐‐ 查看锁,8.0之后需要换成这张表performance_schema.data_locks
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
‐‐ 查看锁等待,8.0之后需要换成这张表performance_schema.data_lock_waits
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
‐‐ 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id
‐‐ 查看锁等待详细信息
show engine innodb status;
3、事务机制
事务:一组操作,要么同时成功、要么同时失败,最终目的是保证数据的一致性。例如用户A向用户B转账500元,那么最终用户A转出的钱和用户B收到的钱都得是500,即钱总数不变。
3.1、并发事务带来的问题
3.1.1、脏写(又名更新丢失)
事务之间的数据被相互覆盖
示例:
1、表中存储了某用户的当前余额为5元。
2、这时有2个并发收款事务a 和 b,同时查到A用户有5元:
3、事务a(收款3元):update 表 set 余额 = 5 + 3 where id = xx;
4、事务b(收款4元): update 表 set 余额 = 5 + 4 where id = xx;
脏写结果:当事务a和b都被执行完毕后,表中的余额只有2个可能:8 或者 9。 但是实际上应该是 5 + 3 + 4 = 12。
3.1.2、脏读
读取到其它事务未提交的数据。(未提交的数据将来如果被回滚了,就成为了脏数据,这种情况0容忍)
3.1.3、不可重复读
同一个事务内的同一个条查询sql前后2次查询结果不一样。 – 这里强调的是当行记录中的某个字段,前后不一致
3.1.4、幻读
同一个事务内的同一个范围查询sql前后2次查询结果行数不一样。-- 这里前调的是行数不一样
示例:事务A查全表是共3条(id为1、2、3)且事务还未提交,这时事务B往表插入一条id为4的记录然后直接提交事务。 这时事务A来update id 为4的这条记录,然后再次全表查询,就会查到4条记录了。
3.2、 事务的四大特性
3.2.1、 A(Atomicity) - 原子性
事务是最小的执行单位,位于事务中的任何操作如果失败,那么就会回滚到事务开始前的状态。
由InnoDB的undo log保证
3.2.2、 C(Consistency) - 一致性
事务执行的前后,必须保证从一个一致性状态转移到另一个一致性状态。例如转账前后的2个账户总金额应该不变。这也是事务实现的最终目的,由其它三个特性 + 业务代码共同保证
如果业务代码没正确触发回滚机制,那么依旧保证不了一致性
3.2.3、 I(Isolation) - 隔离性
事务之间相互隔离,不会互相有所干扰,MySQL通过隔离级别来实现不同程度的隔离(由MySQL锁、MVCC来保证
)目的是为了解决并发事务带来的问题
3.2.3.1、 RU(Read Uncommitted)读未提交 - 未解决脏写、脏读、不可重复读、幻读
3.2.3.2、 RC(Read Committed)读已提交 - 未解决脏写、不可重复读、幻读
脏写解决说明:
可以使用乐观锁
解决脏写问题,类似 update xx set money = 500 where id = xx and lock_verison = 1; // 其中lock_version就是于乐观锁的版本号。
3.2.3.3、 RR(Repeatable Read)可重复读 - 未解决脏写、未完全解决幻读
这里需要注意一下,RR单独有自己的间隙锁
来解决了大部分幻读问题。
没解决的幻读场景:事务A先select到3条记录,还未来得及加间隙锁
时,事务B这时提交了一条新数据到表中。这时事务A再select ,也是3条记录。(MVCC保证)。 但是
如果发生了行锁操作(例如update了这个新数据),再次查询时就是4条记录! – 总结:其实就是还没来得及加间隙锁,间隙就被别的事务钻进去了。
当然,事务A在第一次select的时候,直接加 for update,先加上间隙锁,能避免掉这个幻读场景。
脏写解决说明:
使用update语句时需要侧重使用update xx set money = money + 500
where id = xxx; 这种形式,而不是直接 update xx set money = ${代码中已经加了500的结果值}
where id = xxx;
3.2.3.4、 S(SERIALIZABLE)串行化 - 脏写也被解决掉了
本质上是对同一行数据加读写锁(读时加读锁、写时加写锁)。
脏写解决说明:
1、当一条数据被事务A读过一次,事务A还未提交时,那么事务A就一直持有读锁;
2、事务B也读到了这一条数据,也获得了读锁。
3、事务B如果要修改这条数据,就必须获得写锁,但是事务A还未提交,就会导致事务B获取不到写锁一直阻塞。
4、这时如果事务A也要修改这条数据,也必须获取到写锁,但是发现事务B获得了写锁一直不释放,最终就导致了死锁。
5、以上步骤就解决了脏写的问题,但是代价是死锁。
幻读解决说明:
1、相比RR的间隙锁,可串行化在select时还把满足条件的不存在的行也加上读锁
了(也就是用户不写for update,可串行化也给强制加上类似间隙锁了,刚刚好补了RR的“没解决的幻读场景”)
底层实现原理:
加读锁时,会在select sql后面加lock in share mode
, 同时 select sql后面加for update
就是写锁(update insert delete 自动上写锁)
3.2.3.5、 RC与RR异同点说明
相同点:
1、事务A中当使用了update、insert、delete、select xxx for update都会加上行锁,且事务A内再select这些加了行锁的数据时,事务A内都是直接拿到当前最新的,而不是旧快照。 – 这些都是为了保证 读已提交, 即当前事务修改了数据,别的事务想要并发修改就不被允许了。
不同点:
1、RR相较于RC还需要解决大部分幻读问题,因此多了自己的间隙锁机制
, 如果加行锁时(RR级别加行锁时,会自动去加间隙锁),没用上索引,RR隔离级别会为了锁住间隙,直接兜底加表锁。 但是RC不会。
3.2.4、 D(Durability) - 持久性
事务一旦提交,对数据库的数据改动就是永久性的(就是已经落盘成功), 就是事务提交后立马发生了宕机,数据也不会丢失。由InnoDB的redo log保证
3.3、MVCC机制
MVCC(多版本并发控制)主要作用与RC、RR隔离级别。用于保证读写不阻塞(读快照、写最新),避免了脏读问题,主要通过undo log日志链实现。
每多执行一条针对同一条数据的sql写操作,都会不断的增加如下这个MVCC回滚控制链:
当针对这一条数据进行sql查询的时候,就会按照当前undo log版本链生成一个readview(一致性视图)
,然后仅需要根据该一致性视图来从后往前遍历这个版本链
最终筛选出该查询所在事务能够看到的行(也就是版本快照)。
一致性视图
1、trx_id
: 事务id,是在事务开始后的第一条会加行锁
的sql执行时才会生成(也就是这个时候,事务才开始生成)。例如:update、select xx for update
2、min_id
: 当前所有未提交的事务中,最小的事务id。
3、max_id
: 已经创建事务的最大事务id。
4、readview(一致性视图)
:下图中小于min_id的绿色部分代表已提交的事务
、大于min_id 且 小于max_id 中包含了未提交和已提交的事务、大于max_id的事务id肯定时为开始的事务。
select查询可见性分析步骤示例
会循环从上面图中的MVCC的undo log回滚版本链从后往前依次遍历行,遍历到的当前行的trx_id按照一致性视图来分析是否可见:
1、遍历前先生成一致性视图:readview: [80, 200], 200 (RR仅会在第一个select生成、RC则是每次select时都会生成
)
特别说明=> [80, 200] 中放的是当前所有未提交的事务ID, 200 是当前最大事务ID(max_id)。
2、当trx_id<min_id
:说明是已经提交的事务,可见
,直接返回当前行数据
即可。
3、当min_id <= trx_id <= max_id
: 这里就有2种情况
- trx_id在[80, 200]中,因为[80, 200]里面装的都是未提交的事务ID,因此不可见
,继续往上遍历行
。
- trx_id不在[80, 200]中,说明是已提交的事务,可见
,直接返回当前行数据
即可。
4、当trx_id>max_id: 那说明是还未开始的事务,不可见
,继续往上遍历行
。
按照以上步骤就能根据一致性视图来遍历MVCC的undo log回滚版本链
判断出可见的一个"快照"版本直接返回。
5、拓展问题
5.1、查询操作需要使用事务吗?
本质上是需要考虑RC和RR的情况的。
1、如果一个事务中仅一条查询,本质上加与不加没有区别,但是存在2条以上sql就需要考虑了。
2、RC隔离级别下,有不可重复读的情况,如果数据在业务上面需要每次拉到最新的,就可以用RC。
3、在RR隔离别下,首先一个查询操作是会加读锁的。另外的话是支持可重复读的,在报表场景下,针对某个时间点的统计,用RR会好点。
4、如果是RU,这个就有脏读的情况了。
5.2、大事务的影响
大事务是需要优化的。
1、会长时间占用MySQL连接,会导致数据库连接池不够用。
2、锁定的表行太久,容易锁超时,然后回滚性能浪费。(回滚本身的耗时也长)
3、主从延迟。
4、undo log长时间落库不了,大小会膨胀。
5、容易导致死锁。
5.3、事务优化原则
1、类型读这种前置准备操作尽量放在事务开始之前。(RC)
2、事务中避免使用远程调用,如果非要使用,则需要设置远程调用超时时间,避免超时回滚。
3、一个事务中避免一次性处理太多数据。(事务拆分)
4、更新等涉及到表加锁的情况,尽量放在事务的最后,以防造成锁超时、死锁。
5、能异步处理的尽量异步处理。
6、业务代码一定要正常,例如本来错误异常一个被抛出去,然后事务回滚,但是内部catch导致回滚未触发,导致丢失一致性问题。当然业务代码就可以保证不用事务的情况下一致性,更好。
5.4、死锁问题如何排查
todo
更多推荐
所有评论(0)