我们先来回忆下什么是事务。
考虑一种场景。银行转账,甲转 100 给乙,分为两步:
- 甲扣除 100
- 乙增加 100
如果甲的钱扣了,乙的钱没有增加,那么数据就会不一致,这时候就要用到事务。因为事务的一个特性,就是原子性:要么不做,要么全做。
一、事务的基本要素(ACID)
- 原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。
- 一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
- 隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
- 持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
二、事务的隔离级别
我们回到转账的例子。
甲扣除 100 后,如果事务 A 没有提交,那么另外一个事务 B 来查询甲的余额,那应该是多少呢?这时候就和事务隔离级别有关了。
事务隔离级别有以下 4 种:
- 读未提交(read-uncommitted)
- 不可重复读(read-committed)
- 可重复读(repeatable-read)
- 串行化(serializable)
Mysql 的默认隔离级别为可重复读(repeatable-read)。
2.1 读未提交(read-uncommitted)
我们来看转账的例子,假设 A 一开始的余额为 500。表名为 account,表数据为:
id | name | amount |
---|---|---|
1 | 甲 | 500 |
2 | 乙 | 0 |
设置隔离级别为读未提交。
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400) | |
select amount from account where id = 1; (amount = 400) | |
roll back; | |
commit; |
可以看到,事务 B 读取到了事务 A 未提交的数据。实际上事务 A 进行了回滚,amount 仍未 500,但是事务 B 读到的为 400 ,出现了脏读。因此,实际中,一般不会采用这种隔离级别。
2.2 不可重复读(read-committed)
设置隔离级别为不可重复读。
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400) | |
select amount from account where id = 1; (amount = 500) | |
commit; | |
select amount from account where id = 1; (amount = 400) | |
commit; |
可以看到,在事务 A 提交前的改动,事务 B 是读取不到的。只有 A 事务提交后,B 才能读取到事务 A 的改动。
我们看到,在事务 B 中,先后两次读取,amount 的值是不一样的,这就是不可重复读。
不可重复读会有一个问题,假如事务 B 的逻辑是分两次读取 amount,分别插入到两个表中,那么就会出现数据不一致。
2.3 可重复读(repeatable-read)
设置隔离级别为可重复读。
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400) | |
select amount from account where id = 1; (amount = 500) | |
commit; | |
select amount from account where id = 1; (amount = 500) | |
commit; |
可以看到,不论事务 A 是否提交,事务 B 读到的 amount 值都是不变的。这就是可重复读。
除了上面提到的脏读、不可重复读,还有一种情况是幻读:在事务中,前后两次查询,记录数量是不一样的。
由于 mysql 的 RR(可重复读)一并解决了幻读的问题,所以我们直接看上述场景,在 mysql 中的表现:
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select count(*) from account; (count = 2) | |
insert into account values(3,‘丙’,0); | |
commit; | |
select count(*) from account; (count = 2) | |
commit; |
可见,在事务 A 提交前后,事务 B 查询的结果数量是一直的,并没有出现幻读的情况。
2.4 串行化(serializable)
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
事务隔离级别为串行化时,读写数据都会锁住整张表。因此事务 A 执行的时候,事务 B 会阻塞,不存在并发问题。
三、事务的并发问题
刚刚已经谈到了常见的问题,我们再来总结下:
- 脏读:事务 B 读取了事务 A 更新的数据,然后 A 回滚操作,那么 B 读取到的数据是脏数据。
- 不可重复读:事务 B 多次读取同一数据,事务 A 在事务 B 多次读取的过程中,对数据作了更新并提交,导致事务 B 多次读取同一数据时,结果不一致。
- 幻读:事务 B 多次统计数据量,事务 A 在事务 B 多次统计的过程中,对数据作了新增或删除操作,导致事务 B 多次统计数据时,结果不一致。
小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。
四、快照读、当前读
我们先考虑下这样一个问题(基于 MySQL 的默认隔离级别可重复读 RR )。
假设甲同时转账给乙和丙,那么对于甲来说,就会产生两个事务,事务 A 及事务 B 。这两个事务分别查询余额,并进行扣款操作:
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400) | |
commit; | |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400?) | |
commit; |
根据前面可重复读的特性,事务 B 读到的 amount 应该是 500 ,那么减去 100 后,还会是 400 吗?如果是 400 ,那么不是会造成数据不一致(实际转出了两笔 100 ,应该还剩 300 )。
结果是否定的,事务 B 最后读到的 amount 应该是 300 。
在事务中,执行普通 select 查询之后,会创建快照,后面再执行相同的 select 语句时,查询的其实是前面生成的快照。这也就是为什么会有可重复读。
而如果执行:
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
会执行当前读,获取最新数据。
回到前面的问题,如果事务 B 执行 amount - 100 操作,会触发当前读,读取事务 A 提交后的数据,也就是 400,在此基础上执行 -100 操作,最终 amount 变成 300。
实际上的流程应该如下:
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400) | |
commit; | |
select amount from account where id = 1; (amount = 500,快照读) | |
update account set amount = amount -100 where id = 1;(触发当前读,amount 为 400 -100) | |
select amount from account where id = 1; (amount = 300) | |
commit; |
还有一种情况,假如事务 A 和事务 B 并发修改数据但还未提交,那么会怎么样呢?
事务A | 事务B |
---|---|
start transaction; | start transaction; |
select amount from account where id = 1; (amount = 500) | |
select amount from account where id = 1; (amount = 500) | |
update account set amount = amount -100 where id = 1; | |
update account set amount = amount -100 where id = 1; | |
select amount from account where id = 1; (amount = 400?) | |
select amount from account where id = 1; (amount = 400?) | |
commit; | |
commit; |
由于事务 A 和事务 B 都没有提交,不能触发当前读,那么 amount 最终会是多少呢?会是 400 吗?
答案是 300,了解数据库锁机制的话,就不会有这种困惑了。
事务 A 提交前,会一直持有排他锁(具体是行锁还是表锁,要看查询条件有没有走索引),此时事务 B 更新是会阻塞的。也就是说,只有事务 A 提交,或回滚之后,事务 B 才能获得排它锁,从而进行更新奖品的操作。
五、总结
Mysql 的默认隔离级别为可重复读 RR(repeatable-read)。
**隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。**因此在开发中需要根据实际情况来进行取舍。
MySQL 的存储引擎是 InnoDB ,通过 MVCC(多版本并发控制) 来实现快照读和当前读,有兴趣的可以参考https://blog.youkuaiyun.com/whoamiyang/article/details/51901888。