如何保证数据不丢失 - 事务篇 - 事务的隔离级别

296 阅读16分钟

对于各种数据系统而言,保证数据不丢失,都是一个比较大的话题,我最近正在读《数据密集型应用系统设计》这本书,准备就保证数据不会丢失这个问题做一次总结,另外也会介绍一下各个数据系统对于如何保证数据不丢失的解决方案有哪些。

数据流向

data_direction.svg

数据的流向都是从某个客户端发起写入请求,请求成功后数据写入到数据系统,成功写入到数据系统的数据可以被另外的客户端读取。了解数据流向对于如何保证数据不丢失是一个比较重要的事,因为数据的位置会在三个地方,写入请求中,数据系统,读取请求,保证数据不丢失,即需要保证数据在这三个地方都不会丢失,并且解决方案也不相同。

在写入过程中,保证数据不丢失,就是保证数据能够成功写入数据系统,解决方案通常是事务。

在数据过程中,保证数据不丢失,就是保证数据能够不管在什么情况下,都能够保证成功写入的数据不会丢失,解决方案通常是持久化。另外,如果数据存储的磁盘坏了或者磁盘满了,又如何保证数据不丢失呢?这种问题的解决方案通常都是日志,数据复制和数据分区。

在读取过程中,一般数据系统,只要能够持久化到磁盘,就能保证能够读取到数据,但如果是消息系统,消费者消费数据,如果消费者消费数据失败,可能会导致消息丢失,这类系统的解决方案通常是消费者提供commit机制(也是一种事务机制,或者ack机制)。

本文主要讨论的是事务(单机)。

事务

几乎所有的数据系统都会支持事务处理,尽管具体实现方面会有所不同,另外由于NoSQL或者其他分布式数据库的崛起,为了兼容性能和高可用性,也不一定所有的事务特性都实现了,但事务的概念几乎没有什么变化。

事务的ACID四大特性被大家所熟知,另外就是分布式事务的BASE理论,不过本文将不介绍分布式相关的内容。

ACID分别代表着原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Duration)

原子性

一个事务里包含了多个写操作,完成了一部分写入后,系统发生了故障,包括但不局限于进程crash,网络中断,磁盘满了等异常情况,导致最终没发提交事务,事务就会终止,并且数据库须丢弃或者撤销这些局部完成的更改。

如果没有原子性的保证,多个写操作中间发生了错误,就需要追踪哪些写操作已经生效,这个寻找过程会非常麻烦。或许有些应用实现了重试功能,但如何保证不会重复执行已经生效的写操作,也是比较麻烦的。相比较之下,事务的原子性能够大大简化这个问题,如果事务已经终止,应用程序可以毫无顾忌的重试,因为原子性已经保证了数据没有发生更改。

一致性

对数据有特定的预期状态,任何数据更改必须满足这些状态约束。就拿转账来说,假设用户A和用户B两者的钱加起来一共是5000,那么不管A和B之间如何转账,转几次账,事务结束后两个用户的钱相加起来应该还得是5000,这就是事务的一致性。

这种一致性本质上要求应用层来维护状态的一致性,这不是数据库可以保证的事情,主要数据库也很难检测到不一致的状态,从而能够阻止该操作。当然,关系型数据库可能会通过唯一性约束,外键,触发器等手段来保证,但是手段有限。

隔离性

当多个客户端并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。

并发是一个比较头疼的问题,可能会出现一些意想不到的异常,例如脏读,不可重复读,幻读,脏写,更新丢失等异常情况。如果为了安全,多个事务执行,都可以串行化执行,那这样性能就很低,一些主流的数据库,比如oracle11g甚至根本没有实现串行化。那如何平衡性能与并发的问题,这就需要引入隔离级别了,另外不同的隔离级别也是不同异常情况的解决方案

下面就简单简单介绍一下隔离级别。

隔离级别

transaction.svg

如图所示,两个事务的执行过程为:

  • T1时刻事务A和B同时启动
  • T2时刻事务A和B都查询到v = 1
  • T3时刻事务B将v改成2
  • T4时刻事务B还未提交,事务A就去获取v的值
  • T5时刻事务B提交事务
  • T6时刻事务A再次获取v的值
  • T7时刻事务A提交
  • T8时刻事务A再次获取A的值

下面我们将结合这个时序图,来描述在各个隔离级别下,事务A在不同时刻下获取到v的值为多少

读-未提交(Read Uncommitted)

一个事务的更新语句没有提交,但是别的事务可以读到这个改变。

在这个隔离级别下,事务A在T4时刻获取到v的值就已经为2了,即使事务B还未提交,这种当一个事务读取到了另一个事务尚未提交的修改的现象就是脏读(Dirty Read)

这种隔离级别,是最弱的隔离级别,只能防止脏写,一般不会用,有些数据系统设置都没有提供这种隔离级别的实现。

读-提交(Read Committed)

一个事务提交之后,它做的变更才会被其他事务看到。

在这个隔离级别下,因为事务B在T5时刻提交了事务,所以事务A在T4时刻获取到v的值还是为1,这就防止了脏读。另外,事务A在T6,T8时刻获取到v的值为2。

在这个隔离级别下, 由于事务A在T7时刻才提交,事务A在提交之前,T4,T6两次获取到v的值不一样,产生了不可重复读(Nonrepeatable Read) 的异常。

一个事务对同一行数据重复读取两次,但是却得到了不同的结果。同一查询在同一事务中多次进行,由于其他提交事务所做的修改或删除,每次返回不同的结果集,此时发生不可重复读,又被称为读倾斜(Read Skew)

可重复读(Repeatable Read)

一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。

在这个隔离级别下,由于事务A是在T7时刻才提交,所以事务A在T4和T6时刻获取到v的值都是1,避免了不可重复读。在T8时刻获取到v的值为2。

在这个隔离级别下,无法防止幻读(Phantom Read) ,通过提交事务B之后,事务A出现莫名奇妙的数据遗失或数据增多。幻读主要针对的是数据新增和删除的情况。

Phantom_Read.svg

如上图所示,幻读的发生经过如下:

  • T1时刻,事务AB同时开启
  • T2时刻,事务AB同时查到user表里的数据只有Shawn
  • T3时刻,事务A新增了一个用户John
  • T4时刻,事务A提交
  • T5时刻,事务B再次查看user表,发现多了一条John的用户记录,出现幻读
  • T6时刻,事务B提交

串行化(Serializable)

顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

在这个隔离级别下,事务A在T4和T6时刻获取到v的值都是1,在T8时刻获取到v的值为2。

在这个隔离级别下,由于已经串行化执行了,可以完全避免各种异常问题,即使是难以解决的幻读和写倾斜。

串行化的实现基本是以下3种方法之一

  1. 严格按照串行顺序执行,解决并发问题最直接的方法就是避免并发,所有线程都按照顺序执行。

  2. 两阶段加锁:

    • 分为共享锁与排它锁

      • 多个事务可同时持有共享锁
      • 有一个写入事件则升级共享锁为排它锁,该锁阻塞其它读取与写入,直到事务完成
      • 性能相比较弱隔离级别差得多
  3. 乐观并发控制技术,例如可串行化的快照隔离,mvcc技术

并发写异常情况

上面介绍的五种隔离级别,主要都是为了解决只读事务遇到并发写的时候可以读取到什么的问题,那多个事务并发写,又会发生怎样的异常情况呢?下面主要介绍3种写异常,脏写(Dirty Write),更新丢失(Lost Update),写倾斜(Write Skew)

脏写(Dirty Write)

当两个事务同时尝试去更新某一条数据记录时,就肯定会存在一个先一个后。而当事务A更新时,事务A还没提交,事务B就也过来进行更新,覆盖了事务A提交的更新数据,这就是脏写。

文上提到的4种隔离级别下,都不存在脏写情况。因为在这些隔离级别下,当两个事务A和B尝试去更新同一条数据时,假定A先更新数据,会对更新的数据行记录加上排他锁(也叫写锁,悲观锁),除非事务A提交或终止从而释放排他锁,否则事务B都是无法更新数据的。

更新丢失(Lost Update)

定义

当多个事务并发写同一数据时,先执行的事务所写的数据会被后写的覆盖,这也就是更新丢失。前面的脏写情形,就属于会导致更新丢失问题的一种情形。

除了这个,更新丢失主要发生在read-modify-write类型的事务当中:就是要先查询数据,然后计算新的数据,最后写回新的数据。下面是几个具体的情形例子:

  1. 数值更新,例如计数或账户余额更新(先要查询当前值,再计算出要更新的值,最后执行更新操作写进数据库)
  2. 多个用户同时在线编辑同一份文档。
为何会产生更新丢失

为了理解方便更新丢失的逻辑,我们画一张时序图,再来分析一下。

lost_update_1.svg

  • T1时刻事务A和B同时启动
  • T2时刻事务A和B都查询到v = 1
  • T3时刻事务B执行v = v(T2时刻查到的值) + 1
  • T4时刻事务B还未提交,事务A查询v的值
  • T5时刻事务B提交
  • T6时刻事务A执行v = v(T4时刻查到的值) + 1
  • T7时刻事务A提交
  1. 读提交或者更高的隔离级别下,只要事务B还未提交,那么事务A就永远不知道事务B的更新值,进而就会使得事务A会忽略掉事务B所产生的变更,T3时刻事务B已经将v更新为2,但事务A查询到v的值还是1,所以事务A在T6时刻执行v+1的时候,预期应该变更为3,但实际还是2。
  2. 读未提交隔离级别下,如果事务B的变更,是在事务A查询之前,那么就不会产生更新丢失,T3时刻,事务B已经将v更新为2,T4时刻事务A查询到值已经为2了,所以事务A在T6时刻执行v+1,值已经变成了3,这个是符合预期的。但如果事务B的变更,是在事务A查询之后产生(T3时刻事务A先去查询v的值为1,T4时刻事务B再去更新v的值,T6时刻事务A执行v+1,更新完以后值为2,也不符合预期),那就无法避免更新丢失。
解决方案
  1. 原子写操作

    update table set v = v + 1 where id = 1; 
    

    如果数据系统支持这种原子写操作,那么它就是推荐的最佳方式。

  1. 显式加锁

    如果数据系统不支持原子写操作,另一种防止更新丢失的方法是由应用程序显示锁定待更新的对象,然后应用程序可以执行read-modify-write操作。此时如果有其他事务想要同时读取对象,则必须等待当前正在执行的事务完成。

    begin transaction;
    select v from table where id = 1 for update;
    update table set v = 3 where id = 1;
    commit;
    
  2. 自动检测更新丢失

    原子写操作和显式加锁实质上都是将事务给串行化了。自动检测说的就是当事务管理器检测到事务A造成更新丢失问题,就立即终止事务A,让事务A再一次尝试查询-计算-更新的流程,事务仍然是并行执行的。

    PostgreSQL的可重复读,Oracle的串行以及SQL Server的快照隔离能够自动检测更新丢失,但Mysql的Innodb引擎下的可重复读没有此功能。

  3. CAS(compare and set)

    update table set v = 2 where id = 1 and v = 1;
    

    如果两个事务A和B,现在都要对id=1的记录执行累加操作,只要其中任何一个事务执行了更新操作,另一个事务执行时v=1的条件都不会满足,从而就规避了更新丢失的问题。

    但是有的数据库where条件里v获取的本就是旧的快照数据,即不是某一个事务更新后的新数据,那这里的更新丢失问题还是要发生的了。

写倾斜(Write Skew)

定义

引用数据密集型应用系统设计里的案例来解释,在医院值班系统中,必须确保至少有一个医生在值班,假设这个科室就两个医生,他们都因为身体不舒服同时在系统上点击了请假按钮,我们画一张时序图,看看会发生什么微妙的情况。

write_skew.svg

  • T1时刻,事务AB同时启动事务
  • T2时刻,执行select count(1) from t where on_call = true,返回结果都是2
  • T3时刻,事务B发现返回结果2,满足请假条件,执行update on_call = false;
  • T4时刻,事务A也发现返回结果2,满足请假条件,执行update on_call = false;
  • T5时刻,事务B提交
  • T6时刻,事务A提交

根据时序图发现,事务都成功提交了, 最后的结果就是没有医生在值班,这就违反了至少一名医生在值班的业务要求,这就是写倾斜的异常情况,它既不是脏写,也不是更新丢失,因为两笔事务更新的不是同一个对象(分别是两个医生的值班记录)。

为何会产生写倾斜

所有写倾斜的例子都遵循以下类似的模式:

  1. 首先输入一些匹配条件,查询到所有满足条件的记录。
  2. 再根据返回的记录,应用层根据业务逻辑来决定下一步的操作。
  3. 如果应用层决定继续执行,它将发起数据库写入操作。
解决方案

处理写倾斜的情况会有比较多的限制

  • 单对象的原子操作不起作用了。

  • 快照隔离级别都解决不了这个问题,需要串行化隔离级别

  • 数据库虽然支持一些自定义约束,但是对涉及多个对象的约束支持不好

  • 不使用串行化隔离级别的次优选择是对选择的多行对象显示的加锁,可以这样:

    BEGIN ;
    select * from doctors
    where on_call=true
    and shift_id=1234 for update;
    ​
    update doctors
    set on_call= false
    where  name='Alice'
    and shift_id=1234;
    commit 
    

持久性

保证了事务一旦提交成功,即使存在硬件故障或数据库崩溃的情况,事务写入的数据也不会丢失。

对于单节点数据系统而言,持久性通常意味着数据已经写入了磁盘,如果磁盘坏了还能通过预写日志等手段恢复。

对于多节点数据系统而言,持久性则意味着数据已经成功复制到多个节点,所以事务必须等数据写入和复制完成才能提交事务。

对于持久性而言,主要看各个数据系统是如何实现持久化的,但本文主要介绍一下事务,以后我们将深入了解数据系统的存储模块的设计。

小结

了解完事务后,我们发现事务的本质就是保证了数据能够安全写入到数据系统中。

  • 原子性,保证了多个写操作,要么都成功,要么都失败。
  • 一致性,保证了数据状态一致性,例如转账的案例。
  • 隔离性,不同的隔离级别,分别解决了不同的并发异常,保证数据安全性。
  • 一致性,保证了数据能够安全写入磁盘,即使断电,服务crash,也能保证数据不丢失。

另外附上隔离级别和并发写异常的总览

隔离级别

异常读未提交读提交可重复读串行化
脏读
不可重复读
幻读

并发写异常

异常解决方案
脏写几乎所有数据库实现都可以防治脏写
更新丢失select ... for update
写倾斜只有可串行化的隔离级别才能防止这种异常