Mysql事务与锁的原理
1.Mysql事务基本概念
1.1 事务概念
事务是数据库管理系统(DBMS)执行过程中的一个最小的工作单元,由一个有限的数据库操作序列构成。它可能包含了一个或者一系列的 DML 语句,包括 insert delete update。
1.2 事务四大特性
- 原子性Atomicity
事务是不可再分割的最小的操作单元,一个操作要么全部成功,要么全部失败,不能出现部分成功部分失败。一旦发生前面部分执行成功,后面部分执行失败,可以通过undo log来将前面执行成功的部分数据回滚掉 - 一致性Consistent
数据库/用户自定义的完整性不会被破坏。 - 隔离性Isolation
多个事务并发对表进行操作时,这些事务之间是相互不影响的; (通过MVCC事务版本控制来实现,后文详述) - 持久性Durable
事务一旦提交成功,数据写入磁盘,就是永久性的改变,不因数据库的重启,宕机等因素而丢失数据,它是通过redo log 实现的。但是如果记录redo log的数据页已经被损坏了,又该如何恢复呢?前文有提到过innodb的双写缓冲,它是为数据页创建一个副本,保证数据页的完整性,从而实现崩溃恢复。
1.3 什么时候开启事务
- 执行增删改语句时,数据库自动开启事务
- 手工开启一个事务
begin; //或start transaction
//增删改语句
commit;
- 代码中,通过@Transactional AOP配置的事务,或者JDBC去开启事务
2.事务并发带来的问题
2.1 脏读
- 脏读的定义
脏读就是一个事务读取到了其他事务未提交的数据
事务A查询id=1的数据,然后事务B对id=1的数据进行了修改,但是此时未提交事务,假如此时事务A再次去查询id=1的数据,就会发生前后两次读取不一致的情况,假如事务B的操作回滚了,而事务A去用事务B未提交的数据进行了某个操作,那么此时会造成数据的不一致、
2.2 不可重复读
不可重复读是指一个事务读取到了其他事务修改/删除并已提交的数据导致前后两次读取的数据不一致的情况
事务A通过 id=1 查询到了一条数据, 事务B里面执行了一个 update 操作并通过 commit提交了修改。事务A读取到了事务B修改并提交后的数据,这种情况就叫做不可重复读
2.3 幻读
幻读是指一个事务读取到了其他事务插入的数据导致前后两次读取的数据不一致的情况
事务A进行一个age大于15的范围查询,此时事务B插入一条数据并提交事务,当事务A中再次去进行相同的范围查询时,发现查询出来的数据多了一条,这种现象就是幻读。
脏读,还是不可重复读,还是幻读,它们都是数据库的读一致性的问题,都是在一个事务里面前后两次读取出现了不一致的情况。那么如何解决呢?这些读一致性的问题是由数据库提供一定的事务隔离机制来解决的
3. 事务的隔离机制
3.1 MySQL InnoDB 对隔离级别的支持
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读 (read uncommitted) | 可能 | 可能 | 可能 |
已提交读 (read committed) | 不可能 | 可能 | 可能 |
可重复读 (reoeatable read) | 不可能 | 不可能 | 对InnoDB不可能 |
串行化 (serializable) | 不可能 | 不可能 | 不可能 |
InnoDB 在 RR 的级别就解决了幻读问题,这是InnoDB 默认使用 RR 作为事务隔离级别的原因,既保证了数据的一致性,又支持较高的并发度。
3.2 事务隔离如何实现
3.2.1 基于锁的并发控制 LBCC
要保证前后两次读取的数据一致,在读取数据时,把要读取的数据进行锁定,不允许其他事务进行修改,这种方法叫做基于锁的并发控制Lock Based Concurrency Control. 但是这样的话,一个事务读取时不允许其他的事务进行修改,而应用中大部分都是读多写少的场景,会影响数据操作的效率
3.2.2 多版本并发控制MVCC
如果要让一个事务前后两次读取的数据保持一致,那么我们可以在修改数据的时候给它建立一个备份或者叫快照,后面再来读取这个快照就行了。这种方案我们叫做多版本的并发控制 Multi Version Concurrency Control(MVCC)
- MVCC思想
允许查询到当前事务开始之前已经存在的数据,即使它在后面被修改或删除了;不允许查询当前事务之后新增的数据
- MVCC如何实现
- InnoDB 为每行记录都实现了两个隐藏字段:
DB_TRX_ID,6 字节:插入或更新行的最后一个事务的事务 ID,事务编号是自动递增的(可以认为是创建版本号,在数据新增或者修改为新数据的时候,记录当前事务 ID)
DB_ROLL_PTR,7 字节:回滚指针(可以认为是删除版本号,数据被删除或记录为旧数据的时候,记录当前事务 ID)把这两个事务 ID 理解为版本号。 - MVCC通过undo log 来实现
- MVCC的实现,还与InnoDB锁有关
4 Mysql InnoDB锁的类型与原理
4.1 锁的粒度
InnoDB 里面既有行级别的锁,又有表级别的锁;表锁就是锁住一张表,行锁就是锁住表里面的一行数据。表锁与行锁的区别如下:
锁定粒度: 表锁大于行锁
加锁效率: 表锁大于行锁(表锁直接锁住这张表即可,行锁首先要去检索到这条数据然后才能加锁)
并发性能:表锁小于行锁
4.2 锁的基本模式
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
- Shared and Exclusive Locks 共享锁和排他锁都是属于行锁
- Intention Locks 意向锁是属于表锁
组合起来共四种: 共享锁(S锁),排他锁(X锁),意向共享锁,意向排他锁
4.2.1共享锁
共享锁允许事务在读取一行数据时持有该锁;获取了一行数据的读锁以后,可以用来读取数据,所以它也叫做读锁;
如果事务T1在数据行r上持有了一把共享锁,T2事务请求获取S锁时,会立即获得S锁,因此两个事务都能获取r行上面的S锁;如果事务T2想要获取排他锁,则需要等待。
- 如何加读锁
用select …… lock in share mode;
的方式手工加读锁。
释放锁有两种方式,只要事务结束,锁就会自动事务,包括提交事务和结束事务。
4.2.2排他锁
排他锁允许事务在更新/删除一行数据时持有当前的锁;它是用来操作数据的,所以又叫做写锁。只要一个事务获取了一行数据的排它锁,其他的事务就不能再获取这一行数据的共享锁和排它锁。
- 如何获取排他锁
自动加排他锁:数据库的增删改操作会默认加上一个排它锁。
手工加排他锁,我们用一个 FOR UPDATE 给一行数据加上一个排它锁
DROP TABLE IF EXISTS `student`;
CREATE TABLE `student` (
`sid` int(8) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`qq` varchar(255) DEFAULT NULL,
PRIMARY KEY (`sid`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
- 排他锁的验证
事务1执行update操作,不提交事务:
begin;
update student set name='test' where sid = 1;
事务2尝试获取共享锁和排他锁:
select * from student where sid = 1 lock in share mode;
select * from student where sid = 1 for update;
DELETE from student where sid = 1;
下面例子演示了通过一个更新语句获取排他锁,但是并不提交事务(执行commit命令),然后在其他的事务中尝试获取共享锁和排他锁,结果都是等待中(停止灯一直为红色),也就是第一个更新的操作还未释放排他锁时,其他事务是无法获取锁的。
4.2.3 意向锁的作用
InnoDB的意向锁,由数据库自己维护;
当我们给一行数据加上共享锁之前,数据库会自动在这张表上面加一个意向共享锁。
当我们给一行数据加上排他锁之前,数据库会自动在这张表上面加一个意向排他锁。
换个角度:
如果一张表上面至少有一个意向共享锁,说明有其他的事务给其中的某些数据行加上了共享锁。
如果一张表上面至少有一个意向排他锁,说明有其他的事务给其中的某些数据行加上了排他锁。
- 意向锁存在的意义
如果没有意向锁的话,当我们准备给一张表加上表锁的时候,必须先要去判断有没其他的事务锁定了其中了某些行,如果有的话,肯定不能加上表锁。那么这个时候我们就要去扫描整张表才能确定能不能成功加上一个表锁,如果数据量特别大,比如有上千万的数据的时候,加表锁的效率就很低了。但是引入了意向锁之后就不一样了。我只要判断这张表上面有没有意向锁,如果有,就直接返回失败。如果没有,就可以加锁成功。所以 InnoDB 里面的表锁可以理解成一个标志,就像火车上厕所有没有人使用的灯,是用来提高加锁效率的。
4.3 行锁原理
首先建立三张表:
-- 行锁原理 没有索引
CREATE TABLE `t1` (
`id` int(8) default NULL,
`name` varchar(32) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 有主键索引
CREATE TABLE `t2` (
`id` int(8) NOT NULL AUTO_INCREMENT,
`name` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
-- 有主键索引和唯一索引
CREATE TABLE `t3` (
`id` int(8) NOT NULL AUTO_INCREMENT,
`name` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
INSERT INTO `t1` VALUES (1,'1'),(2,'2'),(3,'3'),(4,'4');
INSERT INTO `t2` VALUES (1,'1'),(4,'4'),(7,'7'),(10,'10');
INSERT INTO `t3` VALUES (1,'1'),(4,'4'),(7,'7'),(10,'10');
4.3.1 没有索引的表
在Navicat中开启两个查询窗口,第一个事务中对id=1的这行数据获取一个排他锁
begin;
select * from t1 where id = 1 for update;
第二个事务中,对id=2的这行数据尝试获取排他锁以及新增一条不存在的数据:
select * from t1 where id=2 for update;
insert into t1 values (5,'5')
结果如上图所示,事务2中的两个操作都被阻塞了,说明这时事务1中的操作,对t1进行了锁表,也就是没有索引时,获取锁时会锁表,而不是锁住某一条记录
4.3.2 有主键索引的表
事务1锁定id=1的这行数据:
begin;
select * from t2 where id=1 for update;
事务2:获取id=1的这行数据的锁被阻塞,但是可以获取其他数据行的锁
select * from t2 where id=1 for update; // 阻塞了
select * from t2 where id=4 for update; // 可以获取到锁
结论,加锁锁住的也不是一行数据
4.3.2 有唯一索引的表
我们看一下 t3 的表结构。字段还是一样的, id 上创建了一个主键索引,name 上创建了一个唯一索引。里面的数据是 1、4、7、10。
-- 事务1
begin;
select * from t3 where name='4' for update;
-- 事务2
select * from t3 where name = '4' for update; -- blocked
select * from t3 where id = 4 for update; -- blocked
用id, name分别取加锁,都被阻塞了,说明锁住的也不是字段,t1,t2,t3三张表的区别就是索引,而InnoDB的行锁,就是通过锁住索引实现。既然锁住的是索引,那么问题来了
- 没有索引时,为什么会锁表
当一张表定义了主键时,InnoDB会选择主键作为聚集索引
当一张表没有显式定义主键,InnoDB会选择第一个不包含NULL值的唯一索引作为主键索引
如果这样的唯一索引也没有,InnoDB会选择内置的6字节长的ROWID作为隐藏的聚集索引
因此当一张表没有索引时,查询时没有使用到索引,会进行全表扫描,然后把每一个隐藏的聚集索引都给锁住,从而表现为锁表了
2. 为什么通过唯一索引给一行数据加锁,主键索引也会被锁住
InnoDB中,使用辅助索引进行查询时,辅助索引存储的是二级索引和主键的值,(比如name=4 这个叶子节点,它存储的是name这个字段的索引以及它对应的主键id的值 4)而主键里面除了索引还存储了完整的数据,因此,通过辅助索引锁定一行数据时,依然会通过主键值找到主键索引,然后将主键索引也给锁定。
4.4 锁的算法
上面测试用的表t2有一个主键索引,我们插入了 4 行数据,主键值分别是 1、4、7、10。因为用主键索引加锁,我们这里的划分标准就是主键索引的值。
这些数据库里面存在的主键值,我们把它叫做 Record,记录,那么这里我们就有 4个 Record。
根据主键,这些存在的记录隔开的数据不存在的区间,把它叫做 Gap,间隙,它是一个左开右开的区间。
最后一个,间隙(Gap)连同它左边的记录(Record),把它叫做临键的区间,它是一个左开右闭的区间。
4.4.1 记录锁
对唯一索引(包括主键索引和唯一索引)进行等值查询,精确匹配到一条数据时,这时使用的是记录锁。
对于前面的t2表,对id=1 和id=2 的两条记录加锁时,是不会发生冲突的,它只锁定 当前查询的这一条记录。
4.4.2间隙锁
当查询的记录不存在,没有命中任何一条记录时,无论是等值查询还是范围查询时,使用的锁都是间隙锁,验证如下:
开启一个事务1,id >4 and id < 7 的区间内没有记录,因此这个范围查询会锁住 (4,7)这个开区间:
begin;
select * from t2 where id >4 and id < 7 for update; -- 或者使用等值查询 select * from t2 where id =6 for update;
在事务2中 尝试插入id=5 id=6的记录:
INSERT INTO `t2` (`id`, `name`) VALUES (5, '5'); -- BLOCKED
INSERT INTO `t2` (`id`, `name`) VALUES (6, '6'); -- BLOCKED
select * from t2 where id =6 for update; -- OK
在动图中可以看到,插入id=5,id=6的记录时,都是阻塞状态,无法获取到锁,但是尝试获取id=6的锁,是可以的,这是因为,间隙锁主要是阻塞插入操作,相同的间隙锁之间不冲突
。
Gap Lock 只在 RR 隔离级别中存在。如果要关闭间隙锁,就是把事务隔离级别设置成 RC,并且把 innodb_locks_unsafe_for_binlog 设置为 ON。
4.4.3 临键锁
当使用了范围查询,不仅仅命中了 Record 记录,还包含了 Gap间隙,在这种情况下我们使用的就是临键锁,它是 MySQL 里面默认的行锁算法,相当于记录锁加上间隙锁;
在t2这个测试表中,如果尝试获取 id>5 且id < 9之间的记录时,可以看到它包含了id=7的一行记录,以及4-7,7-10这三条记录之间的两个间隙;准备如下两个事务进行测试:
事务1:
BEGIN;
select * from t2 where id >5 and id < 9 for update;
事务2:
select * from t2 where id = 4 for update; -- 可以获取到锁
-- 插入两条不存在的数据
insert into t2 values (6,'6'); -- 被阻塞,获取不到锁
insert into t2 values (8,'8'); -- 被阻塞,获取不到锁
-- 获取id=10这条记录的行锁
select * from t2 where id = 10 for update; -- 被阻塞,获取不到锁
select * from t2 where id = 11 for update; -- 可以获取到锁
可以看到,当使用select * from t2 where id >5 and id < 9 for update;这条语句获取锁时,它锁住了 (4,10] 这个左开右闭的区间, id >5 and id < 9 ,它前面的最后一条记录是id=4这条记录,实际上,临键锁锁住的是最后一个key的下一个左开右闭的区间,因此,select * from t2 where id >5 and id < 9 for update;, 锁住的是,(4,7], 以及(7,10] 这两个间隙。
select * from t2 where id >5 and id <=7 for update; -- 锁住(4,7]和(7,10] select * from t2 where id >8 and id <=10 for update; -- 锁住 (7,10],(10,+∞)
为什么要锁住下一个左开右闭的区间?——是为了解决幻读的问题。
4.5 隔离级别的实现
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读 (read uncommitted) | 可能 | 可能 | 可能 |
已提交读 (read committed) | 不可能 | 可能 | 可能 |
可重复读 (reoeatable read) | 不可能 | 不可能 | 对InnoDB不可能 |
串行化 (serializable) | 不可能 | 不可能 | 不可能 |
- Read Uncommited
RU 隔离级别:不加锁。 - Serializable
Serializable 所有的 select 语句都会被隐式的转化为 select … in share mode,会和 update、delete 互斥。这两个很好理解,主要是 RR 和 RC 的区别? - Repeatable Read
RR 隔离级别下,普通的 select 使用快照读(snapshot read),底层使用 MVCC 来实现。加锁的 select(select … in share mode / select … for update)以及更新操作update, delete 等语句使用当前读(current read),底层使用记录锁、或者间隙锁、临键锁。 - Read Commited
RC 隔离级别下,普通的 select 都是快照读,使用 MVCC 实现。加锁的 select 都使用记录锁,因为没有 Gap Lock。
除了两种特殊情况——外键约束检查(foreign-key constraint checking)以及重复键检查(duplicate-key checking)时会使用间隙锁封锁区间。所以 RC 会出现幻读的问题。
InnoDB解决幻读,是间隙锁来实现的;
RR RC 解决的问题不一样,是因为RR是第一次查询时创建查询视图 read view
RC是每次查询时都去创建一个查询视图, 所以每次都会读取到已经提交的数据