crash-safe:redo log Write-Ahead Logging
备份归档:binlog
mysql> create table T(ID int primary key, c int);
mysql> update T set c=c+1 where ID=2;
ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。
SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
mysql> show variables like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。
多版本并发控制(MVCC)
一棵m路查找树(m - way search tree)或者是一棵空树,或者是满足如下性质的树:
(1)根结点最多有m棵子树,并具有如下的结构:
(j,p0,K1,p1,K2,p2,…,Kj,pj)
其中,pi是指向子树的指针( 0≤i≤j
(5)pi指向的子树也是m路查找树,0≤i≤j。
B-树的定义
一棵m阶(order)B-树是一棵平衡的m路查找树,它满足如下性质。
(1)根结点至少有两个子女;
(2)除根结点之外的所有内部结点至少有⎡m/2⎤个子女。
(3)所有外部结点(失败结点)都位于同一层上。
一棵m阶B+树可以定义为:
(1)树中每个非叶结点至多有m棵子树。
(2)根结点至少有2棵子树,除根结点外的每个非叶结点至少有⎡m/2⎤ 棵子树;有j棵子树的非叶结点含有j-1个关键字,且按由小到大的顺序排列。
(3)所有的叶结点都处于同一层上,包含了全部关键字及指向相应记录的指针,且叶结点本身按关键字由小到大的顺序链接。
(4)每个叶结点中的子树棵数nj可以多于m,也可以少于m,视关键字字节数及记录地址指针字节数而定。若设叶结点最多可容纳m1个关键字,则指向记录的地址指针也有m1个,因此,结点中的子树棵数nj的取值范围应为:⎡m1/2⎤≤nj≤m1。若根结点同时又是叶结点,则结点格式同叶结点。
(5)所有的非叶结点可以看成是索引部分,结点中关键字Ki与指向子树的指针pi构成一个对子树(即下一层索引块)的索引项(Ki,pi),其中关键字Ki≤pi指向的子树中最小的关键字。特别地,子树指针p0所指子树中的所有关键字均小于K1。结点格式同B-树。
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;
表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。
基于主键索引和普通索引的查询有什么区别?
如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。
CREATE TABLE `tuser` (
`id` int(11) NOT NULL,
`id_card` varchar(32) DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`ismale` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `id_card` (`id_card`),
KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB
mysql> select * from tuser where name like '张%' and age=10 and ismale=1;
库备份:Flush tables with read lock (FTWRL)
一致性读是好,但前提是引擎要支持这个隔离级别。single-transaction 方法只适用于所有的表使用事务引擎的库
假设你负责实现一个电影票在线交易业务,顾客 A 要在影院 B 购买电影票。我们简化一点,这个业务需要涉及到以下操作:从顾客 A 账户余额中扣除电影票价;
给影院 B 的账户余额增加这张电影票价;
记录一条交易日志。
如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动时间开始的时候,你的 MySQL 就挂了。你登上服务器一看,CPU 消耗接近 100%,但整个数据库每秒就执行不到 100 个事务。这是什么原因呢?
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议
这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:
一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
那如果是我们上面说到的所有事务都要更新同一行的场景呢?
每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。
一致性读,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
这里,我们需要注意的是事务的启动时机。
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。
第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;
第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。
结果:事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1。
三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。
一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
这里我们提到了一个概念,叫作当前读。其实,除了 update 语句外,select 语句如果加锁,也是当前读。
下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。
mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;
再往前一步,假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?
事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
那么,我们再看一下,在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?
这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。
这时,事务 A 的查询语句的视图数组是在执行这个语句的时候创建的,时序上 (1,2)、(1,3) 的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:(1,3) 还没提交,属于情况 1,不可见;(1,2) 提交了,属于情况 3,可见。所以,这时候事务 A 查询语句返回的是 k=2。显然地,事务 B 查询结果 k=3。
前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。
InnoDB 会将更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。
在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。
把change buffer应用到旧的数据页,得到新的数据页的过程称为 merge。
除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。
在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。
唯一索引的更新不能使用 change buffer,实际上也只有普通索引可以使用
将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。
普通索引的所有场景,使用 change buffer 都可以起到加速作用吗?
对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。
这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,我建议你尽量选择普通索引。
mysql> insert into t(id,k) values(id1,k1),(id2,k2);
select * from t where k in (k1, k2);
所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `b` (`b`)
) ENGINE=InnoDB;
往表 t 中插入 10 万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到 (100000,100000,100000)
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
分析一条 SQL 语句:
mysql> select * from t where a between 10000 and 20000;
set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/
一个索引上不同的值的个数,我们称之为“基数”(cardinality)。
采样统计
采样统计的时候,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
当变更的数据行数超过 1/M 的时候,会自动触发重新做一次索引统计。
看看优化器预估的,这两个语句的扫描行数是多少。
rows 这个字段表示的是预计扫描行数。
如果使用索引 a,每次从索引 a 上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。
而如果选择扫描 10 万行,是直接在主键索引上扫描的,没有额外的代价。
优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。
既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。
mysql> select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的 SQL 语句,执行速度却比你预期的慢很多,你应该怎么办呢?
一种方法是,像我们第一个例子一样,采用 force index 强行选择一个索引。
所以第二种方法就是,我们可以考虑修改语句,引导 MySQL 使用我们期望的索引。比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。
mysql> select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1;
第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。
引用文本
[1]: https://time.geekbang.org/column/intro/139?tab=catalog