事务
事务的四大特性
原子性: 构成事务的所有操作必须是一个逻辑单元, 要么全部执行, 要么全部不执行.
一致性: 数据库在事务执行前后状态都必须是稳定的或者是一致的,从数据库层面理解,操作完的数据必须符合你设置的约束(负数),从业务层面理解,操作映射的业务必须满足真实的规则
隔离性: 事物之间不会互相影响. 有锁机制和MVCC机制来实现.
持久性: 不管是数据库重启或崩溃,事务执行成功后必须全部写入磁盘。
对于一致性的理解
一致性是指,事务必须是使数据库从一个一致性状态变到另一个一致性状态。(从一个正常状态转换为另一个正常状态)。
一致性体现在两个层面:
- 数据库机制层面
数据库层面的一致性是,在一个事务执行之前和之后,数据会符合你设置的约束(唯一约束,外键约束,Check约束等)和触发器设置。比如:A有90,向B转了100,这时A的账户余额就是负数,而金额的字段设置了unsigned(即非负数),这时数据库就会报错,违反了非负约束的一致性。 - 业务层面
于业务层面来说,一致性是保持业务的一致性。比如:A有100,B有0,A向B转了100,这时AB的账户之和还得是100。如果不是就违反了业务逻辑的一致性。再比如扣了2块手续费,B只收到了98,那么业务上就必须处理考虑手续费的这2块,不然也就违反了业务的一致性。
mysql事务启动方式
- 显示启动事务语句,
begin
或者start transcation
。配套的提交语句是commit
,回滚语句为rollback
。
START TRANSACTION;
事务代码
commit;
-
set autocommit=0, 这个命令会将自动提交关闭。这意味着如果你只执行一个select语句,这个事务就启动了,并且不会自动提交。这个事务持续存在知道你主动执行commit或rollback语句,或者链接断开。
mysql中查看当前自动提交状态的命令为:
show VARIABLES like 'autocommit';
如下value
为on
代表是自动提交已经打开。
事务并发遇到的问题
-
脏读
-
不可重复读
-
幻读
对比
脏读&不可重复读
- 脏读没有提交,不可重复读提交了
不可重复读&幻读
- 都提交了,但是不可重复读发生在修改和删除,幻读发生在新增
解决事务并发
标准的隔离级别
未提交读:脏读,不可重复复,幻读都没解决
已提交读:解决了脏读问题
可重复读:解决了脏读,不可重复读,没有解决幻读
串行化:解决所有问题,但是慢
mysql的innodn隔离级别
和标准的隔离级别对比,innodb的可重复读解决了幻读问题。也是innodb的模式隔离级别。
事务隔离级别的解决方案
- 基于锁的并发控制(LBCC)----在读取数据前,对其加锁,阻止其他事务对数据进行修改
- 基于多版本的并发控制(MVCC)----快照
以上两种方案在不同的场景协同使用
锁
按锁的粒度分:行锁,表锁
按锁的用法分:乐观锁,悲观锁
按锁的类型分:排他锁,意向锁,共享锁,自增锁
按锁的算法分:间隙锁,记录锁,临键锁,插入意向锁
共享锁(S锁)–用于读取数据的
- 又称读锁,顾名思义,共享锁允许多个事务对于同一数据可以共享一把锁,都只能读,不能修改。
- 行锁
- 加锁方式
select * from student where id = 1 LOCK IN SHARE MODE;
- 释放锁
commit/rollback;
排它锁(X锁)
- 又称写锁,排它锁不能与其他锁并存,如一个事务获取了一个数据行的排它锁,其他的事务就不能够再获取该行的锁,只有获取了排它锁的事务才能够对数据行进行读取和修改。
- 行锁
- 加锁方式
- 自动
delete/update/insert默认加上排它锁
- 手动
select * from student where id=1 FOR UPDATE;
- 自动
- 释放锁
commit/rollback;
意向锁(意向共享锁(IS锁)/意向排它锁(IX锁))
- 表锁
- 由数据引擎自己维护,用户无法手动操作意向锁
- 意向共享锁表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁
- 意向排它锁表示事务准备给数据行加入排它锁,也就是说一个数据行加排它锁前必须先取得该表的IX锁
加表锁的前提:没有其他任何事务已经锁定了该表的任意一行数据
由于这个前提,在加表锁的是时候需要对表的每一行进行扫描来判断是否加了锁
如果有了意向锁,就不需要全表扫描判断了,意向锁相当于一个标志,用于提高数据库加表锁的效率
mysql的innodb是聚蔟索引
在没有指定主键的时候,会去看有没指定唯一索引,如果没有指定唯一索引,会按照行号_rowid
来构建聚蔟索引(可以查到,select _rowid from table;)
锁的本质是锁住了索引
一. 未指定任何主键,索引,内部会按照_rowid
存放
- 因为没有索引,查询操作都是全表扫描
- 给任意一行加排它锁的时候会锁住所有的
_rowid
,导致锁表,不能再给其他行加锁
二. 指定主键
- 行排它锁只会锁住一行,按索引锁的
- 其他行可正常读写和加锁
三. 指定主键索引(id)和辅助索引(name)
- 使用
where name = 4
加锁的时候,会先通过name索引找到对应的主键,按照主键对行进行加锁
innodb之所以在可重复读的隔离级别就可以避免脏读,不可重复复,幻读是因为引入了间隙锁,临键锁,MVCC
记录锁是按照唯一索引等值查询,精准匹配加锁的
间隙锁和临建锁是按照区间进行加锁的
按查询的区间进行区间加锁可防止该区间的插入操作
mysql事务日志
事务日志可以帮助提高事务的效率。使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘。事务日志采用的是追加的方式,因此写日志的操作是磁盘上一小块区域内的顺序I/O,而不像随机I/O需要在磁盘的多个地方移动磁头,所以采用事务日志的方式相对来说要快得多。 事务日志持久以后,内存中被修改的数据在后台可以慢慢地刷回到磁盘。 目前大多数存储引擎都是这样实现的,我们通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。
如果数据的修改已经记录到事务日志并持久化,但数据本身还没有写回磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。
MySQL Innodb中跟数据持久性、一致性有关的日志,有以下几种:
- Bin Log:是mysql服务层产生的日志,常用来进行数据恢复、数据库复制,常见的mysql主从架构,就是采用slave同步master的binlog实现的
- Redo Log:记录了数据操作在物理层面的修改,mysql中使用了大量缓存,修改操作时会直接修改内存,而不是立刻修改磁盘,事务进行中时会不断的产生redo log,在事务提交时进行一次flush操作,保存到磁盘中。当数据库或主机失效重启时,会根据redo log进行数据的恢复,如果redo log中有事务提交,则进行事务提交修改数据。
- Undo Log: 除了记录redo log外,当进行数据修改时还会记录undo log,undo log用于数据的撤回操作,它记录了修改的反向操作,比如,插入对应删除,修改对应修改为原来的数据,通过undo log可以实现事务回滚,并且可以根据undo log回溯到某个特定的版本的数据,实现MVCC
MVCC(理解)
MVCC是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。
当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。
- SELECT
InnoDB会根据以下两个条件检查每行记录:
- InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的过期时间要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
只有符合上述两个条件的记录,才能返回作为查询结果
- INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号。
- DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除标识。
- UPDATE
InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识(过期时间)。
保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作
举例
create table mvcctest(
id int primary key auto_increment,
name varchar(20));
transaction 1:
start transaction;
insert into mvcctest values(NULL,'mi');
insert into mvcctest values(NULL,'kong');
commit;
假设系统初始事务ID为1;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | undefined |
transaction 2:
start transaction;
select * from mvcctest; //(1)
select * from mvcctest; //(2)
commit
SELECT
假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务3 ----可能发生“幻读”
transaction 3:
start transaction;
insert into mvcctest values(NULL,'qu');
commit;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | undefined |
3 | qu | 3 | undefined |
事务3执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2的,所以事务3新增的记录在事务2中是查不出来的,这就通过乐观锁的方式避免了幻读的产生。
UPDATE
假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务4----可能发生“不可重复读”
transaction session 4:
start transaction;
update mvcctest set name = 'fan' where id = 2;
commit;
InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | 4 |
2 | fan | 4 | undefined |
事务4执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2的,所以事务修改的记录在事务2中是查不出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的
DELETE
假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务5----可能发生“不可重复读”
transaction session 5:
start transaction;
delete from mvcctest where id = 2;
commit;
ID | NAME | 创建时间 | 过期时间 |
---|---|---|---|
1 | mi | 1 | undefined |
2 | kong | 1 | 5 |
事务5执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2、并且过期时间大于等于2,所以id=2的记录在事务2 语句2中,也是可以查出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的
MVCC(底层)
版本链+事务id+回滚指针+read-view
事务第一次执行sql就会创建一个read-view,对全库记录了事务提交情况,事务id
通过按照一定的规则判断read-view,就能取出对应正确的数据
当执行查询sql时会生成一致性视图read-view,它由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id(包括提交和未提交的事务) (max_id)组成,查询的数据结果需要跟read-view做比对从而得到快照结果
对比规则
- 事务id<min_ id ,表示这个版本是已提交的事务生成的,这个数据是可见的;(min_id是未提交中最小的事务id,小于他的数据版本都是已提交的–>可见)
- 事务id>max_id ,表示这个版本是由将来启动的事务生成的,是肯定不可见的;(max_id是所有事务中id最大的,大于他的数据版本都没发生–>不可见)
- min_id<=事务id<=max_id, 分情况
- 如果该数据版本的事务id在未提交事务的数组中,表示该版本的数据未提交,–>不可见
- 如果该数据版本的事务id不在未提交事务的数组中,表示该版本的数据已提交,–>可见
对于删除的情况可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将事务id修改成删除操作的事务id,同时在该条记录的头信息. (record header)里的(deleted flag)标记位写上true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果delete_flag为True,意味着记录已经被删除。
如有以下各个事务的时序操作,对应的状态为:
- 对于查询1
- read-view为[1,3], 4–>min_id=1,max_id=4,查看状态3
- 第一个数据版本的事务id为4,在min_id和max_id之间,又因为不在未提交事务的数组里,所以第一个数据版本就是了–>A2
- 对于查询2
- read-view为[1,3], 4–>min_id=1,max_id=4,查看状态5
- 第一个数据版本的事务id为1,在min_id和max_id之间,又因为在未提交事务的数组里,所以通过回滚指针回到第二个版本,第二版本的事务id为1,同理不可见,通过回滚指针回滚到第三个版本,第三个版本的事务id为4,在min_id和max_id之间,又因为不在未提交事务的数组里,所以第三个数据版本就是了–>A2
- 对于查询3
- read-view为[1,3], 4–>min_id=1,max_id=4,查看状态7, 对于可重复读的隔离级别,该处的read-view沿用的第一次查询的read-view
- 第一个数据版本的事务id为3,在min_id和max_id之间,又因为在未提交事务的数组里,所以通过回滚指针回到第二个版本,第二个版本的事务id为3,回滚,第三个版本的事务id为1,,,最终找到第五个版本–>A2
- 对于已提交读隔离级别,该处的read-view为[3],4查询结果为A4
- 对于查询4
- read-view为[3], 4–>min_id=3,max_id=4,查看状态7
- 按规则找到第三个数据版本即为结果–>A4
- 得出read-view是事务第一次执行sql时生成的
参考文献:https://www.jianshu.com/p/f692d4f8a53e