1.锁的分类
根据不同的分类角度可将锁分为:
- 按是否共享分:S 锁、X 锁
- 按粒度分:表级锁、行级锁、全局锁(锁整个库)、页锁(锁数据页)
- 意向锁:意向 S 锁、意向 X 锁:都是表级锁,且 IS 和 IX 之间不互斥。IS 锁和 IX 锁的使命只是为了后续在加表级别的 S 锁和 X 锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
- MDL 锁(元数据锁)
InnoDB 的表级锁比较鸡肋,一般不会被用到,重点在于理解 InnoDB 的行级锁。
InnoDB 常见行级锁:
- Record Locks(正经记录锁):可以加上 S 锁或 X 锁。
- Gap Locks(间隙锁):用于防止数据插入,解决了可重复读隔离级别下幻读的问题。
- Next-Key Locks(临键锁):等价于 正经记录锁 + 间隙锁
- Insert Intention Locks(插入意向锁):其实就是 MySQL 的设计者规定事务发生时需要在内存中获取一个锁,而现在这个事务正好是为了插入数据,所以锁的类型就定为插入意向锁了。
- 隐式锁:面对事务 T1 中插入了一条记录,该记录上没有任何锁。此时,其他的事务对该记录进行读写时就可能产生脏读或脏写的问题。为此,InnoDB 通过事务 id 来判断当前事务是否处于活跃状态,如果是处于活跃状态的,则会自动加上 X 锁。(因为写操作都会加 X 锁,所以写操作都是针对最新的记录的)
按锁的态度分:
-
悲观锁:
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会锁,这样别人再去操作这个数据时就会被阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java 中 synchronized 和 ReentrantLock 等独占锁也是悲观锁思想的实现。悲观锁的特点意味着它适合写操作较多,且并发量较小的场景。
-
乐观锁:
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁。但为保证数据的安全性,还是会在更新的时候会判断一下在此期间别人有没有去更新这个数据,这点不是采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。Java 中的 java.util.concurrent.atomic 包下的原子变量类就是使用了乐观锁的一种实现方式:CAS 实现的。**乐观锁的特点也意味着它适用于读操作较多的场景,**这样可以提高吞吐量。
小贴士:
乐观锁和悲观锁并不是锁,而是锁的设计思想 。
死锁:
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,但互不相让,导致双方进入无休止的等待。
产生死锁的必要条件:
- 存在两个或者两个以上的事务;
- 每个事务都已经持有锁,且在申请另一个锁;
- 一个锁资源同时只能被同一个事务持有;
- 事务之间因为各自持有双方要的另一个锁,而彼此等待;
死锁的关键在于:两个或以上的事务获取锁的顺序不一样,比如事务 T1 要先获取锁 A 再获取锁 B,而事务 T2 要先获取锁 B 再获取锁 A。如果大家都是先获取锁 A,再获取锁 B,那就不会有死锁问题。
死锁的解决方案:
-
方案一:事务直接进入等待,直到超时;
-
方案二:发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行;
但由于上述方案都存在一定的局限性,比如方案一可能造成长时间的等待,方案二可能消耗较多 CPU 资源去做死锁检测(比如有 1000 个事务,就要做 1000 * 1000 次检测)。因此还可以考虑下面的方案:
- 合理设计索引,使业务 SQL 尽可能通过索引定位更少的行,减少锁竞争;
- 调整业务逻辑 SQL 执行顺序,避免 update/delete 长时间持有锁的 SQL在事务前面;
- 避免大事务,尽量将大事务拆成多个小事务来处理,通过小事务缩短锁定资源的时间,发生锁冲突的几率也更小;
- 在并发比较高的系统中,不要显式加锁,特别是在事务里显式加锁。如 select…for update 语句,在该事务提交前,其他事务需要等待该事务释放锁;
- 降低隔离级别。如果业务允许,可以将隔离级别调低,比如从 RR 调为 RC,可以避免掉很多因为 gap 锁造成的死锁;
2.锁的内存结构
在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。而对一条记录加锁的本质就是在内存中创建一个锁结构与之关联(事务执行时才会创建)。
如上图所示,当一个事务想对一条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比方说事务 T1 要对这条记录做改动,就需要生成一个锁结构与之关联。为了方便理解,我们对锁的结构进行了简化,仅保留 3 个重要属性:
- trx 信息:代表这个锁是哪个事务创建的
- is_waiting:False 代表获取锁成功,True 代表获取锁失败
- type:代表锁的类型
在上面的例子中,当事务 T1 改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting 属性就是 false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
对于事务 T2,由于事务 T1 还占用着锁,且锁的类型是 X 锁。因此事务 T2 生成的锁结构中的 is_waiting 属性为 True,代表它需要等待事务 T1 释放锁后才能执行。
3.一致性读与当前读
在 MySQL 中,读操作可以分为快照读(又称一致性读、一致性无锁读)和当前读(又称锁定读)。快照读读取的是记录的可见版本(可能是历史版本),不需要加锁;而当前读读取的是记录的最新版本,并且当前读返回的记录会加上锁(S 锁或 X 锁),以保证其他事务不会再并发修改这条记录。
因此,当一条记录加上锁后,其他事务仍然可以进行快照读操作,因为快照读不需要获取锁。
参考
- 《MySQL 是怎样运行的》
- 《MySQL 45 讲》