锁是计算机协调多个进程或纯线程并发访问某一资源的机制。在数据库中,除了传统的计算机资源,如 CPU, RAM外,数据也是多个用户共享的资源。如何保证数据库并发访问的一致性、有效性是数据库必须解决的一个问题。解决这种竞争通常会产生锁的机制。
在数据库中有两个层面的锁,一种是由 MySQL
实现的锁,一种是由不同的存储引擎提供的锁。例如 InnoDB
, MyISAM
都会实现自己的锁。
1. MySQL 如何控制并发
对于并发可能冲突的操作分别为:
- 读 - 读:不存在问题
- 读 - 写:有隔离性问题,可能会遇到脏读,幻读,不可重复读等
- 写 - 写:有隔离性问题,可能会遇到脏写导致更新丢失
针对上述问题,MySQL
提供了两个锁: 悲观锁,乐观锁。
1.1 乐观锁
假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性
乐观锁(Optimistic Lock),实际上是乐观并发控制(Optimictic ConCurrency Control, "OCC"),对并发问题持有乐观态度,认为一切操作都不会发生冲突因此不会上锁( 所以乐观锁并不是一种锁)。但是在事务提交更新的时候会判断一下在事务执行期间是否有其它进程更新了这部分数据。乐观锁解决了 写- 写 冲突的无锁并发控制( 这里的无锁并不是真的无锁,而是不在执行过程中加锁,在检测冲突阶段还是需要对数据加锁,但是这样极大的减少了加锁的次数)。乐观锁一般来说有一下两种实现方式:
1.1.1 基于快照隔离的并发控制
核心思想是:数据库为每个数据项维护多个版本(快照),每个事务只对属于自己的私有快照进行更新,在事务真正提交前进行有效性检查,使得事务正常提交更新或者失败回滚。
通过记录数据版机制来实现乐观锁。数据库表增加一个 version
来实现。当读取数据时,将 version
字段一同取出,数据每更新一次,对此 version + 1
。当提交数据时,判断数据库表对应记录的当前版本信息与第一次取出来的 version
值相比,如果版本号相同则予以更新,否则视为过期数据,如下图:
如上图所示,如果更新操作顺序执行,则数据版本以此递增,不会产生冲突。但是如果发生不同业务的操作对同一版本的数据进行修改,那么先提交的(图中B事务)会把数据 version
修改为2,此时当 A 在 B之后进行提交时发现 version
已经修改了,那么 A 的更新就会失败。
事务间可能冲突的操作通过数据项的不同版本的快照相互隔离,到真正要写入数据库时才进行冲突检测。因而这是一种乐观并发控制。
1.1.2 基于有效性检查的并发控制
核心思想:事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。
基于有效性检查的事务执行过程分为三个阶段:
- 读阶段:数据项被读入并保存在事务的局部变量中。所有 write 操作都是对局部变量进行,并不对数据库进行真正的更新
- 有效性检查阶段:对事务进行有效性检查,判断时候可以执行 write 操作而不违反可串行性。如果失败,则回滚该事务
- 写阶段:事务已经通过有效性检查,则将该临时变量的结果更新到数据库中
有效性检查通常也是通过事务的时间戳进行比较完成的,不过和基于时间戳排序的规则不一样。该方法允许可能产生冲突的操作并发执行,因为每个事务操作的都是自己工作空间的局部变量,直到有效性检查阶段发现了冲突才回滚,因而这是一种乐观的并发策略。
1.2 悲观锁
假定一定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
悲观锁(Pessimistic Lock) 实际上时悲观并发控制(Pessimistic Concurrency Control, "PCC"),故名思意就是对所有并发操作都持有悲观态度,认为每次对数据的操作都会引起并发问题,因此悲观锁每次操作数据都会先上锁,以屏蔽一切可能违反数据完整性的操作。
1.2.1 基于封锁的并发控制
核心思想:对于并发可能冲突的操作,比如读 - 写,写- 读,写 - 写,通过锁使它们互斥执行。锁通常分为共享锁和排他锁两种类型:(这里感觉和操作系统的读写锁类似?)
- 共享锁(S):事务T对数据A加共享锁,其它事务只能对A加共享锁,不能加排他锁
- 排他锁(X):事务T对数据A加排他锁,其它事务既不能对A加共享锁也不能加排他锁
基于锁的并发控制流程:
- 事务根据自己对数据进行的操作类型申请相应的锁(读申请共享,写申请排他)
- 申请锁的请求被发送给锁管理器。锁管理器根据当前数据向是否已经有锁以及申请的和持有的锁是否有冲突绝顶是否对该请求授予锁
- 若锁被授予,则申请锁的事务可以继续执行;若被拒绝,则申请锁的事务将进行等待(阻塞),直到锁被其它事务释放
可能出现的问题:(这里也和操作系统的很像)
- 死锁:多个事务持有锁并互相循环等待其它事务的锁导致所有事务都无法继续执行
- 饥饿:数据项A一直被加共享锁,导致事务一直无法获取A的排他锁
对于可能发生冲突的并发操作,锁使它们由并行行为变为串行执行,这是一种悲观的并发控制。
1.2.2 基于时间戳的并发控制
核心思想: 对于并发可能冲突的操作,基于时间戳排序规则选定某事务继续执行,其它事务回滚。
系统会在每个事务开始前赋予其一个时间戳,这个时间戳可以是系统时钟也可以是一个不断累加的计数器值,当事务回滚时为其赋予一个新的时间戳,先开始的事务时间戳小与后开始的事务时间戳。
每一个数据项Q由两个时间戳相关的字段:
W-timestamp(Q)
: 成功执行write(Q)
的所有事务的最大时间戳R-timestamp(Q)
:成功执行read(Q)
的所有事务的最大时间戳
时间戳排序规则如下:
- 假设事务T发出 read 请求,此时T的时间戳为
TS
a. 若TS < W-timestamp(Q)
,则 T 需要读入的 Q 已经被覆盖,此时 read 操作被拒绝,T回滚。
b. 若TS >= W-timestamp(Q)
,则执行read
操作,同时把R-timestamp(Q)
设置为TS
与R-timestamp(Q)
中较大的值 - 假设事务T发出 write 请求,此时T的时间戳为
TS
a. 若TS < R-timestamp(Q)
,代表后续已经事务读取,write
操作被拒绝,T回滚
b. 若TS < W-timestamp(Q)
,代表后续已经有事务修改过数据项,wrtie
操作被拒绝,T回滚
c. 其它情况:系统执行write
操作,将W-timestamp(Q)
设置为TS
基于时间戳排序和基于锁实现的本质相同,都是对于可能冲突的并发操作,以串行的方式取代并发执行,因而它也是一种悲观并发控制,它们的区别主要有两点:
- 基于锁是让冲突的事务进行等待,而基于时间戳排序是让冲突的事务进行回滚
- 基于锁冲突的事务执行次序时根据它们申请锁的顺序,先申请的先执行;而基于时间戳排序时根据特定的时间戳排序规则
1.3 MVCC
MVCC的意思时多版本并发控制(Multi-Version Concurrency Control),它解决的是读- 写并发的问题。MVCC一般也看做是一种乐观机制,和间隙锁一样,它可以用来解决幻读的问题,只是间隙锁解决幻读是使用写进程阻塞的方式来进行的,而 MVCC
是以快照的方式来处理这一问题。不同数据库版本对 MVCC
的时间机制不同,本文主要阐述 InnoDB
是如何进行 MVCC
的。
InnoDB
会为每一行增加两个字段,当前行创建时的版本号和删除时版本号(可以为空),事务在写一条记录时会拷贝一份生成这条记录的一个原始拷贝,写操作同样还是会对原记录加锁,但是读操作会读取未加锁的新纪录,这样就保证了读 - 写并行,具体操作如下:
- SELECT:
InnoDB
会根据以下两个条件检查每行记录:- a.
InnoDB
只查找版本早与当前事务版本的数据行,这样可以确保事务读取的行要么时在事务开始前就已经存在的,要么是事务自身插入或者修改过的 - b. 行的删除版本要么未定义,要么大于当前事务版本号。这样可以确保事务读取到的行,在事务开始之前就被删除
- a.
- INSERT:
InnoDB
为新插入的每一行保存当前系统版本号作为行版本号 - DELETE:
InnoDB
为删除的每一行保存当前系统版本号作为删除标识 - UPDATE:
InnoDB
插入一行新纪录,保存当前系统版本号为行版本号,同时保存当前系统版本号为原来的行做删除标识
InnoDB
通过 MVCC
实现了读写并行,但是在不同的隔离级别下,读的方式也是有所区别。在 RU
隔离级别下,每次都是读取最新版本的数据行,所以不能用 MVCC
的多版本,而 SERIALIZABLE
隔离级别每次读取操作都会为记录加上锁,也和 MVCC
不兼容,所以只有 RC
和 RR
这两个级别才有 MVCC
。
尽管 RR
和 RC
的隔离级别都实现了 MVCC
来满足读写并行,但是读的方式不一样:RC
总是读取记录的最新版本,如果该纪律被锁住,则读取该记录最新的一次快照,而 RR
是读取该事务开始时记录的版本。虽然读取的方式不同,但是二者读取的都是快照数据,并不会阻塞写进程,所以这种操作都被称为快照读(Snapshot Read)。快照读在 InnoDB
的实现中就是普通不加锁的 SELECT
版本,而与快照读相对的是当前读,即处理的都是当前的数据,需要加锁,在解决当前读的幻读问题是, MySQL
使用了间隙锁的机制。