关系数据库的ACID中的Isolation隔离对应解决数据被并发读写的问题。

并发问题

数据库在并发场景下问题的定义并不清晰严谨,以下有些概念互相之间会有交叉。

  • 脏读 Dirty Read
    • 读取了未提交的数据
  • 脏写 Dirty Write
    • 一个事务对多行数据的修改不是原子的。导致最终部分行被一个事务修改,部分行被另一事务修改。
    • 本质问题并非是:覆盖了别的事务未提交的数据
  • 不可重复读 Non repeatable read/Read skew
    • 一个事务内读取相同的行2次得到的值不一样。
  • 丢失更新 Lost update
    • 2个事务依赖于有交叉的查询结果,写入相同的行
    • 可以认为丢失更新是写偏差的一个特例
      • 特别的点在于丢失更新2个事务修改的是相同的行。所以一个事务的写入因被覆盖而丢失了。
    • 例子:计数器
  • 写偏差 Write skew
    • 数据的写入依赖于一个查询结果(或条件),但是在提交事务时这个查询结果变了
    • 例子:
      • 值班人数
        • 值班人数必须大于1
        • 事务1:
          • SELECT COUNT(*) FROM workers WHERE on_call = true 返回 2
          • UPDATE workers SET on_call = false WHERE id = 1
        • 事务2:
          • SELECT COUNT(*) FROM workers WHERE on_call = true 返回 2
          • UPDATE workers SET on_call = false WHERE id = 2
        • 2个事务跟新完后导致值班人数为0
  • 幻影 Phantom
    • 一个事务中的写入更改了另一事务中的搜索查询结果
      • 与不可重复读的区别是:不可重复读是行内的值发生了变化,而幻影是查询的结果集发生了变化
    • 幻影在读取场景下就是幻读问题
      • 同一个事务内相同2次的查询结果不一样
    • 幻影在写入场景下会导致写偏差问题
      • 数据的写入依赖于一个查询结果(或条件),但是在提交事务时这个查询结果变了

事务隔离级别

隔离级别 脏读,脏写 不可重复读,丢失更新 写偏差,幻影
读未提交 Read Uncommitted Y Y Y
读已提交 Read Committed N Y Y
可重复读 Repeatable read/Snapshot isolation N N Y
串行化 Serializable N N N
  • 一些数据库实现并没有遵循标准,比如:
    • MySQL.InnoDB 可重复读级别不解决丢失更新问题
      • MySQL.InnoDB 可以用 FOR UPDATE 和 LOCK IN SHARE MODE 来解决丢失跟新问题
    • Oracle的串行化实际为可重复读
  • Repeatable read 和 Snapshot isolation
    • Repeatable read 是标准定义
    • Snapshot isolation 是新概念,一般Repeatable read 和 Snapshot isolation隔离级别解决的问题是类似的

如何实现串行化从而解决并发问题

串行化的目的是让客户端接受到的结果跟访问单线程数据库的结果是一样的,而数据库依然可以用并行执行的模型来实现相同的行为。以下只介绍并行模型下实现串行化的方案。

只读事务

用多版本控制 MVCC,让只读事务一直读取最新的相同的数据快照。

写事务

一个完整的写事务逻辑包含3步:读取数据,计算,写入数据。

  • 虽然写事务的3个逻辑部分可以任意编排穿插,但写事务可以改写成以上的模式。
  • 其中的计算部分一般由应用系统而非数据库执行。

在提交阶段保证事务写入所依赖的计算数据没有变化,就可以保证写入的数据是符合预期的。 而参与计算的数据就是事务读取的数据,或者是其子集。

  • 只读取与计算有关的数据,能减少读取的数据量,从而提高事务的性能。但是这一点需要应用层来保证。
  • 参与计算的不一定是确定的数据行。
    • 比如对于会议室的预订,需要判断会议室在某个时间范围内没有已经被预订。
    • 这个场景要锁定的实际是还没有被插入的数据。通过锁定整表可以解决这个问题,但开销较大。其他的解决办法有:Materializing conflicts, Predicate lock, Index-range locks

那么在事务提交阶段,保证事务读取的数据没有变化,就能保证事务的完整性。 悲观或者乐观策略都可以保证在提交阶段事务读取的数据没有变化。

悲观策略,对应锁

  • 对事务读取的所有数据都上写锁,在提交事务之后再释放这些锁,就能保证事务读取到的数据在事务期间无法被修改
  • Multiple-Granularity Locks可以用来解决行级锁和表级锁共享和互斥的问题 实现伪代码,仅供参考:
读锁锁定所有要读取的数据

	读取数据
	
	计算 // 计算一般在应用端
	
	写锁锁定需要写入的数
		提交事务
	释放写锁
	
释放读锁

乐观策略,对应数据校验

在事务提交阶段,检查事务读取的数据是否有变化。有变化则回滚事务

  • 可以直接进行值比较进行校验
    • 比如, UPDATE t SET content = 'new content' WHERE id = 1234 AND content = 'old content';
  • 也可以校验数据的写入版本
    • Serializable snapshot isolation (SSI) + MVCC正是采用的这个策略

注意,乐观策略同样是需要在某些阶段使用锁的。比如并发获取事务的自增ID,提交阶段的原子化等。但乐观策略可以极大的减少锁覆盖的逻辑范围,从而减少锁竞争。SSI是如今主流的解决方案。 实现伪代码,仅供参考:

读取最新版本的数据

计算 // 计算一般在应用端
	
写锁锁定需要写入的数据
	读锁锁定读取的数据 //这里上读锁而非只校验数据版本是为了达到串行化的目标
	
		校验读取的数据没有被修改过	
			提交事务
		读取的数据被修改过
			回滚事务
		
	释放读锁
释放写锁

数据库的事务并不是一次性提交的。客户端开启事务之后,在客户端提交事务之前的所有发送的SQL都会属于这个事务。而客户端每发送一条SQL,数据库就会执行一次对应的操作并返回结果。这导致数据库在事务开始时是无法判断这个事务是否是只读事务的。这种设计增加了事务隔离的实现难度。