在高并发环境下,由于多用户同时对数据库进行读/写操作,数据的可见性和操作的原子性需要通过事务机制来保障。
下面我们通过4个典型场景来讲解数据库的事务隔离机制。
首先在Mysql数据库中创建1张表:
CREATE TABLE `account` (
`id` int(11) NOT NULL COMMENT 'ID',
`name` varchar(255) DEFAULT NULL COMMENT '姓名',
`account` float(255,0) DEFAULT NULL COMMENT '账户余额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
插入两条测试数据:
insert into account values(1, "小明", 1000);
insert into account values(1, "小强", 1000);
丢失更新
假设现在有2个线程操作account表:
线程A 线程B
读取到小明的account=1000
读取到小明的account=1000
set account=account+200
写回account=1200
set account=account+300
写回account=1300
很明显,在这种多线程更新操作下,线程A的更新丢失了,小明本来应该收到500元,结果只收到了300元。
还我血汗钱,小明要杀程序员祭天了…
于是,聪明的程序员引入了X锁来解决更新丢失的问题。
所谓X锁,又称排他锁(Exclusive Lock)或写锁,即某线程对数据添加X锁后,则独占该数据,其他线程不能更新该数据。该线程释放X锁后,其他线程获取到X锁后才可以进行更新操作,也就是说X锁属于独占锁,比较重。
于是上述的转账操作优化为:
线程A 线程B
获取account的X锁(成功)
读取到小明的account=1000
获取account的X锁(失败)
set account=account+200
写回account=1200 ......
释放account的X锁
获取account的X锁(成功)
读取到小明的account=1200
set account=account+300
写回account=1500
释放account的X锁
X锁优化对应的就是数据库事务隔离的最低级别Read Uncommited。
即Read Uncommited可以避免丢失更新。
脏读
Read Uncommited虽然可以解决更新丢失的问题,但是X锁并不能约束其他线程并行的读取数据。
比如下述场景:
线程A 线程B
获取account的X锁(成功)
读取到小明的account=1000
set account=account+200
写回account=1200
读取到小明的account=1200
Rollback
account恢复为1000
释放account的X锁
尴尬了!
小明现在的数据是错误的
我们用mysql模拟上述操作:
客户端A:
mysql> set session transaction isolation level read uncommitted;
mysql> start transaction;
mysql> select * from account;
再起一个客户端B:
mysql> set session transaction isolation level read uncommitted;
mysql> start transaction;
mysql> update account set account=account+200 where id=1
此时,客户端B的事务还未commit,通过客户端A执行select操作:
可以看到,客户端A的事务看到了客户端B的事务里未提交的修改数据。
此时,数据库中小明的account仍然是1000,可以起一个客户端C(未开启事务)来验证:
也就是说,客户端A中读取的数据与数据库中实际值不一致,出现了脏读。
出现脏读的原因主要是X锁仅对多线程的更新操作添加了约束,而对读取操作没做要求。
解决方法也就呼之欲出了,对读取操作也进行加锁呗。
那么是不是直接对读取操作也加X锁呢?
这样就太重了,而且由于X锁的独占性,当多线程环境下仅有读操作时,也需要频繁的加锁和释放锁,但实际上仅有读操作时,并发环境下并不会引发脏读(因为并没有线程更改数据嘛)。
于是,聪明的程序员引入了S锁来解决脏读的问题,同时又保证了锁的轻量性。
S锁,又称共享锁(Share Lock)或读锁,S锁与X锁的关系可以用1句话总结:
如果一个数据加了X锁,就没法加S锁;同样加了S锁,就没法加X锁。
当然,加了S锁的数据还可以继续添加S锁,因为并发读是互不影响的。
同时,在高并发环境下,为了防止单个线程长时间被S锁锁住,故有如下约定:
读数据前添加S锁,读完之后立即释放。
添加S锁机制之后,上面的流程优化如下:
线程A 线程B
获取account的X锁(成功)
读取到小明的account=1000
set account=account+200
写回account=1200
获取account的S锁(失败)
Rollback
...... account恢复为1000
释放account的X锁
获取account的S锁(成功)
读取到小明的account=1000
很明显,S锁限制了读时写和写时读,只有当写线程commit释放X锁之后,读线程才能获取到S锁完成数据的读取。
这种只能更新数据commit之后,才能读取到最新数据的事务隔离级别称为Read Committed。
即Read Committed可以避免脏读。
不可重复读
在Read Committed事务隔离级别下,我们为了防止高并发环境下读线程长时间被锁住,做了以下规定:
读数据前添加S锁,读完之后立即释放。
此时,会出现以下问题:
线程A 线程B
获取account的S锁(成功)
读取到的account=1000
释放account的S锁
获取account的X锁(成功)
set account=account+200
做其他事情...
写回account=1200
释放account的X锁
获取account的S锁(成功)
重新读取到的account=1200
What?
与之前读的不一样了?
此时,在同一个事务中重新读取的数据发生了变化,即不可重复读。
同样用mysql数据库演示上述过程:
客户端A:
mysql> set session transaction isolation level read committed;
mysql> start transaction;
mysql> select * from account;
此时再起一个客户端B:
mysql> set session transaction isolation level read committed;
mysql> start transaction;
mysql> update account set account=account+200 where id=1;
mysql> select * from account;
mysql> commit;
此时,在客户端A的事务中继续查询:
故客户端A同一个事务中小明的account出现了2个不同的值,即出现了不可重复读。
而解决不可重复读的方法也很简单,把S锁的规定升级一下即可:
读数据前添加S锁,事务提交之后才可以释放。
此时,上面的流程变为:
线程A 线程B
获取account的S锁(成功)
读取到的account=1000
获取account的X锁(失败)
做其他事情...
获取account的S锁(成功)
读取到的account=1000 ......
提交事务
释放account的S锁
获取account的X锁(成功)
set account=account+200
写回account=1200
释放account的X锁
此时对应的数据库事务隔离级别即为Repeatable Read。
Repeatable Read解决了不可重复读的问题。
幻读
通过X锁和S锁的组合应用,我们解决了数据的更新丢失、脏读、不可重复读3个问题,但由于X锁和S锁仅是对数据的更新(修改)和读取进行了限制,而对数据的添加和删除未做限制,那么即使在Repeatable Read隔离级别下,仍然会出现如下问题:
线程A 线程B
获取数据的S锁(成功)
查询account表
[(1, "小明", 1000)
(2, "小强", 1000)]
做其他事情... 插入数据(3, "小花", 1000)
提交
插入数据(3, "小花", 1000)
报错:'3' for key 'PRIMARY'
查询account表
[(1, "小明", 1000)
(2, "小强", 1000)]
What?
这哪里有id为3的数据,眼花了?
线程B的插入操作让线程A出现了幻觉,所以该种异常称之为幻读。
同样用mysql数据库演示上述过程:
客户端A:
mysql> set session transaction isolation level repeatable read;
mysql> start transaction;
mysql> select * from account;
此时再起一个客户端B:
mysql> set session transaction isolation level repeatable read;
mysql> start transaction;
mysql> insert into account values(3, "小红", 1000);
mysql> select * from account;
mysql> commit;
此时,客户端A在事务中继续执行:
mysql> insert into account values(3, "小阁", 1500);
mysql> select * from account;
还有一种幻读,指的是:
线程A 线程B
获取数据的S锁(成功)
查询account表中的人数
返回2
做其他事情... 插入数据(3, "小花", 1000)
提交
查询account表中的人数
返回3
What?
刚才还是2的?
只是MySQL的InnoDB引擎默认的Repeatable Read级别已经通过MVCC自动帮我们解决了,所以该级别下, 我们也模拟不出该种幻读的场景。
至于MVCC是啥,后面抽空再聊,哈哈…
说实话,幻读和不可重复读很容易混淆:
- 不可重复读,主要是说在同一事务中多次读取一条记录, 发现该记录中某些列值被修改过;
- 幻读,主要是说在同一事务中多次读取一个范围内的记录(包括查询所有结果或者聚合统计)、插入时,发现结果不一致。
解决幻读,只能放出我们的终极大招了,对整个事务加X锁,将事务的执行串行化,对应的数据库事务隔离级别为Serializable。
即Serializable解决了幻读的问题。
总结
- Read Uncommitted通过X锁来实现,锁住数据更新的阶段;
- Read Committed通过X锁和S锁来实现,且读完即释放S锁;
- Repeatable Read通过X锁和S锁来实现,事务提交之后释放S锁;
- Serializeable通过X锁来实现,锁住整个事务。
隔离级别 | 丢失更新 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
Read Uncommitted | No | Yes | Yes | Yes |
Read Committed | No | No | Yes | Yes |
Repeatable Read | No | No | No | Yes |
Serializeable | No | No | No | No |