伴鱼技术团队

Technology changes the world

伴鱼开放平台 上线了! 源于实践的解决方案,助力企业成就未来!

数据库隔离级别剖析

前言

在线应用业务中,数据库是一个非常重要的组成部分,特别是现在的微服务架构为了获得水平扩展能力,我们倾向于将状态都存储在数据库中,这要求数据库能够正确、高性能处理请求,但这是一个几乎不可能达到的要求,所以数据库的设计者们定义了隔离级别这一个概念,在高性能与正确性之间提供了一个缓冲地带,明确地告诉使用者,我们提供正确性差一点但是性能好一点的模式和正确性好一点但是性能差一点的模式,使用者可以按照你们的业务场景来选择使用。

本质

从本质上讲,隔离级别是定义数据库并发控制的。在应用程序的开发中,我们通常利用锁进行并发控制,确保临界区的资源不会出现多个线程同时进行读写的情况,这对应数据库的隔离级别为可串行化(最高的隔离级别)。现在发现离级别其实是和我们日常开发经常碰到的一个概念了吧,那么现在肯定会有一个问题,为什么在应用程序中可提供可串行化的隔离级别,而数据库却不能提供呢?其根本的原因是应用程序的对临界区都是内存操作,数据库要保证持久性(ACID中的Durability)需要把临界区的数据持久化到磁盘,磁盘操作比内存操作要慢好几个数量级(一次随机访问内存、SSD磁盘和SATA磁盘对应的操作时间分别为几十纳秒、几十微秒和几十毫秒),这会导致临界区持有锁时间变长,对临界区资源竞争将变的异常激烈,数据库的性能会大大降低。

隔离级别

数据库的隔离级别,SQL-92 标准定义了 4 种隔离级别:读未提交 (READ UNCOMMITTED)、读已提交 (READ COMMITTED)、可重复读 (REPEATABLE READ)、串行化 (SERIALIZABLE)。详见下表:

但是由于各数据库的具体实现各不相同,导致同一隔离级别可能出现的异常情况也不相同,所以本文直接从各个隔离级别会带来的异常情况来分析隔离级别的定义。

异常情况

从读未提交到可串行化,数据库可能出现的异常为:

脏写

事务a覆盖了其他事务尚未提交的写入。

脏读

事务a读到了其他事务尚未提交的写入。

读倾斜

事务a在执行过程中,对某一个值在不同的时间点读到了不同的值,也叫不可重复读。

更新丢失

两个事务同时执行读-修改-写入操作序列,出现了其中一个覆盖了另一个的写入,但是没有包含对方最新值的情况,导致了被覆盖的数据发生了更新丢失。

幻读

事务先查询了某些符合条件的数据,同时另一个事务执行写入,改变了先前的查询结果。

写倾斜

事务先查询数据库,根据返回的结果而作出某些决定,然后修改数据库。在事务提交的时候,支持决定的条件不再成立。写倾斜是幻读的一种情况,是由于读-写事务冲突导致的幻读。写倾斜也可以看做一种更广义的更新丢失问题。即如果两个事务读取同一组对象,然后更新其中的一部分:不同的事务更新不同的对象,可能发生写倾斜;不同的事务更新同一个对象,则可能发生脏写或者更新丢失。

异常避免

对应四个隔离级别,我们分别来看看他们有什么异常情况,以及怎么通过应用层的优化来避免该异常的发生:

  • 对于脏写,几乎所有的数据库都可以防止,我们用的mysql和TiDB更是没有问题,所以不讨论脏写的情况;
  • 对于脏读,提供读已提交隔离级别及以上的数据库都可以防止异常的出现,如果业务中不能接受脏读,那么隔离级别最少在读已提交隔离级别或者以上;
  • 对于读倾斜,可重复读隔离级别及以上的数据库都可以防止问题的出现,如果业务中不能接受脏读,那么隔离级别最少可重复读隔离级别或者以上;
  • 对于更新丢失,幻读,写倾斜,如果只通过数据库隔离级别来处理的话,那么只有可串行化的隔离级别才能防止问题的出现,然而在生产环境中,我们几乎是不可能开启可串行化隔离级别的,要么是数据库直接不支持,要么是数据库支持,但是性能太差。因而在实际开发中,我们只能在可重复读的隔离级别的基础上,通过一些其他的手段来防止问题的发生。

怎么避免更新丢失

  • 如果数据库提供原子写操作,那么一定要避免在应用层代码中完成“读-修改-写”操作,应该直接通过数据库的原子操作来执行,这样就可以避免更新丢失的问题。数据库的原子操作例如关系数据库中的 udpate table set value=value+1 where key=*,mongodb也提供类似的操作。数据库的原子操作一般通过独占锁来实现,相当于可串行化的隔离级别,所以不会有问题。不过在使用ORM框架的时候,就很容易在应用层代码中完成“读-修改-写”的操作,导致无法使用数据库的原子操作。
  • 另外一个情况,如果数据库不支持原子操作,或者在某一些场景,原子操作不能处理的时候,可以通过对查询结果显示加锁来解决。对于mysql来说,就是 select for update,通过for update告诉数据库,查询出来的数据行一会是需要更新的,需要加锁防止其他的事务也来读取更新导致更新丢失。
  • 一种更好的避免更新丢失的方式是数据库提供自动检测更新丢失的机制。数据库先让事务都并发执行,如果检测到有更新丢失的风险,直接中止当前事务,然后业务层在重试即可。目前PostgreSQL和TiDB的可重复读,Oracle的可串行化等都提供自动检测更新丢失的机制,但是mysql的InnoDB的可重复读并不支持。
  • 在某一些情况下,还可以通过原子比较和设置来实现,例如:update table set value=newvalue where id=* and value=oldvalue。但是该方式有一个问题,如果where条件的判断是基于某一个旧快照来执行的,那么where的判断是没有意义的。所以如果要采用原子比较和设置来避免更新丢失,那么一定要确认数据库比较-设置操作的安全运行条件。

怎么避免幻读中的写倾斜

在前面的讨论中,我们提供了很多种方式来避免更新丢失,那么在写倾斜的时候可以使用吗?

  • 原子操作上不行的,因为涉及到多个对象的更新;
  • 所有的数据库几乎都没有自动检测写倾斜的机制;
  • 数据库自定义的约束功能对于多个对象也基本不支持;
  • 显式加锁方式上可以的,通过select for update,可以确保事务以可串行化的隔离级别,所以这个方案上可行的。但这不是对于所有的情况下都适用,例如select for update 如果在select的时候不能查询到数据,那么这个时候数据库无法对数据进行加锁。例如:在订阅会议室的时候,select的时候会议室还没有被订阅,所以查询不到,数据库也没有办法进行加锁,update的时候,多个事务都可以update成功。所以,显式加锁对于写倾斜不能适用的情况是因为在select阶段没有查询到临界区的数据,导致无法加锁。在这种情况下,我们可以人为的引入用于加锁的数据,然后通过显式加锁来避免写倾斜的问题。比如在订阅会议室的问题中,我们为所有的会议室的所有时间都创建好数据,每一个“时间-会议室”一条数据,这个数据没有其他的意义,只是用来select for update的时候由于select 查询到数据,用于数据库来加锁。
  • 另外一种方式是在数据库提供可串行化隔离级别,并且性能满足业务要求时,直接使用可串行化的隔离级别。

TiDB的隔离级别[1]

TiDB 实现了快照隔离 (Snapshot Isolation, SI) 级别的一致性。为了与 MySQL 保持一致,又称其为“可重复读”。该隔离级别不同于 ANSI 可重复读隔离级别和 MySQL 可重复读隔离级别。

当事务隔离级别为可重复读时,只能读到该事务启动时已经提交的其他事务修改的数据,未提交的数据或在事务启动后其他事务提交的数据是不可见的。对于本事务而言,事务语句可以看到之前的语句做出的修改。对于运行于不同节点的事务而言,不同事务启动和提交的顺序取决于从 PD 获取时间戳的顺序。处于可重复读隔离级别的事务不能并发的更新同一行,当时事务提交时发现该行在该事务启动后,已经被另一个已提交的事务更新过,那么该事务会回滚并启动自动重试。示例如下:

1
2
3
4
5
6
7
8
create table t1(id int);
insert into t1 values(0);

start transaction; | start transaction;
select * from t1; | select * from t1;
update t1 set id=id+1; | update t1 set id=id+1;
commit; |
| commit; -- 事务提交失败,回滚

与 ANSI 可重复读隔离级别的区别

尽管名称是可重复读隔离级别,但是 TiDB 中可重复读隔离级别和 ANSI 可重复隔离级别是不同的。按照 A Critique of ANSI SQL Isolation Levels 论文中的标准,TiDB 实现的是论文中的快照隔离级别。该隔离级别不会出现狭义上的幻读 (A3),但不会阻止广义上的幻读 (P3),同时,SI 还会出现写偏斜,而 ANSI 可重复读隔离级别不会出现写偏斜,会出现幻读。

与 MySQL 可重复读隔离级别的区别

MySQL 可重复读隔离级别在更新时并不检验当前版本是否可见,也就是说,即使该行在事务启动后被更新过,同样可以继续更新。这种情况在 TiDB 会导致事务回滚,导致事务最终失败,而 MySQL 是可以更新成功的。MySQL 的可重复读隔离级别并非快照隔离级别,MySQL 可重复读隔离级别的一致性要弱于快照隔离级别,也弱于 TiDB 的可重复读隔离级别。

总结

本文我们讨论了数据库出现隔离级别这个概念的根本原因是数据库设计者因为要保证持久性,因而有大量的磁盘操作,导致临界区变长,性能急剧下降,提出的一个trade-off的方案,让使用者根据自己的业务场景来选择不同的隔离级别,然后我们讨论了不同的隔离级别导致的异常情况的处理方法,确保可以写出高性能并且正确的程序,最后我们介绍了tidb隔离级别的情况。

参考

欢迎关注我的其它发布渠道