前言
前阵子参与了字节跳动后端青训营,其中大项目编写涉及到数据持久化一般选择使用MySQL。由于时间原因,数据库使用我选择了无脑三板斧:1.建立了索引加速查询、2.关闭自动提交事务、3.在需要确保原子性的数据库操作之间手动创建和提交事务。
这么一看,仿佛即使是实际开发也与你此前听闻的一些MySQL相关名词:读写锁、间隙锁、多版本并发控制、redolog、binlog、undolog毫不相干,在讲本文的主题之前,我先引入一个真实场景。
在某次不够规范的小组开发过程中,开发成员选择测试程序的方式比较原始,大家共享一个测试数据库,各自使用测试账号进行接口的测试,这就意味着数据库中的记录在某一时刻有可能被多个事务访问,甚至在其他人测试的同时,某张数据表的结构被另一位同学修改。
多事务并发访问,反映到开发者这边,就是查询接口有时速度很慢。如果你是直接使用数据库管理工具操作数据库表数据/结构,对应的就是Navicat不时的陷入较长时间的无响应状态。
当然导致数据库访问速度变慢的原因有很多:sql语句编写不规范、数据库服务器的性能差、网络状况不佳等,但是本文所侧重的点在于探究MySQL的锁机制,在其中发挥了什么作用。
相信在完成本文的阅读之后,你会明白上面的场景的发生,可能是MySQL的锁机制从中作祟。
MySQL的锁有哪几种
全局锁
MySQL可以通过显式命令对整个数据库实例加全局读锁:
此时整个数据库处于只读状态,所有数据记录的更新、数据库/表结构的改动提交都会被阻塞,这可以用于全库的数据备份。
表级锁
表锁
表锁可以通过以下显式命令实现对一个表添加读/写锁:
如果A线程为t1表添加了读锁,为t2表添加了写锁。则其他线程将只能读t1,写t1被阻塞;读/写t2都会被阻塞。而A线程在执行unlocktables之前,也只能执行读t1、读/写t2的操作。
元数据锁(metadatalock)
MDL锁不需要显式使用,在访问一个表的时候会被自动加上,并且当事务完成提交时释放。当对一个表数据做CRUD操作的时候,自动加MDL读锁;当对该表结构作出改动的时候,自动加MDL写锁。
读锁之间不互斥,因此多个线程才可以同时访问一张数据表。
读写锁之间、写锁之间是互斥的(被读锁占用时,加写锁的线程被阻塞/被写锁占用时,加读锁/写锁的线程都被阻塞),这也是为了确保表结构的修改和表的数据的操作不发生冲突。
这里展示一个多线程并发操作同一个数据表的案例:
这里线程B会因为线程A的事务还没有提交,而添加列的操作需要获取MDL写锁因此被阻塞,同时线程C申请MDL读锁的请求又被阻塞在了线程B申请MDL写锁的请求之后,此时表t在线程A事务提交之前,完全丧失了读写能力。
或许此时你已经对于为什么多人调试程序时数据库访问不时出现卡顿有了一些自己的想法,当然这只是锁机制的冰山一角。
行级锁
通过上面的讲解,我们明白了,所谓的读写锁并不是单指一个锁叫读锁/写锁,而是指不同粒度的锁有读锁和写锁两种状态,允许的并发程度也有所不同。行级锁也是如此(针对记录行的锁,锁粒度进一步缩小),行锁的存在也使得事务并发访问数据库的性能进一步的提高,并且依旧有读写锁之分,下面介绍。
但区别于全局锁和表级锁,MySQL行锁是由各个存储引擎自己实现的,并不是所有的存储引擎都支持行锁(MyISAM不支持),由于现在MySQL用户大多选择使用InnoDB存储引擎,所以本文将以InnoDB引擎为默认选择。
两阶段锁协议
在InnoDB事务当中,行锁在需要的时候添加,并且直到事务提交才释放(锁的添加和释放分两个阶段进行),举个例子:
事务A(线程A)在提交之前,占有id=1这条行记录的写锁,事务B(线程B)修改同一行的操作将被阻塞。
死锁与检测
死锁原本是操作系统当中的概念,意思是多个线程都在等待其他线程释放自己需要的资源,使得这些线程陷入无限制的等待。
在这个例子当中,线程A的事务和线程B的事物分别占有id=1和id=2这两条记录的写锁,使得两个线程在试图获取其他线程占用的锁资源时陷入死锁。
InnoDB存储引擎默认开启了死锁检测,每个新来的被阻塞的线程,都会主动判断是否是自己的加入导致死锁(检测逻辑就是判断自己需要的行资源是否被别的线程的事务占有),时间复杂度O(n),一旦检测到,则回滚当前线程的事务,确保其他线程可以得到执行。
这里你会发现,如果同时有多个线程修改同一条记录,一旦并发度很高,则需要消耗O(n^2)时间去完成死锁检测,就会消耗大量CPU资源在死锁检测上,而使得数据库IO的性能下降。
此时你是否又对我最初给出的小组开发时访问数据库慢的场景有了自己的思考,其实在高QPS情况下,发生死锁检测的概率是大大高于小组开发场景的
因此控制热点记录的并发访问数量,是提升数据库IO性能的重要前提。
多版本并发控制(MVCC)
上面讲述了InnoDB的update操作会占用行记录的写锁,那么你自然而然就想到,select查询操作是否就占用了行记录的读锁呢?不完全正确,这就不得不提及MySQL的InnoDB引擎的用于控制事务隔离级别的多版本并发控制机制。
简言之就是每条行记录值的变化是由一个链式的结构组织的,存放在undolog文件当中,undolog在事务发生回滚的时候,用于回溯事务对行记录的修改过程。
而InnoDB存储引擎默认的事务隔离级别是可重复读(ReadRepeatable),简单来说:就是当事务A启动期间,普通的select查询将无法访问到其他事务在此期间对表记录的改动。
关于多版本并发控制(MVCC)这里我没有过多深入讲解,详情给出我的另一篇文章: