1. MySQL中的锁:
数据库设计的初衷是处理并发问题。 作为多用户共享的资源,当出现并发访问的时候,数据库需要合理的控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
根据加锁的范围,MySQL里面的锁大致可分为三类:全局锁、表级锁、行锁。
1.1 全局锁:
全局锁就是对整个数据库实现加锁。
MySQL提供一个全局加“读锁”的方法,命令是:
mysql> flush tables with read lock;
Query OK, 0 rows affected (0.00 sec)
全局锁的典型场景是,做全库逻辑备份,也就是把整个库中的每个表都select出来存成文本。
这种方法有副作用:(全局锁锁定期间,整个库只能读不能写,会造成如下问题)
① 如果在 主库 上备份,那么在备份期间 都不能执行更新,业务基本上就得停摆;
② 如果在 从库 上备份,那么在备份期间 从库都不能执行主库同步过来的binlog,会导致主从延迟。
所以,如果存储引擎支持 事务 的话(可以在事务开始时生成一致性视图read-view),那么就不必使用 FTWRL这种方式了,只有存储引擎不支持事务时(如MyISAM)才需要使用FTWRL全局读锁。(注意需要库中所有表都支持InnoDB,如果库中的某些表支持InnoDB,某些表使用的是MyISAM,那也不行)
官方自带的逻辑备份工具 ‘msqldump’:
当mysqldump使用参数 -single-transaction 的时候,导数据之前会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。
root@linux# mysqldump --single-transaction [database] [table] -u root -p
1.2 表级锁:
MySQL里面的表级锁有两种:一种是“表锁”,一种是 “元数据锁”(meta data lock, MDL)。
1.2.1 表锁:
表锁的语法是:
lock tables ... read / write
例如:
lock tables t1 read, t2 write;
# 对表t1加读锁,其他线程对t1可读;
# 对表t2加写锁,其他线程对t2不可读写
在没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。
而对于InnoDB这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大了。
1.2.2 元数据锁:(即使在InnoDB中,也会默认加MDL)
如何理解“元数据”?(Meta Data)
简单定义就是“描述数据的数据”。在MySQL中可以理解为列名,用于描述 表结构。
为什么需要使用 元数据锁?
假设一个线程正在查询遍历表中的所有数据,如果此时另一个线程对这个表结构做变更,删除了某一列,这会导致线程A查询到的结果与表结构不符。
因此,MySQL在 5.5 版本中引入MDL元数据锁,当对一个表做 增删改查 时,加 MDL读锁(共享锁,增删改查只是修改表中的数据内容、个数,不影响表结构);当要 对表结构做变更 时,加 MDL写锁(排他锁,需要修改表结构,如增一列或删一列)。
注意 MDL元数据锁不需要显式使用,是由存储引擎自动加锁,会直到事务提交才释放,因此一定要小心不要导致锁住线程查询和更新。
注意 MVCC 与 元数据锁的关系:
在一个事务中直接 alter table … add … ,不需要事务提交,这个增加列的操作就会生效。
有的时候有些SQL语句会产生一个隐式的提交操作,即执行完成这些语句后,会有一个隐式的 COMMIT 操作,包括 DDL 语句 等,此时会导致回滚失败。
事务中不应该加 alter table 这些DDL语句,否则会导致回滚失败!
1.3 行级锁:
不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能由一个更新在执行,这就会影响业务并发度。
InnoDB是支持行锁的,这也是 MyISAM被InnoDB替代的重要原因之一。
1.3.1 两阶段锁:
在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这就是两阶段锁协议。
这就提示:如果你的事务中需要锁多个行,要把最可能造成冲突、最可能影响并发读的锁尽量往后放(以缩短占用排他锁的时间)。
1.3.2 死锁和死锁检测:
事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态。
当出现死锁后,有两种策略:
① 一种策略是,直接进入等待,直到超时;
这个超时时间可以通过参数 innodb_lock_wait_timeout 设置;
② 另一种策略是,发起死锁检测,发现死锁后,主动回滚锁链条中的某一个事务,让其他事务得以继续执行。
将参数 innodb_deadlock_detect 设置为 on(默认为on),表示开启这个逻辑。
在InnoDB中,innodb_lock_wait_timeout 的默认值是 50 s,这对于在线业务来说这个等待时间是无法接受的,而设置的过短有可能会出现很多误伤。
所以,在正常情况下我们都是会采用第二种策略,即:主动死锁检测。
主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,然而它也是有额外负担的。
每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n)的操作。
假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是 100万这个量级的。
(第一个加入的线程检测死锁的时间复杂度是O(1),第10个加入的线程检测死锁的时间复杂度是O(10),第100个加入的是O(100),第1000个加入的是O(1000),所以累计的时间复杂度则是O(1)*O(10)*O(100)*O(1000)=100万)。
如果是有100个并发线程要同时更新同一行,时间复杂度也会达到O(100)*O(10)*O(1)=1万。
虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。
因此,你就会看到CPU利用率很高,但是每秒却执行不了几个事务。
那么如何解决这种“热点行更新”导致的性能问题呢?
① 方法一:关掉死锁主动检测,依靠超时解决,然而这种方法对业务是有损的(关掉死锁检测可能会出现大量的锁超时);
② 方法二:控制并发度:
根据上面的分析,你会发现如果并发度能够控制住,比如同一行同时最多只有10个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。
客户端无法控制总的并发度,所以需要在服务端控制并发度。
基本思路是: 对于相同行的更新,在进入存储引擎之前进行排队。这样在InnoDB内部就不会有大量的死锁检测工作了。
具体实现方式是: ① 使用中间件,或者 ② 修改MySQL源码,做到MySQL里面。
2. InnoDB事务中的“快照读”和“当前读”:
启动一个事务时,begin / start transaction; 命令并不是一个事务的起点,在执行到它们之后的 第一个操作InnoDB表 的语句,事务才真正启动。
如果想要马上启动一个事务,可使用命令:
mysql> start transaction with consistent snapshot;
在第一种启动方式中,一致性视图(read view)是在执行第一个快照读语句时创建的;
在第二种启动方式中,一致性视图(read view)是在执行 start transaction with consistent snapshot; 时创建的。
2.1 “快照”在MVCC中是怎么工作的:
InnoDB中,每个事务都有一个唯一的“事务ID”,叫做 ‘transaction id’,它是在事务开始的时候向InnoDB的事务系统申请的,按照申请顺序严格递增。
而每行数据也是有多个版本的。
每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id 赋值给这个数据版本的事务ID,记为 ‘row trx_id’。同时,旧的数据版本要保留,形成 ‘undo log’。
图一:行状态变更图
在实现上,InnoDB为每个事务构造了一个 数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。 “活跃”指的就是,启动了但还没有提交。
“数组中事务ID的最小值” 记为 “低水位”, “当前系统里面已经创建过的事务ID的最大值加1” 记为 “高水位” ,由这个数组和高水位组成了当前事务的一致性视图。
这个视图数组把所有的 row trx_id 分成了几种不同的情况:
图二:数据版本可见性规则
这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id(行数据中的trx_id值),有以下几种可能情况:
① 如果落在 绿色部分,表示这个版本是 已提交事务 或者是 当前事务自己生成的,这个数据是可见的;
② 如果落在 红色部分,表示这个版本是 由将来启动的事务生成的,是肯定不可见的;
③ 如果落在 黄色部分,那就包含两种情况:
a. 若此行数据的 trx_id 在数组中,则表示这个行数数据的版本是由还没提交的事务生成的,不可见;
b. 若此行数据的 trx_id 不在数组中,则说明当事务启动MVCC快照读时,trx_id对应的事务已经commit,因此这个行数据的版本是可见的。
2.2 当前读 与 快照读:
在MVCC中,读操作是“快照读”,写操作是“当前读”。
更新数据(update)都是 先读后写 的,而这个读,只能读当前的值,称为“当前读”(current read)。
(因为当要去更新数据的时候,就不能在“历史版本”上更新了,否则事务的更新就丢失了)
另外,除了update语句外,select语句如果加锁,也是当前读:
mysql> select * from t where id = 1 lock in share mode;
mysql> select * from t where id = 1 for update;
lock in share mode 加 共享锁(S锁,相当于 读锁);
for update 加 排他锁(X锁,相当于 写锁)。
注:
当在一个事务A中尝试进行“当前读”时,InnoDB会对此行加行级锁,此时如果有另一个事务B已经对此行加行级锁(例如在事务中进行update操作),则事务A的当前读会被阻塞,因为拿不到锁。
所以“当前读”不是想读就能读的,要看有没有其他事务与其有锁冲突。
2.3 事务的“可重复读”的能力是怎么实现的?
可重复读的核心就是 一致性读(consistent read)(快照读);而事务更新数据的时候,只能用当前读。
如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
而“读提交”(Read Committed,RC,不可重复读)和 “可重复读”(Read Repeatable,RR)的逻辑类似,它们的最主要的区别是:
① 在RR级别下,只有在事务开始的时候创建一致性视图(快照),之后事务里的其他查询都共用这个一致性视图;
② 在RC级别下,每一个语句执行前都会重新计算出一个新的视图。
3. 幻读与间隙锁(Gap Lock):
3.1 什么是幻读?幻读有什么问题?
说明:
① 在可重复读隔离级别下(RR),普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读只有在“当前读”的情况下才会出现;
② 幻读仅专指读到了“新插入的行”,读到被其他事务修改的行数据不叫幻读(只有在当前读时才会在事务中读到另一事务修改的数据,快照读时也不会读到其他事务的修改)。
当select查询加了 for update,就是当前读。而当前读的规则,就是要读到所有“已经提交的”记录的最新值。所以,当前读可能导致 在事务中出现不可重复读。
在事务之外的select,默认的是快照读,因为一个独立的命令相当于是加了事务(autocommit = 1),所以即使其他事务加了写锁未提交时,事务之外依然可以直接select读(相当于是一种“隐式的”快照读)。但是如果加上了 for update 或 lock in share mode,则select读将会被阻塞。
只靠行锁是无法解决幻读的,这是因为即使在事务开始时对每一行都加上行锁,在事务执行过程中 insert into 新插入的这一行在事务启动时还不存在,不存在也就无法加锁。(加表锁可以解决幻读,但这样做并发效率太低)
幻读可能导致的问题:
写入到binlog后,会发生数据不一致。无论以后是拿到备库去执行,还是以后用binlog来克隆一个库,都会出问题。
3.2 InnoDB是如何解决幻读的?
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。(快照读不会产生幻读,当前读才会发生幻读)
因此,为了解决幻读问题,InnoDB只好引入新的锁,即 间隙锁(Gap Lock)。
例如向表中插入 6条记录,就会产生 7个间隙,当你执行 select * from t where d = 5 for update; 的时候,就不止是给数据库中已有的 6个记录加上了行锁,还同时加了 7个间隙锁。这样就保证了无法插入新的记录。
间隙锁与读锁、写锁的区别是,两个间隙锁之间不会产生冲突。
3.3 next-key lock:
间隙锁 和 行锁 合称 “next-key lock”,每个 next-key lock 是 “前开后闭区间”。
例如:
# 表中含有以下 6个数据:
0, 5, 10, 15, 20, 25
# 构成 7个 “前开后闭区间”:
(-无穷, 0], (0, 5], (5, 10], (10, 15], (15, 20], (20, 25], (25, +无穷]
实现上,InnoDB给每个索引加了一个不存在的最大值 spuremum,这样才符合我们前面说的“都是前开后闭区间”。
但是间隙锁也存在弊端:
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。
间隙锁是在可重复读隔离级别下才会生效(RR),间隙锁是为了RR级别“当前读”情况下的可重复读,所以如果将隔离级别设置为读提交(RC),就没有间隙锁了。 但同时,你要解决可能出现的数据不一致的问题(存入binlog的逻辑命令可能有误),此时需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合(当业务场景不需要保证可重复读时,可设置为 “RC + binlog row”)。
3.4 小结:关于幻读要知道的事:
① “快照读”下不会出现幻读,只在“当前读”下才会出现;
② 幻读的危害:如果是 for update当前读,读到新增加的行数据也是符合事务的可见性原则的,幻读的危害在于破坏了数据一致性:使用存入binlog中的语句当被从库执行,或使用binlog克隆一个库时,会导致数据不一致;
③ 幻读的解决办法:间隙锁Gap Lock。(注意只有在RR级别下InnoDB才会加间隙锁,RC级别下不会加间隙锁,RC级别也不保证解决幻读)