一、数据库中的锁
在数据库中lock和latch都可以被称为“锁”。
它们锁的对象不一样。latch是用来保证并发线程操作临界资源的,lock用来锁定数据库中的表、页、行等对象。
这里主要介绍的是lock,一般锁定的对象仅在事务commit或rollback后进行释放(不同隔离级别释放的时间不能不同),具有死锁机制。

二、锁的类型
InnoDB存储引擎实现了两种标准的行级锁:
共享锁(S Lock),允许事务读一行数据。
排他锁(X Lock),允许事务删除或更新一行数据。

兼容指的是当一个事务获得行r的锁,另一个事务可以立即获得锁,如果不能立即获得则必须等待事务释放锁才能再获得,则不兼容。
X锁与任何的锁都不兼容,而S锁仅和S锁兼容。需要特别注意的是,S和X锁都是行锁,兼容只对同一记录(row)的兼容性情况。
此外,InnoDB支持多粒度锁定,这种锁定允许事务在行级和表级的锁同时存在。为了支持在不同粒度上进行加锁,InnoDB支持意向锁。
将对象按粒度划分为数据库、表、页、行,如果需要对页上的记录r上X锁,那么需要对数据库、表、页上意向锁,最后对记录r上X锁。
InnoDB支持两种的意向锁:
共享意向锁(IS Lock),允许事务读一行数据。
排他意向锁(IX Lock),允许事务删除或更新一行数据。
由于InnoDB支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫描以外的任何请求。

三、一致性非锁定读
一致性的非锁定行读(consistent nonlocking read,简称CNR)是指InnoDB存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中运行的数据。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此而会等待行上锁的释放,相反地,InnoDB会去读取行的一个快照数据。

之所以称为非锁定读,是因为不需要等待访问数据行上的X锁的释放。快照数据是指该行之前版本的数据,通过undo段来实现。而undo用来在事务中回滚数据,因此快照数据本身是没有额外开销的,此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
在READ COMMITTED和REPEATABLE READ模式下,InnoDB存储引擎使用默认的一致性非锁定读。
READ COMMITTED隔离级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份快照数据。
REPEATABLE READ隔离级别下,对于快照数据,一致性非锁定读总是读取事务开始时的行数据版本。
用下面这个实例举例区别READ COMMITTED和REPEATABLE READ。


最后在第5条,会话A不管是哪种隔离模式,读出来的应该都是一样的,因为会话B的事务还未提交。
但是在第7条的时候,在READ COMMITTED读已提交,由于会话B的事务已提交,所以第7行读出来如下:

在REPEATABLE READ模式,一致性非锁定读总是读取事务开始时的行数据版本所以读出来如下:

四、一致性锁定读(当前读)
在REPEATABLE READ的默认隔离级别下,InnoDB的SELECT使用一致性非锁定读,但是在某些情况下,用户需要显示地对数据库读取操作进行加锁以保证数据逻辑的一致性。
如何解释这句话,READ COMMITTED是最新快照读,REPEATABLE READ是事务开始时快照读,默认使用一致性非锁定读能读到数据且都拿不到锁。
InnoDB对于SELECT语句支持两种一致性锁定读操作。
SELECT ··· FOR UPDATE;// 读取时对记录加S锁,直到事务结束
SELECT ··· LOCK IN SHARE MODE;// 读取时对记录加X锁,直到事务结束
SELECT ··· FOR UPDATE对读取的行记录加X锁,其它事务不能再加任何锁。SELECT ··· LOCK IN SHARE MODE加S锁,其它事务只能再加S锁,加X锁会被阻塞。
有点类似读写锁的概念,一个线程拿了写锁,其它线程就都拿不到锁,一个线程拿了读锁,那么其它线程请求读锁依旧可以拿到,但是拿不到写锁。
对于已被执行了SELECT ··· FOR UPDATE的行数据,也是可以使用SELECT一致性非锁定读进行读取的,因为SELECT读快照,不请求锁。
五、锁的算法
InnoDB存储引擎有3种行锁的算法,分别是:
Record Lock:单个行记录上的锁
Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
Next-Key Lock:Record Lock+Gap Lock,锁定一个范围,并且锁定记录本身
Record Lock总是会锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。
Gap Lock是为了解决Phantom Problem(幻象/幻读),利用这种锁技术,锁定的不是单个值,而是一个范围。
Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在此种算法下,InnoDB对于行的查询都是采用这种锁定算法。
例如:
有10、11、13、20这四个值,那么索引可能被Next-Key Locking的区间为:
1、(-∞,10]
2、(10,11]
3、(11,13]
4、(13,20]
5、(20,+∞)
除了Next-Key Locking,还有Previous-Key Locking,如上面的例子,可锁定的区间为:
1、(-∞,10)
2、 [10,11)
3、 [11,13)
4、 [13,20)
5、 [20,+∞)
然而,当查询的索引含有唯一属性时,InnoDB存储引擎对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围。如表中有1,2,5的id数据:

会话A首先对a=5进行X锁。而由于a是主键且唯一,因此锁定的仅是5这个值,而不是(2,5)的范围,这样在B中插入值4不会阻塞,可以立即插入并返回。即锁定由Next-Key Lock算法降级为Record Lock,从而提高了并发性。
Next-Key Lock降级为Record Lock仅在查询的列是唯一索引的情况下。
若是辅助索引,则情况会完全不同如:
CREATE TABLE z(a INT,b INT,PRIMARY KEY(a),KEY(b))
INSERT INTO z SELECT 1,1;
INSERT INTO z SELECT 3,1;
INSERT INTO z SELECT 5,3;
INSERT INTO z SELECT 7,6;
INSERT INTO z SELECT 10,8;
表z的b列是辅助索引,若在会话A中执行下面的SQL:
SELECT * FROM z WHERE b=3 FOR UPDATE;
这是SQL语句通过索引列b进行查询,因此其使用传统的Next-Key Locking加锁,并且由于两个索引,其需要分别进行锁定。对于聚集索引,其仅对a等于5的索引加上record Lock。对于辅助索引,其加上的是Next-Key Lock,锁定的范围是(1,3],需要注意的是,InnoDB存储引擎还会对辅助索引下一个键值加上gap lock,即还有一个辅助索引的范围为(3,6)的锁,因此,在B会话中运行下面的SQL语句,都会被阻塞:
SELECT * FORM z WHERE a=5 LOCK IN SHARE MODE;
INSERT INTO z SELECT 4,2;
INSERT INTO z SELECT 6,5;
而执行下面的语句,不会阻塞:
INSERT INTO z SELECT 8,6;
INSERT INTO z SELECT 2,0;
INSERT INTO z SELECT 6,7;
六、解决Phantom Problem
Phantom Problem是指在同一事物下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。
比如表t由1、2、5这三个值组成。


上图在READ COMMITTED下会出现幻读。第3行和第7行的两次SELECT结果不一致。
InnoDB采用Next-Key Lock避免Phantom Problem。对于上述的SQL语句SELECT * FROM T WHERE a >2 FOR UPDATE,其锁住的不是5这单个值,而是对(2,+∞)这个范围加了X锁,因此对于这个范围的插入都是不被允许的,从而避免了幻读,如下图所示:

使用SELECT ··· LOCK IN SHARE MODE避免了幻读.
单纯的REPEATABLE READ允许幻读,要使用一致性锁定读,才能在REPEATABLE READ下解决幻读。
七、锁问题
7.1 脏读
事务A读到了事务B还未提交的内容。

7.2 不可重复读
不可重复读是指在A事务内多次读取同一数据集合,在A事务还没有结束时,B事务也访问了相同集合,并做了DML操作,导致A事务两次读这个集合督导的是不一样的数据。
不可重复读和脏读的区别:脏读是读到未提交的数据,而不可重复读读到的是已经提交的数据。

7.3 幻读上面已经讲过了
7.4 丢失更新
更新丢失是指并发下两次更新同时进行,后一次更新覆盖了前一次更新的情况,更新丢失是数据没有保证一致性导致的。
用户A在银行卡有100元钱,某一刻用户B向A转账50元(称为B操作),同时有用户C向A转账50元(称为C操作);
B操作从数据库中读取他此时的余额100,计算新的余额为100+50=150
C操作也从数据库中读取他此时的余额100,计算新的余额为100+50=150
B操作将balance=150写入数据库,之后C操作也将balance=150写入数据库
最终A的余额变为150
上面的例子,A同时收到两笔50元转账,最后的余额应该是200元,但却因为并发的问题变为了150元,原因是B和C向A发起转账请求时,同时打开了两个数据库会话,进行了两个事务,后一个事务拿到了前一个事务的中间状态数据,导致更新丢失。
常用的解决思路有两种:
悲观锁\排他锁
悲观锁在读取数据的时候都会认为会有别人去修改,于是在取数据的时候会对当前数据加一个锁,在操作结束前,不允许其余操作更改。要注意悲观锁和乐观锁都是业务逻辑层次的定义,不同的设计可能会有不同的实现。在MySQL层常用的悲观锁实现方式是采用一致性锁定锁。
begin;
select * from account where id = 1 for update;
update account set balance=150 where id =1;
commit;
乐观锁
乐观锁是指在获取数据时候不加锁,乐观的认为操作不会有冲突,在update的时候再去检查冲突。
有的可以通过版本号实现乐观锁。
begin;
select balance from account where id=1;
-- 得到balance=100;然后计算balance=100+50=150
update account set balance = 150 where id=1 and balance = 100;
commit;
参考文章
《MySQL技术内幕InnoDB存储引擎第2版》
MYSQL使用锁解决并发下的更新丢失问题