数据库的隔离性

前言

​ 大多数数据库都是支持多个客户端同时访问。如果读取得是不同得数据,那肯定没有什么问题;但如果访问相同得记录,就可能遇到并发问题。

​ 而且,网上大部分的文章都是谈论 “读-写并发“带来的问题,但”写-写并发“讨论的很少。再进一步说,对于幻读,解决的办法一般也是指可串行化,但是2PC的可串行化会让数据库的效率很低很低。

​ 因此,下面想谈谈数据库的隔离性。

1633436392828

读-提交

  读-提交是最基本的事务隔离级别,它只是提供以下两个保证。

  • 读数据时,只能看到已经成功提交的数据 (防止脏读)
  • 写数据时,不会覆盖已经已经提交成功的数据 (防止脏写)

防止脏写 写写并发

  如果两个事务同时尝试更新相同的对象,会发生什么情况呢?我们不清楚写入的顺序,但可以想象后写的操作,会覆盖较早写入的操作。

Session A Session B
begin begin
update data = 123
update data = 456
commit commit

  很显然,如果不做什么特殊的处理,那么seesionB的写入会覆盖A的写入。

如果A在提交前,需要查询一次data,然后交付应用层进行处理的话,很显然就会出现逻辑上的错误。

防止脏写能避免这些问题

  • 当一个记录,被两个事务同时写入的时候。能够避免 旧的写入被覆盖掉

如何实现读提交?

数据库通常使用行锁来防止脏写

  • 当事务想修改某个对象时,需要获得这个对象的写锁
  • 然后一直持有该锁,知道事务被提交
  • 另一个事务想获得锁,如果失败,则会被阻塞

那数据库如何防止脏读

  • 当事务想要读取某个对象时,那就给对象加一个读锁
  • 如果此时有事务想要修改,那就要加写锁,但是读锁,写锁互斥。所以写锁加不上去
  • 同理,如果某个对象有写锁,那么读锁也加不上去。

MVCC 和 可重复读

  表面上,读-提交这个隔离级别,可能会认为它已经满足了事务所需要的一切特征。但是,还是字面意思,如果一个事务的过程中,如果两次查询不一样,那么肯定是不行的。

我们看一个例子

1633437092102

  两次,查询不一样,显然不行的。我们,其实更想要的是,在事务开启,到事务的提交。在这段时间里,我们对同一个对象的查询结果是 一致的,自洽的

  快照级别是解决上面问题最常见的手段。其总体想法是,每个事务都从数据库的一致性快照中读取。然后,每次查询,都从这个一致性快照里面进行查询。因此,在这个事务的生存期间,读到数据,都是自洽的。

如何实现MVCC

  在事务启动的时候,就会”拍一个快照”。这个快照是基于整个库的。我们看一下,这个快照是怎么执行的。

  InnoDB引擎中,开启一个事务的时候,会分配一个 transaction ID,这个是完全严格递增的。

  然后,每个数据都有很多个版本。当一个事务对某个对象进行更新时,会生成一条记录,大致是transaction id | 对象的值 | 上一条记录的位置

1633438091862

​ 上图,是一个对象的若干个版本。 分别由事务id是10,15,17,25来更新的。

​ 当一个事务启动时,会产生一个快照,这个快照其实是一个数组。里面的内容都是在当前事务启动时,所有还没提交的事务id。

​ so,read - view = [ 事务id1(低水位),事务id2….事务idN(一般是当前事务)(高水位)]

1633438516538

​ 那么,我们就分情况来讨论一下可见性。当我们遍历,对象的版本链时,可以根据它的版本ID进行可见判断,分为以下几种情况

  • 版本ID < 低水位
    • 表明,这个版本,在事务启动之前就已经提交了,因此当前事务是能看见的
  • 版本ID > 高水位
    • 说明,这个版本,是事务启动之后才提交的,当前事务是无法看见这个版本
  • 版本ID 在两个水位之间
    • 如果版本ID,不在事务的Read-View中,显然还是能看见的。因为Read-View记录的是,事务启动时,仍然没被提交的事务ID。所以,如果版本ID不在Read-View中,那么这个版本必然是被提交的
    • 如果版本ID,在Read-View中,显然,是不可见的。

当前读

​ 如果事务启动之后,执行update操作,那么一般来说是在最新的版本上面更新的。

防止更新丢失

​ 当目前为止,所讨论的读-提交Read-View,都是只解读事务遇到并发时可以看到什么**(读-写并发)。**

​ 总体来说,还没有真正解决两个写事务之间的并发。

​ 当两个事务在同样的数据对象执行类似操作,由于隔离性,后写的操作,不知道前写的事务做了什么东西。因此,会导致第一次写的记录,会丢失。例如:

  • 两个用户同时编辑一个wiki页面,so,必然后保存的人,会覆盖前写的内容。

CAS自动检测冲突

​ 如果两个事务并发写同一个对象。可以借鉴CAS的思想来进行检测冲突。

​ 每次update的时候做一次 Compare and Set操作,如果CAS成功则提交该事务。如果CAS失败,则放弃并终止这个事务。

1
2
3
-- 原子并发
update table set field = 'new content'
where id = '111' and field = 'old content';

多数据副本的写并发

​ 参考,多数据重新的数据同步,采用lamport时间戳,来进行同步。

其他加锁的操作,防止写并发较为常见,不展开了

幻读和可串行化

​ 下面来讲讲幻读。首先想到一个这样的场景,某个医院的调班制度是这样的:如果当前值班的人数大于2,那么就可以调班。如果事务是按照,下面的顺序进行执行的话,就会出现错误。

1633441183758

​ 在他们开启查询时,都发现值班人数是大于2的。应用层,逻辑处理之后,发现可以调班。因此执行调班操作,但是执行完毕之后,值班人数为0。

​ 这种问题,快照隔离是处理不了的。因为两个事务开启时,创建的快照在整个事务的生存期间,是自洽的。因此,都会看到2。

​ 更多的幻读,写倾斜例子:

  • 会议室预定系统
  • 超卖问题
  • 棋盘问题,对于每个棋子我们可以加锁,但是我们想要避免落到同一个地方则比较困难。因为两个移动棋子的事务并发启动的时候,每个Read-View,都看到”同一个地方“是空的,因此都向往那里移动。
  • 创建用户名问题,原理同上

这种问题,称为幻读问题,而且这些问题一般符合下面这些特点:

  • Step1:根据某个条件,查询出一些记录 (结果可以是空)
  • Step2:查询记录交付给应用层进行逻辑判断
  • Step3:应用层决策交给DB进行处理,增删查改
    • 这个操作,会影响先前select的结果。
  • 只要符合上述这些条件,都很容易引起幻读的问题。

如何解决写倾斜or幻读问题

对于避免写倾斜,其实有很多方案。

1
2
3
4
5
1.可串行化,避免写倾斜。
- 采用2PC,当一个事务提交/中止的时候,才把锁给释放。
- 但是这种操作,效率很低

2.版本向量的办法,(maybe,我也不是很确定这个行不行)

可串行化

​ 可串行化是最强的隔离级别,它在一定程度上保证事务的并发,让最后的结果看起来一次只有一个事务进行执行。一般来说,实现可串行化有下面三种方案

  • 严格串行执行
  • 2PC
  • 可串行化的快照隔离

​ 下面,我将简单介绍一下 串行执行 和2PC。详细讲讲可串行化的快照,因为这是一个比较新的算法,如果没有意外的话,以后数据库实现可串行化都会采用这个算法,因为效率大大提高了。

串行执行—单线程

​ 解决并发的根本手段就是避免并发:就是每次只有一个线程在CPU执行。完全回避了并发会遇到的各种隔离性问题,数据共享问题。

​ 但是直到最近,数据库设计者才真正考虑用单线程实现串行的隔离 (比如说redis)。

​ 我觉得原因可能有两种:

  • 硬件提升很快,现在内存,CPU提升比较大。内存能够一次性容纳很多数据,因此避免了磁盘IO的损耗
  • 单线程,避免了频繁的上下文切换

综上,单线程模型,效率还能接受,但不到万不得已,不建议用

2PC

​ 就是对事务操作的对象加锁,区间锁之类的。然后到最后提交才释放。

​ 因为加锁了导致效率很低,很多事务被阻塞住了。

​ 下篇文章再仔细讲讲区间锁,这里先不讲了。

可串行快照

​ 虽然两阶段锁可以保证串行化,但是性能差强人意并且无法拓展。有句话说,你隔离得越严实,性能就会越差。那么性能和隔离性之间是否无法兼得?

​ 未必,最近出现了一种叫做**可串行化快照(Serializable Snapshot Isolation)**的技术。它提供完整性的隔离保证,并且性能损耗较小。

​ 但是,SSI仍旧需要在实践中检验它的性能,相信以后会称为未来数据库的标配。

乐观和悲观

​ 2PC显然是一种悲观的思想,它基于这样的设计思想:如果某些操作能引起并发错误,那么干脆就直接放弃,等到安全的时候再让该事务进行

​ 相比之下,可串行化快照是一种乐观的思想:

  • 事务不管怎么样都会执行,都是在执行的过程中会记录一些信息,帮助自己判断。
  • 等到事务将要提交的时候,数据库底层会判断是否出现冲突(根据记录的信息),如果发生冲突的话,事务重试

​ 但是,这个有一个前提,就是事务之间竞争不是很大。如果竞争很大的话,这种算法最终会退化成2PC的效率。

可穿行快照具体实现

​ 顾名思义,可串行化快照是基于快照的。也就是说,数据库的所有操作都是基于事务中的一致性快照。在这个的基础上,增加了相关算法来检测,写入之间的串行化冲突

基于过期时间来确定的

​ 在讨论写倾斜的时候,讨论过写倾斜有三个特点。(1)根据条件查询 (2)应用层逻辑判断 (3)进入db修改,这个修改对先前的查询有影响。

​ 因此,当前事务在快照隔离的情况下,数据可能在查询期间,就被其他事务修改。因此当前的接下来决策信息,会被干扰了。

​ 所以,要避免写倾斜产生。关键在于要让数据库检测,当前事务对某个对象的修改是否会对别的事务对该对象的查询有影响。

大致可以分为两种情况来讨论:

  • 查询是否作用于一个即将过期的MVCC对象 (当前事务在读取对象之前,该对象已经有别的事务未提交的写入,该对象对当前事务不可见)

  • 检查写入是否会影响之前的读取 (可能会影响应用层的逻辑判断)

写入(未提交) - 读取

1633451169315

​ 上面这个例子,如果是基于MVCC的隔离下。事务43的提交是晚于事务42的提交的。那么也就是说,事务42是可以提交成功的,那么就会改变 值班人数 >= 2这个条件。虽然事务43的Read - View里面,仍旧是2个人值班(虽然实际上前提条件已经变了)。

​ 这样,数据库要跟踪那些因为MVCC可见性问题带来的被忽略的写操作

如何解决这个问题?

  • 对符合前提条件的记录,建立一张表。
  • 对这些查询出来记录,进行修改的时候,插入一条记录 [record | create_transaction id | delete transaction id]
  • 等到事务提交的时候,检查一下该表。
    • 如果这个更新不是自己做的,那么就中止本次事务的提交(因为前提条件被别的事务破坏了)
    • 如果是自己的transaction id,那么就提交

更新是否影响之前的读

1633451743812

具体实现

  • 维护一张读表
  • 更新的时候,看看读表中是否有数据。如果有则通知对应的事务,让其在提交时放弃并重试。

​ 在上面这个例子中时这样的。两个事务42,43对db进行查询。因此读表插入两条记录,记录了两个事务分别读取了什么记录。

​ 当事务42对record 1234进行更新后,在读表中查到record 1234的更新会影响到事务43。那么就通知到事务43,并且让他提交时放弃并重试。

ok,以上就是本章想要探讨的全部内容。如果想要交流讨论的,欢迎邮箱联系~