gitbook/深入浅出分布式技术原理/docs/499721.md
2022-09-03 22:05:03 +08:00

120 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 24事务隔离性正确与性能之间权衡的艺术
你好,我是陈现麟。
通过上节课的学习,我们掌握了通过 2PC 实现分布式事务原子性的技术原理,并且也明白了 2PC 在可用性等方面存在的问题,这些知识能够帮助我们在极客时间的架构选型中,做出正确的选择。
同时,我们还讨论了事务原子性的定义,区分出了事务的原子性并不等价于操作系统里面的原子操作,事务的原子性只定义了操作的不可分割性,而不关心多个事务是否由于并发相互竞争而出现错误,那么在本节课中,我们就一起来讨论事务并发执行的问题,即事务的隔离性。
我们先一起来讨论隔离性的级别和各个隔离级别可能出现的异常情况,然后分析在业务代码中,如何避免异常情况的出现,最后通过讨论隔离性的实现方式,让你进一步理解隔离级别。
## 什么是隔离性
隔离性定义的是,如果多个事务并发执行时,事务之间不应该出现相互影响的情况,它其实就是数据库的并发控制。可能你对隔离性还有点陌生,其实在编程的过程中,隔离性是我们经常会碰到的一个概念,下面我们就具体讨论一下。
在应用程序的开发中,我们通常会利用锁进行并发控制,确保临界区的资源不会出现多个线程同时进行读写的情况,这其实就对应了事务的最高隔离级别:可串行化,它能保证多个并发事务的执行结果和一个一个串行执行是一样的。
现在你就会发现,隔离级别是我们日常开发中经常碰到的一个概念,那么你肯定会有一个疑问,为什么应用程序中可以提供可串行化的隔离级别,而数据库却不能呢?
其实根本原因就是**应用程序对临界区大多是内存操作而数据库要保证持久性即ACID 中的 Durability需要把临界区的数据持久化到磁盘可是磁盘操作比内存操作要慢好几个数量级**,一次随机访问内存、 SSD 磁盘和 SATA 磁盘,对应的操作时间分别为几十纳秒、几十微秒和几十毫秒,这会导致临界区持有的时间变长,对临界区资源的竞争将会变得异常激烈,数据库的性能则会大大降低。
所以,数据库的研究者就对事务定义了隔离级别这个概念,也就是在高性能与正确性之间提供了一个缓冲地带,相当于明确地告诉使用者,我们提供了正确性差一点但是性能好一点的模式,以及正确性好一点但是性能差一点的模式,使用者可以按照自己的业务场景来选择。
## 隔离级别与异常情况
通过对隔离性定义的讨论,我们知道了隔离性是高性能与正确性之间的一个权衡,那么它都提供了哪些权衡呢?
首先这个权衡是由隔离级别Isolation Level来定义的 SQL-92 标准定义了 4 种事务的隔离级别读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read和串行化Serializable在后面的发展过程中又增加了快照隔离级别Snapshot Isolation
由于我们在讨论事务隔离级别的时候,经常通过是否避免某一些异常情况来定义,所以在具体讨论每一个隔离级别之前,我们先来看看事务并发时可能会出现的异常情况,具体有以下几种。
**其一脏写Dirty Write**,即有两个事务 T1 和 T2 T1 更改了 x ,在 T1 提交之前, T2 随之也更改了 x ,这就是脏写,这时因为 T1 还没有提交,所以 T2 更改的就是 T1 的中间状态。假如现在 T2 提交了, T1 就要回滚,如果回滚到 T1 开始前的状态,已经提交的 T2 对 x 的操作就丢失了;假如不回滚到 T1 开始前的状态,已经 Roll Back 的 T1 的影响就还存在于数据库中。能够允许这种现象的数据库基本是不可用的,因为它已经不能完成事务的 Roll Back 了。
**其二脏读Dirty Read**,即有两个事务 T1 和 T2 T1 更改了 x ,将 x 从 0 修改为 5 ,在 T1 提交之前, T2 对 x 进行了读取操作,读到 T1 的中间状态 x = 5 ,这就是脏读。假设最终 T1 Roll Back 了,而 T2 却根据 T1 的中间状态 x = 5 做了一些操作,那么最终就会出现不一致的结果。
**其三不可重复读Nonrepeatable read/ 读倾斜Read Skew**,即有两个事务 T1 和 T2 T1 先读了 x = 0 ,然后 T2 更改了 x = 5 ,接着提交成功,这时如果 T1 再次读取 x = 5 ,就是不可重复读。不可重复读会出现在一个事务内,两次读同一个数据而结果不一样的情况。
**其四丢失更新Loss of Update**,即有两个事务 T1 和 T2 T1 先读 x = 0 ,然后 T2 读 x = 0 ,接着 T1 将 x 加 3 后提交, T2 将 x 加 4 后提交,这时 x 的值最终为 4 T1 的更新丢失了,如果 T1 和 T2 是串行的话,最终结果为 7 。
**其五幻读Phantom Read**,即有两个事务 T1 和 T2 T1 根据条件 1 从表中查询满足条件的行,随后 T2 往这个表中插入满足条件 1 的行或者更新不满足条件 1 的行,使其满足条件 1 后提交,这时如果 T1 再次通过条件 1 查询,则会出现在一个事务内,两次按同一条件查询的结果却不一样的情况。
**其六写倾斜Write Skew**,即假如 x y 需要满足约束 x + y >= 0 ,初始时 x = -3 y = 5 ,事务 T1 先读 x 和 y ,然后事务 T2 读 x 和 y ,接着事务 T2 将 y 更新为 3 后提交,事务 T1 将 x 改为 -5 后提交,最终 x = -5 y = 3 不满足约束 x + y >= 0 。
讨论完这些异常情况后,我们再通过一个表格来看看,事务的隔离级别与这些异常情况的关系。
![图片](https://static001.geekbang.org/resource/image/4c/c6/4c8d3dc02c8da4f20a4f08a084e990c6.jpg?wh=1920x1045)
我们从表格中可以看到,在隔离级别的一致性强度上,读未提交 < 读已提交 < 可重复读 <> 快照 < 串行化可重复度和快照隔离级别之间是不可以比较的
这里要特别注意由于 SQL 标准对隔离级别的定义还存在不够精确的地方并且标准的定义有时还与实现有关系而各个数据库对隔离级别的具体实现又各不相同**所以上面的表格只是对常见的隔离级别异常情况的定义你可以把它当成一个通用的标准参考**。当你使用某一个数据库时需要读一下它的文档确定好它的每一个隔离级别具体的异常情况
## 如何避免异常情况
现在我们已经知道了每一个隔离级别可能会出现的异常情况如果当前数据库使用了某一个隔离级别我们也知道它有哪些异常情况是否有办法来避免呢
其实这是一个非常好的问题不过有些异常情况只能通过提升隔离级别来避免那么接下来我们就针对每一种异常情况来一一讨论一下
其一对于脏写几乎所有的数据库都可以防止异常的出现并且我们可以理解为出现脏写的数据库是不可用的所以这里就不讨论脏写的情况了
其二对于脏读提供读已提交隔离级别及以上的数据库都可以防止异常的出现如果业务中不能接受脏读那么隔离级别最少在读已提交隔离级别或者以上
其三对于不可重复读或读倾斜,“可重复读隔离级别及以上的数据库都可以防止问题的出现如果业务中不能接受不可重复读和读倾斜那么隔离级别最少在可重复读隔离级别或者以上
其四对于丢失更新如果数据库的隔离级别不能达到可重复读隔离级别或者以上那么我们可以考虑以下的几种方法来避免
首先**如果数据库提供了原子写操作那么一定要避免在应用层代码中进行修改操作应该直接通过数据库的原子操作执行避免更新丢失的问题**。例如关系数据库中的 udpate table set value value 1 where key MongoDB 中的 $set、$unset 等操作
数据库的原子操作一般通过独占锁来实现相当于可串行化的隔离级别所以不会有问题不过在使用 ORM 框架时很容易在应用层代码中完成修改的操作导致无法使用数据库的原子操作
其次**如果数据库不支持原子操作或者在某些场景中原子操作不能处理时可以通过对查询结果显式加锁来解决**。对于 MySQL 来说就是 select for update 通过 for update 告诉数据库查询出来的数据行过一会是需要更新的需要加锁防止其他的事务对同一块数据也进行读取加更新操作从而导致更新丢失的情况
最后**我们还可以通过原子比较和设置来实现**例如 update table set value newvalue where id and value oldvalue 但是这个方式有一个问题如果 where 条件的判断是基于某一个旧快照来执行的那么 where 的判断就是没有意义的所以要是采用原子比较和设置避免更新丢失的话一定要确认数据库比较设置操作的安全运行条件
我们把第五点和第六点合在一起讨论对于幻读和写倾斜如果数据库的隔离级别不能达到可串行化的隔离级别我们就可以考虑通过**显式加锁**来避免幻读和写倾斜通过对事务利用 select for update 显式加锁可以确保事务以可串行化的隔离级别运行所以这个方案是可以避免幻读和写倾斜的但不是在所有的情况下都适用比如 select for update 如果在 select 时不能查询到数据那么这时的数据库将无法对数据进行加锁
例如在订阅会议室时多个事务先通过 select for update 查询会议室某一时段的订阅记录当该会议室在这个时间点还没有被订阅时就都查询不到订阅记录select for update 也就无法进行显式加锁如果后面多个事务都会订阅成功就会导致一个会议室在某一时段只能订阅一次的约束被破坏
所以显式加锁对于写倾斜不能适用的情况就是如果在 select 阶段没有查询到临界区的数据就会导致无法加锁。**这种情况下我们可以人为引入用于加锁的数据然后通过显式加锁来避免写倾斜的问题**。比如在订阅会议室时我们为所有会议室的所有时间都创建好数据每一个时间会议室一条数据这个数据没有其他的意义只是在 select for update 数据库可以 select 查询到数据来进行加锁操作
## 如何来实现隔离性
到这里我们已经讨论完事务的隔离级别每一个隔离级别可能遇到的异常情况以及避免这些异常情况的具体技术方案最后我们一起来讨论一下事务的隔离性是如何实现的
既然事务的隔离性是用来确保多个事务并发执行时的正确性的那么**我们就可以依据应用程序开发中经常使用的并发控制策略来思考事务的隔离性如何实现**这样就可以轻松得出如下的几个方法
首先**最容易想到的是通过锁来实现事务的隔离性**。对于锁的方案最简单的策略是整个数据库只有一把互斥锁持有锁的事务可以执行其他的事务只能等待但是这个策略有很明显的问题那就是锁的粒度太粗会导致整个数据库的并发度变为 1
不过我们可以进行优化为事务所操作的每一块数据都分配一把锁通过降低锁的粒度来增加事务的并发度同时相对于互斥锁来说读写锁是一个更好的选择通过读写锁多个事务对同一块数据的读写和写写操作会相互阻塞但却能允许多个读操作并发进行
这样我们就得到了一个事务的并发模型但是一个事务通常由多个操作组成那么一个事务在持有锁修改某一个数据后不能立即释放锁如果立即释放锁在其他的事务读到这个修改或者基于这个修改进行写入操作当前事务却因为后续操作出现问题而回滚的时候就会出现脏读或脏写的情况
对于这个问题有一个解决方法即事务对于它持有的锁在当前的数据操作完成后不能立即释放需要等事务结束提交或者回滚完成后才能释放锁。**这个加锁的方式就是两阶段锁2PL第一阶段当事务正在执行时获取锁第二阶段在事务结束时释放所有的锁**。
那么现在是否就得到了可串行化的隔离性呢其实还不是的我们现在还没有解决幻读和写倾斜的问题幻读指的是其他的事务改变了当前事务的查询结果在幻读的情况下可能会导致写倾斜比如前面提到的例子当订阅会议室的事务进行 select 操作时由于会议室还没有被订阅所以数据库没有办法对订阅记录加锁这样多个事务同时操作就会导致一个会议室在同一个时间内出现多个订阅记录的异常情况
关于这个问题我们可以通过**谓词锁Predicate Lock**来解决它类似于前面描述的读写/互斥锁但是它的加锁对象不属于特定的对象例如表中的一行它属于所有符合某些搜索条件的对象如果对符合下面 SELECT 条件的对象加锁
**SELECT \* FROM bookings WHERE room\_id = 888 AND start\_time < 2022-02-02 13:00 AND end\_time > 2022-02-02 12:00;**
这样就可以避免一个会议室在同一个时间内被订阅多次的情况了同时**间隙锁Next-Key Locking**也可以解决这个问题它是关于谓词锁的简化以及性能更好的一个实现
其次**我们可以通过多版本并发控制MVCC , Multi-Version Concurrency Control实现隔离性**。数据库为每一个写操作创建了一个新的版本同时给每一个对象保留了多个不同的提交版本读操作读取历史提交的版本这样对同一个数据来说只有写写事务会发生冲突读读事务和读写事务是不会发生冲突的对于写写冲突的问题可以通过加锁的方式来解决不过对于 MVCC 来说相对于悲观锁乐观锁是一个更常见的选择
另外通过 MVCC 来实现隔离性由于读操作都是读取旧版本的数据所以数据库需要知道哪些读取结果可能已经改变了然后中止事务不然就会导致写倾斜的问题出现这需要数据库能够检测出异常情况然后中止事务**而实现这个异常检测机制的 MVCC 我们称为可序列化快照隔离SSI , Serializable Snapshot Isolation这是一个比较新的研究方向目前还处于快速发展中**。
最后是一个最简单的方式通过**避免并发的情况出现在单个线程上按顺序一次只执行一个事务**。这个方式避免了并发的出现但是也失去了并发带来的多机多核的计算能力提升目前在一些基于内存的数据库上使用过比如 Redis 同时它也在研发和发展中
## 总结
本节课中我们先掌握了有哪些隔离级别以及每一个隔离级别可能出现的异常情况这样在业务开发的过程中我们对程序可能出现的异常情况就心中有数了
其次我们一起学习了如何避免异常情况的出现在以后的业务选型过程中我们不仅知道如何来选择数据库的隔离级别也知道了当数据库的隔离级别不能调整时如何通过应用开发手段来避免一些异常情况
最后我们讨论了如何实现数据库的隔离级别这个过程能帮我们更深刻地理解隔离性的知识和原理
## 思考题
你能在银行转账的业务场景下举一个出现写倾斜的例子吗
欢迎你在留言区发表你的看法如果这节课对你有帮助也推荐你分享给更多的同事朋友