MySQL事务与锁
事务和锁是数据库处理并发操作的重要手段,而事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。
事务
事务的四大特征:
- 原子性(A): 所有操作要么全部完成要么全部失败,并不存在完成一般的情况。
- 一致性(C): 一致性是指数据库的完整性约束没有被破坏,事务执行前后都是合法的状态。同时也可以理解为实际场景上的约束,例如:一个账户减少的金额和另一个账户上增加的金额是一致的。
- 隔离性(I): 事务之间彼此之间相互隔离、互不干扰。
- 持久性(D): 事务只要提交成功,那么对数据库的修改就会永久生效。
并发事务
-
脏读: 一个事务在处理过程中,读取到了其他事务修改后但尚未提交的数据。
-
丢失修改: 事务A读取到某个数据x,并对x进行了修改。而另一个事务B也读取了数据x,也对x进行了修改。那么事务A对x的修改发生了丢失。
-
不可重复读: 指在一个事务内多次读取某个数据,发现读取的数据出现了不一致的现象。
-
幻读: 在一个事务内读取数据,读取到原本不存在的数据,即被其他事务插入的数据。
-
写偏斜: 可以理解为事务commit之前写前提被破坏,导致写入了违反业务一致性的数据。写倾斜发生在数据的写前提与写目标不相同的情况。例如:两个事务A和B,A将寄存器x的值复制到y,B将寄存器y的值复制到x。只有两个事务,串行执行A、B或者B、A都是x = y,但在快照隔离级别下出现以下结果:
即出现了业务上的不一致现象。点击查看
1. 事务A读取x 2. 事务B读取y 3. 事务A将x写入y 4. 事务B将y写入到x
不可重复读注重在数据的修改,而幻读注重在数据的插入和删除。
隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | × | × | × |
| 读已提交 | √ | × | × |
| 可重复读 | √ | √ | × |
| 串行化 | √ | √ | √ |
注: 事务的隔离级别越高并发度就越低。对于InnoDB而言,其可重复读级别就解决了幻读问题,因此MySQL默认的隔离级别就是可重复读。
锁
- 按细粒度分:
- 表锁:意向锁、自增锁
- 行锁:间隙锁、临键锁、记录锁
- 按锁的方式分:
- 排他锁:写锁(X),在事务需要修改一条记录时,需要先获取该记录的写锁。写锁在一个时刻最多只能被一个事务持有。
- 共享锁:读锁(S),在事务要读取一段数据时,需要先获取该记录的读锁。读锁可以同时被多个事务持有。
注: 当一个事务已经持有某行记录的写锁(x),则另一个事务无法对这行记录添加读锁(S),反之亦然。
意向锁
加意向锁的目的时为了表明某个事务将要锁定某行数据。意向锁包含以下两种:
- 意向共享锁(IS):将要对数据加共享锁
- 意向排他锁(IX):将要对数据加排他锁
当某个事务A对某行加X锁后,另一个事务B想对整张表进行修改,就不再需要对每行加锁,而是直接申请标级别的X锁。但是A在申请行级别的X锁之前一定已经拿到了IX锁,因此事务A便知道有其他事务在对表里的内容进行修改,从而被阻塞。
注: IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突,而意向锁之间并不出发生冲突。
自增锁
自增锁(auto-inc locks)是一种特殊的表级别锁。(设置为auto-increment的列必须是索引或是索引的一部分)
5.1.22 之前, InnoDB自增值是通过其本身的自增长计数器来获取值,该实现方式是通过 表锁机制来完成的(AUTO-INC LOCKING),锁不是在每次事务完成后释放,而是在完成对自增长值插入的SQL语句后释放,要等待其释放才能进行后续操作。比如说当表里有一个auto_increment字段的时候,innoDB会在内存里保存一个计数器用来记录auto_increment的值,当插入一个新行数据时,就会用一个表锁来锁住这个计数器,直到插入结束。如果大量的并发插入,表锁会引起SQL堵塞
在5.1.22之后, InnoDB为了解决自增主键锁表的问题,引入了参数innodb_autoinc_lock_mode,该实现方式是通过 轻量级互斥量的增长机制 完成的。它是专门用来在使用auto_increment的情况下调整锁策略
innodb_autoinc_lock_mode:可以设定3个值,0,1,2
insert前提知识
INSERT-LIKE: 指所有的插入语句,比如 INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT,LOAD DATA等
Simple inserts:指在插入前就能确定插入行数的语句,包括INSERT、REPLACE,不包含INSERT…ON DUPLICATE KEY UPDATE这类语句。
Bulk inserts: 指在插入前不能确定得到插入行的语句。如INSERT…SELECT, REPLACE…SELECT, LOAD DATA.
Mixed-mode inserts:指其中一部分是自增长的,有一部分是确定的。insert t1(id,xx) values(1,11)(null,22)
0. 传统模式(traditonal) 通过表锁的方式进行,所有类型的insert都用AUTO-inc locking。可以发现当并发量增加时,MySQL的性能就会下降,因为同时只能执行一条语句。

1:连续模式(consecutive) 默认值,产生一个轻量锁,对于simple insert 自增长值的产生使用互斥量对内存中的计数器进行累加操作,对于bulk insert 则还是使用表锁的方式进行。实际上是为能够提前指导插入数据行数的语句分配好自增id,然后把相应的空间预留出来,就可以执行下一句了。
2:交叉模式(interleaved) 对所有的insert-like 自增长值的产生使用互斥量机制完成,并发性能最高,并发插入可能导致自增值不连续,可能会导致Statement 的 Replication 出现不一致,使用该模式,需要用 Row Replication的模式。

存在的缺陷:
- 当使用连续模式或交叉模式并且bin log采用了statement格式进行存储,如果发生了bulk insert会出现主从不一致的情况(改成row格式则不会出现相应的现象)
- id不连续,当使用了连续模式,对bulk insert由于使用了预留i。
例如:有两张结构相同的表 test1 和 test2。其中 test1 包含了5条数据,test2 是空的。

将 test1 的数据插入到 test2中


当我们再次插入一条数据时,应该id自增成6,但实际上id为8。

第一次插入数据时,申请了1个id,第二次插入数据时申请了2个id,第三次插入数据时申请了4个id(id的申请是上次的2倍)。但对于第四次预留的id并没有使用完,因此当再次插入数据时,需要重新申请一个id,当前最大的id是7因此插入后的数据id为8。
注: 因此可以将innodb_autoinc_lock_mode设置为2,并将bin log设置成row记录格式。
InnoDB行级锁
InnoDB的行锁实际上是加在索引上,如果加锁查询时没有使用过索引则会对整个聚簇索引进行加锁,即相当于对整个表加锁。根据加锁的范围不同,主要包含以下锁:
- 记录锁(Record locks) :锁住某行记录
- 间隙锁(Gap locks) :指两个记录中逻辑上未填入数据的部分,例如(1,4)(4,7)等。间隙锁就是锁定某些间隙区间的。当我们使用用等值查询或者范围查询,并且没有命中任何一个record,此时就会将对应的间隙区间锁定。例如select * from t where id =3 for update;或者select * from t where id > 1 and id < 4 for update;就会将(1,4)区间锁定。
- 临键锁(Next-key locks) :临建指的是间隙加上右边的记录组成的左开右闭区间,例如(-∞,1]、(1,4]、(4,7]、(7,+∞)。临键锁就是记录锁(Record Locks)和间隙锁(Gap Locks)的结合,即除了锁住记录本身,还要再锁住索引之间的间隙。当我们使用范围查询,并且命中了部分record记录,此时锁住的就是临键区间。注意,临键锁锁住的区间会包含最后一个record的右边的临键区间。
注: RC及RC以下并不会使用间隙锁。
如何判断加的是表锁还是行锁
大部分博客的说法:InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。 InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!
实际上,上面的说法是不准确的,包含两个错误:1. 并不是使用表锁、而是间隙锁锁住了整张表。2.判断加表锁和行锁首先要确定隔离级别。对于RU和RC隔离度下,不存在间隙锁,对于这两种隔离级别,无论条件列是否有索引,都不会锁表,只锁行!
例如:当前隔离级别为RR,开启两个事务A和B,并且两个事务都进行如下操作。

不走任何索引的情况下,整张表被锁住了,但是实际上是对每张表的每行记录加了X锁。

走索引的情况如下,实际上只有一条记录被加了X锁。

MVCC
多版本并发控制(MVCC),一般在RC或RR两个隔离级别下。MySQL的MVCC是基于InnoDB的undo版本链和ReadView一致性视图实现的。
undo版本链
对于一条记录实际上包含隐藏列db_row_id、trx_id和db_roll_ptr等。
- db_row_id :行id,B+树索引要求每个表都有主键,如果创建表时没有定义主键会自动寻找一个非空的唯一键作为row_id,如果还是找不到则InnoDB系统会为每条记录生成一个6字节的row_id。row_id是存在的,区别在于是否能够通过select语句查出来,具体的如果有主键且是数值型是能够显示的,没有主键但有非空唯一键且也是数值型也能够显示。而系统自动分配的row_id是不能显示的,它从0开始递增,当用完时会自动覆盖。row_id并不是真正存在的列,只是非空唯一键的别名。
- db_trx_id :事务id,修改该行数据的事务id。
- db_roll_ptr : 回滚指针,将不同的版本串联起来。

ReadView
MySQL的一致性读是通过ReadView来实现的,ReadView是MySQL底层实现的一个结构体,是和SQL语句绑定的,在每个SQL语句执行前申请或获取。可以将其理解为构造快照的前提或者依据,一个快照所呈现的数据是什么样子(版本)的基本依赖于ReadView中所存储的数据。
主要包含以下变量:
- low_limit_id : 当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前Read View都是不可见的。理解起来就是在创建Read View视图之后创建的事务对于该事务肯定是不可见的。
- up_limit_id : 当前已经提交的事务号 + 1,事务号 < up_limit_id ,对于当前Read View都是可见的。理解起来就是创建Read View视图的时候,之前已经提交的事务对于该事务肯定是可见的
- trx_id : 为活跃事务id列表,即Read View初始化时当前未提交的事务列表。所以当进行RR读的时候,trx_ids中的事务对于本事务是不可见的(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。理解起来就是创建RV时,将当前活跃事务ID记录下来,后续即使他们提交对于本事务也是不可见的。
- creator_trx_id : 创建当前视图的事务id
ReadView可见性判断
- trx_id < up_limit_id || creator_trx_id == trx_id
可见,事务的id小于最小已提交事务的id,即事务在当前事务创建前就已经存在了。或者事务的id等于当前事务自己的id,对于自己修改的数据,肯定可见。 - trx_id > = low_limit_id
不可见,事务id大于ReadView记录的最大的活跃事务id,说明该事务在当前事务开启后才创建,因此对于该事务的数据是不可见的。 - trx_id是否在trx_id活跃列表里
- 不存在:说明该事务在当前ReadView产生后就已经提交了,这种情况下数据可以显示。
- 已存在:表示当前ReadView生成后,该事务仍然处于活跃状态,因此是不可见的。

MVCC与事务隔离级别
- 生成时机
- RC:RC隔离级别下,每个快照读都会生成一个ReadView。
- RR:只有同一个事务的第一个快照读才会创建ReadView,之后的快照读都是同一个ReadView。
- 幻读问题
- 快照读:通过MVCC来进行控制,不需要加锁(乐观并发机制)
- 当前读:通过Next-key lock来解决问题
注: RC下,每一次快照读都会生成一个ReadView,因此在一个事务内能够看到其他事务提交后的修改数据。而在RR下,每个事务都只会使用当前事务内部第一次快照读生成的Read View。
MVCC与索引相关
- 无论是聚簇索引还是二级索引,只要键值发生了更新,就会产生新的版本,将老版本的delete位置为1,同时插入新版本。
- 对于聚簇索引,如果操作没有修改primary key,则直接在原有的基础上进行修改,老版本进入undo表空间,通过roll_ptr进行回滚。
- 对于二级索引,如果更新操作没有更新其键值,那么二级记录保持不变。(InnoDB的数据实际上都在聚簇索引中)
- 对于二级索引,更新操作无论更新primary key,或者是二级索引键值,都会导致二级索引产生新版本数据。
- 聚簇索引设置记录deleted位时,会同时更新TRX_ID列。老版本TRX_ID进入undo表空间;二级索引设置deleted位时,不写入undo。
注: 与主键索引不同,二级索引并不存储版本信息,那么二级索引如何进行可见性判断呢?实际上二级索引的多版本可见性需要通过聚簇索引来判断,二级索引存储了max_trx_id,用于快速判断当前页面所有项是否都可见,可以实现页面级别的索引覆盖。二级索引中的项需要与聚簇索引里的项相比较,保证可见性的一致。
MVCC出现幻读的特例
对于快照读,通过MVCC机制,MySQL可以避免幻读。对于当前读,MySQL通过MVCC + next-key lock能够避免幻读问题。
例如:左边事务A、右边事务B

事务B插入一条数据,由于MVCC机制事务A的ReadView是第一次快照读时生成的,因此并不会看到这条数据。

当事务A尝试更新事务B插入的这条数据时,发现该数据被锁住了,因此一直处于等待状态

现在将事务B提交,在更新该数据,发现事务A更新了成功并且查找出了不存在的数据,出现了幻读现象。

解决方法:1. 使用串行化隔离级别 2. 使用间隙锁,即select … for update;
常见当前读的操作
```
除了第一个是共享锁,其他操作都是排他锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
```
Purge
InnoDB由于要支持多版本协议,因此无论是更新,删除,都只是设置记录上的deleted bit标记位,而不是真正的删除记录。后续这些记录的真正删除,是通过Purge后台进程实现的。
- purge是通过遍历undo实现的。
- purge的粒度是一条记录上的一个操作。如果一条记录被update了3次,产生3个old版本,均可purge。那么purge读取undo,对于每一个操作,都会调用一次purge。一个purge删除一个操作产生的old版本(按照操作从老到新的顺序)。
- purge按照先二级索引,最后聚簇索引的顺序进行。
- purge二级索引,通过构造出的索引项进行查找定位。不能直接针对某个二级页面进行,因为不知道记录的存放page。
- 对于二级索引设置deleted bit为不需要记录undo,因为purge是根据聚簇索引undo实现。因此二级索引deleted bit被设置为1的项,没有记录undo,仍旧可以被purge。
- purge是一个耗时的操作。二级索引的purge,需要search_path定位数据,相当于每个二级索引,都做了一次index unique scan。
- 一次delete操作,IO翻番。第一次IO是将记录的deleted bit设置为1;第二次的IO是将记录删除。
参考链接
- https://zhuanlan.zhihu.com/p/187345419
- https://blog.csdn.net/qq_43722916/article/details/117358169
- https://www.jdon.com/55452
- https://blog.51cto.com/shijianfeng/3524544#
- https://juejin.cn/post/6855129007336521741#heading-8
- https://www.jianshu.com/p/38dd4f64ebd1
- https://www.cnblogs.com/detectiveHLH/p/14832940.html
- https://blog.csdn.net/qiuyepiaoling/article/details/49923703
- https://juejin.cn/post/7066633257781035045
- https://huaweicloud.csdn.net/6335770ed3efff3090b57de3.html
- https://www.php.cn/mysql-tutorials-460111.html
- https://zhuanlan.zhihu.com/p/66791480
- https://blog.csdn.net/shaochenshuo/article/details/76137652
- https://www.cnblogs.com/rjzheng/p/9950951.html

浙公网安备 33010602011771号