本文基于mysql innodb存储引擎
一、事务的基本要素(ACID)
1、原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。
2、一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
3、隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
4、持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
二、事务的并发问题
1、脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
2、不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。
3、幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。
小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
三、MySQL事务隔离级别
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提交(read-uncommitted) | 是 | 是 | 是 |
不可重复读(read-committed) | 否 | 是 | 是 |
可重复读(repeatable-read) | 否 | 否 | 是 |
串行化(serializable) | 否 | 否 | 否 |
mysql默认的事务隔离级别为repeatable-read
事物隔离场景:
想象一个场景。抽奖,如果用户中奖了,一般有如下几个流程:
扣减奖品数量;
记录用户中奖信息;
试想如果扣减奖品数量了,结果记录用户中奖数据的时候失败了,那么数据就会出现不一致的问题。这种场景,就可以使用事务。因为事务的一个特性,就是原子性:要么不做,要么全做。
上述问题解决了。再想一下这样的场景:在抽奖前,先查询奖品剩余数量,如果剩余数量<1,则任务抽奖活动已经结束,不再进行抽奖。如果事务A扣减奖品数量但未提交,事务B查询剩余奖品数量,此时应该是多少呢?这就和事务的隔离级别有关系了。
在讨论隔离级别前,我们先做一些数据库的初始化操作:
建表:
初始化1个奖品:
insert into Tran_test (id,count) values(1,1)
未提交读
事务中的修改,即使没有提交,也会被其他事务读取。
下面通过mysql演示:
设置隔离级别为为提交读:
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务A | 事务B |
start transaction; | start transaction; |
select * from Tran_test where id=1; (count=1) | |
update Tran_test set count=count-1 where id=1; | |
select * from Tran_test where id=1;(count=0) | |
select * from Tran_test;(count=0) | |
roll back; | |
commit; | |
可以看到,事务B读取到了事务A未提交的数据,它任务抽奖活动已经结束。但如果此时事务A回滚,count仍然为1,则活动实际是未结束的,这就是脏读。因此,实际中,一般不会采用这种隔离级别。
提交读
提交读隔离级别可以解决上述脏读问题,其只能读到其他事务已经提交的数据。
更改数据库隔离级别:
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
事务A | 事务B |
start transaction; | start transaction; |
select * from Tran_test where id=1; (count=1) | |
update Tran_test set count=count-1 where id=1; | |
select * from Tran_test;(count=0) | |
select * from Tran_test where id=1;(count=1) | |
commit; | |
select * from Tran_test where id=1;(count=0) | |
commit |
可以看到,在事务A提交前的改动,事务B是读取不到的。只有A事务提交后,B才能读取到事务A的改动。
我们看到,在事务B中,先后两次读取,count的值是不一样的,这就是不可重复读。而可重复读隔离级别可以解决这个问题。
可重复读
更改数据库隔离级别:
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
事务A | 事务B |
start transaction; | start transaction; |
select * from Tran_test where id=1; (count=1) | |
update Tran_test set count=count-1 where id=1; | |
select * from Tran_test;(count=0) | |
select * from Tran_test where id=1;(count=1) | |
commit; | |
select * from Tran_test where id=1;(count=1) | |
commit |
可以看到,不论事务A是否提交,事务B读到的count值都是不变的。这就是可重复读。
除了上面提到的脏读、不可重复读,还有一种情况是幻读:在事务中,前后两次查询,记录数量是不一样的。
比如事务B是事务A插入一条记录的前后执行查询,会发现相同的查询条件,查出来的记录数不一样。由于mysql的RR(可重复读)一并解决了幻读的问题,所以我们直接看上述场景,在mysql中的表现:
事务A | 事务B |
start transaction; | start transaction; |
select count(1) from Tran_test;(1) | |
insert into Tran_test (id, count) value (2,2); | |
commit; | |
select count(1) from Tran_test;(1) | |
commit | |
可见,在事务A提交前后,事务B查询的结果数量是一直的,并没有出现幻读的情况。
一点思考
下面默认都是讨论的msyql RR隔离级别的情况。
如果两个用户同时抽奖,而且同时中奖。两者都进入了中奖的事务。A事务扣减了奖品数量,B也执行了扣减数量。假设奖品数量是N,如果是可重复读,那么,如果两个事务并行进行,那么不论A有没有提交,B读到的数量都是N,执行后为N-1,而事务A也是N-1,这样不就有问题了吗?我们期望的是N-2。
当初这个问题让我很困惑。这反应了当时我对数据库锁和快照读、当前读两个知识点的欠缺。
快照读、当前读
将设事务A已经提交,由于是可重复读,那事务B读到的奖品数量一致是N,执行-1,数据变成N-1,而不是我们期望的N-2。
如果理解了快照读和当前读的概念,上面的困惑就不会存在了。
在事务中,执行普通select查询之后,会创建快照,后面再执行相同的select语句时,查询的其实是前面生成的快照。这也就是为什么会有可重复读。
而如果执行
会执行当前读,获取最新数据。回到前面的问题,如果事务B执行N-1操作,会触发当前读,读取事务A提交后的数据,也就是N-1,在此基础上执行-1操作,最终N变成N-2。
并发更新
上面解决了事务A已经提交的额情况。但如果事务A更新奖品数量后但还未提交呢?此时事务B执行当前读拿到的也是N啊。了解数据库锁机制的话,就不会有这种困惑了。事务A提交前,会一直持有排他锁(具体是行锁还是表锁,要看查询条件有没有走索引),此时事务B更新是会阻塞的。也就是说,只有事务A提交,或回滚之后,事务B才能获得排它锁,从而进行更新奖品的操作。
补充:
1、SQL规范所规定的标准,不同的数据库具体的实现可能会有些差异
2、mysql中默认事务隔离级别是可重复读时并不会锁住读取到的行
3、事务隔离级别为读提交时,写数据只会锁住相应的行
4、事务隔离级别为可重复读时,如果有索引(包括主键索引)的时候,以索引列为条件更新数据,会存在间隙锁间隙锁、行锁、下一键锁的问题,从而锁住一些行;如果没有索引,更新数据时会锁住整张表。
5、事务隔离级别为串行化时,读写数据都会锁住整张表
6、隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大,鱼和熊掌不可兼得啊。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed,它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。