背景
试想一下,当我们购买商品的时候,一般会有一下几个流程:
- 扣减商品数量
- 记录购买信息
如果商品扣减了,结果记录用户购买信息的时候失败了,那么数据就会出现不一致的问题。这种场景就可以使用事务。因为事务的一个特性就是原子性:要么不做,要么全做(事务的ACID的特性最终目的是为了保证数据的一致性)
上述问题解决了。再试想一下,在购买前,先查询商品剩余数量,如果商品<1,则商品售罄;如果事务A扣减商品数量但未提交,事务B查询剩余商品的数量,此时应该是多少呢?这就和事务的隔离级别有关系了。
实践
在讨论隔离级别之前,我们先做一些数据库初始化工作
CREATE TABLE `goods` (
`id` int(11) NOT NULL,
`count` int(11) NOT NULL DEFALUT '0' COMMENT '商品数量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
初始化一个商品
insert into goods (id,count) values (1,1);
定义
在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事物内和事务间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统开销也更低。
1.未提交读 READ UNCOMMITTED
事务中的修改,即使没有提交,对其他事务也是可见的。事务可以读取未提交的数据,也称为脏读。(实际应用中很少使用)
mysql演示:
将隔离级别设置为未提交读
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
执行过程 | 事务A | 事务B |
---|---|---|
1 | start transcation; | start transcation; |
2 | select count from goods where id = 1;(count = 1) | |
3 | update goods set count = count - 1 where id = 1; | |
4 | select count from goods where id = 1;(count = 0) | select count from goods where id = 1;(count = 0) |
5 | rollbak; | |
6 | commit; |
可以看到事务B读取到了事务A未提交的数据,它商品售卖已结束。但如果此时事务A回滚,但售卖实际是未结束的。这就是脏读
2.提交读 READ COMMITTED
提交读可以满足上面提到的隔离性的简单定义:一个事务开始时,只能“看见”已经提交的事务所做的修改。换句话说一个事务开始直到提交之前,所做的任何修改都不可见。
这个隔离级别也叫不可重复读(nonrepeatable read),因为执行两次查询,可能会得到不同的结果(事务开始前和事务结束后)
mysql演示:
将隔离级别设置为提交读
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
执行过程 | 事务A | 事务B |
---|---|---|
1 | start transcation; | start transcation; |
2 | select count from goods where id = 1;(count = 1) | |
3 | update goods set count = count - 1 where id = 1; | |
4 | select count from goods where id = 1;(count = 0) | select count from goods where id = 1;(count = 1) |
5 | commit; | select count from goods where id = 1;(count = 0) |
6 | commit; |
可以看到事务A提交之前的改动事务B是读取不到的。只有事务A提交之后事务B才能读到事务A的改动。在事务B中先后两次读取,count的值是不一样的,这就叫做不可重复读。
3.可重复读 REPEATABLE READ
REPEATABLE READ 可以解决脏读的问题。该级别保证了再同一个事务中多次读取同样的记录结果都是一致的。
但理论上,可重复读隔离级别还是无法解决另一个幻读的问题。所谓幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行。
InnoDB和XtraDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)解决了幻读的问题。
可重复读是MYSQL的默认事务隔离级别
mysql演示:
将隔离级别设置为可重复读
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
执行过程 | 事务A | 事务B |
---|---|---|
1 | start transcation; | start transcation; |
2 | select count from goods where id = 1;(count = 1) | |
3 | update goods set count = count - 1 where id = 1; | |
4 | select count from goods where id = 1;(count = 0) | select count from goods where id = 1;(count = 1) |
5 | commit; | select count from goods where id = 1;(count = 1) |
6 | commit; |
可以看到,无论事务A是否提交,事务B读到的结果都是一致的。
思考
下面默认都是讨论MYSQL RR 隔离级别的情况。
如果用户1和用户2同时购买一件商品,并且都进入到了购买的事务中。A事务扣减了商品数量,B也扣减了商品数量。假设商品总数是N,那么两个事务并发执行,那么不论A有没有提交,B得到的数量都是N,B事务执行后为N-1,而事务A也是N-1,这样就有问题了,期望的是N-2。
快照读、当前读
在事务中,执行普通select查询后,会创建快照,后面再执行相同的select查询语句时,查询的其实是前面生成的快照。这就是为什么会有可重复读。
哪些读操作是快照读?哪些是当前读?
- 快照读:简单的select操作,属于快照读,不加锁
select * from table where ?
- 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。
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 ?;
所有以上的语句,都属于当前读。当前读读取的记录都是最新版本,并且读取之后还需要保证其他并发事务不能修改当前记录,对读取记录加锁。除了第一条语句外,对读取记录加S锁(共享锁)外,其他操作,加的都是X锁(排他锁)。
sql语句执行过程
以update为例。当update sql发给mysql之后,mysql会根据where条件读取第一天满足条件的记录,然后InnoDB引擎会将第一条记录返回,并加锁(当前读)。待mysql收到这条加锁的记录之后,会再发起一个update请求,更新这条记录。
一条记录完成之后,在读取下一条,直至没有满足条件的记录为止。delete的操作也一样。insert会有稍许不同,简单来说,就是insert操作会触发unique key的冲突检查,也会进行一个当前读。
InnoDB与mysql的交互,是一条条进行的,因此加锁也是一条条进行的。
回到刚才的问题。当事务B执行N-1更新操作的时候,会触发当前读,读取事务A提交后的数据,也就是事务A的N-1,再次基础上执行-1操作,最终变成N-2。
并发更新
上面的问题解决了事务A已经提交的情况。但如果事务A更新产品数量没有提交呢?此时事务B执行当前读拿到的也是N。
了解数据库锁机制。
事务A提交前,会一直持有排它锁(具体行锁还是表锁,要看查询有没有走索引),此时事务B是会阻塞的。也就是说只有事务A提交或者回滚之后,事务B才能获得排它锁,从而进行更新操作
4.可串行化(SERIALIZABLE)
SERIALIZABLE是最高的隔离级别。它通过强制事务串行执行,避免了幻读的问题,简单的来说SERIALIZABLE会在读取的每一行数据都加锁,所以可能导致大量的锁超时和锁争用的问题。
MVCC并发控制退化为基于锁的并发控制,不区别快照读与当前读,所有操作均为当前读。