什么是事物
事务是一个不可分割的数据库操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。事务是逻辑上的一组操作,要么都执行,要么都不执行。
事物的四大特性(ACID)
原子性(Atomicity)
也就是我们刚才说的不可再分,也就意味着我们对数据库的一系列的操作,要么都是成功,要么都是失败,不可能出现部分成功或者部分失败的情况,以刚才提到的转账的场景为例,一个账户的余额减少,对应一个账户的增加,这两个一定是同时成功或者同时失败的。全部成功比较简单,问题是如果前面一个操作已经成功了,后面的操作失败了,怎么让它全部失败呢?这个时候我们必须要回滚。
原子性,在 InnoDB 里面是通过 undo log 来实现的,它记录了数据修改之前的值(逻辑日志),一旦发生异常,就可以用 undo log 来实现回滚操作。
一致性(consistent)
指的是数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。比如主键必须是唯一的,字段长度符合要求。
除了数据库自身的完整性约束,还有一个是用户自定义的完整性。
举例:
-
比如说转账的这个场景,A 账户余额减少 1000,B 账户余额只增加了 500,这个时候因为两个操作都成功了,按照我们对原子性的定义,它是满足原子性的, 但是它没有满足一致性,因为它导致了会计科目的不平衡。
-
还有一种情况,A 账户余额为 0,如果这个时候转账成功了,A 账户的余额会变成-1000,虽然它满足了原子性的,但是我们知道,借记卡的余额是不能够小于 0 的,所以也违反了一致性。用户自定义的完整性通常要在代码中控制。
隔离性(isolation)
有了事务的定义以后,在数据库里面会有很多的事务同时去操作我们的同一张表或者同一行数据,必然会产生一些并发或者干扰的操作,对隔离性就是这些很多个的事务,对表或者 行的并发操作,应该是透明的,互相不干扰的。通过这种方式,我们最终也是保证业务数据的一致性。
隔离性是通过MVCC以及锁来进行实现的,后续会对MVCC做详细的讲解
持久性(Durable)
我们对数据库的任意的操作,增删改,只要事务提交成功,那么结果就是永久性的,不可能因为我们重启了数据库的服务器,它又恢复到原来的状态了。
持久性怎么实现呢?数据库崩溃恢复(crash-safe)是通过什么实现的?持久性是通过 redo log 来实现的,我们操作数据的时候,会先写到内存的 buffer pool 里面,同时记录 redo log,如果在刷盘之前出现异常,在重启后就可以读取 redo log的内容,写入到磁盘,保证数据的持久性。
总结:原子性,隔离性,持久性,最后都是为了实现一致性
扩展
顺序读写和随机读写
- 机械硬盘
在顺序读写场景下有相当出色的性能表现,但一遇到随机读写性能则直线下降。
顺序读是随机读性能的400倍以上。顺序读能达到84MB/S
顺序写是随机读性能的100倍以上。顺序写性能能达到79M/S - 固态硬盘
顺序读:220.7MB/s。随机读:24.654MB/s。
顺序写:77.2MB/s。随机写:68.910MB/s。
总结:对于固态硬盘,顺序读的速度仍然能达到随机读的10倍左右。但是随机写还是顺序写,差别不大。
MySQL的日志
中继日志
假设做主从复制,master 每执行一条SQL记录在binlog当中,从节点如果同步master的数据,直接从binlog读取的话,master写到binlog是顺序读写的,但是你slave直接执行binlog可是随机读写的,顺序读写比随机读写效率快很多,会存在效率变慢,效率慢就有可能存在SQL堆积执行不完的情况。所以需要relaylog记录binlog中新增的SQL,这个过程很快是顺序执行的。
在这个过程中存在组提交的概念,就是为了优化效率,没必要一条条写到binlog,可以进行分组提交,通过对事务进行分组,优化减少了生成二进制日志所需的操作数,而slave读取relaylog也同样,执行并不是单线程执行,可以进行MTS(enhanced multi-threaded slave)操作
前滚日志
假设做内存同步到磁盘操作,内存通过flush刷写到磁盘效率是随机读写,效率很慢的,假设这个过程中服务挂了,不就数据丢失了么,这时做了个一个操作,就是在写之前记录到log buffer日志里,然后再把log buffer顺序写到 磁盘里的redolog日志里,这个过程是会很快的,让磁盘读取redolog日志尽量保证数据不丢失
undolog和binlog哪个先执行呢
此时发现两种方法都不行啊,该怎么办呢,引入两阶段提交概念
MVCC
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。
当前读
像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
快照读(提高数据库的并发查询能力)
像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
当前读、快照读、MVCC关系
MVCC多版本并发控制指的是维持一个数据的多个版本,使得读写操作没有冲突,快照读是MySQL为实现MVCC的一个非阻塞读功能。MVCC模块在MySQL中的具体实现是由三个隐式字段,undo日志、read view三个组件来实现的。
解决的问题
数据库并发场景有三种,分别为:
- 读读:不存在任何问题,也不需要并发控制
- 读写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读
- 写写:有线程安全问题,可能存在更新丢失问题
MVCC是一种用来解决读写冲突的无锁并发控制,也就是为事务分配单项增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照,所以MVCC可以为数据库解决一下问题:
-
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
-
解决脏读、幻读、不可重复读等事务隔离问题,但是不能解决更新丢失问题
实现原理
MVCC的实现原理主要依赖于记录中的三个隐藏字段,undolog,read view来实现的。
-
隐藏字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段- DB_TRX_ID
6字节,最近修改事务id,记录创建这条记录或者最后一次修改该记录的事务id - DB_ROLL_PTR
7字节,回滚指针,指向这条记录的上一个版本,用于配合undolog,指向上一个旧版本 - DB_ROW_JD
6字节,隐藏的主键,如果数据表没有主键,那么innodb会自动生成一个6字节的row_id
记录如图所示:
在上图中,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本 - DB_TRX_ID
-
undo log
undolog被称之为回滚日志,表示在进行insert,delete,update操作的时候产生的方便回滚的日志当进行insert操作的时候,产生的undolog只在事务回滚的时候需要,并且在事务提交之后可以被立刻丢弃
当进行update和delete操作的时候,产生的undolog不仅仅在事务回滚的时候需要,在快照读的时候也需要,所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除(当数据发生更新和删除操作的时候都只是设置一下老记录的deleted_bit,并不是真正的将过时的记录删除,因为为了节省磁盘空间,innodb有专门的purge线程来清除deleted_bit为true的记录,如果某个记录的deleted_id为true,并且DB_TRX_ID相对于purge线程的read view 可见,那么这条记录一定时可以被清除的)
下面我们来看一下undolog生成的记录链
1、假设有一个事务编号为1的事务向表中插入一条记录,那么此时行数据的状态为:
2、假设有第二个事务编号为2对该记录的name做出修改,改为lisi
在事务2修改该行记录数据时,数据库会对该行加排他锁
然后把该行数据拷贝到undolog中,作为 旧记录,即在undolog中有当前行的拷贝副本
拷贝完毕后,修改该行name为lisi,并且修改隐藏字段的事务id为当前事务2的id,回滚指针指向拷贝到undolog的副本记录中
事务提交后,释放锁
3、假设有第三个事务编号为3对该记录的age做了修改,改为32
在事务3修改该行数据的时,数据库会对该行加排他锁然后把该行数据拷贝到undolog中,作为旧纪录,发现该行记录已经有undolog了,那么最新的旧数据作为链表的表头,插在该行记录的undolog最前面
修改该行age为32岁,并且修改隐藏字段的事务id为当前事务3的id,回滚指针指向刚刚拷贝的undolog的副本记录
事务提交,释放锁
从上述的一系列图中,大家可以发现,不同事务或者相同事务的对同一记录的修改,会导致该记录的undolog生成一条记录版本线性表,即链表,undolog的链首就是最新的旧记录,链尾就是最早的旧记录。
undolog中的数据不会无限增加,后台会有专门的线程用来进行undolog的清除工作,叫做purge
当进行insert操作的时候,事务提交之后对应的undolog就会被清除掉
当进行update或者delete操作的时候,事务提交不一定会清除,因为update,delete操作进行过程中可能会有其他的并发事务存在,有可能需要进行历史数据版本的读取,所以之后在事务提交或者不需要读取历史记录的时候才会删除
-
Read View
上面的流程如果看明白了,那么大家需要再深入理解下read view的概念了。Read View是事务进行快照读操作的时候生产的读视图,在该事务执行快照读的那一刻,会生成一个数据系统当前的快照,记录并维护系统当前活跃事务的id,事务的id值是递增的。
其实Read View的最大作用是用来做可见性判断的,也就是说当某个事务在执行快照读的时候,对该记录创建一个Read View的视图,把它当作条件去判断当前事务能够看到哪个版本的数据,有可能读取到的是最新的数据,也有可能读取的是当前行记录的undolog中某个版本的数据
Read View遵循的可见性算法主要是将要被修改的数据的最新记录中的DB_TRX_ID(当前事务id)取出来,与系统当前其他活跃事务的id去对比,如果DB_TRX_ID跟Read View的属性做了比较,不符合可见性,那么就通过DB_ROLL_PTR回滚指针去取出undolog中的DB_TRX_ID做比较,即遍历链表中的DB_TRX_ID,直到找到满足条件的DB_TRX_ID,这个DB_TRX_ID所在的旧记录就是当前事务能看到的最新老版本数据。
Read View的可见性规则如下所示:
首先要知道Read View中的三个全局属性:
- trx_list: 一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID(1,2,3)
- up_limit_id: 记录trx_list列表中事务ID最小的ID(1)
- low_limit_id: Read View生成时刻系统尚未分配的下一个事务ID,(4)
具体的比较规则如下:
- 首先比较DB_TRX_ID < up_limit_id,如果小于,则当前事务能看到DB_TRX_ID所在的记录,如果大于等于进入下一个判断
- 接下来判断DB_TRX_ID >= low_limit_id,如果大于等于则代表DB_TRX_ID所在的记录在Read View生成后才出现的,那么对于当前事务肯定不可见,如果小于,则进入下一步判断
- 判断DB_TRX_ID是否在活跃事务中,如果在,则代表在Read View生成时刻,这个事务还是活跃状态,还没有commit,修改的数据,当前事务也是看不到,如果不在,则说明这个事务在Read View生成之前就已经开始commit,那么修改的结果是能够看见的。
整体处理流程
假设有四个事务同时在执行,如下图所示:
事务1 | 事务2 | 事务3 | 事务4 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
从上述表格中,我们可以看到,当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View视图,可以看到事务1和事务3还在活跃状态,事务4在事务2快照读的前一刻提交了更新,所以,在Read View中记录了系统当前活跃事务1,3,维护在一个列表中。同时可以看到up_limit_id的值为1,而low_limit_id为5,如下图所示:
在上述的例子中,只有事务4修改过该行记录,并在事务2进行快照读前,就提交了事务,所以该行当前数据的undolog如下所示:
当事务2在快照读该行记录的是,会拿着该行记录的DB_TRX_ID去跟up_limit_id,lower_limit_id和活跃事务列表进行比较,判读事务2能看到该行记录的版本是哪个。
具体流程如下:先拿该行记录的事务ID(4)去跟Read View中的up_limit_id相比较,判断是否小于,通过对比发现不小于,所以不符合条件,继续判断4是否大于等于low_limit_id,通过比较发现也不大于,所以不符合条件,判断事务4是否处理trx_list列表中,发现不再次列表中,那么符合可见性条件,所以事务4修改后提交的最新结果对事务2 的快照是可见的,因此,事务2读取到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度的最新版本。如下图所示:
当上述的内容都看明白了的话,那么大家就应该能够搞清楚这几个核心概念之间的关系了,下面我们讲一个不同的隔离级别下的快照读的不同。
所以接下来再分析两个案例
RC、RR级别下的InnoDB快照读有什么不同
因为Read View生成时机的不同,从而造成RC、RR级别下快照读的结果的不同
-
在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照即Read View,将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见
-
在RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动和事务的快照,这些事务的修改对于当前事务都是不可见的,而早于Read View创建的事务所做的修改均是可见
-
在RC级别下,事务中,每次快照读都会新生成一个快照和Read View,这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因。
总结:在RC隔离级别下,是每个快照读都会生成并获取最新的Read View,而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View.
事务并发会带来什么问题?
当很多事务并发的去操作数据库的表或者行的时候,会出现脏读,不可重复读,幻读等问题。
-
脏读
当一个事务正在访问数据并且对数据进行了修改,但这种修改还没有提交到数据库中时,另一个事务也访问了这个数据并使用了它。这种情况下,第二个事务读取的数据是第一个事务尚未提交的修改,因此可能是不准确或不一致的。脏读通常发生在事务隔离级别较低的情况下,例如读未提交(Read Uncommitted)级别。 -
不可重复读
在一个事务内,多次读取同一数据。在这个事务还没有结束时,另一个事务也访问了该同一数据并对其进行了修改。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,第一个事务两次读到的数据可能是不一样的。这种情况通常发生在读已提交(Read Committed)或可重复读(Repeatable Read)的事务隔离级别下。 -
幻读
当事务不是独立执行时发生的一种现象,通常发生在可重复读(Repeatable Read)的事务隔离级别下。例如,第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。在第二个事务中,由于第一个事务的修改,导致第二个事务在读取数据时出现了额外的行或缺失的行,就像产生了幻觉一样。产生幻读的根本原因:
如果在同一个事务中如果同时出现了快照读和当前读,那么就会产生幻读问题,如果只有快照读,那么是不会出现幻读问题的。CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB ; INSERT into user VALUES (1,'1',20),(5,'5',20),(15,'15',30),(20,'20',30);
假设有如下业务场景:
时间 事务1 事务2 begin; T1 select * from user where age = 20;2个结果 T2 insert into user values(25,‘25’,20);commit; T3 select * from user where age =20;2个结果 T4 update user set name=‘00’ where age =20;此时看到影响的行数为3 T5 select * from user where age =20;三个结果 执行流程如下:
- T1时刻读取年龄为20 的数据,事务1拿到了2条记录
- T2时刻另一个事务插入一条新的记录,年龄也是20
- T3时刻,事务1再次读取年龄为20的数据,发现还是2条记录,事务2插入的数据并没有影响到事务1的事务读取
- T4时刻,事务1修改年龄为20的数据,发现结果变成了三条,修改了三条数据
- T5时刻,事务1再次读取年龄为20的数据,发现结果有三条,第三条数据就是事务2插入的数据,此时就产生了幻读情况
此时大家需要思考一个问题,在当下场景里,为什么没有解决幻读问题?
其实通过前面的分析,大家应该知道了快照读和当前读,一般情况下select * from …where …是快照读,不会加锁,而 for update,lock in share mode,update,delete都属于当前读,如果事务中都是用快照读,那么不会产生幻读的问题,但是快照读和当前读一起使用的时候就会产生幻读。
如果都是当前读的话,如何解决幻读问题呢?
truncate table user; INSERT into user VALUES (1,'1',20),(5,'5',20),(15,'15',30),(20,'20',30);
时间 事务1 事务2 begin; T1 select * from user where age =20 for update; T2 insert into user values(25,‘25’,20);此时会阻塞等待锁 T3 select * from user where age =20 for update; 此时,可以看到事务2被阻塞了,需要等待事务1提交事务之后才能完成,其实本质上来说采用的是间隙锁的机制解决幻读问题。
什么是隔离级别?有哪些隔离级别?
隔离级别是对事务并发控制的等级,描述了一个事务必须与由其他事务进行的资源或数据更改相隔离的程度。数据库的事务隔离级别有四种,分别是读未提交、读已提交、可重复读、序列化,不同的隔离级别下会产生脏读、幻读、不可重复读等相关问题,因此在选择隔离级别的时候要根据应用场景来决定,使用合适的隔离级别。
各种隔离级别和数据库异常情况对应情况如下:
隔离级别 | 脏读 | 不可重复 读 | 幻读 |
---|---|---|---|
READ- UNCOMMITTED | √ | √ | √ |
READ-COMMITTED | × | √ | √ |
REPEATABLE- READ | × | × | √ |
SERIALIZABLE | × | × | × |
SQL 标准定义了四个隔离级别:
- READ-UNCOMMITTED(读取未提交): 事务的修改,即使没有提交,对其他事务也都是可见的。事务能够读取未提交的数据,这种情况称为脏读。
- READ-COMMITTED(读取已提交): 事务读取已提交的数据,大多数数据库的默认隔离级别。当一个事务在执行过程中,数据被另外一个事务修改,造成本次事务前后读取的信息不一样,这种情况称为不可重复读。
- REPEATABLE-READ(可重复读): 这个级别是MySQL的默认隔离级别,它解决了脏读的问题,同时也保证了同一个事务多次读取同样的记录是一致的,但这个级别还是会出现幻读的情况。幻读是指当一个事务A读取某一个范围的数据时,另一个事务B在这个范围插入行,A事务再次读取这个范围的数据时,会产生幻读
- SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
事务隔离机制的实现基于锁机制和并发调度。其中并发调度使用的是MVVC(多版本并发控制),通过保存修改的旧版本信息来支持并发一致性读和回滚等特性。
因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是READ-COMMITTED(读取提交内容):,但是你要知道的是InnoDB 存储引擎默认使用 **REPEATABLE-READ(可重读)**并不会有任何性能损失。
如何解决数据的读一致性问题
-
LBCC
第一种,既然要保证前后两次读取数据一致,那么读取数据的时候,锁定我要操作的数据,不允许其他的事务修改就行了。这种方案叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。如果仅仅是基于锁来实现事务隔离,一个事务读取的时候不允许其他时候修改,那就意味着不支持并发的读写操作,而我们的大多数应用都是读多写少的,这样会极大地影响操作数据的效率。
-
MVCC
Innodb中包含哪些锁?
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
锁的基本模式——共享锁
第一个行级别的锁就是我们在官网看到的 Shared Locks (共享锁),我们获取了一行数据的读锁以后,可以用来读取数据,所以它也叫做读锁。而且多个事务可以共享一把读锁。那怎么给一行数据加上读锁呢?
我们可以用 select lock in share mode;的方式手工加上一把读锁。
释放锁有两种方式,只要事务结束,锁就会自动事务,包括提交事务和结束事务。
锁的基本模式——排它锁
第二个行级别的锁叫做 Exclusive Locks(排它锁),它是用来操作数据的,所以又叫做写锁。只要一个事务获取了一行数据的排它锁,其他的事务就不能再获取这一行数据的共享锁和排它锁。
排它锁的加锁方式有两种,第一种是自动加排他锁,可能是同学们没有注意到的:
我们在操作数据的时候,包括增删改,都会默认加上一个排它锁。
还有一种是手工加锁,我们用一个 FOR UPDATE 给一行数据加上一个排它锁,这个无论是在我们的代码里面还是操作数据的工具里面,都比较常用。
释放锁的方式跟前面是一样的。
锁的基本模式——意向锁
意向锁是由数据库自己维护的。
也就是说,当我们给一行数据加上共享锁之前,会自动在这张表上面加一个意向共享锁。
当我们给一行数据加上排他锁之前,会自动在这张表上面加一个意向排他锁。
反过来说:
如果一张表上面至少有一个意向共享锁,说明有其他的事务给其中的某些数据行加上了共享锁。
记录锁
记录锁表示的是给记录行所在的索引上添加的锁。例如select c1 from t where c1 = 10 for update;防止任何其他事务插入、更新或者删除c1=10的行
记录锁总是锁定索引记录,即使表没有定义索引,innodb也会创建一个隐藏的聚簇索引,并使用该索引进行记录锁定
间隙锁
间隙锁的锁定范围是索引记录之间的间隙,或者在第一个索引记录之前或最后一个索引记录之后的间隙,间隙锁是针对事务隔离级别为可重复读或以上级别。
临键锁
临键锁是记录锁和间隙锁的组合,也就是索引记录本身加上之前的间隙,间隙锁保证在RR级别不会出现幻读问题,防止在同一个事务内得到的结果不一致。假设索引包含10,11,13,20这几个值,那么临键锁的范围就是(-∞,10],(10,11],(11,13],(13,20],(20,+∞)。对于最后一个间隔,临键锁锁定索引中最大值以上的间隙,以及值高于索引中任何实际值的supremum(在查看加锁信息的时候可以看到这个标识)。
上面简单介绍了下各种锁的基础概念,实际情况下锁的情况会更加复杂,需要根据不同表的索引情况来判断,可以参考文档《mysql的加锁情况》。
什么是死锁?如何解决死锁的问题?
死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对应的资源,从而导致恶性循环的现象,如图所示:
解决死锁的思路一般就是切断环路,尽量避免并发形成环路:
- 如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁的概率
- 在同一个事务中,尽可能做到一次锁定所有的资源,减少死锁产生的概率
- 对于非常容易产生死锁的业务部分,可以尝试使用升级锁粒度,通过表锁来减少死锁的概率
- 死锁和索引密不可分,合理优化索引
分享两个死锁分析很好的文章,大家可以做参考:
https://mp.weixin.qq.com/s?__biz=MzkyMzU5Mzk1NQ==&mid=2247505885&idx=1&sn=7601fcf37cdc4e801d1a098ff2effe6a&source=41#wechat_redirect
https://mp.weixin.qq.com/s?__biz=MzkyMzU5Mzk1NQ==&mid=2247505849&idx=1&sn=1ef7f984aa3729ae56ee107b2922c9e1&source=41#wechat_redirect
mysql的加锁情况
# 查看锁信息需要开启两个变量
show variables like 'innodb_status_output%';
set global innodb_status_output = on;
set global innodb_status_output_locks = on;
1、REPEATABLE-READ隔离级别+表无显式主键和索引
创建表t,没有索引和主键,并插入测试数据
create table t(id int default null,name char(20) default null);
insert into t values(10,'10'),(20,'20'),(30,'30');
手动开启事务,执行语句并采用for update方式(当前读)
begin;
select * from t for update;
show engine innodb status\G
从返回的信息中,可以看到对表添加了IX锁和4个记录锁,表中的三行记录上分别添加了Next-key Lock锁,防止有数据变化发生幻读,例如进行了更新、删除操作。同时会出现“ 0: len 8; hex 73757072656d756d; asc supremum;;”这样的描述信息,此操作也是为了防止幻读,会将最大索引值之后的间隙锁住并用supremum表示高于表中任何一个索引的值。
同表下,如果加上where条件之后,是否会产生Next-key Lock呢?执行如下语句:
begin;
select * from t where id = 10 for update;
show engine innodb status\G
从上述反馈信息中,可以发现跟不加where条件的加锁情况是一样的,会同时出现多个行的临键锁和supremum,这到底是为什么呢?
出现supremum的原因是:虽然where的条件是10,但是每次插入记录时所需要生成的聚簇索引Row_id还是自增的,每次都会在表的最后插入,所以就有可能插入id=10这条记录,因此要添加一个supremum防止数据插入。
出现其他行的临键锁的原因是:为了防止幻读,如果不添加Next-Key Lock锁,这时若有其他会话执行DELETE或者UPDATE语句,则都会造成幻读。
2、REPEATABLE-READ隔离级别+表有显式主键无索引
创建如下表并添加数据:
create table t2(id int primary key not null,name char(20) default null);
insert into t2 values(10,'10'),(20,'20'),(30,'30');
在此情况下要分为三种情况来进行分析,不同情况的加锁方式也不同:
1、不带where条件
begin;
select * from t2 for update;
show engine innodb status\G
通过上述信息可以看到,与之前的加锁方式是相同的。
2、where条件是主键字段
begin;
select * from t2 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,只会对表中添加IX锁和对主键添加了记录锁(X locks rec but not gap),并且只锁住了where条件id=10这条记录,因为主键已经保证了唯一性,所以在插入时就不会是id=10这条记录。
3、where条件包含主键字段和非关键字段
begin;
select * from t2 where id = 10 and name = '10' for update;
show engine innodb status\G
通过看到,加锁方式与where条件是主键字段的加锁方式相同,因为根据主键字段可以直接定位一条记录。
3、REPEATABLE-READ隔离级别+表无显式主键有索引
1、不带where条件,跟之前的情况类似
2、where条件是普通索引字段或者(普通索引字段+非索引字段)
创建如下表:
create table t3(id int default null,name char(20) default null);
create index idx_id on t3(id);
insert into t3 values(10,'10'),(20,'20'),(30,'30');
执行如下语句:
begin;
select * from t3 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,对表添加了IX锁,对id=10的索引添加了Next-Key Lock锁,区间是负无穷到10,对索引对应的聚集索引添加了X记录锁,为了防止幻读,对索引记录区间(10,20)添加间隙锁。
此时大家可以开启一个新的事务,插入负无穷到id=19的全部记录都会被阻塞,而大于等于20 的值不会被阻塞
3、where条件是唯一索引字段或者(唯一索引字段+非索引字段)
创建如下表:
create table t4(id int default null,name char(20) default null);
create unique index idx_id on t4(id);
insert into t4 values(10,'10'),(20,'20'),(30,'30');
执行如下语句:
begin;
select * from t4 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,添加了id索引的记录锁,以及对应的聚簇索引的记录锁
4、REPEATABLE-READ隔离级别+表有显式主键和索引
此情况可以分为以下几种:
1、表有显式主键和普通索引
创建如下表:
create table t5(id int not null,name char(20) default null,primary key(id),key idx_name(name));
insert into t5 values(10,'10'),(20,'20'),(30,'30');
(1)不带where条件
begin;
select * from t5 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对supremum添加临键锁,对name索引列添加临键锁,对主键索引添加X记录锁
(2)where条件是普通索引字段
begin;
select * from t5 where name='10' for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name添加临键锁,对主键索引列添加X记录锁,为了防止幻读,对name的(10,20)添加间隙锁
begin;
select * from t5 where name <= '10' for update;
show engine innodb status\G
跟上面对比,发现改成范围查询后,变成了(10,20]范围变成了临键锁,=号是间隙锁
(3)where条件是主键字段
begin;
select * from t5 where id=10 for update;
show engine innodb status\G
通过上述信息可以看到,对表添加了意向锁,对主键添加了记录锁。
(4)where条件同时包含普通索引字段和主键索引字段
begin;
select * from t5 where id=10 and name='10' for update;
show engine innodb status\G
此处大家需要注意,如果在执行过程中使用的是主键索引,那么跟使用主键字段是一致的,如果使用的是普通索引,那么跟普通字段是类似的,其实本质点就在于加锁的字段不同而已。
2、表有显式主键和唯一索引
创建如下表:
create table t6(id int not null,name char(20) default null,primary key(id),unique key idx_name(name));
insert into t6 values(10,'10'),(20,'20'),(30,'30');
(1)不带where条件
begin;
select * from t6 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对supremum添加临键锁,对name索引列添加临键锁,对主键索引添加X记录锁
(2)where条件是唯一索引字段
begin;
select * from t6 where name='10' for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name和主键添加行锁
(3)where条件是主键字段
begin;
select * from t6 where id=10 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后主键添加行锁
(4)where条件是唯一索引字段和主键字段
begin;
select * from t6 where id=10 and name='10' for update;
show engine innodb status\G
此处大家需要注意,如果在执行过程中使用的是主键索引,那么跟使用主键字段是一致的,如果使用的是唯一索引,那么跟唯一索引字段是一样的,其实本质点就在于加锁的字段不同而已。
5、READ-COMMITTED隔离级别+表无显式主键和索引
创建表t,没有索引和主键,并插入测试数据
create table t7(id int default null,name char(20) default null);
insert into t7 values(10,'10'),(20,'20'),(30,'30');
手动开启事务,执行语句并采用for update方式(当前读)
begin;
select * from t7 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对表的三行记录添加记录锁(聚簇索引)
同表下,如果加上where条件之后,是否会产生Next-key Lock呢?执行如下语句:
begin;
select * from t7 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后会对聚集索引添加记录锁,因为RC隔离级别无法解决幻读问题,所以不会添加临键锁。
6、READ-COMMITTED隔离级别+表有显式主键无索引
创建如下表并添加数据:
create table t8(id int primary key not null,name char(20) default null);
insert into t8 values(10,'10'),(20,'20'),(30,'30');
在此情况下要分为三种情况来进行分析,不同情况的加锁方式也不同:
1、不带where条件
begin;
select * from t8 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对表的三行记录添加记录锁(主键)
2、where条件是主键字段
begin;
select * from t8 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对表id=10的积累添加记录锁
3、where条件包含主键字段和非关键字段
begin;
select * from t8 where id = 10 and name = '10' for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对表id=10的积累添加记录锁
7、READ-COMMITTED隔离级别+表无显式主键有索引
创建如下表:
create table t9(id int default null,name char(20) default null);
create index idx_id on t9(id);
insert into t9 values(10,'10'),(20,'20'),(30,'30');
1、不带where条件,跟之前的情况类似
begin;
select * from t9 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对表的三行记录添加记录锁(聚簇索引)
2、where条件是普通索引字段或者(普通索引字段+非索引字段)
执行如下语句:
begin;
select * from t9 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,对表添加了IX锁,对id=10的索引添加了行锁,对索引对应的聚集索引添加了行锁,
3、where条件是唯一索引字段或者(唯一索引字段+非索引字段)
创建如下表:
create table t10(id int default null,name char(20) default null);
create unique index idx_id on t10(id);
insert into t10 values(10,'10'),(20,'20'),(30,'30');
执行如下语句:
begin;
select * from t10 where id = 10 for update;
show engine innodb status\G
通过上述信息可以看到,对表添加了IX锁,对id=10的索引添加了行锁,对索引对应的聚集索引添加了行锁。
8、READ-COMMITTED隔离级别+表有显式主键和索引
此情况可以分为以下几种:
1、表有显式主键和普通索引
创建如下表:
create table t11(id int not null,name char(20) default null,primary key(id),key idx_name(name));
insert into t11 values(10,'10'),(20,'20'),(30,'30');
(1)不带where条件
begin;
select * from t11 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name索引列添加记录锁,对主键索引添加X记录锁
(2)where条件是普通索引字段
begin;
select * from t11 where name='10' for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name添加X记录锁,对主键索引列添加X记录锁
(3)where条件是主键字段
begin;
select * from t11 where id=10 for update;
show engine innodb status\G
通过上述信息可以看到,对表添加了意向锁,对主键添加了记录锁。
(4)where条件同时包含普通索引字段和主键索引字段
begin;
select * from t11 where id=10 and name='10' for update;
show engine innodb status\G
此处大家需要注意,如果在执行过程中使用的是主键索引,那么跟使用主键字段是一致的,如果使用的是普通索引,那么跟普通字段是类似的,其实本质点就在于加锁的字段不同而已。
2、表有显式主键和唯一索引
创建如下表:
create table t12(id int not null,name char(20) default null,primary key(id),unique key idx_name(name));
insert into t12 values(10,'10'),(20,'20'),(30,'30');
(1)不带where条件
begin;
select * from t12 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name索引列添加X记录锁,对主键索引添加X记录锁
(2)where条件是唯一索引字段
begin;
select * from t12 where name='10' for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后对name和主键添加行锁
(3)where条件是主键字段
begin;
select * from t12 where id=10 for update;
show engine innodb status\G
通过上述信息可以看到,首先对表添加IX锁,然后主键添加行锁
(4)where条件是唯一索引字段和主键字段
begin;
select * from t12 where id=10 and name='10' for update;
show engine innodb status\G
此处大家需要注意,如果在执行过程中使用的是主键索引,那么跟使用主键字段是一致的,如果使用的是唯一索引,那么跟唯一索引字段是一样的,其实本质点就在于加锁的字段不同而已。