一般我们都熟知,事务有四大属性:ACID,即:
-
A:Atomic。原子性,任何一个事务都是一个原子性的,事务要么成功要么失败,不存在事务的一部分执行成功,一部分执行失败
-
C:Consitence,一致性,事务的一致性可以理解为数据的完成性的约束,这些包括主键约束、唯一键约束、
-
I:Isonlation,隔离性,事务之间不能相互影响。
-
D:Durability 事务一旦被提交,则在数据库中的改变将永久保存下来,不会改变
Mysql中InnoDB支持标准的事务ACID。
Mysql中事务很多特性都和锁有关。
表锁
MySQL中最大粒度的锁是表锁:
LOCK TABLE 表名称 READ; --读锁表,不允许对表的任何修改
LOCK TABLE 表名称 WRITE; --写锁表,阻塞其他事务对表的读和写
行锁
- 排它锁(X lock):写锁,同一份数据加了写锁,其他事务不能加任何锁,可以通过如下加排它锁:
SELECT ....... FOR UPDATE
- 共享锁(S lock):读锁,同一份数据,其他事务只能获取共享锁,无法加排它锁,可以通过如下加共享锁
SELECT ..... LOCK IN SHARE MODE
释放表锁:
UNLOCK TABLES;
当出现如下场景时:
事务A获取行锁,锁住了一行数据,只能读不能写,
事务B申请获取表锁,这时候怎么判断事务B是否能够获取到表锁呢,常规判断如下:
- 判断表是否被其他事务获取表锁
- 判断表中每行数据是否有行锁
这种判断比较繁琐,主要在第二步的时候需要判断表中每行数据是否有行锁,为此MySQL设计了意向锁,来简化操作,上面的场景优化为:
- 事务A先获取表的一项共享锁,然后获取行锁
- 事务B的判断则改为:
(1)判断表锁这步不变
(2)判断表上有意向共享锁的时候,这时表中有行数据被锁住,事务B等待
意向锁是表级别的锁
由此意向锁和排它锁以及共享锁组合,之间的兼容关系如下:
排它锁 | 意向排它锁 | 共享锁 | 意向共享锁 | |
---|---|---|---|---|
排它锁 | Conflict | Conflict | Conflict | Conflict |
意向排它锁 | Conflict | Compatible | Conflict | Compatible |
共享锁 | Conflict | Conflict | Compatible | Compatible |
意向共享锁 | Conflict | Compatible | Compatible | Compatible |
MySQL官方锁的解释: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html#innodb-intention-locks
The intention locking protocol is as follows:
- Before a transaction can acquire a shared lock on a row in a table, it must first acquire an IS lock or stronger on the table.
- Before a transaction can acquire an exclusive lock on a row in a table, it must first acquire an IX lock on the table.
可以发现:
- 如果想要获取行的共享锁,必须先获取表级的意向共享锁
- 如果想要获取行的排它锁,必须先获取表级的意向排它锁
MySQL InnoDB存储引擎主要有三种行锁算法:
-
Record Lock 记录锁,单个记录上的行锁,总是去锁住索引上的记录,即使数据没有索引,MySQL会隐式的创建索引
-
Gap Lock 间隙所,锁住的是一个范围区间,不包含记录本身,即开区间:
(x,y)
-
Next-Key Lock 临键锁,Record Lock和Gap Lock的组合,锁定范围区间并且包含记录本身,闭区间:
[x,y]
一般间隙锁条件为:
- 使用普通索引锁定;
- 使用多列唯一索引;
- 使用唯一索引锁定多行记录。
接下来我们用实例来说明,表结构如下
create table test(
a int primary key,
b int,
c int
-- key(b)先不含普通索引测试
);
insert into test values(1,1,1);
insert into test values(2,2,2);
insert into test values(3,3,3);
insert into test values(5,5,5);
insert into test values(10,10,10);
insert into test values(12,12,12);
insert into test values(18,18,18);
insert into test values(23,23,23);
当执行如下语句的时候:
select * from test where a=1 for update;
这个时候间隙锁会退还为记录锁,锁住的只有a=1的这行记录,
当执行如下语句:
begin;
select * from test where a between 5 and 10 for update;
这时候锁住的区间是(5,10],(10,12]
这时候如果执行如下操作:
begin;
insert into test values(4,4,4); --执行成功,不会阻塞等待
insert into test values(8,8,8,8); --阻塞等待
insert into test values(12,12,12,12); --阻塞等待
insert into test values(13,13,13,13); --执行成功,不会阻塞等待
如果我们锁住的是一条不存在的记录时呢?
select * from test where a = 8 for update;
这时候执行如下操作:
insert into test values(4,4,4); --执行成功,不会阻塞等待
insert into test values(5,5,5); -- 执行成功,不会阻塞等待
insert into test values(10,10,10); -- 执行成功,不会阻塞等待
insert into test values(7,7,7); --阻塞等待
insert into test values(8,8,8,8); --阻塞等待
可以发现,这时候,实际锁住的数据是 8 所在的区间,即:(5,10)
通过上面的例子我们可以发现:如果表中只有唯一主键
,则:
- 当我们查询主键一条指定的记录,如果存在,则加锁的是记录锁,如果记录不存在,则加锁的是间隙锁
- 如果查询的的是一个范围的话,则加锁的是间隙锁,且还会额外锁住相邻的下一个范围
如果是普通索引呢?表结构如下:
create table test(
a int primary key,
b int,
c int,
key(b)
);
insert into test values(1,1,1);
insert into test values(6,4,2);
insert into test values(9,8,3);
insert into test values(10,11,5);
insert into test values(11,15,10);
insert into test values(12,17,12);
insert into test values(18,22,18);
insert into test values(23,23,23);
进行如下操作:
begin;
select * from test where b=8 for upate;
在另外一个事务中进行如下操作:
insert into test values(3,3,1); --操作成功
insert into test values(4,12,1); --操作成功
insert into test values(13,11,1); -- 操作成功
insert into test values(6,6,1); --阻塞等待
insert inot test values(2,11,1) ; --阻塞等待
insert into test values(7,11,1); --阻塞等待
这里,我们理解,在普通索引上,锁住的应该是(4,11)这个区间,但是:
insert into test values(13,11,1); -- 操作成功
insert into test values(7,11,1); --阻塞等待
一个成功,一个却失败了,这是因为:
在普通索引和唯一索引同时存在时,数据间隙的分析是优先根据普通索引排序,然后根据唯一索引排序
,锁住的实际上根据普通索引和唯一索引构成的这段数据区间。
通过分析发现,
select * from test where a = 8 for update;
实际上锁住的是 (b=4,a=6) ~ (b=11,a=10)的这个区间里面,
因此对于普通索引:
- 在普通索引上,如果加锁,不管任何查询,都会产生间隙锁
- 在普通索引和唯一索引并存的条件下,数据的间隙是优先根据普通索引排序,在根据唯一索引进行排序
MySQL中使用间隙锁解决了幻读的问题。
数据库事务中比较注意的几个问题:
- 脏读 脏读是读取了别的事务尚未提交的数据,如果事务回滚了,则这部分已经读取的数据就是脏数据了。
- 不可重复读 指的是在一个事务内,多次读取同样的条件下的数据,返回的结果不一样,
一般不可重复读主要指的是其他事务更新update导致当前事务前后读取同样条件的数据不一致
- 幻读 指的是当前事务多次读取同样条件下的数据,读取到了新的数据,
一般幻读主要是其他事务进行了insert或者delete,当前事务多次读取同样条件的数据,数据条数会增加
MySQL中在RR事务级别下,通过 临键锁 解决了幻读问题
MySQL在不同事务模式下,提供了两种读模式:一致性非锁定读(非加锁,快照读)、当前读(加锁,也称锁定读)。
MySQL中在RC和RR的事务级别下都提供了MVCC(Multiversion Concurrency Control , 多版本控制)。引入了MVCC之后,MySQL中除了写写,读读、读写、写读都能够并发运行。MVCC只在 RC和RR事务隔离级别下工作(这里的读指的是快照读)
。
RR隔离事务级别依靠 MVCC+临键锁 实现,MVCC则依靠 undo log、Read view 、隐藏字段。
MySQL中每行记录还包含了如下隐藏字段:
- DATA_TRX_ID 标记最后更新这条行记录的transaction id
- DATA_ROLL_PTR 指向当前记录项的rollback segment的undo log记录,找之前版本的数据就是通过这个指针
- DB_ROW_ID,当由innodb自动产生聚集索引时,聚集索引包括这个DB_ROW_ID的值,否则聚集索引中不包括这个值,这个用于索引当中
- DELETE BIT位用于标识该记录是否被删除,这里的不是真正的删除数据,而是标志出来的删除,真正意义的删除是在commit的时候
所谓的Read View指的是Consitent Read View一致读视图
在RC和RR隔离级别下,每次更新都会更新MVCC快照的数据,RR和RC的MVCC不同之处在于Readview:
- RC在每次select的时候都是读取最新的ReadView快照,如果这期间其他事务更新了数据,前后读取数据不一致,即: 不可重复读
- RR第一次select读取ReadView快照之后,后续读取的都是该快照,不改变,即实现了可重复读
ReadView实际上是有多个版本,成一个类似链式结构,包含了当前活跃的事务,那么怎么读取哪一个版本的Readview呢,即所谓的可见性,在InnoDB引擎中,Readview中有一个tx_ids的集合,按从大到小排序,记录了当前Readview中活跃的、未提交的事务ID,当前事务读取一条记录时,首先将当前行记录的DATA_TRX_ID
设置为tx_id,将tx_id与tx_ids集合进行比较,获取可见读取的记录:
- 如果tx_id < tx_ids中最小的事务id,表明
最后修改当前行记录的事务
在当前事务
创建快照之前就已经提交了,该行记录对当前事务是可见,直接返回该行记录 - 如果tx_id > tx_ids最大的事务id,表明
最后修改当前行记录的事务
在当前事务
创建快照之后才更新当前行记录,提交了事务,当前行记录对当前事务不可见,这时候需要进行步骤4 - 如果 tx_ids最小的事务id< tx_id <tx_ids最大的事务id,表明
当前事务
在创建快照的时候,最后修改当前行记录的事务
可能处于活跃状态或者已经提交状态,这时候需要在tx_ids中进行查找,如果tx_id在tx_ids中,则表明当前事务
创建快照之后tx_id的事务修改了记录,当前行记录对当前事务
不可见,这时候需要进行步骤4,如果tx_id不在tx_ids中,则表明当前事务
创建快照之前 tx_id的事务已经提交了事务,当前行记录对当前事务可见 - 根据当前行记录上的
DATA_ROLL_PTR
查找undo log回滚段中最新的旧事务号id,赋值给tx_id,然后继续执行上述步骤判断
MySQL中undo log
记录数据更新前的记录,是逻辑记录,如果事务回滚,则根据undo log回滚到之前的记录。
MySQL中redo log则是记录更新后的记录,用来数据库异常恢复.
undo log
分为两种,分别为:insert undo log
、update undo log
.
insert undo log
是insert操作产生的undo log,对当前事务可见,对其他事务不可见,在当前事务提交insert之后直接删除。
updae undo log
是delete和update操作产生的undo log,可能会在mvcc中提供记录读取,不能再事务提交后立马进行删除。
MySQL中redo log
和undo log
来实现事务的持久性
。redo log保证事务的持久性,undlo log来实现事务回滚和MVCC。InnoDB引擎中,每次事务提交,都必须将事务相关数据强制写到redo log中。
MySQL中数据是写入到内存缓冲池中的,然后在以一定的频率刷新到磁盘中,这样在宕机或其他故障其他情况下可能造成数据的丢失,持久性无法保证
,因此在MySQL InnoDB中,数据写缓冲池中会先写入到redo log的缓冲中,redo log在以一定频率刷新到磁盘中。
通过参数innodb_flush_log_at_trx_commit
来控制redo log缓冲写入到redo log file的频率:
- 1 (默认)每次提交事务都会立即调用系统指令 fsync将缓冲 立马写入磁盘
- 0 每次提交事务写入redo log缓冲,不立即写入磁盘,每 1s 或者 redo log 缓冲使用大于一半时,写入磁盘
- 2 每次提交事务写入redo log 缓冲,不写入磁盘,但是会将redo log穿冲写系统缓冲
在0的情况下,可能会数据丢失,最坏情况丢失大概1s左右的数据
在2的情况下,如果只是mysq服务实例挂了,重启数据能够恢复,不会丢失数据,但是如果服务器宕机,数据依然丢失
在1的情况下,数据不会丢失,但是性能没有上面两个好,如果对数据要求性比较高,建议设置为1.
MySQL中更新一条数据,是先将数据从数据文件读取到buffer pool 中,然后将更新前的记录写入到redo log中,然后直接在buffer pool中更新记录,注意这时候是没有写入磁盘的,然后当提交事务的时候,会将当前更改的记录写入到redo log中 ,redo log根据上面的配置,然后将redo log写入到磁盘中。
MySQL中数据什么时候更新到磁盘的数据文件中,MySQL中checkpoint机制,来进行数的写入,对于redo log而言,由于redo log是循环写入的,当redo log快满无法写入的时候,将触发checkpoint,将buffer pool中的脏页和redo log中的数据写入到数据文件中。
在没有进行checkpoint的时候,这时候更新的数据实际上是在buffer pool和redo log中的,由于buffer pool是在内存的,当宕机后数据就没了,而redo log是写入磁盘的,能够保证数据不丢失,而在进行了checkpoint之后,这部分数据也就永久的保存下来了。
二阶段提交
MySQLz中事务提交是采用两阶段提交机制的。主要分为三个步骤:
- 写入redolog,并设置redolog状态为prepare状态
- 写入binlog
- 提交事务,并设置redolog状态为commit状态
相当于是将写redolog拆成了prepare和commit两个阶段。这么做更多的是为了保持主从一致性
如果不使用二阶段提交,那么当写入redolog成功后崩溃,这时候binglog就没有这条记录,重启后,master会比slave多出已提交记录,如果先写binglog,后写redolog,写完binlog之后崩溃,这时候崩溃,重启后,主从还是不一致。
上面了解到,每个事务实际上都会有一个事务ID,XID
。如果在redolog prepare阶段之后崩溃,那么重启后,这时候redolog是prepare状态不是commit状态,通过redolog的XID去binlog中找XID对应的记录,找不到,这时候回滚该事务。如果在写入binglog之后崩溃,这时候能够在binlog找找到对应的事务,提交事务即可。