一、事务简介
注:本文下面的所有介绍,都是基于MySQL InnoDB存储引擎,其他引擎的表现,会有较大的区别。为了让文章比较通俗易懂,也方便自己以后自己更容易理解,参考了大量的文章,如有错误,请及时指出!
事务的本质其实就是锁和并发的一个结合体。
其实事务的隔离级别(ACID)就是通过锁的机制来实现,锁的应用最终导致不同事务的隔离级别,只不过隐藏了加锁细节,SQL92中事务的隔离级别就是针对锁的实现。下面的解释我们将会搞懂SQL92中事务隔离级别的标准。
所以接下来要大概介绍一下MySQL InnoDB的锁机制,理解锁机制后就能轻松理解事务的概念了。
二、MySQL的锁机制
MySQL锁机制分为表级锁和行级锁。
表级锁:对整张表加锁。开销小,并发度最低。
行级锁:对某行记录加锁。由于行锁只锁住有限的数据,对于其它数据不加限制,所以并发能力强,MySQL的InnoDB默认是用行锁来处理并发事务,并发度较高。
MySQL行级锁中的共享锁与排他锁:
共享锁(读锁):其他事务可以读,但不能写。也就是多个事务只能读数据不能改数据。
排他锁(写锁) :是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁。也就是其他事务既不能读,也不能写。
三、单个事务单元
对于单条SQL语句,数据库系统自动将其作为一个事务执行,这种事务被称为隐式事务。
如果要手动把多条SQL语句作为一个事务执行,使用BEGIN开启一个事务,使用COMMIT提交一个事务,这种事务被称为显式事务。
在执行SQL语句的时候,由于业务要求,一系列操作要么必须全部执行,如果有一条语句执行失败,就要全部撤销。例如下面的SQL,从Bob的账户给Smith的账户转账100元(假设id为1的是Bob的账户,id为2的是Smith的账户):
--从Bob的账户给Smith的账户转账100元
BEGIN;
-- 第一步:将Bob的账户余额减去100
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 第二步:将Smith的账户余额加上100
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
复制代码
上面的SQL语句可以用如下图的步骤表示:
锁定Bob账户。
锁定Smith账户。
查看Bob是否有100元。
从Bob账号中减少100元。
从Smith账户中增加100元。
解锁Bob账户。
解锁Smith账户。
假如有3个线程1,2,3同时并行执行数据库操作,当线程1锁定了Bob和Smith的账户以后,其他的线程就会被排除在了锁外,也就是2和3线程。如下图:
如上图可知,在线程1执行整个事务的过程中,MySQL给Bob和Smith的数据行加锁。所以只有线程1进入到了锁内,在这个事务单元结束之前,其他的线程只能等待在锁外。所以线程1和线程2不会看到有中间状态。
中间状态指的是Bob账户只要减去了100元,Smith账户则必定加上了100元,不能存在其他情况。
因为mysql的InnoDB引擎默认的修改数据语句都会自动给涉及到的数据加上排他锁,所以要锁定涉及到Bob和Smith账户信息的数据。
这种把多条语句作为一个整体进行操作的功能,这就被称为数据库事务。数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败。如果事务失败,那么效果就和没有执行这些SQL一样,不会对数据库数据有任何改动。
所以,数据库事务具有ACID这4个特性:
A:Atomic,原子性,将所有SQL作为原子工作单元执行,要么全部执行,要么全部不执行。
C:Consistent,一致性,事务完成后,所有数据的状态都是一致的,即Bob账户只要减去了100元,Smith账户则必定加上了100元,不能存在其他情况。
I:Isolation,隔离性,如果有多个事务并发执行,每个事务作出的修改必须与其他事务隔离。
D:Duration,持久性,即事务完成后,对数据库数据的修改被持久化存储。
四、多个事务单元和隔离级别
事务的隔离级别是当存在多个事务同时操作相同数据时, 对事务之间相互影响的要求标准。数据库的隔离级别来解决事务并发带来的问题,数据库的锁机制是为了构建这些隔离级别存在的。
对于两个或多个并发执行的事务,涉及到操作同一条记录的时候,如果没有加锁,就很有可能会发生问题。因为并发操作会带来数据的不一致性。
我们操作数据库就是进行增删改查的操作。select相当于读的操作。update,delete,insert相当于写的操作。多个并发执行的事务其实就是读和写的并发执行(好好理解)。
所以事务单元之间并发的关系也就是读写、写读、读读、写写四种状态的组合。
4.1、序列化读写(Serializable)
序列化读写(Serializable):是最严格的隔离级别完全串行化的读写,每次读都需要获得表级共享锁,读写相互都会阻塞。
在Serializable隔离级别下,所有事务按照次序依次执行,也就是将所有的请求排队执行。因此,脏读、不可重复读、幻读都不会出现。
虽然Serializable隔离级别下的事务具有最高的安全性,但是,由于事务是串行执行,只有前一个事务执行完,后面的事务才会执行,所以效率会大大下降,系统的性能会非常差,性能差就代表了这种方式一定是不可用的。所以一般都不会使用Serializable隔离级别。
4.2、可重复读(Repeated Read)
MySQL5.7的默认事务隔离级别就是可重复读(Repeated Read)。 可重复读(Repeated Read):也就是读读并行,写读、读写、写写这三种状态串行化。
读读并行
写读串行
读写串行
写写串行
可重复读(Repeated Read)其实就是在目标数据上加上了读锁。其他事务可以读,但不能写。也就是多个事务只能读数据不能改数据。所以只有读读可以并发执行。
来看看下面在可重复读(Repeated Read)的情况下多个事务并发的操作会造成的问题:
时刻事务A事务B1SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2BEGIN;BEGIN;
3select * from my_users;
4insert into my_users values(1,'张三');
5COMMIT;
6select * from my_users;
7update my_users set username = '李四' where id = 1;
8select * from my_users;
9COMMIT;
注意: begin 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot这个命令。所以上面的SQL语句事务A会先加读锁。
运行结果如下:
这里事务A和事务B是并不是并行执行的,而是串行执行的,是在事务A执行的过程中,事务A加了读锁后执行了事务B,并且是在Repeated Read这样的事务隔离级别下执行的,如果在A中完全遵循Repeated Read的语义,那么查询出来的数据应该还是Empty才对,但是事务B已经执行了insert语句,如果还是Empty的话这样会导致更新丢失的问题,事务A的更新(update)被丢弃了。
所以,宁愿不产生更新丢失的问题也要违反Repeated Read的语义。
在事务的ACID语义中,I就是代表事务的隔离级别,而Repeated Read只是其中的一种隔离级别,
而更新丢失违反了ACID中的D(持久性),在I(隔离级别)的语义无法满足并且又不会产生致命影响时优先满足ACD。
可以参考这篇文章深入理解。
因为持久性和隔离级别产生了冲突,所以这样就产生了幻读。幻读就是没有读到的记录,以为数据不存在,但是可以更新成功的,并且,更新成功后,再次读取,就出现了。数据库领域总会碰到这样的不完美,遇到特殊场景时就会发明一些新的 SQL 语法或配置参数去弥补。
4.3、读已提交(Read Committed)
读已提交(Read Committed):也就是读读并行,读写并行,写读、写写这二种状态串行化。如下图:
读读并行
写读串行
读写并行
写写串行
来看看下面在读已提交(Read Committed)的情况下多个事务并发的操作会造成的问题:
时刻事务A事务B1SET TRANSACTION ISOLATION LEVEL READ COMMITTED;SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
2begin;begin;
3select * from my_users;
4update my_users set username = '王五' where id = 1;
5COMMIT;
6select * from my_users;
7commit;
因为事务A先进行读的事务,在读已提交(Read Committed)隔离级别下,这里事务A和事务B是读写并行执行的,这就意味这在读的时候会取消读锁,允许写,就会有一个新的事务写进来。
这样事务A的读操作就会出现问题:第一次读的时候是李四,而在第二次读的时候就是王五了,两次读的数据不一致。这就是不可重复读。
不可重复读是指,在一个事务内,多次读同一数据,在这个事务还没有结束时,如果另一个事务恰好修改了这个数据,那么,在第一个事务中,两次读取的数据就可能不一致。
4.4、读未提交(Read Uncommitted)
读未提交(Read Uncommitted)是隔离级别最低的一种事务级别。就是读不加锁,只加写锁。所以就有了读读并行、写读并行、读写并行这三种并发的事务操作,只有写写是串行化的。如下图:
读读并行
写读并行
读写并行
写写串行
读未提交(Read Uncommitted)的时候写是串行的(加锁),所有的读都是并行的(没有加锁。这种方式遇到的问题就很好理解了。
这就意味这会读的时候会读到没有提交(commit)完成的写(update,delete,insert)的操作,如果因为一些故障或错误,没有提交完成的写操作的事务回滚了,就会读到错误的数据,所以这个问题就叫做脏读。
脏读:一个事务读取了另一个事务未提交的数据。
4.5、MVCC多版本并发控制
待续...
五、总结
对于两个或多个并发执行的事务,如果涉及到操作同一条记录的时候,可能会发生问题。因为并发操作会带来数据的不一致性,包括脏读、不可重复读、幻读等。数据库系统提供了隔离级别来让我们有针对性地选择事务的隔离级别,避免数据不一致的问题。
数据库的隔离级别是用来解决事务并发带来的问题,透过现象看本质,其实是数据库的锁构建了这些隔离级别的。知道了隔离级别背后的原理后,真的不用在背这些名词了。