mysql45讲-笔记(1~10讲)

mysql45讲-笔记

  • 01 | 基础架构:一条SQL查询语句是如何执行的?
  • 02 | 日志系统:一条SQL更新语句是如何执行的?
  • 03 | 事务隔离:为什么你改了我还看不见?
  • 04 | 深入浅出索引(上)
    • m路查找树
    • B-树
    • B+树
    • 基于主键索引和普通索引的查询有什么区别
    • 建表语句里一定要有自增主键?
    • 覆盖索引
    • 最左前缀原则
    • 索引下推优化(index condition pushdown)
  • 06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?
    • 全局锁
    • 表级锁
    • 行锁
    • 热点行更新导致的性能问题呢?
  • 08 | 事务到底是隔离的还是不隔离的?
    • “快照”在 MVCC 里是怎么工作的?
    • 读提交和可重复读 实现上的区别
  • 09 | 普通索引和唯一索引,应该怎么选择?
    • change buffer
      • 什么条件下可以使用 change buffer 呢?
      • change buffer 的使用场景
    • 索引选择和实践
    • change buffer 和 redo log
  • 10 | MySQL为什么有时候会选错索引?
    • 优化器的逻辑
    • 扫描行数是怎么判断的?
    • MySQL 是怎样得到索引的基数的呢?
    • 索引选择异常和处理

01 | 基础架构:一条SQL查询语句是如何执行的?

mysql45讲-笔记(1~10讲)_第1张图片

02 | 日志系统:一条SQL更新语句是如何执行的?

crash-safe:redo log Write-Ahead Logging
备份归档:binlog

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
mysql> create table T(ID int primary key, c int);
mysql> update T set c=c+1 where ID=2;

mysql45讲-笔记(1~10讲)_第2张图片
两阶段提交

03 | 事务隔离:为什么你改了我还看不见?

ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。

  1. 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
  2. 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  3. 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  4. 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

mysql> show variables like 'transaction_isolation';

+-----------------------+----------------+

| Variable_name | Value |

+-----------------------+----------------+

| transaction_isolation | READ-COMMITTED |

+-----------------------+----------------+

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。
mysql45讲-笔记(1~10讲)_第3张图片
多版本并发控制(MVCC)

04 | 深入浅出索引(上)

m路查找树

一棵m路查找树(m - way search tree)或者是一棵空树,或者是满足如下性质的树:
(1)根结点最多有m棵子树,并具有如下的结构:
(j,p0,K1,p1,K2,p2,…,Kj,pj)
其中,pi是指向子树的指针( 0≤i≤j (2)Ki (3)在pi指向的子树中所有的关键字都大于Ki,但小于Ki+1,0 (4)在p0指向的子树中所有的关键字都小于K1,而pj指向的子树中所有的关键字都大于Kj。
(5)pi指向的子树也是m路查找树,0≤i≤j。
mysql45讲-笔记(1~10讲)_第4张图片

B-树

B-树的定义
一棵m阶(order)B-树是一棵平衡的m路查找树,它满足如下性质。
(1)根结点至少有两个子女;
(2)除根结点之外的所有内部结点至少有⎡m/2⎤个子女。
(3)所有外部结点(失败结点)都位于同一层上。
mysql45讲-笔记(1~10讲)_第5张图片

B+树

一棵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-树。
mysql45讲-笔记(1~10讲)_第6张图片

基于主键索引和普通索引的查询有什么区别

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),两棵树的示例示意图如下。

mysql45讲-笔记(1~10讲)_第7张图片
基于主键索引和普通索引的查询有什么区别?
如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

建表语句里一定要有自增主键?

  1. 每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂;
  2. 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
  3. 如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节;主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小

覆盖索引

如果执行的语句是 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

mysql45讲-笔记(1~10讲)_第8张图片
索引项是按照索引定义里面出现的字段顺序排序的

  1. 如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的;
  2. 空间

索引下推优化(index condition pushdown)


mysql> select * from tuser where name like '张%' and age=10 and ismale=1;

mysql45讲-笔记(1~10讲)_第9张图片
mysql45讲-笔记(1~10讲)_第10张图片

06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?

全局锁

库备份:Flush tables with read lock (FTWRL)
一致性读是好,但前提是引擎要支持这个隔离级别。single-transaction 方法只适用于所有的表使用事务引擎的库

表级锁

  1. 表锁
  2. 元数据锁(meta data lock,MDL)

行锁

假设你负责实现一个电影票在线交易业务,顾客 A 要在影院 B 购买电影票。我们简化一点,这个业务需要涉及到以下操作:从顾客 A 账户余额中扣除电影票价;
给影院 B 的账户余额增加这张电影票价;
记录一条交易日志。

如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动时间开始的时候,你的 MySQL 就挂了。你登上服务器一看,CPU 消耗接近 100%,但整个数据库每秒就执行不到 100 个事务。这是什么原因呢?

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议
mysql45讲-笔记(1~10讲)_第11张图片
这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:
一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

热点行更新导致的性能问题呢?

那如果是我们上面说到的所有事务都要更新同一行的场景呢?

每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。

08 | 事务到底是隔离的还是不隔离的?

一致性读,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?


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);

mysql45讲-笔记(1~10讲)_第12张图片
这里,我们需要注意的是事务的启动时机。
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;
第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。

结果:事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1。

“快照”在 MVCC 里是怎么工作的?

mysql45讲-笔记(1~10讲)_第13张图片

三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。

一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

mysql45讲-笔记(1~10讲)_第14张图片

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况
    a.若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    b. 若 row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

mysql45讲-笔记(1~10讲)_第15张图片
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(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’,会怎么样呢?
mysql45讲-笔记(1~10讲)_第16张图片
事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。
mysql45讲-笔记(1~10讲)_第17张图片
可重复读的核心就是一致性读(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。

09 | 普通索引和唯一索引,应该怎么选择?

前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。

change buffer

InnoDB 会将更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。
在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。

把change buffer应用到旧的数据页,得到新的数据页的过程称为 merge。
除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。
在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

什么条件下可以使用 change buffer 呢?

唯一索引的更新不能使用 change buffer,实际上也只有普通索引可以使用

将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

change buffer 的使用场景

普通索引的所有场景,使用 change buffer 都可以起到加速作用吗?
对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。

索引选择和实践

这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,我建议你尽量选择普通索引。

change buffer 和 redo log

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

mysql45讲-笔记(1~10讲)_第18张图片

select * from t where k in (k1, k2);

mysql45讲-笔记(1~10讲)_第19张图片
所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

10 | MySQL为什么有时候会选错索引?

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;

使用 explain 命令查看语句执行情况
mysql45讲-笔记(1~10讲)_第20张图片

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*/
  • 第一句,是将慢查询日志的阈值设置为 0,表示这个线程接下来的语句都会被记录入慢查询日志中;
  • 第二句,Q1 是 session B 原来的查询;
  • 第三句,Q2 是加了 force index(a) 来和 session B 原来的查询语句执行情况对比。
    mysql45讲-笔记(1~10讲)_第21张图片
    Q1 扫描了 10 万行,显然是走了全表扫描,执行时间是 40 毫秒。Q2 扫描了 10001 行,执行了 21 毫秒。

优化器的逻辑

扫描行数是怎么判断的?

一个索引上不同的值的个数,我们称之为“基数”(cardinality)。
表 t 的 show index 结果

MySQL 是怎样得到索引的基数的呢?

采样统计
采样统计的时候,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
当变更的数据行数超过 1/M 的时候,会自动触发重新做一次索引统计。
看看优化器预估的,这两个语句的扫描行数是多少。
mysql45讲-笔记(1~10讲)_第22张图片
rows 这个字段表示的是预计扫描行数。
如果使用索引 a,每次从索引 a 上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。
而如果选择扫描 10 万行,是直接在主键索引上扫描的,没有额外的代价。
优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。
既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。
mysql45讲-笔记(1~10讲)_第23张图片

mysql> select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 1;

mysql45讲-笔记(1~10讲)_第24张图片

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

你可能感兴趣的:(知识点梳理,mysql,数据库,dba)