vlambda博客
学习文章列表

《高性能mysql实战》小结(2)

本节的主要内容是:“MySQL 事务与锁机制”。

事务及其特性
首先看看什么是事务?事务具有哪些特性?关于事务,上大学的时候,你应该有接触过相关的课程。简单来说,事务是指作为单个逻辑工作单元执行的一系列操作,这些操作要么全做,要么全不做,是一个不可分割的工作单元。

一个逻辑工作单元要成为事务,在关系型数据库管理系统中,必须满足 4 个特性,即所谓的 ACID:原子性、一致性、隔离性和持久性。
 · 一致性:事务开始之前和事务结束之后,数据库的完整性限制未被破坏。
 · 原子性:事务的所有操作,要么全部完成,要么全部不完成,不会结束在某个中间环节。
 · 持久性:事务完成之后,事务所做的修改进行持久化保存,不会丢失。
 · 隔离性:当多个事务并发访问数据库中的同一数据时,所表现出来的相互关系。

ACID 及它们之间的关系如下图所示,比如 4 个特性中有 3 个与 WAL 有关系,都需要通过 Redo、Undo 日志来保证等。

并发事务控制
单版本控制-锁
先来看锁,锁用独占的方式来保证在只有一个版本的情况下事务之间相互隔离,所以锁可以理解为单版本控制。
在 MySQL 事务中,锁的实现与隔离级别有关系,在 RR(Repeatable Read)隔离级别下,MySQL 为了解决幻读的问题,以牺牲并行度为代价,通过 Gap 锁来防止数据的写入,而这种锁,因为其并行度不够,冲突很多,经常会引起死锁。现在流行的 Row 模式可以避免很多冲突甚至死锁问题,所以推荐默认使用 Row + RC(Read Committed)模式的隔离级别,可以很大程度上提高数据库的读写并行度。

多版本控制-MVCC
多版本控制也叫作 MVCC,是指在数据库中,为了实现高并发的数据访问,对数据进行多版本处理,并通过事务的可见性来保证事务能看到自己应该看到的数据版本。
那个多版本是如何生成的呢?每一次对数据库的修改,都会在 Undo 日志中记录当前修改记录的事务号及修改前数据状态的存储地址(即 ROLL_PTR),以便在必要的时候可以回滚到老的数据版本。例如,一个读事务查询到当前记录,而最新的事务还未提交,根据原子性,读事务看不到最新数据,但可以去回滚段中找到老版本的数据,这样就生成了多个版本。
多版本控制很巧妙地将稀缺资源的独占互斥转换为并发,大大提高了数据库的吞吐量及读写性能。


MVCC 实现原理
MySQL InnoDB 存储引擎,实现的是基于多版本的并发控制协议——MVCC,而不是基于锁的并发控制。

MVCC 最大的好处是读不加锁,读写不冲突。在读多写少的 OLTP(On-Line Transaction Processing)应用中,读写不冲突是非常重要的,极大的提高了系统的并发性能,这也是为什么现阶段几乎所有的 RDBMS(Relational Database Management System),都支持 MVCC 的原因。 

快照读与当前读
在 MVCC 并发控制中,读操作可以分为两类: 快照读 (Snapshot Read)与 当前读 (Current Read)。
快照读 :读取的是记录的可见版本(有可能是历史版本),不用加锁。
当前读 :读取的是记录的最新版本,并且当前读返回的记录,都会加锁,保证其他事务不会再并发修改这条记录。 

注意:MVCC 只在 Read Commited 和 Repeatable Read 两种隔离级别下工作。

如何区分快照读和当前读呢?可以简单的理解为:
快照读 :简单的 select 操作,属于快照读,不需要加锁。 
当前读 :特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。 

MVCC 多版本实现
为了让大家更直观地理解 MVCC 的实现原理,这里举一个“事务对某行记录更新的过程”的案例来讲解 MVCC 中多版本的实现。

假设 F1~F6 是表中字段的名字,1~6 是其对应的数据。后面三个隐含字段分别对应该行的隐含ID、事务号和回滚指针,如下图所示。
《高性能mysql实战》小结(2)
隐含 ID(DB_ROW_ID),6 个字节,当由 InnoDB 自动产生聚集索引时,聚集索引包括这个 DB_ROW_ID 的值。
事务号(DB_TRX_ID),6 个字节,标记了最新更新这条行记录的 Transaction ID,每处理一个事务,其值自动 +1。
回滚指针(DB_ROLL_PT),7 个字节,指向当前记录项的 Rollback Segment 的 Undo log记录,通过这个指针才能查找之前版本的数据。

具体的更新过程,简单描述如下。
首先,假如这条数据是刚 INSERT 的,可以认为 ID 为 1,其他两个字段为空。
然后,当事务 1 更改该行的数据值时,会进行如下操作,如下图所示。
《高性能mysql实战》小结(2)
用排他锁锁定该行;记录 Redo log;
把该行修改前的值复制到 Undo log,即图中下面的行;
修改当前行的值,填写事务编号,使回滚指针指向 Undo log 中修改前的行。

接下来,与事务 1 相同,此时 Undo log 中有两行记录,并且通过回滚指针连在一起。因此,如果 Undo log 一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的是在 InnoDB 中存在 purge 线程,它会查询那些比现在最老的活动事务还早的 Undo log,并删除它们,从而保证 Undo log 文件不会无限增长,如下图所示。
《高性能mysql实战》小结(2)

并发事务问题及解决方案
上文讲述了 MVCC 的原理及其实现。那么随着数据库并发事务处理能力的大大增强,数据库资源的利用率也会大大提高,从而提高了数据库系统的事务吞吐量,可以支持更多的用户并发访问。但并发事务处理也会带来一些问题,如:脏读、不可重复读、幻读。下面一一解释其含义。

脏读
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫作"脏读"(Dirty Reads)。

不可重复读
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫作“ 不可重复读”(Non-Repeatable Reads)。

幻读
一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”(Phantom Reads)。

解决方案
产生的这些问题,MySQL 数据库是通过事务隔离级别来解决的,上文已经详细讲解过,这里再进行简单的说明。

在上文讲 MySQL 事务特性的隔离性的时候就已经详细地讲解了事务的四种隔离级别。这里最好记住各种事务隔离级别及各自都解决了什么问题,如下图所示。
这里就需要重点注意不可重复读和幻读的区别了。前面讲了它们的含义,这个提醒大家的是:不可重复读重点在于 UPDATA 和 DELETE,而幻读的重点在于 INSERT。它们之间最大的区别是如何通过锁机制来解决它们产生的问题。这里说的锁只是使用悲观锁机制。

MySQL 锁分类
 MySQL 中有三种级别的锁:页级锁、表级锁、行级锁。
表级锁: 开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。会发生在: MyISAM memory InnoDB BDB 等存储引擎中。
行级锁: 开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。会发生在: InnoDB 存储引擎。
页级锁: 开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。会发生在: BDB 存储引擎。
三种级别的锁分别对应存储引擎关系如下图所示。
注意:MySQL 中的表锁包括读锁和写锁。只需记住这个表锁模式兼容矩阵即可。

InnoDB 中的锁
在 MySQL InnoDB 存储引擎中,锁分为行锁和表锁。其中行锁包括两种锁。
共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

这里写得有点抽象,通俗来讲就是:
共享锁【S锁】
又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
排他锁【X锁】
又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。

另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。表锁又分为三种。
意向共享锁(IS): 事务计划给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
意向排他锁(IX): 事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。
自增锁(AUTO-INC Locks): 特殊表锁,自增长计数器通过该“锁”来获得子增长计数器最大的计数值。
在加行锁之前必须先获得表级意向锁,否则等待 innodb_lock_wait_timeout 超时后根据innodb_rollback_on_timeout 决定是否回滚事务。

InnoDB 行锁
InnoDB 行锁是通过对索引数据页上的记录(record)加锁实现的。主要实现算法有 3 种:Record Lock、Gap Lock 和 Next-key Lock。
Record Lock 锁:单个行记录的锁(锁数据,不锁 Gap)。
Gap Lock 锁:间隙锁,锁定一个范围,不包括记录本身(不锁数据,仅仅锁数据前面的Gap)。
Next-key Lock 锁:同时锁住数据,并且锁住数据前面的 Gap。


排查 InnoDB 锁问题
排查 InnoDB 锁问题通常有 2 种方法。
 · 打开 innodb_lock_monitor 表,注意使用后记得关闭,否则会影响性能。
 · 在 MySQL 5.5 版本之后,可以通过查看 information_schema 库下面的 innodb_locks、innodb_lock_waits、innodb_trx 三个视图排查 InnoDB 的锁问题。

InnoDB 死锁
在 MySQL 中死锁不会发生在 MyISAM 存储引擎中,但会发生在 InnoDB 存储引擎中,因为 InnoDB 是逐行加锁的,极容易产生死锁。那么死锁产生的四个条件是什么呢?
 · 互斥条件:一个资源每次只能被一个进程使用; 
 · 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放; 
 · 不剥夺条件:进程已获得的资源,在没使用完之前,不能强行剥夺; 
 · 循环等待条件:多个进程之间形成的一种互相循环等待资源的关系。

在发生死锁时,InnoDB 存储引擎会自动检测,并且会自动回滚代价较小的事务来解决死锁问题。但很多时候一旦发生死锁,InnoDB 存储引擎的处理的效率是很低下的或者有时候根本解决不了问题,需要人为手动去解决。

既然死锁问题会导致严重的后果,那么在开发或者使用数据库的过程中,如何避免死锁的产生呢?这里给出一些建议:
1、加锁顺序一致;
2、尽量基于 primary 或 unique key 更新数据。
3、单次操作数据量不宜过多,涉及表尽量少。
4、减少表上索引,减少锁定资源。
5、相关工具:pt-deadlock-logger。

最后给出了一些开发建议来避免线上业务因死锁造成的不必要的影响。
 · 更新 SQL 的 where 条件时尽量用索引;
 · 加锁索引准确,缩小锁定范围;
 · 减少范围更新,尤其非主键/非唯一索引上的范围更新。
 · 控制事务大小,减少锁定数据量和锁定时间长度 (innodb_row_lock_time_avg)。
 · 加锁顺序一致,尽可能一次性锁定所有所需的数据行。