mysql的并发_MySQL并发控制

如果数据库中的所有事务都是串行执行的,那么它非常容易成为整个应用的性能瓶颈,虽然说没法水平扩展的节点在最后都会成为瓶颈,但是串行执行事务的数据库会加速这一过程;而并发(Concurrency)使一切事情的发生都有了可能,它能够解决一定的性能问题,但是它会带来更多诡异的错误。

引入了并发事务之后,如果不对事务的执行进行控制就会出现各种各样的问题,你可能没有享受到并发带来的性能提升就已经被各种奇怪的问题折磨的欲仙欲死了。

概述

如何控制并发是数据库领域中非常重要的问题之一,不过到今天为止事务并发的控制已经有了很多成熟的解决方案,而这些方案的原理就是这篇文章想要介绍的内容,文章中会介绍最为常见的三种并发控制机制:

9025acd9b094

悲观并发控制

、乐观并发控制

、多版本并发控制

分别是悲观并发控制、乐观并发控制和多版本并发控制,其中悲观并发控制其实是最常见的并发控制机制,也就是锁;而乐观并发控制其实也有另一个名字:乐观锁,乐观锁其实并不是一种真实存在的锁,我们会在文章后面的部分中具体介绍;最后就是多版本并发控制(MVCC)了,与前两者对立的命名不同,MVCC 可以与前两者中的任意一种机制结合使用,以提高数据库的读性能。

悲观并发控制

控制不同的事务对同一份数据的获取是保证数据库的一致性的最根本方法,如果我们能够让事务在同一时间对同一资源有着独占的能力,那么就可以保证操作同一资源的不同事务不会相互影响。

最简单的、应用最广的方法就是使用锁来解决,当事务需要对资源进行操作时需要先获得资源对应的锁,保证其他事务不会访问该资源后,在对资源进行各种操作;在悲观并发控制中,数据库程序对于数据被修改持悲观的态度,在数据处理的过程中都会被锁定,以此来解决竞争的问题。

读写锁

为了最大化数据库事务的并发能力,数据库中的锁被设计为两种模式,分别是共享锁和互斥锁。当一个事务获得共享锁之后,它只可以进行读操作,所以共享锁也叫读锁;而当一个事务获得一行数据的互斥锁时,就可以对该行数据进行读和写操作,所以互斥锁也叫写锁。

InnoDB存储引擎实现了两种标准的行级锁:

共享锁(S Lock),允许事务读一行数据。

排它锁(X Lock),允许事务删除或更新一行数据。

9025acd9b094

读写锁

共享锁和互斥锁除了限制事务能够执行的读写操作之外,它们之间还有『共享』和『互斥』的关系,也就是多个事务可以同时获得某一行数据的共享锁,但是互斥锁与共享锁和其他的互斥锁并不兼容,我们可以很自然地理解这么设计的原因:多个事务同时写入同一数据难免会发生各种诡异的问题。

锁的粒度

到目前为止我们都没有对不同粒度的锁进行讨论,一直以来我们都讨论的都是数据行锁,但是在有些时候我们希望将多个节点看做一个数据单元,使用锁直接将这个数据单元、表甚至数据库锁定起来。这个目标的实现需要我们在数据库中定义不同粒度的锁:

9025acd9b094

数据库锁的层次

当我们拥有了不同粒度的锁之后,如果某个事务想要锁定整个数据库或者整张表时只需要简单的锁住对应的节点就会在当前节点加上显示(explicit)锁,在所有的子节点上加隐式(implicit)锁;虽然这种不同粒度的锁能够解决父节点被加锁时,子节点不能被加锁的问题,但是我们没有办法在子节点被加锁时,立刻确定父节点不能被加锁。

在这时我们就需要引入意向锁来解决这个问题了,当需要给子节点加锁时,先给所有的父节点加对应的意向锁,意向锁之间是完全不会互斥的,只是用来帮助父节点快速判断是否可以对该节点进行加锁:例如,如果需要对页上的一条记录r加X锁,那么需要分别对数据库A、表、页上意向锁IX,最后对记录r上X锁。如果想要对表加X锁,那么需要对数据库加上意向锁IX。所以如果在记录r已经加了X锁的情况下,想要在表上加X锁,就会与表上的IX意向锁冲突。

InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。

9025acd9b094

意向锁

这里是一张引入了两种意向锁,意向共享锁和意向互斥锁之后所有的锁之间的兼容关系;到这里,我们通过不同粒度的锁和意向锁加快了数据库的吞吐量。

自增长与锁

在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当对含有自增长计数器的表进行插入操作时,这个计数器会被初始化。插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称作AUTO-INC Locking。这种锁其实是采用一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放。

虽然AUTO-INC Locking从一定程度上提高了并发插入的效率,但任然存在性能上的问题。

从MySQL 5.1.22版本开始,InnoDB存储引擎提供了一种轻量级互斥量的自增长实现机制,提供了一个参数innodb_autoinc_lock_mode来控制自增长的模式。

9025acd9b094

插入类型

9025acd9b094

innodb_autoinc_lock_mode参数说明

外键与锁

外键主要用于引用完整性的约束检查。在InnoDB存储引擎中,对于一个外键列,如果没有显式地对这个列加索引,InnoDB存储引擎会自动对其加一个索引,因为这样可以避免表锁。

对于外键值的插入或更新,首先需要查询父表中的记录,即SELECT父表。且不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此使用的是SELECT 。。。LOCK IN SHARE MODE方式,即主动对父表加一个S锁。

锁的算法

行锁的3中算法

InnoDB存储引擎有3种行锁的算法:Record Lock:单个行记录上的锁

Gap Lock:间隙锁,锁定一个范围,但不包含记录本身

Next-Key Lock:Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身

Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。

Next-Key Lock结合了Gap Lock和Record Lock,在Next-Key算法下,InnoDB对于行的查询都是采用了这种锁定算法。例如一个索引有10,11,13和20这四个值,那么该索引可能被Next-Key Locking的区间为:

(-无穷,10],(10,11],(11,13],(13,20],(20,+无穷]

利用这种锁定技术,锁定的不是单个值,而是一个范围,是谓词锁的一种改进。

当查询的索引含有唯一属性是,InnoDB存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围。

若是辅助索引,则情况会完全不同,InnoDB会对键所在区间进行Next-Key Locking,同时对下一个键值加上gap lock。

幻读(Phantom Problem)

在默认的事务隔离级别下,即REPEATABLE READ下,InnoDB存储引擎采用Next-Key Locking机制来避免幻读。

幻读是指在同一事务下,连续执行两次同样的SQL语句可能会导致不同的结果,第二次可能会返回之前不存在的行。

锁问题

脏读

脏读指一个事务读取到了另一个事物中未提交的数据,违反了数据库的隔离性。

不可重复读

不可重复读是指一个事务多次读取统一数据集合,读取的值却发生了变化。

与脏读的区别是,一个读到了未提交的数据,另一个读到了已经提交的数据。

丢失更新

丢失更新是指一个事务的更新操作会被另一个事物的更新操作所覆盖。

在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题,因为即使是READ UNCOMMITTED的事务隔离级别,对于行的DML操作,需要对行或其他粗粒度级别的对象加锁。

但是在生产应用中还有另一个逻辑意义的丢失更新问题:事务T1查询一行数据,展示给用户1;

事务T2查询同一行数据,展示给用户2;

用户1修改这行数据,更新数据库;

用户2修改这行数据,更新数据库;

那么用户1的修改就被覆盖了。要避免丢失更新发生,需要让事务在这种情况下的操作变成串行化,而不是并行的操作。即在上述四个步骤的第一步和第二步中,对用户记录加上一个排它X锁。这样他们就只能进行串行操作了。

阻塞

因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事物中的锁释放它所占用的资源,这就是阻塞。

在默认情况下InnoDB存储引擎不会回滚超时引发的错误异常。

两阶段锁协议

两阶段锁协议(2PL)是一种能够保证事务可串行化的协议,它将事务的获取锁和释放锁划分成了增长(Growing)和缩减(Shrinking)两个不同的阶段。

9025acd9b094

两阶段锁协议

在增长阶段,一个事务可以获得锁但是不能释放锁;而在缩减阶段事务只可以释放锁,并不能获得新的锁,如果只看 2PL 的定义,那么到这里就已经介绍完了,但是它还有两个变种:

Strict 2PL:事务持有的互斥锁必须在提交后再释放;

Rigorous 2PL:事务持有的所有锁必须在提交后释放;

虽然锁的使用能够为我们解决不同事务之间由于并发执行造成的问题,但是两阶段锁的使用却引入了另一个严重的问题,死锁;不同的事务等待对方已经锁定的资源就会造成死锁,我们在这里举一个简单的例子:

9025acd9b094

死锁

死锁

死锁在多线程编程中是经常遇到的事情,一旦涉及多个线程对资源进行争夺就需要考虑当前的几个线程或者事务是否会造成死锁;解决死锁大体来看有两种办法,一种是从源头杜绝死锁的产生和出现,另一种是允许系统进入死锁的状态,但是在系统出现死锁时能够及时发现并且进行恢复。

预防死锁

有两种方式可以帮助我们预防死锁的出现,一种是保证事务之间的等待不会出现环,也就是事务之间的等待图应该是一张有向无环图,没有循环等待的情况或者保证一个事务中想要获得的所有资源都在事务开始时以原子的方式被锁定,所有的资源要么被锁定要么都不被锁定。

但是这种方式有两个问题,在事务一开始时很难判断哪些资源是需要锁定的,同时因为一些很晚才会用到的数据被提前锁定,数据的利用率与事务的并发率也非常的低。一种解决的办法就是按照一定的顺序为所有的数据行加锁,同时与 2PL 协议结合,在加锁阶段保证所有的数据行都是从小到大依次进行加锁的,不过这种方式依然需要事务提前知道将要加锁的数据集。

另一种预防死锁的方法就是使用抢占加事务回滚的方式预防死锁,当事务开始执行时会先获得一个时间戳,数据库程序会根据事务的时间戳决定事务应该等待还是回滚,在这时也有两种机制供我们选择,一种是 wait-die 机制:

9025acd9b094

wait-die 机制

当执行事务的时间戳小于另一事务时,即事务 A 先于 B 开始,那么它就会等待另一个事务释放对应资源的锁,否则就会保持当前的时间戳并回滚。

另一种机制叫做 wound-wait,这是一种抢占的解决方案,它和 wait-die 机制的结果完全相反,当前事务如果先于另一事务执行并请求了另一事务的资源,那么另一事务会立刻回滚,将资源让给先执行的事务,否则就会等待其他事务释放资源:

9025acd9b094

wound-wait 机制

两种方法都会造成不必要的事务回滚,由此会带来一定的性能损失,更简单的解决死锁的方式就是使用超时时间,但是超时时间的设定是需要仔细考虑的,否则会造成耗时较长的事务无法正常执行,或者无法及时发现需要解决的死锁,所以它的使用还是有一定的局限性。

死锁的恢复

如果数据库程序无法通过协议从原理上保证死锁不会发生,那么就需要在死锁发生时及时检测到并从死锁状态恢复到正常状态保证数据库程序可以正常工作。在使用检测和恢复的方式解决死锁时,数据库程序需要维护数据和事务之间的引用信息,同时也需要提供一个用于判断当前数据库是否进入死锁状态的算法,最后需要在死锁发生时提供合适的策略及时恢复。

等待图

在上一节中我们其实提到死锁的检测可以通过一个有向的等待图来进行判断,较之超时的解决方案,这是一种更为主动的死锁检测方式。

如果一个事务依赖于另一个事务正在处理的数据,那么当前事务就会等待另一个事务的结束,这也就是整个等待图中的一条边:

9025acd9b094

等待图

如上图所示,如果在这个有向图中出现了环,就说明当前数据库进入了死锁的状态TransB -> TransE -> TransF -> TransD -> TransB,在这时就需要死锁恢复机制接入了。

如何从死锁中恢复其实非常简单,最常见的解决办法就是选择整个环中一个事务进行回滚,以打破整个等待图中的环,在整个恢复的过程中有三个事情需要考虑:

9025acd9b094

死锁恢复

每次出现死锁时其实都会有多个事务被波及,而选择其中哪一个任务进行回滚是必须要做的事情,在选择牺牲品(Victim)时的黄金原则就是最小化代价,所以我们需要综合考虑事务已经计算的时间、使用的数据行以及涉及的事务等因素;当我们选择了牺牲品之后就可以开始回滚了,回滚其实有两种选择一种是全部回滚,另一种是部分回滚,部分回滚会回滚到事务之前的一个检查点上,如果没有检查点那自然没有办法进行部分回滚。在死锁恢复的过程中,其实还可能出现某些任务在多次死锁时都被选择成为牺牲品,一直都不会成功执行,造成饥饿(Starvation),我们需要保证事务会在有穷的时间内执行,所以要在选择牺牲品时将时间戳加入考虑的范围。

锁升级

锁升级是指将当前锁升级为粒度更大的锁。例如将行锁升级为页锁,将页锁升级为表锁。

InnoDB不存在锁升级的问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理,采用的是位图的方式。因此不管一个事务锁住页中一个记录还是多个纪录,其开销通常都是一致的。

假设一张表有3 000 000个数据页,每页大约有100条记录,那么总共有300 000 000条记录。若有一个事务执行全表更新的SQL语句,则需要对所有记录加X锁。若根据每行记录产生锁对象进行加锁,且每个锁占用10字节,则锁对象需要3GB内存。而InnoDB存储引擎根据页进行加锁,并采用位图方式,假设每个页存储的锁信息占用30个字节,则锁对象仅需90MB内存。由此可见两者对于锁资源开销的差距之大。

乐观并发控制

除了悲观并发控制机制 - 锁之外,我们其实还有其他的并发控制机制,乐观并发控制(Optimistic Concurrency Control)。乐观并发控制也叫乐观锁,但是它并不是真正的锁,很多人都会误以为乐观锁是一种真正的锁,然而它只是一种并发控制的思想。

在这一节中,我们将会先介绍基于时间戳的并发控制机制,然后在这个协议的基础上进行扩展,实现乐观的并发控制机制。

基于时间戳的协议

锁协议按照不同事务对同一数据项请求的时间依次执行,因为后面执行的事务想要获取的数据已将被前面的事务加锁,只能等待锁的释放,所以基于锁的协议执行事务的顺序与获得锁的顺序有关。在这里想要介绍的基于时间戳的协议能够在事务执行之前先决定事务的执行顺序。

每一个事务都会具有一个全局唯一的时间戳,它即可以使用系统的时钟时间,也可以使用计数器,只要能够保证所有的时间戳都是唯一并且是随时间递增的就可以。

9025acd9b094

乐观锁 | 悲观锁

基于时间戳的协议能够保证事务并行执行的顺序与事务按照时间戳串行执行的效果完全相同;每一个数据项都有两个时间戳,读时间戳和写时间戳,分别代表了当前成功执行对应操作的事务的时间戳。

该协议能够保证所有冲突的读写操作都能按照时间戳的大小串行执行,在执行对应的操作时不需要关注其他的事务只需要关心数据项对应时间戳的值就可以了:

9025acd9b094

时间戳冲突协议

无论是读操作还是写操作都会从左到右依次比较读写时间戳的值,如果小于当前值就会直接被拒绝然后回滚,数据库系统会给回滚的事务添加一个新的时间戳并重新执行这个事务。

基于验证的协议

乐观并发控制其实本质上就是基于验证的协议,因为在多数的应用中只读的事务占了绝大多数,事务之间因为写操作造成冲突的可能非常小,也就是说大多数的事务在不需要并发控制机制也能运行的非常好,也可以保证数据库的一致性;而并发控制机制其实向整个数据库系统添加了很多的开销,我们其实可以通过别的策略降低这部分开销。

而验证协议就是我们找到的解决办法,它根据事务的只读或者更新将所有事务的执行分为两到三个阶段:

9025acd9b094

基于验证的协议

在读阶段,数据库会执行事务中的全部读操作和写操作,并将所有写后的值存入临时变量中,并不会真正更新数据库中的内容;在这时候会进入下一个阶段,数据库程序会检查当前的改动是否合法,也就是是否有其他事务在 RAED PHASE 期间更新了数据,如果通过测试那么直接就进入 WRITE PHASE 将所有存在临时变量中的改动全部写入数据库,没有通过测试的事务会直接被终止。

为了保证乐观并发控制能够正常运行,我们需要知道一个事务不同阶段的发生时间,包括事务开始时间、验证阶段的开始时间以及写阶段的结束时间;通过这三个时间戳,我们可以保证任意冲突的事务不会同时写入数据库,一旦由一个事务完成了验证阶段就会立即写入,其他读取了相同数据的事务就会回滚重新执行。

作为乐观的并发控制机制,它会假定所有的事务在最终都会通过验证阶段并且执行成功,而锁机制和基于时间戳排序的协议是悲观的,因为它们会在发生冲突时强制事务进行等待或者回滚,哪怕有不需要锁也能够保证事务之间不会冲突的可能。

多版本并发控制

到目前为止我们介绍的并发控制机制其实都是通过延迟或者终止相应的事务来解决事务之间的竞争条件(Race condition)来保证事务的可串行化;虽然前面的两种并发控制机制确实能够从根本上解决并发事务的可串行化的问题,但是在实际环境中数据库的事务大都是只读的,读请求是写请求的很多倍,如果写请求和读请求之前没有并发控制机制,那么最坏的情况也是读请求读到了已经写入的数据,这对很多应用完全是可以接受的。

InnoDB通过多版本控制的方式实现了一致性非锁定读。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放,而会去读取行的一个快照数据。

9025acd9b094

多版本并发控制

在这种大前提下,数据库系统引入了另一种并发控制机制 -多版本并发控制(Multiversion Concurrency Control),每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回;在这时,读写操作之间的冲突就不再需要被关注,而管理和快速挑选数据的版本就成了 MVCC 需要解决的主要问题。

MVCC 并不是一个与乐观和悲观并发控制对立的东西,它能够与两者很好的结合以增加事务的并发量,在目前最流行的 SQL 数据库 MySQL 和 PostgreSQL 中都对 MVCC 进行了实现;但是由于它们分别实现了悲观锁和乐观锁,所以 MVCC 实现的方式也不同。

MySQL 与 MVCC

MySQL 中实现的多版本两阶段锁协议(Multiversion 2PL)将 MVCC 和 2PL 的优点结合了起来,每一个版本的数据行都具有一个唯一的时间戳,当有读事务请求时,数据库程序会直接从多个版本的数据项中具有最大时间戳的返回。

9025acd9b094

多版本两阶段读

更新操作就稍微有些复杂了,事务会先读取最新版本的数据计算出数据更新后的结果,然后创建一个新版本的数据,新数据的时间戳是目前数据行的最大版本+1:

9025acd9b094

多版本两阶段写

数据版本的删除也是根据时间戳来选择的,MySQL 会将版本最低的数据定时从数据库中清除以保证不会出现大量的遗留内容。

总结

数据库的并发控制机制到今天已经有了非常成熟、完善的解决方案,我们并不需要自己去设计一套新的协议来处理不同事务之间的冲突问题,从数据库的并发控制机制中学习到的相关知识,无论是锁还是乐观并发控制在其他的领域或者应用中都被广泛使用,所以了解、熟悉不同的并发控制机制的原理是很有必要的。

参考

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值