6-锁
1.lock与latch
latch一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续时间长,则应用的性能会非常差。在InnoDB总,latch又可以分为mutex(互斥量)和rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。lock是有死锁机制的。
2.InnoDB存储引擎中的锁
①锁的类型
InnoDB实现了两种表中的行级锁:
- 共享锁(S Lock),允许事务读一行数据。
- 排他锁(X Lock),允许事务删除或更新一行数据。
InnoDB支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同事存在。为了支持在不同粒度上进行加锁操作,InnoDB支持一种额外的锁方式,称之为意向锁(Intention Lock)。
InnoDB支持意向锁设计比较简练,其意向锁即为表级别的锁。支持两种意向锁:
- 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁。
- 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁。
从InnoDB 1.0开始,在INFORMATION_SCHEMA架构下添加了表INNODB_TRX、INNODB_LOCKS(lock_data值并非是“可信”的)、INNODB_LOCK_WAITS。
②一致性非锁定读(consistent nonlocking read)
指InnoDB通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。不需要等待访问的行上X锁的释放。
如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB会去读取行的一个快照数据(该行之前版本的数据,该实现是通过undo段来完成。读取快照信息不需要加锁,因为没有事务需要对历史数据进行修改)。
不同的事务隔离级别读取的方式不同,READ COMMITTED和REPEATABLE READ(InnoDB默认事务隔离级别)下,InnoDB使用非锁定的一致性读,但对于快照数据的定义却不同。
- READ COMMITTED总是读取被锁定行的最新一份快照数据。
- REPEATABLE READ总是读取事务开始时的行数据版本。
③一致性锁定读
在某些情况下,用户需要显式地对数据库读操作进行加锁以保证数据逻辑的一致性。这需要数据库支持加锁语句,即使是对于select的只读操作。InnoDB对select语句支持两种一致性的锁定读(locking read)操作:
- select ... for update (对读取的行记录加一个X锁)
- select ... lock in share mode (对读取的行记录加一个S锁)
对于一致性非锁定读,即使读取的行已经被执行for update,也是可以进行读取的。
for update、lock in share mode必须在一个事务中,当事务提交了,锁也就释放了。
④自增长与锁
在InnoDB的内存结构中,对每个含有自增长值的表都有一个自增长计数器(auto-increment counter)。当对含有自增长计数器的表进行插入操作时,执行类似以下语句得到计数器的值:
select max(auto_inc_col) from t for update;
插入操作会根据这个自增长的计数器值加1赋予自增长列。这个实现方式称做AUTO-INC Locking。这种锁起始是采用一种特殊的表锁机制,为了提高插入性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL越剧后立即释放。
MySQL 5.1.22开始,InnoDB提供了一种轻量级互斥量的增长实现机制,大大提高了自增长值插入的性能。
⑤外键和锁
InnoDB中,对于一个外键列,如果没有显式地对这个列加索引,InnoDB自动对其加一个索引。
对于外键值的插入或更新,首先需要查询父表中的记录,即select父表。但是对于父表的select操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是select ... lock in share mode方式。
3.锁的算法
①行锁的3种算法
InnoDB有3种行锁的算法:
-
Record Lock:单个行记录上的锁。
总是会去锁住索引记录,如果InnoDB表在建立时没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。
-
Gap Lock:间隙锁,锁定一个范围,但不包含记录本身。
-
Next-Key Lock:Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身。
InnoDB在repeatable read事务隔离级别下,采用Next-Key Locking的方式来加锁,在read committed下,仅采用Record Lock。
当查询的索引含有唯一属性时,InnoDB会对Next-Key Lock进行优化,将其降级为Record Lock。
如:
create table t (a int primary key);
insert into t values (1),(2),(5);
select * from t where a=5 for update;//仅仅锁定5这个值,而不是(2,5)这个范围。
create table z (a int, b int, primary key(a), key(b));
insert into z(a,b) values (1,1),(3,1),(5,3),(7,6),(10,8);
select * from z where b=3 for update;//锁定范围(1,3)、(3,6) (b列)
insert into z(a,b) values (8,3);
若没有锁定范围(3,6),那么用户可以插入索引b列为3的记录,这会导致会话A中的用户再次执行同样查询时会返回不同的记录,即导致Phantom Problem问题的产生。
②解决Phantom Problem(幻像问题)
默认的事务隔离级别下(repeatable read)下,InnoDB采用Next-Key Locking机制来避免Phantom Problem(幻像问题)。Oracle数据库需要在serializable的事务隔离级别才能解决Phantom Problem。
Phantom Problem是指:在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。
如:select * from t where a>2 for update;//对(2,+∞)这个范围加X锁。
4.锁问题
①脏读
脏页:在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的。
脏数据:事务对缓冲池中行记录的修改,并且还没有被提交(commit)。
脏读:在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。
对于脏页的读取,是非常正常的。脏数据却截然不同,如果读到了脏数据,即一个事务可以读到另一个事务中未提交的数据,则显然违反了数据库的隔离性。
脏读发生在事务隔离级别为 read uncommitted。
②不可重复读
不可重复读:在一个事务内多次读取同一数据集合,在这个事务还没结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此第一个事务中两次读到的数据可能是不一样的。
不可重复读违反了数据库事务一致性的要求。
read committed允许不可重复读线线。
InnoDB默认事务隔离级别read repeatable,采用Next-Key Lock算法,避免了不可重复读的现象。
③丢失更新
一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。
如:
- 事务T1将行记录r更新为v1,但是事务T1未提交。
- 与此同时,事务T2将行记录r更新为v2,事务T2未提交。(实际数据库并不能更新)
- 事务T1提交。
- 事务T2提交。
但是在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。因为即使是read uncommitted的事务隔离级别,对于行的DML操作,需要对行货其他粗粒度级别的对象加锁。因此在步骤2中事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。
数据库本身不会丢失更新,但是多用户业务逻辑上可能发生丢失更新:
- 事务T1查询一行数据,放入本地内存,并显示给一个终端用户user1。
- 事务T2也查询该行数据,并将获取到的数据显示给终端用户user2。
- user1修改这行记录,更新数据库并提交。
- user2修改这行记录,更新数据库并提交。
解决方案:
步骤1和2中,对用户读取的记录加上一个排他X锁。这样步骤2必须等待步骤1和步骤3完成,最后完成步骤4。
5.阻塞
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它锁占用的资源,这就是阻塞。
阻塞并不是一件坏事,其是为了确保事务可以并发且正常地运行。
InnoDB中,可以通过参数来控制等待的时间(默认50秒),也可以通过参数控制是否在等待超时时对进行中的事务进行回滚操作(默认不回滚)。
如:
会话A 开启事务 select * from t where a<4 for update;//锁定小于等于4的所有记录
会话B 开启事务 insert into t select 5;//可以插入
会话B insert into t select 3;//超时,抛出异常
会话B select * from t;//包含5
因为会话B中的事务虽然抛出了异常,但是既没有进行commit操作,也没有进行rollback。这是十分危险的状态,因此用户必须判断是否需要commit还是rollback,之后再进行下一步的操作。
6.死锁
①死锁的概念
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。
解决死锁的方法:
超时机制:当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。
wait-for graph(等待图):主动的死锁检测方式。InnoDB也采用这种方式。
wait-for graph要求数据保存以下两种信息:
- 锁的信息链表
- 事务等待链表
通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁。
若存在死锁,通常来说InnoDB选择回滚undo量最小的事务。
②死锁的概率
死锁应该非常少发生,若经常发生,则系统是不可用的。死锁的次数应该还少于等待,因为至少需要2次等待才会产生一次死锁。
事务发生死锁的概率与一下几点因素有关:
- 系统中事务的数量越多,发生死锁的概率越大。
- 每个事务操作的数量越多,发生死锁的概率越大。
- 操作数据的集合越小,则发生死锁的概率越大。
③死锁的示例
如果程序是串行的,那么不可能发生死锁。死锁只存在于并发的情况。
示例1:
- 事务A:select * from t where a=1 for update;
- 事务B:select * from t where a=2 for update;
- 事务A:select * from t where a=2 for update;//等待
- 事务B:select * from t where a=1 for update;//死锁,回滚,ERROR 1212(40001):Deadlock found when trying to get lock;try restarting transaction
- 事务A得到记录为2的这个资源。
InnoDB并不会回滚大本分的错误异常,但是死锁除外。发现死锁后,InnoDB会马上回滚一个事务。
示例2:
- 事务A:select * from t where a=4 for update;//对记录4持有了X锁
- 事务B:select * from t where a<=4 lock in share mode;//获取小于4的记录的锁,等待记录4的锁
- 事务A:insert into t values (3);//ERROR 1213(40001):Deadlock found when trying to get lock;try restarting transaction
- 事务B:获得锁,正常运行。
7.锁升级(Lock Escalation)
锁升级:指将当前锁的粒度降低。
InnoDB存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的是位图的方式。因此不管一个事务锁住页中一个记录还是多个记录,其开销通常都是一致的。