一、什么是锁?
锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享文件的并发访问。InnoDB会在航级别对表数据上锁,也会在数据库内部其他多个地方使用锁,从而允许对多种不同资源提供并发访问。例如,操作缓冲池中的LRU列表,删除、添加、移动LRU列表中的元素,为了保证一致性,必须有锁的介入。数据库系统使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性。
对于MyISAM引擎,其锁是表锁设计。并发情况下的读没有问题,但是并发插入时的性能就要差一些了,若插入是在“底部”,MyISAM还是可以有一定的并发写入操作。
二、lock 与 latch
在数据库中,lcok 与 latch 都可以被称为 “锁”。但是两者有着截然不同的含义。
latch 一般被称为轻量级的锁,因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在InnoDB中,latch 又可以分为mutex(互斥量)和rwlock(读写锁)。目的是用来保证并发线程操作邻近资源的正确性,并且通常没有死锁检测机制。
lock 的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务 commit 或 rowback 后进行释放。此外,lock是有死锁机制的。
三、InnoDB 存储引擎中的锁
3.1 锁的类型
InnoDB 存储引擎实现了如下两种标准的行级锁:
- 共享锁(SLock),允许事务读一行数据。
- 排它锁(XLock),允许事务删除或更新一行数据。 排它锁和共享锁的兼容性
| X | S | |
|---|---|---|
| X | 不兼容 | 不兼容 |
| S | 不兼容 | 兼容 |
可以发现 X 锁与任何的锁都不兼容,而S锁仅和S锁兼容。
此外,InnoDB支持多粒度锁定,允许事务在行级上的锁和表级上的锁同事存在。为了支持在不同力度上进行加锁操作,InnoDB支持一种额外的锁方式,称为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意味着事务希望在更细力度上进行加锁。
InnoDB 支持意向锁设计比较简练,其意向锁即为表级别的锁。目的是为了在一个事务中揭示下一行将被请求的所类型。其支持两种意向锁:
- 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁。
- 意向排它锁(IX Lock),事务想要获得一张表中某几行的排它锁。
由于InnoDB支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫意外的任何请求。
InnoDB 存储引擎中锁的兼容性
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 不兼容 |
| IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
| S | 兼容 | 不兼容 | 兼容 | 不兼容 |
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
3.2 一致性非锁定读
一致性非锁定读是指InnoDB存储引擎通过行多版本控制的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE或UPDATE操作,这是读取操作不会去等待行上锁的释放。而是去读取行的一个快照数据。如图所示:
快照数据是指改行的之前版本的数据,该实现是通过undo段来完成。而undo用来在事务中回滚数据,因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本。一个行数据可能有不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发空值,称之为多版本并发控制-MVCC。
对于 READ COMMITED,非一致性读总是读取被锁定行的最新一份快照数据。
对于 REPEATABLE READ, 总是读取事务开始时的行数据版本。
3.3 一致性锁定读
在某些情况下,用户需要显示地对数据库读取操作进行加锁以保证数据逻辑的一致性。二者要求数据库支持加锁语句,即使是对于SELECT的只读操作。InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读操作:
- SELET···FOR UPDATE: 对行记录加X锁,其他事务不能对已锁定的行加任何锁。
- SELECT···LOCK IN SHARE MODE: 对行记录加S锁,其他事务可以向北锁定的行加S锁,如果加X锁会被阻塞。
四、锁的算法
4.1 行锁的 3 种算法
InnoDB存储引擎有3种行锁的算法,分别是:
- Record Lock:单个行记录上的锁
- Gap Lock: 间隙锁,锁定一个范围,但不包含记录本身
- Next-Key Lock: Record Lock + Gap Lock, 锁定一个范围,并且锁定记录本身
Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表建立的时候没有设置任何一个索引,那么会使用隐式的主键进行锁定。
Next-Key Lock是结合了 Gap Lock 和 Record Lock 的一种锁定算法, 在Next-Key Lock 算法下,InnoDB 对于行的查询都是采用这种锁定算法。
例如一个索引有 10 , 11 , 13 和 20 这四个值,那么该索引可能被 Next-Key Locking 的区间为:
(-∞, 10], (10, 11], (11, 13],(13, 20],(20, +∞)
当查询的索引含有唯一属性时,InnoDB会对Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。仅在查询的列是唯一索引的情况。
4.2 解决 Phantom Problem(幻读)
在默认的事务隔离级别(REPEATABLE READ)下,InnoDB 采用 Next-Key Locking 机制来避免幻读问题。
幻读是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL 语句可能返回之前不存在的行。
幻读的演示
| 时间 | 会话A | 会话B | |
|---|---|---|---|
| 1 | SET SESSION tx_isolation='READ-OMMITTED' | ||
| 2 | BEGIN | ||
| 3 | SELECT * FROM t WHERE a>2 FOR UPDATE; ---------1.row--------- a:4 | ||
| 4 | BEGIN; | ||
| 5 | INSERT INTO t SELECT 4; | ||
| 6 | COMMIT; | ||
| 7 | SELECT * FROM t WHERE a>2 FOR UPDATE; ----------1.row---------- a:4 ----------2.row---------- a:5 |
通过 Next-Key Locking 实现应用程序的唯一性检查
| 时间 | 会话A | 会话B | |
|---|---|---|---|
| 1 | BEGIN | ||
| 2 | mysql>SELECT * FROM z WHERE b=4 LOCK IN SHARE MODE; | ||
| 3 | mysql>SELECT * FROM z WHERE b=4 LOCK IN SHARE MODE; | ||
| 4 | mysql>INSERT INTO z SELECT 4,4; 阻塞 | ||
| 5 | mysql>INSERT INTO z SELECT4,4; ERROR 1213 (40001):Deadlock found when trying to get lock;try restarting transaction # 抛出死锁异常 | ||
| 6 | #INSERT 插入成功 |
五、 锁问题
5.1 脏读
脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另外一个事务 中未提交的数据,则显然违反了数据库的隔离性。
脏读指的就是在不同的事务下,当前事务可以读到另外事务未提交的数据。
脏读的发生条件是需要事务的隔离级别为 READ UNCOMMITED,
脏读隔离看似毫无用处,但在一些比较特殊的情况下还是可以将事务的隔离级别设置为 READ UNCOMMITED。 例如 replication 环境中的 slave 节点, 并且在 slave 上的查询并不需要特别精确的返回值。
脏读的示例
| 时间 | 会话A | 会话B |
|---|---|---|
| 1 | SET @@tx_isolation='read-ncommitted'; | |
| 2 | SET @@tx_isolation='read-ncommitted'; | |
| 3 | BEGIN | |
| 4 | mysql>SELECT * FROM t\G; ---------1.row--------- a:1 1 row in set(0.00 sec) | |
| 5 | INSERT INTO t SELECT 2; | |
| 6 | mysql>SELECT * FROM t\G; ---------1.row--------- a:1 ---------2.row--------- a:2 2 row in set(0.00 sec) |
5.2 不可重复读
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些 DML(crud) 操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。
不可重复读 和 脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的是已经提交的数据,其违反了数据库事务一致性的要求。
不可重复读的示例
| 时间 | 会话A | 会话B |
|---|---|---|
| 1 | SET @@tx_isolation='read-committed'; | |
| 2 | SET @@tx_isolation='read-committed'; | |
| 3 | BEGIN | BEGIN |
| 4 | mysql>SELECT * FROM t\G; ---------1.row--------- a:1 1 row in set(0.00 sec) | |
| 5 | INSERT INTO t SELECT 2; | |
| 6 | COMMIT; | |
| 7 | mysql>SELECT * FROM t\G; ---------1.row--------- a:1 ---------1.row--------- a:2 2 row in set(0.00 sec) |
5.3 丢失更新
丢失更新是另一个锁导致的问题,就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。例如:
1)事务 T1 将行记录r更新为v1,但是事务T1并未提交。
2)与此同时,事务 T2 将行记录 r 更新为 v2,事务 T2 未提交。
3)事务T1提交。
4)事务T2提交。
但是,在当前数据库的任何隔离级别下都不会导致数据库理论意义上的丢失更新问题。这是因为各隔离级别对于行的 DML 操作,需要对行或其他粗粒度级别的对象加锁。因此对于2)步骤会被阻塞,直到事务T1提交。
虽然数据库能组织丢失更新的问题,但在生产应用中还有另一个逻辑意义的丢失更新问题。实际上,在所有多用户计算机系统环境下都有可能产生这个问题。在出现下面的情况时,就会发生丢失更新:
1)事务T1查询一行数据,放入本地内存,并显示给一个终端用户User1。
2)事务T2也查询这行数据,并将取得的数据显示给终端用户User2。
3)User1 修改这行记录,更新数据库并提交。
4)User2 修改这行记录,更新数库并提交。
以上步骤便会导致用户User1的修改更新操作“丢失”了。要避免丢失更新发生,需要让事务在这种情况下的操作变成串行化,而不是并行的操作。(采用排他X锁)
丢失更新问题的处理方法
| 时间 | 会话A | 会话B |
|---|---|---|
| 1 | BEGIN; | |
| 2 | SELECT case into @cash FROM account WHERE user=pUser | |
| 3 | SELECT cash into @cash FROM account WHERE user= pUser FOR UPDATE; #等待 | |
| ··· | ··· | |
| m | UPDATE account SET cash=@cash-9000 WHERE user=pUser | |
| m+1 | COMMIT | |
| m+2 | UPDATE account SET cash=@cash-1 WHERE user=pUser; | |
| m+3 | COMMIT |
六、阻塞
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。
七、死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。解决死锁问题最简单的方式是不要有等待,将任何的等待都转化为回滚,并且事务重新开始。但在线上环境中,这可能导致并发性能的下降,甚至任何一个事务都不能进行。而这所带来的问题远比死锁问题更为严重,因为这很难被发现并且浪费资源。
解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超时设置(innodb_lock_wait_timeout)的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。