之前在深入了解数据库理论的时候,了解到事物的不同隔离级别可能存在的问题。为了更好的理解所以在MySQL数据库中测试复现这些问题。关于脏读和不可重复读在相应的隔离级别下都很容易的复现了。但是对于幻读,我发现在可重复读的隔离级别下没有出现,当时想到难道是MySQL对幻读做了什么处理?
测试:
创建一张测试用的表dept:
CREATE TABLE `dept` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8
insert into dept(name) values(“后勤部”)
事物 1 | 事物 2 |
---|---|
begin | begin |
select * from dept | |
- | insert into dept(name) values("研发部") |
- | commit |
select * from dept | |
commit |
根据上面的流程执行,预期来说应该是事物1的第一条select查询出一条数据,第二个select查询出两条数据(包含事物2提交的数据)。
但是在实际测试中发现第二条select实际上也只查询处理一条数据。这是但是根据数据库理论的可重复读的实现(排他锁和共享锁)这是不应该的情况。
在了解实际原因前我们先复习下事物的相关理论。
事务(Transaction),一般是指要做的或所做的事情。在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。事务由事务开始(begin transaction)和事务结束(end transaction)之间执行的全体操作组成。在关系数据库中,一个事务可以是一组SQL语句或整个程序。
一个数据库事务通常包含对数据库进行读或写的一个操作序列。它的存在包含有以下两个目的:
为数据库操作提供了一个从失败中恢复到正常状态的方法,同时提供了数据库在异常状态下仍能保持一致性的方法。
当多个应用程序在并发访问数据库时,可以在这些应用程序之间提供一个隔离方法,保证彼此的操作互相干扰。
事务具有4个特性:原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID 特性。
原子性(atomicity):
一个事务应该是一个不可分割的工作单位,事务中包括的操作要么都成功,要么都不成功。
一致性(consistency):
事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
隔离性(isolation):
一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据在事物未提交前对并发的其他事务是隔离的,并发执行的各个事务之间不能互相影响。
持久性(durability):
一个事务一旦成功提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
事物之间的几个特性并不是一组同等的概念:
如果在任何时刻都只有一个事物,那么其天然是具有隔离性的,这时只要保证原子性就能具有一致性。
如果存在并发的情况下,就需要保证原子性和隔离性才能保证一致性。
如果不考虑事务的隔离性,会发生以下几种问题:
脏读:脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。当一个事务正在多次修改某个数据,而在这个事务中这多次的修改都还未提交,这时一个并发的事务来访问该数据,就会造成两个事务得到的数据不一致。
不可重复读:不可重复读是指在对于数据库中的某条数据,一个事务范围内多次查询返回不同的数据值(这里不同是指某一条或多条数据的内容前后不一致,但数据条数相同),这是由于在查询间隔,该事物需要用到的数据被另一个事务修改并提交了。不可重复读和脏读的区别是,脏读是某一事务读取了另一个事务未提交的脏数据,而不可重复读则是读取了其他事务提交的数据。需要注意的是在某些情况下不可重复读并不是问题。
幻读:幻读是事务非独立执行时发生的一种现象。例如事务T1对一个表中所有的行的某个数据项做了从“1”修改为“2”的操作,这时事务T2又对这个表中插入了一行数据项,而这个数据项的数值还是为“1”并且提交给数据库。而操作事务T1的用户如果再查看刚刚修改的数据,会发现还有一行没有修改,其实这行是从事务T2中添加的,就好像产生幻觉一样,这就是发生了幻读。幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读可能发生在update,delete操作中,而幻读发生在insert操作中。
排它锁(Exclusive),又称为X 锁,写锁。
共享锁(Shared),又称为S 锁,读锁。
读写锁之间有以下的关系:
即读写锁之间的关系可以概括为:多读单写
在事物中存在以下几种隔离级别:
读未提交(Read Uncommitted):解决更新丢失问题。如果一个事务已经开始写操作,那么其他事务则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过“排他写锁”实现,即事物需要对某些数据进行修改必须对这些数据加 X 锁,读数据不需要加 S 锁。
读已提交(Read Committed):解决了脏读问题。读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。这可以通过“瞬间共享读锁”和“排他写锁”实现, 即事物需要对某些数据进行修改必须对这些数据加 X 锁,读数据时需要加上 S 锁,当数据读取完成后立刻释放 S 锁,不用等到事物结束。
可重复读取(Repeatable Read):禁止不可重复读取和脏读取,但是有时可能出现幻读数据。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。Mysql默认使用该隔离级别。这可以通过“共享读锁”和“排他写锁”实现,即事物需要对某些数据进行修改必须对这些数据加 X 锁,读数据时需要加上 S 锁,当数据读取完成并不立刻释放 S 锁,而是等到事物结束后再释放。
串行化(Serializable):解决了幻读的问题的。提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,不能并发执行。仅仅通过“行级锁”是无法实现事务序列化的,必须通过其他机制保证新插入的数据不会被刚执行查询操作的事务访问到。
上面的内容解释了一些数据库理论的概念,但是在MySQL、ORACLE这样的数据库中,为了性能的考虑并不是完全按照上面介绍的理论来实现的。
多版本并发控制(Multi-Version Concurrency Control, MVCC)是MySQL中基于乐观锁理论实现隔离级别的方式,用于实现读已提交和可重复读取隔离级别的实现。
在说到如何实现前先引入两个概念:
系统版本号:一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
事务版本号:事务开始时的系统版本号。
在MySQL中,会在表中每一条数据后面添加两个字段:
创建版本号:创建一行数据时,将当前系统版本号作为创建版本号赋值
删除版本号:删除一行数据时,将当前系统版本号作为删除版本号赋值
select时读取数据的规则为:创建版本号<=当前事务版本号,删除版本号为空或>当前事务版本号。
创建版本号<=当前事务版本号保证取出的数据不会有后启动的事物中创建的数据。这也是为什么在开始的示例中我们不会查出后来添加的数据的原因
删除版本号为空或>当前事务版本号保证了至少在该事物开启之前数据没有被删除,是应该被查出来的数据。
insert时将当前的系统版本号赋值给创建版本号字段。
插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行,实际上这里的更新是通过delete和insert实现的。
删除时将当前的系统版本号赋值给删除版本号字段,标识该行数据在那一个事物中会被删除,即使实际上在位commit时该数据没有被删除。根据select的规则后开启懂数据也不会查询到该数据。
从最开始我们的测试示例和上面的理论支持来看貌似在MySQL中通过MVCC就解决了幻读的问题,那既然这样串行化读貌似就没啥意义了,带着疑问继续测试。
测试前数据:
事物 1 | 事物 2 |
---|---|
begin | begin |
select * from dept | |
- | insert into dept(name) values("研发部") |
- | commit |
update dept set name="财务部"(工作中如果不想被辞退一定要写where条件) | |
commit |
根据上面的结果我们期望的结果是这样的:
id name 1 财务部 2 研发部
但是实际上我们的经过是:
本来我们希望得到的结果只是第一条数据的部门改为财务,但是结果确实两条数据都被修改了。这种结果告诉我们其实在MySQL可重复读的隔离级别中并不是完全解决了幻读的问题,而是解决了读数据情况下的幻读问题。而对于修改的操作依旧存在幻读问题,就是说MVCC对于幻读的解决时不彻底的。
出现了上面的情况我们需要知道为什么会出现这种情况。在查阅了一些资料后发现在RR级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,不是数据库最新的数据。这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库最新版本数据的方式,叫当前读 (current read)。
当执行select操作是innodb默认会执行快照读,会记录下这次select后的结果,之后select 的时候就会返回这次快照的数据,即使其他事务提交了不会影响当前select的数据,这就实现了可重复读了。快照的生成当在第一次执行select的时候,也就是说假设当A开启了事务,然后没有执行任何操作,这时候B insert了一条数据然后commit,这时候A执行 select,那么返回的数据中就会有B添加的那条数据。之后无论再有其他事务commit都没有关系,因为快照已经生成了,后面的select都是根据快照来的。
对于会对数据修改的操作(update、insert、delete)都是采用当前读的模式。在执行这几个操作时会读取最新的记录,即使是别的事务提交的数据也可以查询到。假设要update一条记录,但是在另一个事务中已经delete掉这条数据并且commit了,如果update就会产生冲突,所以在update的时候需要知道最新的数据。也正是因为这样所以才导致上面我们测试的那种情况。
select的当前读需要手动的加锁:
select * from table where ? lock in share mode; select * from table where ? for update;
在测试过程中最开始我以为使用begin
语句就是开始一个事物了,所以在上面第二次测试中因为先开始的事物1,结果在事物1中却查到了事物2新增的数据,当时认为这和前面MVCC中的select的规则不一致了,所以做了如下测试:
SELECT * FROM information_schema.INNODB_TRX //用于查询当前正在执行中的事物
可以看到如果只是执行begin
语句实际上并没有开启一个事物。
下面在begin
后添加一条select语句:
所以要明白实际上是对数据进行了增删改查等操作后才开启了一个事物。
很明显可重复读的隔离级别没有办法彻底的解决幻读的问题,如果我们的项目中需要解决幻读的话也有两个办法:
实际上很多的项目中是不会使用到上面的两种方法的,串行化读的性能太差,而且其实幻读很多时候是我们完全可以接受的。
可重复读是mysql默认的隔离级别,并不是一定存在幻读问题。 例如:两个事务A和B同时开启,A事务修改数据,增加数据,删除数据。 B事务只要刚开始begin,没有任何操作(注意,查询也不行)。这个时候,A事务提交。B事务查到的数据就是最新的。也就是事务开始前看到的数据和事务开始后看到的数据不一致。但这是事务前后看到的不一致,不算幻读。
这里B事务在A事务没有提交前,不能有任何操作,查询也不行,因为mysql的mvcc机制,一旦B在A提交前做了增删改查操作,B就会生成快照,那么即使A事务提交了。B看到的也只会是自己的快照版本。A的insert,update,delete对B都不可见。
也就是说,不存在幻读现象。
可重复读首先是基于读已提交的基础上。多个事务并发操作时,一些事务没有提交的,必然对于其他事务不可见。而在可重复读中,由于事务都是对自己的快照版本进行操作。
begin开始后,第一条sql语句,会拿最新的数据,作为快照。之后在事务提交前,操作的都是快照。查询并不对快照有影响,但是insert,update,delete会更改快照。比如,update某一行数据,如果快照和数据库库不一致,会拿到数据库最新的数据进行update操作,生成快照。事务没提交,数据库中的数据自然不会更改。
类似的,insert,delete也是对快照数据进行更改。
有个现象很有趣:如果数据库中某个数据id=7被B事务删除了并已提交。A事务如果begin后没做任何操作,自然看不到被删除的那条数据。但是如何A事务begin后有操作,生成了快照版本,那么B删除了,A看到的仍然是自己的快照,也就有id=7那条数据。并且,A可以执行delete命令,但是结果是0行被影响。也就是没有作用。并且再次查询时,还是能够看到它。而且显示4 rows in set. 猜测是软删除的问题。既然快照能够select到,说明数据存在,数据库中不显示了,但是快照仍然可以看到,说明是软删除。同时,快照知道该数据被删除了,也就是快照也有标志位被改了。猜测底层是软删除吧。
同理,如果A事务begin后啥也没做,B事务插入一条数据并提交,A事务查询看到的肯定是最新的数据,并生成快照。 那么如果A做了操作,已经有快照了,A是看不到最近插入的数据的。那么A能插入吗?此时数据库中是有了插入的数据的。应该插入失败才对吧?没错,结果就是失败。但是很神奇,A查询就是查不到该数据,因为快照中没有,但是插入不进去。因为事实上数据库中是有的。
由上,可以做些猜测,快照版本和数据库中真正的数据并不是完全隔离的,也就是不是直接复制一份作为快照。如果这么做,那如果有几百个事务,难道数据备份几百份?如果数据量很大,那也太浪费了。。。。。
还有就是快照问题。我们怎么得到最新的数据呢?用update。我们如果一个数据已经有了,当前快照没有。那么你insert是报错的,但你怎么查它还是没有。。。但是你知道数据库是有的,所以你用update正常操作,再次查询,就有数据了,然后当前为新的快照。待提交后更新到数据库。
经过尝试,如果A事务快照有数据库中没有的数据,我们删除不报错,但是现实0行被影响,update也是0行被影响,select该数据还是存在。目前没找到好的方法让它“消失”。而且查询时,它在行数里面被计数了。
更新数据会加行锁,这是mysql默认的。但是where条件要是索引,才加行锁。否则会升级为表锁,那时将会阻塞其他事务一切更新操作。 查询当然不会阻塞了。但是同样,查询会生成快照哦。InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。
这个比较简单,主要是索引键,比如where id >= 5 and id <= 20,那么如果你的数据库中id有1,3,6,10,15,20这么六条数据。也就是有1-3,3-6,6-10,10-15,15-20,20-无穷。这么多间隙。根据where条件5-20之间会被上锁,注意,5落在3-6之间,那么这整个间隙都会被封锁,也就是只能对1-3,21-无穷进行更新操作。(我说的更新默认就是增删改),查询当然是不受影响的。而且查询查的是快照嘛。 如果where id >= 6 and id <= 20,那么1-3和3-6(不包括6,因为6在where条件中)是可以更新的。 其他的都要被阻塞,等待锁的释放。
同样得,如果A事务插入数据未提交,那么B事务上间隙锁时,如果该数据在范围内,上锁也会失败,会阻塞住,等A commit或者rollback,B就可以执行了。