『浅入深出』MySQL 中事务的实现html
在关系型数据库中,事务的重要性不言而喻,只要对数据库稍有了解的人都知道事务具备 ACID 四个基本属性,而咱们不知道的可能就是数据库是如何实现这四个属性的;在这篇文章中,咱们将对事务的实现进行分析,尝试理解数据库是如何实现事务的,固然咱们也会在文章中简单对 MySQL 中对 ACID 的实现进行简单的介绍。前端
事务其实就是并发控制的基本单位;相信咱们都知道,事务是一个序列操做,其中的操做要么都执行,要么都不执行,它是一个不可分割的工做单位;数据库事务的 ACID 四大特性是事务的基础,了解了 ACID 是如何实现的,咱们也就清除了事务的实现,接下来咱们将依次介绍数据库是如何实现这四个特性的。mysql
原子性程序员
在学习事务时,常常有人会告诉你,事务就是一系列的操做,要么所有都执行,要都不执行,这其实就是对事务原子性的刻画;虽然事务具备原子性,可是原子性并非只与事务有关系,它的身影在不少地方都会出现。面试
因为操做并不具备原子性,而且能够再分为多个操做,当这些操做出现错误或抛出异常时,整个操做就可能不会继续执行下去,而已经进行的操做形成的反作用就可能形成数据更新的丢失或者错误。算法
事务其实和一个操做没有什么太大的区别,它是一系列的数据库操做(能够理解为 SQL)的集合,若是事务不具有原子性,那么就没办法保证同一个事务中的全部操做都被执行或者未被执行了,整个数据库系统就既不可用也不可信。sql
回滚日志数据库
想要保证事务的原子性,就须要在异常发生时,对已经执行的操做进行回滚,而在 MySQL 中,恢复机制是经过回滚日志(undo log)实现的,全部事务进行的修改都会先记录到这个回滚日志中,而后在对数据库中的对应行进行写入。编程
这个过程其实很是好理解,为了可以在发生错误时撤销以前的所有操做,确定是须要将以前的操做都记录下来的,这样在发生错误时才能够回滚。缓存
回滚日志除了可以在发生错误或者用户执行 ROLLBACK 时提供回滚相关的信息,它还可以在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还可以马上经过查询回滚日志将以前未完成的事务进行回滚,这也就须要回滚日志必须先于数据持久化到磁盘上,是咱们须要先写日志后写数据库的主要缘由。
回滚日志并不能将数据库物理地恢复到执行语句或者事务以前的样子;它是逻辑日志,当回滚日志被使用时,它只会按照日志逻辑地将数据库中的修改撤销掉看,能够理解为,咱们在事务中使用的每一条 INSERT 都对应了一条 DELETE,每一条 UPDATE 也都对应一条相反的 UPDATE 语句。
在这里,咱们并不会介绍回滚日志的格式以及它是如何被管理的,本文重点关注在它究竟是一个什么样的东西,究竟解决了、如何解决了什么样的问题,若是想要了解具体实现细节的读者,相信网络上关于回滚日志的文章必定很多。
事务的状态
由于事务具备原子性,因此从远处看的话,事务就是密不可分的一个总体,事务的状态也只有三种:Active、Commited 和 Failed,事务要不就在执行中,要否则就是成功或者失败的状态:
可是若是放大来看,咱们会发现事务再也不是原子的,其中包括了不少中间状态,好比部分提交,事务的状态图也变得愈来愈复杂。
事务的状态图以及状态的描述取自 Database System Concepts 一书中第 14 章的内容。
Active:事务的初始状态,表示事务正在执行;
Partially Commited:在最后一条语句执行以后;
Failed:发现事务没法正常执行以后;
Aborted:事务被回滚而且数据库恢复到了事务进行以前的状态以后;
Commited:成功执行整个事务;
虽然在发生错误时,整个数据库的状态能够恢复,可是若是咱们在事务中执行了诸如:向标准输出打印日志、向外界发出邮件、没有经过数据库修改了磁盘上的内容甚至在事务执行期间发生了转帐汇款,那么这些操做做为可见的外部输出都是没有办法回滚的;这些问题都是由应用开发者解决和负责的,在绝大多数状况下,咱们都须要在整个事务提交后,再触发相似的没法回滚的操做
以订票为例,哪怕咱们在整个事务结束以后,才向第三方发起请求,因为向第三方请求并获取结果是一个须要较长事件的操做,若是在事务刚刚提交时,数据库或者服务器发生了崩溃,那么咱们就很是有可能丢失发起请求这一过程,这就形成了很是严重的问题;而这一点就不是数据库所能保证的,开发者须要在适当的时候查看请求是否被发起、结果是成功仍是失败。
并行事务的原子性
到目前为止,全部的事务都只是串行执行的,一直都没有考虑过并行执行的问题;然而在实际工做中,并行执行的事务才是常态,然而并行任务下,却可能出现很是复杂的问题:
当 Transaction1 在执行的过程当中对 id = 1 的用户进行了读写,可是没有将修改的内容进行提交或者回滚,在这时 Transaction2 对一样的数据进行了读操做并提交了事务;也就是说 Transaction2 是依赖于 Transaction1 的,当 Transaction1 因为一些错误须要回滚时,由于要保证事务的原子性,须要对 Transaction2 进行回滚,可是因为咱们已经提交了 Transaction2,因此咱们已经没有办法进行回滚操做,在这种问题下咱们就发生了问题,Database System Concepts 一书中将这种现象称为不可恢复安排(Nonrecoverable Schedule),那什么状况下是能够恢复的呢?
A recoverable schedule is one where, for each pair of transactions Ti and Tj such that Tj reads a data item previously written by Ti , the commit operation of Ti appears before the commit operation of Tj .
简单理解一下,若是 Transaction2 依赖于事务 Transaction1,那么事务 Transaction1 必须在 Transaction2 提交以前完成提交的操做:
然而这样还不算完,当事务的数量逐渐增多时,整个恢复流程也会变得愈来愈复杂,若是咱们想要从事务发生的错误中恢复,也不是一件那么容易的事情。
在上图所示的一次事件中,Transaction2 依赖于 Transaction1,而 Transaction3 又依赖于 Transaction1,当 Transaction1 因为执行出现问题发生回滚时,为了保证事务的原子性,就会将 Transaction2 和 Transaction3 中的工做所有回滚,这种状况也叫作级联回滚(Cascading Rollback),级联回滚的发生会致使大量的工做须要撤回,是咱们难以接受的,不过若是想要达到绝对的原子性,这件事情又是不得不去处理的,咱们会在文章的后面具体介绍如何处理并行事务的原子性。
持久性
既然是数据库,那么必定对数据的持久存储有着很是强烈的需求,若是数据被写入到数据库中,那么数据必定可以被安全存储在磁盘上;而事务的持久性就体如今,一旦事务被提交,那么数据必定会被写入到数据库中并持久存储起来。
当事务已经被提交以后,就没法再次回滚了,惟一可以撤回已经提交的事务的方式就是建立一个相反的事务对原操做进行『补偿』,这也是事务持久性的体现之一。
重作日志
与原子性同样,事务的持久性也是经过日志来实现的,MySQL 使用重作日志(redo log)实现事务的持久性,重作日志由两部分组成,一是内存中的重作日志缓冲区,由于重作日志缓冲区在内存中,因此它是易失的,另外一个就是在磁盘上的重作日志文件,它是持久的
当咱们在一个事务中尝试对数据进行修改时,它会先将数据从磁盘读入内存,并更新内存中缓存的数据,而后生成一条重作日志并写入重作日志缓存,当事务真正提交时,MySQL 会将重作日志缓存中的内容刷新到重作日志文件,再将内存中的数据更新到磁盘上,图中的第 四、5 步就是在事务提交时执行的。
在 InnoDB 中,重作日志都是以 512 字节的块的形式进行存储的,同时由于块的大小与磁盘扇区大小相同,因此重作日志的写入能够保证原子性,不会因为机器断电致使重作日志仅写入一半并留下脏数据。
除了全部对数据库的修改会产生重作日志,由于回滚日志也是须要持久存储的,它们也会建立对应的重作日志,在发生错误后,数据库重启时会从重作日志中找出未被更新到数据库磁盘中的日志从新执行以知足事务的持久性。
回滚日志和重作日志
到如今为止咱们了解了 MySQL 中的两种日志,回滚日志(undo log)和重作日志(redo log);在数据库系统中,事务的原子性和持久性是由事务日志(transaction log)保证的,在实现时也就是上面提到的两种日志,前者用于对事务的影响进行撤销,后者在错误处理时对已经提交的事务进行重作,它们能保证两点:
发生错误或者须要回滚的事务可以成功回滚(原子性);
在事务提交后,数据没来得及写会磁盘就宕机时,在下次从新启动后可以成功恢复数据(持久性);
在数据库中,这两种日志常常都是一块儿工做的,咱们能够将它们总体看作一条事务日志,其中包含了事务的 ID、修改的行元素以及修改先后的值。
一条事务日志同时包含了修改先后的值,可以很是简单的进行回滚和重作两种操做,在这里咱们也不会对重作和回滚日志展开进行介绍,可能会在以后的文章谈一谈数据库系统的恢复机制时提到两种日志的使用。
隔离性
其实做者在以前的文章 『浅入浅出』MySQL 和 InnoDB 就已经介绍过数据库事务的隔离性,不过为了保证文章的独立性和完整性,咱们还会对事务的隔离性进行介绍,介绍的内容可能稍微有所不一样。
事务的隔离性是数据库处理数据的几大基础之一,若是没有数据库的事务之间没有隔离性,就会发生在 并行事务的原子性 一节中提到的级联回滚等问题,形成性能上的巨大损失。若是全部的事务的执行顺序都是线性的,那么对于事务的管理容易得多,可是容许事务的并行执行却能可以提高吞吐量和资源利用率,而且能够减小每一个事务的等待时间。
当多个事务同时并发执行时,事务的隔离性可能就会被违反,虽然单个事务的执行可能没有任何错误,可是从整体来看就会形成数据库的一致性出现问题,而串行虽然可以容许开发者忽略并行形成的影响,可以很好地维护数据库的一致性,可是却会影响事务执行的性能。
事务的隔离级别
因此说数据库的隔离性和一致性实际上是一个须要开发者去权衡的问题,为数据库提供什么样的隔离性层级也就决定了数据库的性能以及能够达到什么样的一致性;在 SQL 标准中定义了四种数据库的事务的隔离级别:READ UNCOMMITED、READ COMMITED、REPEATABLE READ 和 SERIALIZABLE;每一个事务的隔离级别其实都比上一级多解决了一个问题:
RAED UNCOMMITED:使用查询语句不会加锁,可能会读到未提交的行(Dirty Read);
READ COMMITED:只对记录加记录锁,而不会在记录之间加间隙锁,因此容许新的记录插入到被锁定记录的附近,因此再屡次使用查询语句时,可能获得不一样的结果(Non-Repeatable Read);
REPEATABLE READ:屡次读取同一范围的数据会返回第一次查询的快照,不会返回不一样的数据行,可是可能发生幻读(Phantom Read);
SERIALIZABLE:InnoDB 隐式地将所有的查询语句加上共享锁,解决了幻读的问题;
以上的全部的事务隔离级别都不容许脏写入(Dirty Write),也就是当前事务更新了另外一个事务已经更新可是还未提交的数据,大部分的数据库中都使用了 READ COMMITED 做为默认的事务隔离级别,可是 MySQL 使用了 REPEATABLE READ 做为默认配置;从 RAED UNCOMMITED 到 SERIALIZABLE,随着事务隔离级别变得愈来愈严格,数据库对于并发执行事务的性能也逐渐降低。
对于数据库的使用者,从理论上说,并不须要知道事务的隔离级别是如何实现的,咱们只须要知道这个隔离级别解决了什么样的问题,可是不一样数据库对于不一样隔离级别的是实现细节在不少时候都会让咱们遇到意料以外的坑。
若是读者不了解脏读、不可重复读和幻读到底是什么,能够阅读以前的文章 『浅入浅出』MySQL 和 InnoDB,在这里咱们仅放一张图来展现各个隔离层级对这几个问题的解决状况。
隔离级别的实现
数据库对于隔离级别的实现就是使用并发控制机制对在同一时间执行的事务进行控制,限制不一样的事务对于同一资源的访问和更新,而最重要也最多见的并发控制机制,在这里咱们将简单介绍三种最重要的并发控制器机制的工做原理。
锁
锁是一种最为常见的并发控制机制,在一个事务中,咱们并不会将整个数据库都加锁,而是只会锁住那些须要访问的数据项, MySQL 和常见数据库中的锁都分为两种,共享锁(Shared)和互斥锁(Exclusive),前者也叫读锁,后者叫写锁。
读锁保证了读操做能够并发执行,相互不会影响,而写锁保证了在更新数据库数据时不会有其余的事务访问或者更改同一条记录形成不可预知的问题。
时间戳
除了锁,另外一种实现事务的隔离性的方式就是经过时间戳,使用这种方式实现事务的数据库,例如 PostgreSQL 会为每一条记录保留两个字段;读时间戳中报错了全部访问该记录的事务中的最大时间戳,而记录行的写时间戳中保存了将记录改到当前值的事务的时间戳。
使用时间戳实现事务的隔离性时,每每都会使用乐观锁,先对数据进行修改,在写回时再去判断当前值,也就是时间戳是否改变过,若是没有改变过,就写入,不然,生成一个新的时间戳并再次更新数据,乐观锁其实并非真正的锁机制,它只是一种思想,在这里并不会对它进行展开介绍。
多版本和快照隔离
经过维护多个版本的数据,数据库能够容许事务在数据被其余事务更新时对旧版本的数据进行读取,不少数据库都对这一机制进行了实现;由于全部的读操做再也不须要等待写锁的释放,因此可以显著地提高读的性能,MySQL 和 PostgreSQL 都对这一机制进行本身的实现,也就是 MVCC,虽然各自实现的方式有所不一样,MySQL 就经过文章中提到的回滚日志实现了 MVCC,保证事务并行执行时可以不等待互斥锁的释放直接获取数据。
隔离性与原子性
在这里就须要简单提一下在在原子性一节中遇到的级联回滚等问题了,若是一个事务对数据进行了写入,这时就会获取一个互斥锁,其余的事务就想要得到改行数据的读锁就必须等待写锁的释放,天然就不会发生级联回滚等问题了。
不过在大多数的数据库,好比 MySQL 中都使用了 MVCC 等特性,也就是正常的读方法是不须要获取锁的,在想要对读取的数据进行更新时须要使用 SELECT ... FOR UPDATE 尝试获取对应行的互斥锁,以保证不一样事务能够正常工做。
一致性
做者认为数据库的一致性是一个很是让人迷惑的概念,缘由是数据库领域其实包含两个一致性,一个是 ACID 中的一致性、另外一个是 CAP 定义中的一致性。
这两个数据库的一致性说的彻底不是一个事情,不少不少人都对这二者的概念有很是深的误解,当咱们在讨论数据库的一致性时,必定要清楚上下文的语义是什么,尽可能明确的问出咱们要讨论的究竟是 ACID 中的一致性仍是 CAP 中的一致性。
ACID
数据库对于 ACID 中的一致性的定义是这样的:若是一个事务原子地在一个一致地数据库中独立运行,那么在它执行以后,数据库的状态必定是一致的。对于这个概念,它的第一层意思就是对于数据完整性的约束,包括主键约束、引用约束以及一些约束检查等等,在事务的执行的先后以及过程当中不会违背对数据完整性的约束,全部对数据库写入的操做都应该是合法的,并不能产生不合法的数据状态。
A transaction must preserve database consistency - if a transaction is run atomically in isolation starting from a consistent database, the database must again be consistent at the end of the transaction.
咱们能够将事务理解成一个函数,它接受一个外界的 SQL 输入和一个一致的数据库,它必定会返回一个一致的数据库。
而第二层意思实际上是指逻辑上的对于开发者的要求,咱们要在代码中写出正确的事务逻辑,好比银行转帐,事务中的逻辑不可能只扣钱或者只加钱,这是应用层面上对于数据库一致性的要求。
Ensuring consistency for an individual transaction is the responsibility of the application programmer who codes the transaction. - Database System Concepts
数据库 ACID 中的一致性对事务的要求不止包含对数据完整性以及合法性的检查,还包含应用层面逻辑的正确。
CAP 定理中的数据一致性,实际上是说分布式系统中的各个节点中对于同一数据的拷贝有着相同的值;而 ACID 中的一致性是指数据库的规则,若是 schema 中规定了一个值必须是惟一的,那么一致的系统必须确保在全部的操做中,该值都是惟一的,由此来看 CAP 和 ACID 对于一致性的定义有着根本性的区别。
总结
事务的 ACID 四大基本特性是保证数据库可以运行的基石,可是彻底保证数据库的 ACID,尤为是隔离性会对性能有比较大影响,在实际的使用中咱们也会根据业务的需求对隔离性进行调整,除了隔离性,数据库的原子性和持久性相信都是比较好理解的特性,前者保证数据库的事务要么所有执行、要么所有不执行,后者保证了对数据库的写入都是持久存储的、非易失的,而一致性不只是数据库对自己数据的完整性的要求,同时也对开发者提出了要求 - 写出逻辑正确而且合理的事务。
最后,也是最重要的,当别人在将一致性的时候,必定要搞清楚他的上下文,若是对文章的内容有疑问,能够在评论中留言。
浅谈数据库并发控制 - 锁和 MVCC
转自https://draveness.me/database-concurrency-control
在学习几年编程以后,你会发现全部的问题都没有简单、快捷的解决方案,不少问题都须要权衡和妥协,而本文介绍的就是数据库在并发性能和可串行化之间作的权衡和妥协 - 并发控制机制。
若是数据库中的全部事务都是串行执行的,那么它很是容易成为整个应用的性能瓶颈,虽说无法水平扩展的节点在最后都会成为瓶颈,可是串行执行事务的数据库会加速这一过程;而并发(Concurrency)使一切事情的发生都有了可能,它可以解决必定的性能问题,可是它会带来更多诡异的错误。
引入了并发事务以后,若是不对事务的执行进行控制就会出现各类各样的问题,你可能没有享受到并发带来的性能提高就已经被各类奇怪的问题折磨的欲仙欲死了。
概述
如何控制并发是数据库领域中很是重要的问题之一,不过到今天为止事务并发的控制已经有了不少成熟的解决方案,而这些方案的原理就是这篇文章想要介绍的内容,文章中会介绍最为常见的三种并发控制机制:
分别是悲观并发控制、乐观并发控制和多版本并发控制,其中悲观并发控制实际上是最多见的并发控制机制,也就是锁;而乐观并发控制其实也有另外一个名字:乐观锁,乐观锁其实并非一种真实存在的锁,咱们会在文章后面的部分中具体介绍;最后就是多版本并发控制(MVCC)了,与前二者对立的命名不一样,MVCC 能够与前二者中的任意一种机制结合使用,以提升数据库的读性能。
既然这篇文章介绍了不一样的并发控制机制,那么必定会涉及到不一样事务的并发,咱们会经过示意图的方式分析各类机制是如何工做的。
悲观并发控制
控制不一样的事务对同一份数据的获取是保证数据库的一致性的最根本方法,若是咱们可以让事务在同一时间对同一资源有着独占的能力,那么就能够保证操做同一资源的不一样事务不会相互影响。
最简单的、应用最广的方法就是使用锁来解决,当事务须要对资源进行操做时须要先得到资源对应的锁,保证其余事务不会访问该资源后,在对资源进行各类操做;在悲观并发控制中,数据库程序对于数据被修改持悲观的态度,在数据处理的过程当中都会被锁定,以此来解决竞争的问题。
读写锁
为了最大化数据库事务的并发能力,数据库中的锁被设计为两种模式,分别是共享锁和互斥锁。当一个事务得到共享锁以后,它只能够进行读操做,因此共享锁也叫读锁;而当一个事务得到一行数据的互斥锁时,就能够对该行数据进行读和写操做,因此互斥锁也叫写锁。
共享锁和互斥锁除了限制事务可以执行的读写操做以外,它们之间还有『共享』和『互斥』的关系,也就是多个事务能够同时得到某一行数据的共享锁,可是互斥锁与共享锁和其余的互斥锁并不兼容,咱们能够很天然地理解这么设计的缘由:多个事务同时写入同一数据不免会发生各类诡异的问题。
若是当前事务没有办法获取该行数据对应的锁时就会陷入等待的状态,直到其余事务将当前数据对应的锁释放才能够得到锁并执行相应的操做。
两阶段锁协议
两阶段锁协议(2PL)是一种可以保证事务可串行化的协议,它将事务的获取锁和释放锁划分红了增加(Growing)和缩减(Shrinking)两个不一样的阶段。
在增加阶段,一个事务能够得到锁可是不能释放锁;而在缩减阶段事务只能够释放锁,并不能得到新的锁,若是只看 2PL 的定义,那么到这里就已经介绍完了,可是它还有两个变种:
Strict 2PL:事务持有的互斥锁必须在提交后再释放;
Rigorous 2PL:事务持有的全部锁必须在提交后释放;
虽然锁的使用可以为咱们解决不一样事务之间因为并发执行形成的问题,可是两阶段锁的使用却引入了另外一个严重的问题,死锁;不一样的事务等待对方已经锁定的资源就会形成死锁,咱们在这里举一个简单的例子:
两个事务在刚开始时分别获取了 draven 和 beacon 资源面的锁,而后再请求对方已经得到的锁时就会发生死锁,双方都没有办法等到锁的释放,若是没有死锁的处理机制就会无限等待下去,两个事务都没有办法完成。
死锁的处理
死锁在多线程编程中是常常遇到的事情,一旦涉及多个线程对资源进行争夺就须要考虑当前的几个线程或者事务是否会形成死锁;解决死锁大致来看有两种办法,一种是从源头杜绝死锁的产生和出现,另外一种是容许系统进入死锁的状态,可是在系统出现死锁时可以及时发现而且进行恢复。
预防死锁
有两种方式能够帮助咱们预防死锁的出现,一种是保证事务之间的等待不会出现环,也就是事务之间的等待图应该是一张有向无环图,没有循环等待的状况或者保证一个事务中想要得到的全部资源都在事务开始时以原子的方式被锁定,全部的资源要么被锁定要么都不被锁定。
可是这种方式有两个问题,在事务一开始时很难判断哪些资源是须要锁定的,同时由于一些很晚才会用到的数据被提早锁定,数据的利用率与事务的并发率也很是的低。一种解决的办法就是按照必定的顺序为全部的数据行加锁,同时与 2PL 协议结合,在加锁阶段保证全部的数据行都是从小到大依次进行加锁的,不过这种方式依然须要事务提早知道将要加锁的数据集。
另外一种预防死锁的方法就是使用抢占加事务回滚的方式预防死锁,当事务开始执行时会先得到一个时间戳,数据库程序会根据事务的时间戳决定事务应该等待仍是回滚,在这时也有两种机制供咱们选择,一种是 wait-die 机制:
当执行事务的时间戳小于另外一事务时,即事务 A 先于 B 开始,那么它就会等待另外一个事务释放对应资源的锁,不然就会保持当前的时间戳并回滚。
另外一种机制叫作 wound-wait,这是一种抢占的解决方案,它和 wait-die 机制的结果彻底相反,当前事务若是先于另外一事务执行并请求了另外一事务的资源,那么另外一事务会马上回滚,将资源让给先执行的事务,不然就会等待其余事务释放资源:
两种方法都会形成没必要要的事务回滚,由此会带来必定的性能损失,更简单的解决死锁的方式就是使用超时时间,可是超时时间的设定是须要仔细考虑的,不然会形成耗时较长的事务没法正常执行,或者没法及时发现须要解决的死锁,因此它的使用仍是有必定的局限性。
死锁检测和恢复
若是数据库程序没法经过协议从原理上保证死锁不会发生,那么就须要在死锁发生时及时检测到并从死锁状态恢复到正常状态保证数据库程序能够正常工做。在使用检测和恢复的方式解决死锁时,数据库程序须要维护数据和事务之间的引用信息,同时也须要提供一个用于判断当前数据库是否进入死锁状态的算法,最后须要在死锁发生时提供合适的策略及时恢复。
在上一节中咱们其实提到死锁的检测能够经过一个有向的等待图来进行判断,若是一个事务依赖于另外一个事务正在处理的数据,那么当前事务就会等待另外一个事务的结束,这也就是整个等待图中的一条边:
如上图所示,若是在这个有向图中出现了环,就说明当前数据库进入了死锁的状态 TransB -> TransE -> TransF -> TransD -> TransB,在这时就须要死锁恢复机制接入了。
如何从死锁中恢复其实很是简单,最多见的解决办法就是选择整个环中一个事务进行回滚,以打破整个等待图中的环,在整个恢复的过程当中有三个事情须要考虑:
每次出现死锁时其实都会有多个事务被波及,而选择其中哪个任务进行回滚是必需要作的事情,在选择牺牲品(Victim)时的黄金原则就是最小化代价,因此咱们须要综合考虑事务已经计算的时间、使用的数据行以及涉及的事务等因素;当咱们选择了牺牲品以后就能够开始回滚了,回滚其实有两种选择一种是所有回滚,另外一种是部分回滚,部分回滚会回滚到事务以前的一个检查点上,若是没有检查点那天然没有办法进行部分回滚。
在死锁恢复的过程当中,其实还可能出现某些任务在屡次死锁时都被选择成为牺牲品,一直都不会成功执行,形成饥饿(Starvation),咱们须要保证事务会在有穷的时间内执行,因此要在选择牺牲品时将时间戳加入考虑的范围。
锁的粒度
到目前为止咱们都没有对不一样粒度的锁进行讨论,一直以来咱们都讨论的都是数据行锁,可是在有些时候咱们但愿将多个节点看作一个数据单元,使用锁直接将这个数据单元、表甚至数据库锁定起来。这个目标的实现须要咱们在数据库中定义不一样粒度的锁:
当咱们拥有了不一样粒度的锁以后,若是某个事务想要锁定整个数据库或者整张表时只须要简单的锁住对应的节点就会在当前节点加上显示(explicit)锁,在全部的子节点上加隐式(implicit)锁;虽然这种不一样粒度的锁可以解决父节点被加锁时,子节点不能被加锁的问题,可是咱们没有办法在子节点被加锁时,马上肯定父节点不能被加锁。
在这时咱们就须要引入意向锁来解决这个问题了,当须要给子节点加锁时,先给全部的父节点加对应的意向锁,意向锁之间是彻底不会互斥的,只是用来帮助父节点快速判断是否能够对该节点进行加锁:
这里是一张引入了两种意向锁,意向共享锁和意向互斥锁以后全部的锁之间的兼容关系;到这里,咱们经过不一样粒度的锁和意向锁加快了数据库的吞吐量。
乐观并发控制
除了悲观并发控制机制 - 锁以外,咱们其实还有其余的并发控制机制,乐观并发控制(Optimistic Concurrency Control)。乐观并发控制也叫乐观锁,可是它并非真正的锁,不少人都会误觉得乐观锁是一种真正的锁,然而它只是一种并发控制的思想。
在这一节中,咱们将会先介绍基于时间戳的并发控制机制,而后在这个协议的基础上进行扩展,实现乐观的并发控制机制。
基于时间戳的协议
锁协议按照不一样事务对同一数据项请求的时间依次执行,由于后面执行的事务想要获取的数据已将被前面的事务加锁,只能等待锁的释放,因此基于锁的协议执行事务的顺序与得到锁的顺序有关。在这里想要介绍的基于时间戳的协议可以在事务执行以前先决定事务的执行顺序。
每个事务都会具备一个全局惟一的时间戳,它便可以使用系统的时钟时间,也可使用计数器,只要可以保证全部的时间戳都是惟一而且是随时间递增的就能够。
基于时间戳的协议可以保证事务并行执行的顺序与事务按照时间戳串行执行的效果彻底相同;每个数据项都有两个时间戳,读时间戳和写时间戳,分别表明了当前成功执行对应操做的事务的时间戳。
该协议可以保证全部冲突的读写操做都能按照时间戳的大小串行执行,在执行对应的操做时不须要关注其余的事务只须要关心数据项对应时间戳的值就能够了:
不管是读操做仍是写操做都会从左到右依次比较读写时间戳的值,若是小于当前值就会直接被拒绝而后回滚,数据库系统会给回滚的事务添加一个新的时间戳并从新执行这个事务。
基于验证的协议
乐观并发控制其实本质上就是基于验证的协议,由于在多数的应用中只读的事务占了绝大多数,事务之间由于写操做形成冲突的可能很是小,也就是说大多数的事务在不须要并发控制机制也能运行的很是好,也能够保证数据库的一致性;而并发控制机制其实向整个数据库系统添加了不少的开销,咱们其实能够经过别的策略下降这部分开销。
而验证协议就是咱们找到的解决办法,它根据事务的只读或者更新将全部事务的执行分为两到三个阶段:
在读阶段,数据库会执行事务中的所有读操做和写操做,并将全部写后的值存入临时变量中,并不会真正更新数据库中的内容;在这时候会进入下一个阶段,数据库程序会检查当前的改动是否合法,也就是是否有其余事务在 RAED PHASE 期间更新了数据,若是经过测试那么直接就进入 WRITE PHASE 将全部存在临时变量中的改动所有写入数据库,没有经过测试的事务会直接被终止。
为了保证乐观并发控制可以正常运行,咱们须要知道一个事务不一样阶段的发生时间,包括事务开始时间、验证阶段的开始时间以及写阶段的结束时间;经过这三个时间戳,咱们能够保证任意冲突的事务不会同时写入数据库,一旦由一个事务完成了验证阶段就会当即写入,其余读取了相同数据的事务就会回滚从新执行。
做为乐观的并发控制机制,它会假定全部的事务在最终都会经过验证阶段而且执行成功,而锁机制和基于时间戳排序的协议是悲观的,由于它们会在发生冲突时强制事务进行等待或者回滚,哪怕有不须要锁也可以保证事务之间不会冲突的可能。
多版本并发控制
到目前为止咱们介绍的并发控制机制其实都是经过延迟或者终止相应的事务来解决事务之间的竞争条件(Race condition)来保证事务的可串行化;虽然前面的两种并发控制机制确实可以从根本上解决并发事务的可串行化的问题,可是在实际环境中数据库的事务大都是只读的,读请求是写请求的不少倍,若是写请求和读请求以前没有并发控制机制,那么最坏的状况也是读请求读到了已经写入的数据,这对不少应用彻底是能够接受的。
在这种大前提下,数据库系统引入了另外一种并发控制机制 - 多版本并发控制(Multiversion Concurrency Control),每个写操做都会建立一个新版本的数据,读操做会从有限多个版本的数据中挑选一个最合适的结果直接返回;在这时,读写操做之间的冲突就再也不须要被关注,而管理和快速挑选数据的版本就成了 MVCC 须要解决的主要问题。
MVCC 并非一个与乐观和悲观并发控制对立的东西,它可以与二者很好的结合以增长事务的并发量,在目前最流行的 SQL 数据库 MySQL 和 PostgreSQL 中都对 MVCC 进行了实现;可是因为它们分别实现了悲观锁和乐观锁,因此 MVCC 实现的方式也不一样。
MySQL 与 MVCC
MySQL 中实现的多版本两阶段锁协议(Multiversion 2PL)将 MVCC 和 2PL 的优势结合了起来,每个版本的数据行都具备一个惟一的时间戳,当有读事务请求时,数据库程序会直接从多个版本的数据项中具备最大时间戳的返回。
更新操做就稍微有些复杂了,事务会先读取最新版本的数据计算出数据更新后的结果,而后建立一个新版本的数据,新数据的时间戳是目前数据行的最大版本 +1:
数据版本的删除也是根据时间戳来选择的,MySQL 会将版本最低的数据定时从数据库中清除以保证不会出现大量的遗留内容。
PostgreSQL 与 MVCC
与 MySQL 中使用悲观并发控制不一样,PostgreSQL 中都是使用乐观并发控制的,这也就致使了 MVCC 在于乐观锁结合时的实现上有一些不一样,最终实现的叫作多版本时间戳排序协议(Multiversion Timestamp Ordering),在这个协议中,全部的的事务在执行以前都会被分配一个惟一的时间戳,每个数据项都有读写两个时间戳:
当 PostgreSQL 的事务发出了一个读请求,数据库直接将最新版本的数据返回,不会被任何操做阻塞,而写操做在执行时,事务的时间戳必定要大或者等于数据行的读时间戳,不然就会被回滚。
这种 MVCC 的实现保证了读事务永远都不会失败而且不须要等待锁的释放,对于读请求远远多于写请求的应用程序,乐观锁加 MVCC 对数据库的性能有着很是大的提高;虽然这种协议可以针对一些实际状况作出一些明显的性能提高,可是也会致使两个问题,一个是每一次读操做都会更新读时间戳形成两次的磁盘写入,第二是事务之间的冲突是经过回滚解决的,因此若是冲突的可能性很是高或者回滚代价巨大,数据库的读写性能还不如使用传统的锁等待方式。
1. MVCC简介与实践
MySQL 在InnoDB引擎下有当前读和快照读两种模式。
1 当前读即加锁读,读取记录的最新版本号,会加锁保证其余并发事物不能修改当前记录,直至释放锁。插入/更新/删除操做默认使用当前读,显示的为select语句加lock in share mode或for update的查询也采用当前读模式。
2 快照读:不加锁,读取记录的快照版本,而非最新版本,使用MVCC机制,最大的好处是读取不须要加锁,读写不冲突,用于读操做多于写操做的应用,所以在不显示加[lock in share mode]/[for update]的select语句,即普通的一条select语句默认都是使用快照读MVCC实现模式。因此楼主的为了让你们明白所作的演示操做,既有当前读也有快照读……
1.1 什么是MVCC
MVCC是一种多版本并发控制机制。
1.2 MVCC是为了解决什么问题?
大多数的MYSQL事务型存储引擎,如,InnoDB,Falcon以及PBXT都不使用一种简单的行锁机制.事实上,他们都和MVCC–多版本并发控制来一块儿使用.
你们都应该知道,锁机制能够控制并发操做,可是其系统开销较大,而MVCC能够在大多数状况下代替行级锁,使用MVCC,能下降其系统开销.
1.3 MVCC实现
MVCC是经过保存数据在某个时间点的快照来实现的. 不一样存储引擎的MVCC. 不一样存储引擎的MVCC实现是不一样的,典型的有乐观并发控制和悲观并发控制.
2.MVCC 具体实现分析
下面,咱们经过InnoDB的MVCC实现来分析MVCC使怎样进行并发控制的. InnoDB的MVCC,是经过在每行记录后面保存两个隐藏的列来实现的,这两个列,分别保存了这个行的建立时间,一个保存的是行的删除时间。这里存储的并非实际的时间值,而是系统版本号(能够理解为事务的ID),没开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会做为事务的ID.下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操做的.
2.1简单的小例子
create table yang( id int primary key auto_increment, name varchar(20));
假设系统的版本号从1开始.
INSERT
InnoDB为新插入的每一行保存当前系统版本号做为版本号. 第一个事务ID为1;
start transaction; insert into yang values(NULL,'yang') ; insert into yang values(NULL,'long'); insert into yang values(NULL,'fei'); commit;
1
2
3
4
5
对应在数据中的表以下(后面两列是隐藏列,咱们经过查询语句并看不到)
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | undefined |
2 | long | 1 | undefined |
3 | fei | 1 | undefined |
SELECT
InnoDB会根据如下两个条件检查每行记录: a.InnoDB只会查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样能够确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的. b.行的删除版本要么未定义,要么大于当前事务版本号,这能够确保事务读取到的行,在事务开始以前未被删除. 只有a,b同时知足的记录,才能返回做为查询结果.
DELETE
InnoDB会为删除的每一行保存当前系统的版本号(事务的ID)做为删除标识. 看下面的具体例子分析: 第二个事务,ID为2;
start transaction; select * from yang; //(1) select * from yang; //(2) commit;
1
2
3
4
假设1
假设在执行这个事务ID为2的过程当中,刚执行到(1),这时,有另外一个事务ID为3往这个表里插入了一条数据; 第三个事务ID为3;
start transaction; insert into yang values(NULL,'tian'); commit;
1
2
3
这时表中的数据以下:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | undefined |
2 | long | 1 | undefined |
3 | fei | 1 | undefined |
4 | tian | 3 | undefined |
而后接着执行事务2中的(2),因为id=4的数据的建立时间(事务ID为3),执行当前事务的ID为2,而InnoDB只会查找事务ID小于等于当前事务ID的数据行,因此id=4的数据行并不会在执行事务2中的(2)被检索出来,在事务2中的两条select 语句检索出来的数据都只会下表:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | undefined |
2 | long | 1 | undefined |
3 | fei | 1 | undefined |
假设2
假设在执行这个事务ID为2的过程当中,刚执行到(1),假设事务执行完事务3后,接着又执行了事务4; 第四个事务:
start transaction; delete from yang where id=1; commit;
1
2
3
此时数据库中的表以下:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | 4 |
2 | long | 1 | undefined |
3 | fei | 1 | undefined |
4 | tian | 3 | undefined |
接着执行事务ID为2的事务(2),根据SELECT 检索条件能够知道,它会检索建立时间(建立事务的ID)小于当前事务ID的行和删除时间(删除事务的ID)大于当前事务的行,而id=4的行上面已经说过,而id=1的行因为删除时间(删除事务的ID)大于当前事务的ID,因此事务2的(2)select * from yang也会把id=1的数据检索出来.因此,事务2中的两条select 语句检索出来的数据都以下:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | 4 |
2 | long | 1 | undefined |
3 | fei | 1 | undefined |
UPDATE
InnoDB执行UPDATE,其实是新插入了一行记录,并保存其建立时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间.
假设3
假设在执行完事务2的(1)后又执行,其它用户执行了事务3,4,这时,又有一个用户对这张表执行了UPDATE操做: 第5个事务:
start transaction; update yang set name='Long' where id=2; commit;
1
2
3
根据update的更新原则:会生成新的一行,并在原来要修改的列的删除时间列上添加本事务ID,获得表以下:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | 4 |
2 | long | 1 | 5 |
3 | fei | 1 | undefined |
4 | tian | 3 | undefined |
2 | Long | 5 | undefined |
继续执行事务2的(2),根据select 语句的检索条件,获得下表:
id | name | 建立时间(事务ID) | 删除时间(事务ID) |
---|---|---|---|
1 | yang | 1 | 4 |
2 | long | 1 | 5 |
3 | fei | 1 | undefined |
仍是和事务2中(1)select 获得相同的结果.
总结
数据库的并发控制机制到今天已经有了很是成熟、完善的解决方案,咱们并不须要本身去设计一套新的协议来处理不一样事务之间的冲突问题,从数据库的并发控制机制中学习到的相关知识,不管是锁仍是乐观并发控制在其余的领域或者应用中都被普遍使用,因此了解、熟悉不一样的并发控制机制的原理是颇有必要的。
微信公众号【黄小斜】大厂程序员,互联网行业新知,终身学习践行者。关注后回复「Java」、「Python」、「C++」、「大数据」、「机器学习」、「算法」、「AI」、「Android」、「前端」、「iOS」、「考研」、「BAT」、「校招」、「笔试」、「面试」、「面经」、「计算机基础」、「LeetCode」 等关键字能够获取对应的免费学习资料。