我们不能失去信仰

我们在这个世界上不停地奔跑...

0%

MySQL事务的隔离级别及锁机制

数据库是如何实现隔离级别(InnoDB)

事务隔离性,本质上解决事务的读写冲突。

  • 事务的四个特性ACID

    原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)

  • 事务的隔离级别

    事务的隔离级别分为 未提交读提交读可重复读串行化

  • 隔离级别的实现

    数据库会创建一个一致性视图(一致性视图InnoDB用来支持 RC 和 RR隔离级别的实现),访问的时候以视图的逻辑结果为准。具体使用哪种逻辑取决于数据库的隔离级别。MySQL中另外一个视图是用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。

    可重复读 隔离级别下,这个 视图 是在事务启动时创建,整个事务存在期间都用这个视图。

    读提交 隔离级别下,这个视图 是在每个 SQL 语句开始执行的时候创建

    读未提交 隔离级别下,直接返回记录上的最新值,没有视图概念。

    串行化 隔离级别下直接用加锁的方式来避免并行访问。

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作,记录在回滚日志中。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

在 InnoDB 中,读操作被实现为一种叫做 非锁定读取 的技术。我们通常会说数据库里有读锁和写锁,只有读锁是非互斥的,那么如果一个读操作要在一个正在被修改的数据上进行,那么是无法获取该行对象的读锁的,这便是数据库串行化隔离界别的实现方式。但是在其他的隔离级别下,我们不需要这么严格的隔离,我们允许了读操作可以在正在被修改并加上了排它锁的行数据上进行,这就是所谓的 非锁定读取 概念。

数据库 MVCC 和 回滚(undo)日志,我们每一次的修改操作,并不是直接对行数据进行操作,比如我们设置 ID 为 3 的行的 A 属性为10,并不是直接修改表中的数据,而是新加一行,同时数据表中还有一些隐藏的属性,比如每一行的事务 ID,所以每一行的数据可能会有多个版本,每一个修改过它的事务都会有一行,并且还会有关联的 undo日志,表示这个操作原来的数据是什么,可以用它做回滚。

如果我们直接把数据修改了,那么其他事务就用不了原来的值了,违反了事务的一致性。那么一个事务读取某一行的数据返回结果取决于隔离级别,如果是 Read Committed,那么返回的是最新的事务的提交值,所以未提交的事务修改的值是不会读到的,这就是 Read Committed 实现的原理(结合提交读的 SQL 查询是在查询前创建视图,即本次查询返回的结果就是本次查询前已提交事务的结果)。如果是 Read Repeatable 级别,那么只能返回发起时间比当前事务早的事务的提交值和比当前事务晚的删除事务删除的值(结合可重复读的 SQL 查询时在事务开启时就创建了视图,如果后面有的事务进行了提交,就需要通过 MVCC 版本及 undo 日志进行回滚)

关于可串行化的隔离界别,也很简单,严格的按照加锁协议来就可以,该级别可以避免上述所有的问题。

在 Oracle 等数据库中,为了避免幻读,只能采用串行化隔离级别,但是在 InnoDB 引擎中,可重复读(RR) 级别也可以实现。InnoDB 引擎中,有三种行锁:

  • Record Lock:单个行记录上的锁。
  • Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。
  • Next-Key Lock:前面两个锁的结合,可以解决幻读。

正常的锁一般是锁一行,称为是 Record 锁,而 Next-Key Lock 则是锁一个范围内的行。

总结 InnoDB 里面的锁的互斥:

  1. 在可串行化级别,严格按照加锁协议,只有共享锁可以并行,其他全部阻塞。
  2. 其他的隔离级别,就放宽了很多。不过写-写是肯定不行的,读-读是肯定行的,读-写也是可以的,只不过读到什么内容就由 MVCC 来根据具体的隔离级别决定,写-读一般都可以,只有在启用了使用了 Next-Key Lock 的情况下会被拒绝。

可重复读和提交读的不同在于,RR 是在事务开始后执行第一条语句的时候创建一个一致性视图,之后后面的语句共用同一个一致性视图,而 RC 是在每条 SQL 执行的时候创建一个一致性视图,并且每条 SQL 的一致性视图都不一样,所以提交读可以在事务中读到启动事务比自己玩但是提交比自己早的数据,而可重复读只能读到提交比自己启动事务早的数据。

上面所有说的都是针对读,下面来说说 update 操作,update 操作针对的是当前读,当要同时更新同一行数据时,会发生阻塞,直到锁被释放。

全局锁(server层实现)

全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的更新语句(增删改)、数据定义语句(建表、修改结构)和更新类事务的提交语句都会被阻塞。

全局锁的典型使用场景是做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。

通过 FTWRL 确保不会有其他线程对数据库做更新,然后对整个库做备份。在备份过程中整个库完全处于只读状态

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停止。
  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。

逻辑备份为了逻辑时间点和视图一致,即在由事务隔离级别的 InnoDB 引擎下,我们可以使用 mysqldump –single-transaction 在导数据之前就启动一个事务,来确保一致性视图。而由于 MVCC 的支持,这个过程数据时可以正常更新的。

但是在 MyISAM 这种不支持事务的存储引擎上,如果备份过程中有更新,总是能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了。

表级锁(server层实现)

MySQL 里面表级锁有两种:一种是表锁,一种是元数据锁(meta data lock,DML)

  • 表锁

表锁的语法是: lock tables xxx read/write , unlock tables xxx 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

举例:如果在某个线程A中执行 lock tables t1 read, t2 write; 则其他线程对 t1 写及对 t2 读写的语句都会被阻塞。同时,线程 A 在执行 unlock table 之前,也只能执行读 t1、读写 t2 操作。连写 t1 都不允许,自然也不能访问其他表。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不会使用 lock tables 命令来控制并发。

  • MDL 锁(metadata lock)

MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是保证读写的正确性。在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

sessionA sessionB sessionC sessionD
select * from t
select * from t
alter table t add f int;
select * from t

上表中,从上向下表示时间的顺序, A 和 B 都可以正常执行(读锁),当执行到 C 的时候,C 试图给表 t 加写锁,由于此时 A、B 的读锁还未释放,所以 C 就在等待写锁,而 D 就会被 C 阻塞掉。所以就会引起服务暂时不可用,即 C 释放写锁前,这个表完全不可读写了。

客户端可能会不断超时重试,最终导致整个 MySQL 全是连接,导致 MySQL 不可用。事务中的 MDL 锁是在语句开始时申请,但是语句结束后,并不会立刻释放,而是需要等到事务结束后才释放。

所以在做 DDL 的时候,一定要先查看此时是否有长事务在执行,如果有看看能否先 kill 掉或者等他执行完成,如果一直不间断有长事务执行,我们可以使用 alter table t NOWAIT add colalter table t WAIT N add col 对其选择不等待或者设置超时时间来避免导致整张表不可读写。

举例在 MyISAM 引擎中:

session1 session2 session3
lock table user read;
lock table user write;
select * from user limit 1 \G;

session1 执行后会立即获得读锁

session2 执行后会阻塞在那里

session3 也会阻塞在那里。

因为 session2 在等待 session1 释放读锁,并且 session2 要给表加写锁,session2 后面的读写都会被阻塞掉。

行锁(引擎层实现)

在 MyISAM 引擎中由于不支持行锁只能使用表锁,同一张表上任何时刻只能有一个更新在执行。InnoDB 是支持行锁,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个称为两阶段锁协议

InnoDB 行级锁的实现是通过锁索引记录实现的。如果更新的字段上没有索引,就会锁整个表。

通过上面这个设定可以优化我们的事务,如果你的事务中需要锁定多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

在某些秒杀活动可能会直接将 MySQL 打挂,可能是发生了死锁,比如 CPU 消耗接近 100%,但整个数据库每秒就执行不到 100 个事务。这时就可能发生了死锁。

死锁和死锁检测

从锁定资源的角度来看,MySQL 中的锁分为:

表级锁(如MyISAM):对整张表加锁,开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。

行级锁(如 InnoDB):对某行记录加锁。开销大,加锁慢;会出现死锁;锁定粒度小,发生锁冲突的概率低,并发度也高。

页面锁(如 BDB):开销和加锁时间介于表锁和行锁之间;会出现死锁;锁的粒度介于行锁和表锁之间,并发度一般。

出现死锁后的策略:

  • 直接进入等待,直到超时。这个超时时间可以通过参数 inno_db_lock_wait_timeout 来设置。默认值为 50 秒。
  • 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on 表示开启死锁检测。

第一种超时时间,如果设置太短会误杀,如果设置较长则会造成响应等待时间较长;第二种死锁检测会占用CPU资源,如果并发较大如达到1000个以上,可能大部分资源都拿去做死锁检测了,实际上每秒并不能执行很多事物。如果确保业务一定不会发生死锁,可以临时关闭死锁检测,这种做法也是有风险的,一旦发生了死锁就会造成大量的超时,对业务是有损的。

另一种就是控制并发速度,如果同一行同时最多只有10个线程在更新,那么死锁检测成本很低,就不会出现占用大量 CPU 的问题。并发控制可以在数据库端做,如果有中间件可以考虑在中间件实现;如果有修改 MySQL 源码的能力,可以在 MySQL 里面做。基本思路就是,对相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会出现大量的死锁检测工作了。

从设计上优化:比如以某个收钱账户为例,可以考虑创建多个收钱账户,每次修改的时候随机挑选一个账户,查询的时候将10个账户的值加起来然后返回,这样冲突的概率变为原来的 1/10 ,可以减少锁等待的个数,也就减少了死锁检测的 CPU 消耗。但是这样做也得考虑到账户值减少的问题,比如某个账户值变为 0 后,发生退款该怎么办,需要特殊处理。

长事务的影响

  • 回滚(undo)日志无法释放,占用大量磁盘空间,在 MySQL 5.5 及之前版本,由于回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务提交,回滚段被清理,文件也不会变小。最终为了清理回滚段,只能重建整个库。
  • 占用资源锁,如 MDL 锁。
  • 以及其他的影响…………….

在开发过程中,尽可能的减少事务范围,少用长事务,如果无法避免,保证逻辑日志空间足够用,并且支持动态日志空间增长。监控 innodb_trx 表,发现长事务报警。