脏读、不可复读、幻读小记
脏读、不可复读、幻读
以上这三个问题都是隔离级别不够时,数据库事务并行所导致的。因此只要达到要求的隔离级别或者数据库事务全部进行串行执行,就可以避免这三个问题。这也是Redis采取单线程的原因之一。单线程执行事务天然提供了最高的隔离级别——可序列化。
脏读,当一个数据库事务修改某一行时(例如把字段从1改到2),在事务未提交时,另一个就可以读取到事务修改后的值(即数值2),这就是脏读。因为数据库事务在未提交前,都可能因为各种原因而回滚。如果回滚,另一个事务脏读得到的数据就是非法数据。解决方案很简单,那就是数据库同时保存两个值。一个是未提交前的数据,一个是提交后的数据。如果写事务未提交,读事务就只能读取到旧值;如果写事务提交了,读事务就可以获取新值。这就是隔离级别读已提交的实现。
不可复读。在上面的脏读实现中,数据库存储了两个值。此次,读事务发生在写事务前,并读取了两次数据。在两次读取之间,写事务提交了。那么按照读已提交的实现,第一次读取是旧值。第二次读就是新值。这显然是有问题的。逻辑上,读事务发生在写事务前。尽管因为CPU切片了,或者其他原因,导致了它第二次读慢于写事务。但第二次读逻辑上也是快于写事务的。第二次读到的也应该是写事务提交前的值。
为了防止上述问题,数据库为每一行设计了一个隐藏的字段——事务id。每一个事务发起时,都会从数据库取得一个递增的唯一id。当这个事务提交并修改一行时,会将事务id赋值到行的事务id字段。并把旧一行存储起来。当某个事务读到这一行时,会对照数据库中这一行的基于事务id字段的多个版本。只有当事务id字段的值小于且最接近读事务id的行的值会被读事务读进来。因为这是在他之前最近的一次修改。这种机制也叫MVCC。
幻读常常与写偏差一起提起。幻读的例子如下。医院的某一个段时间会有多个医生值班。但是如果值班医生有紧急事,医院是允许医生请假的。但这段时间至少要有一个医生,也就是说最后一个值班医生是不允许请假的。假设请假系统的表有三个字段{时间,医生id,是否值班}。
如上图右侧所示。假设此时,只有医生A一人值班,他发起了请假事务。此时在事务进行期间,医生B自告奋勇来值班。在数据中发起了写事务,并插入一条新值。由于A事务早于B事务,因此即便医生B先完成了事务,该段时间多了一个值班医生。A医生依旧无法读到B医生的记录,无法统计到该时段的值班医生人生有2人。从而无法请到假。但只要医生A再请假一次就可以成功,即便这两次请假之间再没有任何人操作数据库。一个事务中的写入改变了另一个事务的搜索查询的结果,这就是幻读。
在快照隔离的级别下,常常跟幻读一起出现的还有写偏差。如上面图左侧流程图所示。同时段内,同时有两个医生在值班。因为急事都需要进行请假。A医生首先发起请假,B医生随后也发起了请假。由于A医生的事务还未提交,因此B医生发现此时医院还有另外一个人值班,请假成功,写入数据库。尽管B医生已经写入成功了,A医生由于早于B医生请假,根据MVCC,他无法看到B医生也请假的结果。当发现医院还有另外一个人请假时,他也请假成功了。这个时候出现了一个严重事故,该时段没有医生请假。这就是写偏差。
为了解决幻读和写偏差,数据库需要设置可序列化级别。
可序列化级别
作为最高级别的隔离级别,可序列化级别的性能也是最差的。目前行业内主要有两种处理方式。一种是串行化事务的执行。幻读和偏差都是因为事务的并行导致的,因此只要串行,便可以保证这两个问题不出现。
两相锁定。这是关系数据库经常采用的方法。当一个事务读取一个行时,数据库会给该行添加共享锁。共享锁允许其他事务也读取该行数据。当一个事务写一个行时,数据库会给该行添加排它锁。添加排它锁,将导致其他事务关于该行的读写都无法执行而进入堵塞,直到锁释放。通过这两种锁,数据库来保证数据的正确性。
拿上面的写偏差举例子。A医生读取数据时,会给所有的医生(无论他值不值班)加上共享锁。随后B医生也开始读取数据,获取共享锁成功,一样读入了数据。B医生先一步开始写,共享锁升级为排他锁(相当于去获取排它锁)。但是此时A医生已经获取了共享锁,B医生因此无法获取排它锁成功。A医生也一样,因为无法获取排它锁,所以也无法写入。此时AB都陷入了死锁。数据库会检测到这种死锁,把这两个事务回滚,分别先后执行。
自此,数据库解决了写偏差问题。但同时也引入了锁和因为锁带来的死锁问题,使得性能大幅度下降。在问及Redis单线程原因时,其中有一个原因便是来自于此。单线程串行化执行事务的Redis在提供了可序列化的事务隔离界别时,还避免引入了复杂的锁实现和死锁问题。
但是上述简单的实现依然无法解决幻读问题。基于行的锁定是无法解决插入新数据行带来的幻读问题。因此数据库不把锁附加到行上,而是索引上。一般为了方便搜索,时间字段都会附加索引。二相提交会把锁附加到时间字段索引上。拿上面幻读案例分析。当A医生搜索深夜1:00-4:00值班医生的所有行方便统计值班医生数量时,会把共享锁附加到值为1:00-4:00的时间字段索引上,当B医生想要插入一个新的行到1:00-4:00这段时间,会去获取这段索引的排他锁不成功而进入堵塞,直到A医生执行事务完毕。这叫索引范围锁,也叫间隙锁。
MySQL命令行参考
查询隔离级
SELECT @@global.tx_isolation;
SELECT @@session.tx_isolation;
SELECT @@tx_isolation;
设置隔离级别
set global transaction isolation level read committed;
set session transaction isolation level read committed;