悲观(乐观)锁

乐观锁和悲观锁并不是锁,而是锁的设计思想

乐观锁:乐观锁(Optimistic Locking)认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。

  • 乐观锁的版本号机制
    在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE … SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
  • 乐观锁的时间戳机制
    时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

悲观锁:悲观锁(Pessimistic Locking)也是一种思想,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

总结:乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。

按锁粒度

表锁

表锁一般有表锁、元数据锁(MDL)、意向锁、Auto-INC锁

表级别的锁定是MySQL各存储引擎中最大颗粒度的锁定机制。该锁定机制最大的特点是实现逻辑非常简单,带来的系统负面影响最小,所以获取锁和释放锁的速度很快。由于表级锁一次会将整个表锁定,所以可以很好的避免困扰我们的死锁问;当然锁定颗粒度大所带来最大的负面影响就是出现锁定资源争用的概率也会最高,大大降低并发度;使用表级锁定的主要是MyISAM,MEMORY,CSV等一些非事务性存储引擎

下面先介绍一下MySQL里表级别的锁:表锁和元数据锁(meta data lock,MDL)

元数据锁
  1. 举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表,在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大
  2. MDL作用是防止DDL和DML并发的冲突,保证读写的正确性, ****MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性:可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。因此,在MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁
Auto-Inc锁

表里的主键通常都会设置成自增的,这是通过对主键字段声明 AUTO_INCREMENT 属性实现的。之后可以在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 AUTO-INC锁实现的。AUTO-INC锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放

工作过程:在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 AUTO_INCREMENT 修饰的字段的值是连续递增的。

但是, AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞,因此, 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增,一样也是在插入数据的时候,会为被 AUTO_INCREMENT 修饰的字段加上轻量级锁,然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁

InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。

  • 当 innodb_autoinc_lock_mode = 0,就采用 AUTO-INC 锁,语句执行结束后才释放锁;
  • 当 innodb_autoinc_lock_mode = 2,就采用轻量级锁,申请自增主键后就释放锁,并不需要等语句执行后才释放。
  • 当 innodb_autoinc_lock_mode = 1:
    • 普通 insert 语句,自增锁在申请之后就马上释放;
    • 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;

当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在「主从复制的场景」中会发生数据不一致的问题

行锁

(1)与表锁正相反,行锁最大的特点就是锁定对象的颗粒度很小,也是目前各大数据库管理软件所实现的锁定颗粒度最小的。由于锁定颗粒度很小,所以发生锁定资源争用的概率也最小,能够给予应用程序尽可能大的并发处理能力从而提高系统的整体性能
(2)虽然能够在并发处理能力上面有较大的优势,但是行级锁定也因此带来了不少弊端。由于锁定资源的颗粒度很小,所以每次获取锁和释放锁需要做的事情也更多,带来的消耗自然也就更大了。此外,行级锁定也最容易发生死锁,使用行级锁定的主要是InnoDB存储引擎

注:行级锁提高了多个事务并发处理的能力,同时也容易导致死锁的发生(4个必要条件),两者并不冲突!

Record Lock

锁定的是索引记录,而不是行数据,即锁定的是key,记录锁(Record Lock)是针对索引记录(index record)的锁定。例如,SELECT * FROM t WHERE id = 1 FOR UPDATE;会阻止其他事务对表 t 中 id = 1 的数据执行插入、更新,以及删除操作。

Gap Lock

对于键值在条件范围内但并不存在的记录, 叫做”间隙 (GAP)”, InnoDB也会对这个”间隙”加锁, 这种锁机制就是所谓的间隙锁它会锁定索引记录间隙, 但不包括记录本身(左开右开区间),确保索引记录的间隙不变。

间隙锁是针对事务隔离级别为可重复读或以上级别而已的,(例如,SELECT * FROM t WHERE c1 BETWEEN 1 and 10 FOR UPDATE会阻止其他事务将 1 到 10 之间的任何值插入到 c1 字段中(但是可以修改1和10),即使该列不存在这样的数据;因为这些值都会被锁定)

间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的

Next-Key Lock

Next-Key Lock是行锁和间隙锁的组合,当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改或者插入记录。

MVCC 不能解决幻影读问题,Next-Key Locks 就是为了解决这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Locks 可以解决幻读问题;

记录锁和间隙锁组合起来就叫Next-Key Lock,默认情况下InnoDB工作在可重复读隔离级别,并且会以Next-Key Lock的方式对数据行进行加锁,这样可以有效防止幻读的发生;

它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。它锁定一个前开后闭区间,例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:

1
2
3
4
5
(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)

其中:(10, 11) 是一个间隙锁的锁定范围,(10, 11] 是一个 next-key 锁的锁定范围

插入意向锁

一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。

如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。

举个例子,假设事务 A 已经对表加了一个范围 id 为(3,5)间隙锁,当事务 A 还没提交的时候,事务 B 向该表插入一条 id = 4 的新记录,这时会判断到插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态(MySQL加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。

  1. 插入意向锁名字虽然有意向锁(表锁),但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁

  2. 如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁

  3. 插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)

  4. 插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)

页锁

  除了表锁、行锁外,MySQL还有一种相对偏中性的页级锁,页锁是MySQL中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。另外,页级锁定和行级锁定一样,会发生死锁。使用页级锁定的主要是BerkeleyDB存储引擎

适用场景

从锁的角度来说,表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如Web应用;而行级锁则更适合于有大量按索引条件并发更新数据的情况,同时又有并发查询的应用场景

全局锁

全局锁是对整个数据库实例加锁。使用场景一般在全库逻辑备份时,MySQL提供加全局读锁的命令:**Flush tables with read lock (FTWRL)**,这个命令可以使整个库处于只读状态。使用该命令之后,数据更新语句、数据定义语句和更新类事务的提交语句等修改数据库的操作都会被阻塞。

还有一种锁全局的方式:set global readonly=true ,相当于将整个库设置成只读状态,但这种修改global配置量级较重,和全局锁不同的是:如果执行Flush tables with read lock 命令后,如果客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。但将库设置为readonly后,客户端发生异常断开,数据库依旧会保持readonly状态,会导致整个库长时间处于不可写状态。

两者对比:

(1)在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库(从库只负责读,只有readonly权限)。因此修改 全局global 变量的方式影响面更大

(2)在异常处理机制上有差异:如果执行FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高

(3)readonly 对super用户权限无效:业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都会被锁住

按锁级别

共享(读)锁

即S锁,读锁是可以并发获取的(共享的),一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁

排它(写)锁

即X锁,写锁只能给一个事务处理(排他的)。当你想获取写锁时,需要等待之前的读锁都释放后方可加写锁;而当你想获取读锁时,只要该数据没有被写锁锁住,都可以获取到读锁

读锁与写锁均属于行级锁!

意向锁

意向锁属于表级锁,其设计目的主要是为了在一个事务中揭示下一行将要被请求锁的类型。InnoDB 中的两个表锁:

意向共享锁(IS):表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁;

意向排他锁(IX):类似上面,表示事务准备给数据行加入排他锁,说明事务在一个数据行加排他锁前必须先取得该表的IX锁

(1)InnoDB为了让表锁和行锁共存而使用了意向锁,举个例子:事务A锁住表中的一行(写锁)。事务B锁住整个表(写锁),此时会发现一个很明显的问题:事务A既然锁住了某一行,其他事务就不可能修改这一行。这与”事务B锁住整个表就能修改表中的任意一行“形成了冲突。所以,没有意向锁的时候,行锁与表锁共存就会存在问题!

(2)意向锁是表级锁的一种,它是由数据库引擎自行维护的,用户自己无需也无法操作意向锁,有了意向锁之后,前面例子中的事务A在申请行锁(写锁)之前,数据库会自动先给事务A申请表的意向排他锁。当事务B去申请表的写锁时就会失败,因为表上有意向排他锁(A申请到的)之后事务B申请表的写锁时会被阻塞。

(3)所以,意向锁的作用就是:当一个事务在需要获取资源的锁定时,如果该资源已经被排他锁占用,则数据库会自动给该事务申请一个该表的意向锁。如果自己需要一个共享锁定,就申请一个意向共享锁。如果需要的是某行(或者某些行)的排他锁定,则申请一个意向排他锁(意向锁能够将检查行锁的时间复杂度由 O(n) 变成 O(1),其加锁的具体做法就是,当一个事务想要获取表中某一行的(共享/排他)锁的时候,它会自动尝试给当前表的加上意向(共享/排他)锁)。

(4)意向锁它只会和表锁具有互斥性,不会影响行锁的加锁。事务获取行锁的时候会先尝试获取意向锁(与表锁进行竞争),如果成功,那么会再去竞争相应的行锁。

封锁协议

1级封锁协议(对应read uncommitted)

定义: 事务T在修改数据data之前必须先对其加X锁,直到事务结束才释放。

事务结束包括正常结束和非正常结束(ROLLBACK)。

1级封锁协议可防止修改丢失,并保证事务T是可恢复的,这里的”修改丢失“指的是事务T内的修改, 在事务T提交或回滚之前, 不可能发生”被其他事务修改覆盖”的情况,然而在1级封锁协议中,如果仅仅是读数据不对其进行修改,是不需要加锁的,所以它不能保证可重复读和不读”脏”数据

2级封锁协议(对应read committed)

定义: 1级封锁协议 + “事务T在读取数据data之前必须先对其加S锁,读完后即可释放S锁”。

效果: 2级封锁协议除防止了修改丢失,还可进一步防止读”脏”数据。

3级封锁协议(对应repeatable read)

定义: 1级封锁协议 + “事务T在读取数据data之前必须先对其加S锁,直到事务结束才释放S锁”。

与2级协议的定义对比逐字一下:

1级封锁协议 + “事务T在读取数据data之前必须先对其加S锁,读完后即可释放S锁
1级封锁协议 + “事务T在读取数据data之前必须先对其加S锁,直到事务结束才释放S锁

效果: 3级封锁协议除防止了修改丢失和不读’脏’数据外,还进一步防止了不可重复读(事务T内读取数据时, 在事务结束之前都不会释放S锁, 所以就不存在其他事务”修改”的可能性(原因: S和X锁互斥), 所以就没有”脏读”和”可重复读”的情况)

4级封锁协议(对应serialization)

4级封锁协议是对3级封锁协议的增强,其实现机制也最为简单,直接对事务中所读取或者更改的数据所在的表加表锁,也就是说,其他事务不能读/写该表中的任何数据。这样五类并发问题都得以避免!

两段锁协议:指事务必须分成两个阶段对数据进行加锁和解锁,第一段是获得封锁,也称扩展阶段->事务可以获得任何数据项上任何类型的锁,但是不能释放锁;第二段是释放封锁,也称收缩阶段->事务可以释放任何数据项上任何类型的锁,但是不能获得锁(两段锁协议是可串行化的充分条件,不是必要条件)

隐式锁定

innodb存储引擎采用两阶段锁定协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。隐式锁定在事务的执行过程中会进行锁定,锁只有在commit或rollback的时候才会同时被释放

隐式上锁(默认,自动加锁自动释放)

1
2
select //不会上锁
insertupdatedelete //上写锁

显式上锁(手动)

1
2
select * from tableName lock in share mode;//读锁
select * from tableName for update;//写锁解锁(手动)

解锁(手动)

1
2
3
1. 提交事务(commit
2. 回滚事务(rollback
3. kill 阻塞进程

InnoDB 也可以使用特定的语句进行显示锁定:

1
2
SELECT ... LOCK In SHARE MODE;
SELECT ... FOR UPDATE;

多版本并发控制MVCC

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

在封锁一节中提到,加锁能解决多个事务同时执行时出现的并发一致性问题。在实际场景中读操作往往多于写操作,因此又引入了读写锁来避免不必要的加锁操作,例如读和读没有互斥关系。读写锁中读和写操作仍然是互斥的,而 MVCC 利用了多版本的思想,写操作更新最新的版本快照,而读操作去读旧版本快照,没有互斥关系,这一点和CopyOnWrite 类似,在 MVCC 中事务的修改操作(DELETE、INSERT、UPDATE)会为数据行新增一个版本快照。