BREAK IN TO BREAK OUT Lyn
  1. 1 BREAK IN TO BREAK OUT Lyn
  2. 2 かかってこいよ NakamuraEmi
  3. 3 One Last You Jen Bird
  4. 4 Libertus Chen-U
  5. 5 Hypocrite Nush
  6. 6 Time Bomb Veela
  7. 7 Warcry mpi
  8. 8 Flower Of Life 发热巫女
  9. 9 Life Will Change Lyn
  10. 10 Last Surprise Lyn
2019-11-02 00:18:15

综述MySQL的锁机制

上篇数据库文章谈到了事务隔离的表现,这篇文章就连带着学习一下事务隔离背后的实现——锁机制。锁的设计目的其实很直观,因为数据库中操作占比最多的读写动作肯定避不开多线程同步,为了解决并发带来的数据一致性问题,就有了锁机制定义的一系列访问规则来控制线程对数据的访问。

分类介绍

就分类而言,锁有多种划分依据。就像一个人在不同环境下有着不同的身份,锁在各种场景下也有着对应的分类名。就先从最容易理解,也是最通用的分类依据——锁的粒度开始谈起吧。

全局锁

最大粒度的锁自然就是锁住整个数据库实例。当需要让数据库只读时,可以使用Flush tables with read lock这个命令加上全局锁。

启动全局锁的场景有限,一般都是用来做全库逻辑备份才会用到,而且用全局锁做备份还有不小的局限,毕竟会阻塞所有数据写入,导致业务停摆。所以更科学的全库备份的方法会利用到事务的一致性视图,只有在不支持事务的数据库才会用到全局锁备份。

表锁

粒度再细一点,就到单张表的程度了,在MySQL中表锁还可以按隐式和显式划分。

显式的表锁需要使用lock tables ... read/write这个命令开启,使用unlock tables或者客户端断开时释放。表锁可以理解为一种线程到表的点对点限制锁,从一个线程发起表锁命令后,不仅代表着该表不允许其他线程读或者写,还代表着该线程只允许读或者写被该线程锁的表。

隐式的表锁称作元数据锁(metadata lock)。正如名称所言,是防止在一个线程操作表时,同时另一个线程修改表的元数据带来不一致问题,也就是DDL操作和DML操作的并发冲突,所以当线程操作一张表时会隐式的自动加上。根据操作类型还可以更细节的划分如下:

  • 表共享读锁:当对一张表做DML操作时,会加上该类锁,和lock tables ... read显式命令锁相同,限制其他线程的写入,但不限制读取。
  • 表独占写锁:当对一张表做DDL操作时,会加上该类锁,和lock tables ... write显式命令锁相同,限制其他线程的读写。

行锁

这也是最常用的粒度锁了,顾名思义就是以数据表中的行记录为单位加锁,比如事务A更新了表的行1,事务B可以同时更新除行1外的其他行,而对行1更新则必须等到事务A操作完成。需要注意一点就是并不是所有数据库都支持行锁,因为行锁是在引擎层实现的,比如MyISAM就不支持行锁。这样一类比,MyISAM同样不支持事务,好像事务和行锁有不小的联系,事实也确实如此,行锁的加上与释放与事务有着密不可分的联系,

具体来说,事务中的时间可以被分为加锁阶段和解锁阶段,只有在事务提交或者回滚时处于解锁阶段,其余时间都为加锁阶段,这样保证了多个事务并发调度下的隔离性。学名称作两阶段锁协议

以上三类就是以InnoDB为存储引擎的MySQL的粒度锁,按粒度从大到小,开销会依次增大,加锁速度依次降低,锁冲突概率依次降低。同时,由于锁会占用内存空间,每个粒度的锁数量也是有一定限制的,当超过当前粒度锁数量的阈值时,就会升级为更大粒度的锁来代替。

除了锁粒度划分外,还可以从数据库行为层面进行划分。实际上已经在上文的表锁中接触过了,根据lock tableswrite 或者read,也就将表锁划分为了如下两类。

共享锁

也叫读锁或者S锁。行为就是其他线程的限制写入,不限制读写。表共享锁的加法已经介绍过,行共享锁在普通的select时不会默认加上,需要手动使用下列语句声明:

select id from table where ... lock in share mode;

被索引出的行都会根据两阶段锁协议加上共享锁。

独占锁

也叫写锁或者X锁。行为就是限制其他线程的读写。先说一下隐式添加的场景,表独占锁的隐式添加就如上文说的,是对表做DDL操作时。行独占锁的隐式添加,是对行做删改查操作时。同样的,对于select语句也有特定的语法加上行独占锁。

select id from table where ...  for update;

简单理解这语句,就是select造成的效果和update一样,也就是加上行独占锁了。

好的,现在我们已经明白了两种分类,应该可以理解下面这句话了——表锁和行锁,都有自己的共享锁和独占锁。

意向锁

那么现在来看一个场景,事务A给表1的行1加了共享锁,之后事务B给表1加了独占锁。如果事务B加锁成功,那么理论上事务B就就可以修改表1的所有行,而行1是有共享锁不允许事务A以外的事务进行修改的。行锁和表锁共存的情况就会有这种冲突,如何避免呢?

最直接的想法,当然是阻止事务B的表锁申请直到A释放行锁了。两种方案,其一,从内到外的遍历行检查行锁,效率实在不行。其二,从内到外抛出一个标记,此表已被锁。可行,这个标记就是意向锁了。具体分为如下两类:

  • 意向共享锁(IS):当事务打算加上行共享锁时,同时也会向该表申请加上意向共享锁。
  • 意向独占锁(IX):当事务打算加上行独占锁时,同时也会向该表申请加上意向独占锁。

因为意向锁是一种弱锁,如果另外一个事务请求锁可以与意向锁兼容就会覆盖,反之就要等待意向锁释放。兼容关系情况如下图:

最后再谈锁设计思路上的两种划分吧。

悲观锁

顾名思义,就是认为并发问题总会出现,所以设计时相比其他因素,要更重视并发冲突时的数据保护。看上文的共享锁和独占锁,就是悲观锁的设计思路。

乐观锁

同理,想法比较快乐的锁设计,就是认为并发处理数据产生冲突的场景不多,换句话说相比于悲观锁,防止数据冲突的设计更少一些,一般都是在数据正式提交更新时才会进行一次检测。当然可能有人要问了,为什么不总是设计成悲观锁保证更安全准确的数据呢,这就是效率和准确度之间的平衡了,乐观锁的优势就是效率,根据不同场景自然有不同的选择。乐观锁的具体设计上最常见的是如下两种方案:

  • 版本号机制:数据表中加上一个数据版本号字段,当数据被修改时该值加1,读取时将该值读出。每次更新时,都会对该值进行比对,如果相等才进行更新。
  • 时间戳机制:原理和版本号一样,只是将标记变成时间戳,当更新时间戳前后不一致时就是版本冲突了。

特例分析

再看一些在InnoDB中锁机制相关的一些特殊案例。

间隙锁

这种锁主要用来解决范围查询时幻读的问题。比如当事务A在为id在1~100的数据加锁时,表内只存在id为1的数据,这行数据加锁很正常,但实际情况是id为1~100的行都会被加锁,即使其目前不存在。这样看起来有点反常规,但仔细想一下是合理的,因为如果不锁的话,事务B新增了id为2的数据,在事务A未结束时,新增id为2的数据就会出现幻读。

当然这样保证并发安全的锁自然会带来比较严重的锁等待影响性能,是否开启间隙锁还需要根据具体业务权衡。

死锁

最后谈到的这个特例并不是一种锁的种类,而是锁机制带来的问题。类似于循环依赖,有锁,就有锁等待,当多个线程相互等待形成循环依赖时,就产生了死锁。

举个例子,事务A开启,更新id为1的数据行,此时给id为1的行加上了行独占锁,事务B开启,更新id为2的数据,此时id为2的行也加上了行独占锁,然后事务A更新id为2的行,因为事务B还未结束,事务A进入锁等待。此时,事务B再更新id为1的行,因为事务A也还未结束,事务B进入锁等待。相互等待,死锁就产生了。可以预见锁机制越严格的情况就越需要注意死锁问题。

出现死锁后,数据库有两种策略应对:

  • 通过参数innodb_lock_wait_timeout来设置锁等待超时退出的时间。
  • 参数innodb_deadlock_detect设置为on开启死锁检测。当死锁产生时主动回滚死锁依赖中的某一个事务保证其他事务继续执行。

不过仔细想想,以上两种方案都不太靠谱。

对于第一种超时等待,如果设置时间太长,对于线上服务来说肯定是难以接受的,但设置时间太短的话,又会误伤正常的锁等待。所以这个时间设置基本上很难拿捏。

对于第二种死锁检测,需要知道检测是有时间复杂度为O(n)的开销的。如果多个线程要更新同一行,量级就会迅速增加消耗大量资源。

所以以上两种都只能做兜底,在死锁真正到这一步时,还要在前面考虑更多方案。

  • 尽量用更低的隔离级别。锁也是一把双刃剑,加锁越频繁,死锁可能性也越大。所以需要针对具体场景设置不同的隔离级别。
  • 尽量使用索引访问数据,增加锁的精确度,减少锁之间的依赖概率。
  • 不同事务操作多个表时,尽量约定好相同的顺序。
  • 尽量把事务拆得更小。

可以看到这些方案都用到了“尽量”的描述,就是因为死锁是不能完全避免的,只能通过更合理的设计来有意识的降低死锁发生概率。

小结

现在了解了MySQL的锁机制,就可以回头再分析不同事务隔离级别的实现原理了。

-- EOF --

添加在分类「 前端开发 」下,并被添加 「数据库」 标签。