一条SQL查询语句是如何执行的?

img

  1. Server 层

    1. Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等)
  2. 存储引擎层

    1. 存储引擎层负责数据的存储和提取
    2. 架构模式是插件式的,支持InnoDB、MyISAM、Memory等多个存储引擎。现在最常用的存储引擎是InnoDB,它从MySQL 5.5.5版本开始成为了默认存储引擎。
    3. 执行create table建表的时候,如果不指定引擎类型,默认使用的就是InnoDB
    4. 你也可以通过指定存储引擎的类型来选择别的引擎,比如在create table语句中使用engine=memory, 来指定使用内存引擎创建表

连接器

1
mysql -h$ip -P$port -u$user -p
  • 如果用户名或密码不对,你就会收到一个”Access denied for user”的错误,然后客户端程序结束执行。
  • 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。

一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。

1
show processlist #查看全部连接
1
2
3
wait_timeout #控制客户端无动静后最长的连接时间
SHOW VARIABLES LIKE 'wait_timeout';
SET GLOBAL wait_timeout = <desired_timeout_in_seconds>;

连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。

  1. 长连接
    1. 长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接
  2. 短连接
    1. 短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。

使用长连接后存在的问题

  1. 内存占用太大被系统杀掉,mysql表现异常重启

如何解决

  1. 定期断开长连接。
  2. MySQL 5.7或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

查询缓存

缓存以key-value对的形式,被直接缓存在内存中。key是查询的语句,value是查询的结果。如果你的查询能够直接在这个缓存中找到key,那么这个value就会被直接返回给客户端。

缓存失效非常快

3. 分析器

  1. 词法分析

    1. 你输入的是由多个字符串和空格组成的一条SQL语句,MySQL需要识别出里面的字符串分别是什么,代表什么
    2. 如识别”select”这个关键字
  2. 语法分析

    词法分析的结果,语法分析器会根据语法规则,判断你输入的这个SQL语句是否满足MySQL语法

4. 优化器

选择走哪个索引

5. 执行器

  1. 检查执行权限
      1. 返回没有权限错误
      1. 调用存储引擎层接口

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

  1. 更新语句会把表T上所有缓存结果都清空
  2. 分析器会通过词法和语法解析知道这是一条更新语句
  3. 优化器决定要使用ID这个索引。
  4. 执行器负责具体执行,找到这一行,然后更新

redo log(重做日志)

redo log是InnoDB引擎特有的日志

Redo log 比作粉板 mysql比作账本

粉板和账本配合的整个过程,其实就是MySQL里经常说到的WAL技术,WAL的全称是Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。

具体流程:

InnoDB引擎就会先把记录写到redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

InnoDB的redo log是固定大小的,比如可以配置为一组4个文件,每个文件的大小是1GB,那么这块“粉板”总共就可以记录4GB的操作。

循环写

write pos是当前记录的位置一边写一边后移、checkpoint是当前要擦除的位置也是往后推移并且循环的,擦除记录前要把记录更新到数据文件

write pos和checkpoint之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果write pos追上checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把checkpoint推进一下。

有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

img

binlog(归档日志)

  1. redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以使用。

  2. redo log是物理日志,记录的是“在某个数据页上做了什么修改”;binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这一行的c字段加1 ”。

  3. redo log是循环写的,空间固定会用完;binlog是可以追加写入的。“追加写”是指binlog文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

update语句的内部流程

img

两阶段提交

Redo Log 进入 Prepared 状态时,表示该事务的 Redo Log 已经被持久化到磁盘,但还没有提交。

通过将 binlog 持久化到磁盘

为什么需要两阶段提交?

redo log和binlog是两个独立的逻辑,如果不用两阶段提交,要么就是先写完redo log再写binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。

  1. 先写redo log后写binlog

    假设在redo log写完,binlog还没有写完的时候,MySQL进程异常重启。由于我们前面说过的,redo log写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行c的值是1。
    但是由于binlog没写完就crash了,这时候binlog里面就没有记录这个语句。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句

    如果需要用这个binlog来恢复临时库的话,由于这个语句的binlog丢失,这个临时库就会少了这一次更新,恢复出来的这一行c的值就是0,与原库的值不同。

  2. 先写binlog后写redo log

​ 如果在binlog写完之后crash,由于redo log还没写,崩溃恢复以后这个事务无效,所以这一行c的值是0。但是binlog里面已经记录了“把c从0改成1”这个日志。所以,在之后用binlog来恢复的时候就多了一个事务出来,恢复出来的这一行c的值就是1,与原库的值不同。

总结

redo log用于保证crash-safe能力。innodb_flush_log_at_trx_commit这个参数设置成1的时候,表示每次事务的redo log都直接持久化到磁盘。

sync_binlog这个参数设置成1的时候,表示每次事务的binlog都持久化到磁盘。这个参数我也建议你设置成1,这样可以保证MySQL异常重启之后binlog不丢失。

1
2
3
4
my.cnf
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1

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

隔离性与隔离级别

ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)

  1. 脏读(dirty read)
  2. 不可重复读(non-repeatable read)
  3. 幻读(phantom read)

SQL标准的事务隔离级别包括:

  1. 读未提交(read uncommitted)

    一个事务还没提交时,它做的变更就能被别的事务看到

  2. 读提交(read committed)

    一个事务提交之后,它做的变更才会被其他事务看到。

  3. 可重复读(repeatable read)

    一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的

  4. 串行化(serializable )

    对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

    1
    2
    mysql> create table T(c int) engine=InnoDB;
    insert into T(c) values(1);

img

  1. 读未提交

    V1、V2、V3 都是2

  2. 读提交

    V1=1、V2、V3 都是2

  3. 可重复读

    V1 v2都是1、 v3是2

  4. 串行化

    事务B执行“将1改成2”的时候,会被锁住。直到事务A提交后,事务B才可以继续执行。

    V1、V2值是1,V3的值是2。

事务隔离的实现

img

假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。

这一个记录的值分别是1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)对于read-view A,要得到1,就必须将当前值依次执行图中所有的回滚操作得到。

什么时候删除回滚日志

在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的read-view的时候。

为什么建议你尽量不要使用长事务

这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

如何避免长事务

  1. 确认是否使用了set autocommit=0。确认是否使用了set autocommit=0。这个确认工作可以在测试环境中开展,把MySQL的general_log开起来,然后随便跑一个业务逻辑,通过general_log的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成1。
  2. 只读事务可以去掉
  3. 业务连接数据库的时候,根据业务本身的预估,通过SET MAX_EXECUTION_TIME命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。
  4. 监控 information_schema.Innodb_trx表,设置长事务阈值,超过就报警/或者kill;
  5. 把innodb_undo_tablespaces设置成2(或更大的值)。

事务的启动方式

  1. 显式启动事务语句, begin 或 start transaction。配套的提交语句是commit,回滚语句是rollback。
  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个select语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行commit 或 rollback 语句,或者断开连接。
1
commit work and chain #提交事务并自动启动下一个事务
1
2

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60#查找持续时间超过60s的事务

深入浅出索引(上)

给表添加索引

1
2
3
CREATE INDEX idx_username ON users (username);
CREATE INDEX idx_username_email ON users (username, email);

创建时添加索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
列定义后面使用 INDEX 或 KEY 关键字来实现。
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255),
email VARCHAR(255),
INDEX idx_username (username),
INDEX idx_email (email)
);

CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255),
email VARCHAR(255),
KEY idx_username (username),
KEY idx_email (email)
);

如何删除索引

1
2
3
4
5
DROP INDEX idx_username ON users;
想删除 users 表上的 PRIMARY KEY 索引
ALTER TABLE users DROP PRIMARY KEY;
如果要删除的索引是唯一索引
ALTER TABLE table_name DROP INDEX index_name;
1
2
3
4
5
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;

img

索引类型

  1. 主键索引 | 聚簇索引

    主键索引的叶子节点存的是整行数据

  2. 非主键索引 | 二级索引

    非主键索引的叶子节点内容是主键的值

查询非主键索引会查到主键id 然后从主键树中查找 id并返回数据,叫做回表

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

  • 如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树;
  • 如果语句是select * from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表

索引维护

如果插入id为400 但R5所在的页满,会导致页分裂

当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

解决办法

  1. 使用自增主键

​ 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。插入新记录的时候可以不指定ID的值,系统会获取当前ID最大值加1作为下一条记录的ID值。

主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

深入浅出索引(下)

img

1
select * from T where k between 3 and 5
  1. 在k索引树上找到k=3的记录,取得 ID = 300;
  2. 再到ID索引树查到ID=300对应的R3;
  3. 在k索引树取下一个值k=5,取得ID=500;
  4. 再回到ID索引树查到ID=500对应的R4;
  5. 在k索引树取下一个值k=6,不满足条件,循环结束。

回到主键索引树搜索的过程,我们称为回表

覆盖索引

select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

最左前缀原则

B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

img

当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。

你要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是”where name like ‘张%’”。这时,你也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。

不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。

在建立联合索引的时候,如何安排索引内的字段顺序

当已经有了(a,b)这个联合索引后,一般就不需要单独在a上建立索引了。因此,第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。

索引下推

在索引遍历过程中,对索引中包含的字段先做判断

img

索引下推的执行流程

img

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

全局锁

tables with read lock (FTWRL)
1
Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

使用场景

全局锁的典型使用场景是,做全库逻辑备份

缺点

如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆

如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。

single-transaction方法只适用于所有的表使用事务引擎的库。**如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一

既然要全库只读,为什么不使用set global readonly=true的方式呢?确实readonly方式也可以让全库进入只读状态,但我还是会建议你用FTWRL方式,主要有两个原因:

  • 一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改global变量的方式影响面更大,我不建议你使用。
  • 二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。

表级锁

  1. 表锁

    1. 表锁的语法是 lock tables … read/write、unlock tables主动释放锁也可以在客户端断开的时候自动释放。lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。
  2. 元数据锁(meta data lock,MDL)

    MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

    在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

img

  1. session A先启动,这时候会对表t加一个MDL读锁。由于session B需要的也是MDL读锁,因此可以正常执行。
  2. session C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。
  3. 有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。
  4. ,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

如何安全地给小表加字段?

  1. 解决长事务

  2. MySQL的information_schema 库的 innodb_trx 表你可以查到当前执行中的事务。如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。

    1. use information_schema
      select * from innodb_trx
      
      1
      2
      3
      4
      5
      6
      7
      8



      3. 你要变更的表是一个热点表,上面的请求很频繁,而你不得不加个字段,你该怎么做呢,alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好

      4. ```
      ALTER TABLE tbl_name NOWAIT add column ...
      ALTER TABLE tbl_name WAIT N add column ...

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

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

img

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。

这里设置

1
2
SET autocommit = 1;  -- 开启自动提交
SET autocommit = 0; -- 关闭自动提交

在MySQL里,有两个“视图”的概念:

  • 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view … ,而它的查询方法与表一样。
  • 另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。

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

InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。

每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id

数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。

img

虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx_id也是25。

语句更新会生成undo log(回滚日志)吗?那么,undo log在哪呢?

图2中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依次执行U3、U2算出来。

InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。

img

对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:

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

InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

img

从图中可以看到,**第一个有效更新是事务C,把数据从(1,1)改成了(1,2)**。这时候,这个数据的最新版本的row trx_id是102,而90这个版本已经成为了历史版本。

第二个有效更新是事务B**,把数据从(1,2)改成了(1,3)**。这时候,这个数据的最新版本(即row trx_id)是101,而102又成为了历史版本。

你可能注意到了,在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了。但这个版本对事务A必须是不可见的,否则就变成脏读了。

好,现在事务A要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的:

  • 找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读

更新逻辑

事务B的update语句,如果按照一致性读,好像结果不对哦?

img

如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了。因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

当前读。其实,除了update语句外,select语句如果加锁,也是当前读。

1
2
select k from t where id=1 lock in share mode;
select k from t where id=1 for update;

img

事务C’的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过了,虽然事务C’还没提交,但是(1,2)这个版本也已经生成了,并且是当前的最新版本。那么,事务B的更新语句会怎么处理呢?

事务C’没提交,也就是说**(1,2)这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁**,才能继续它的当前读。

img

事务的可重复读的能力是怎么实现的?

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

设置数据库的隔离级别

1
2
3
4
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

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

img

查询过程

InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB。

查找到索引所在的数据页,然后一条条比对

更新过程

change buffer

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。

在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

虽然名字叫作change buffer,实际上它是可以持久化的数据。也就是说,change buffer在内存中有拷贝,也会被写入到磁盘上。

将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge

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

对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入(4,400)这个记录,就要先判断现在表中是否已经存在k=4的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer了。

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

change buffer用的是buffer pool里的内存,因此不能无限增大。change buffer的大小,可以通过参数innodb_change_buffer_max_size来动态设置。这个参数设置为50的时候,表示change buffer的大小最多只能占用buffer pool的50%。

change buffer的使用场景

对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。

假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用

索引选择和实践

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

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭change buffer。而在其他情况下,change buffer都能提升更新性能。

普通索引和change buffer的配合使用,对于数据量大的表的更新优化还是很明显的。

change buffer 和 redo log

img

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1在内存中,直接更新内存;
  2. Page 2没有在内存中,就在内存的change buffer区域,记录下“我要往Page 2插入一行”这个信息
  3. 将上述两个动作记入redo log中(图中3和4)。

做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。

同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

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

优化器的逻辑

优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。

当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

我们这个简单的查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描行数的时候出问题了。

扫描行数是怎么判断的?

MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。

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

nnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。

img

Q1的结果还是符合预期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。

到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描37000行的执行计划不用,却选择了扫描行数是100000的执行计划呢?

这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。

1
analyze table t 可以用来重新统计索引信息

索引选择异常和处理

其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢?

一种方法是,像我们第一个例子一样,采用force index强行选择一个索引。**MySQL会根据词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需要扫描多少行。如果force index指定的索引在候选索引列表中,就直接选择这个索引,不再评估其他索引的执行代价。

img

既然优化器放弃了使用索引a,说明a还不够合适,所以第二种方法就是,我们可以考虑修改语句,引导MySQL使用我们期望的索引。比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。

img

第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。

怎么给字符串字段加索

1
2
alter table SUser add index index1(email);
alter table SUser add index index2(email(6));
img img

使用第一种查询到直接返回

第二种查询到需要再查ID 然后才返回

首先,你可以使用下面这个语句,算出这个列上有多少个不同的值:

1
select count(distinct email) as L from SUser;
  1. 直接创建完整索引,这样可能比较占用空间;
  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
  4. 创建hash字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。

为什么我的MySQL会“抖”一下?

当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”

img

掌柜在什么情况下会把粉板上的赊账记录改到账本上?

  • 第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确的账目记录到账本中才行。这个场景,对应的就是InnoDB的redo log写满了。这时候系统会停止所有更新操作,把checkpoint往前推进,redo log留出空间可以继续写。我在第二讲画了一个redo log的示意图,这里我改成环形,便于大家理解。

    img

  • 第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。

    • “内存不够用了,要先将脏页写到磁盘”,InnoDB用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
      • 第一种是,还没有使用的;
      • 第二种是,使用了并且是干净页;
      • 第三种是,使用了并且是脏页。
      • 当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。
      • 这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
        你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿redo log出来应用不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态:
  • 一种是内存里存在,内存里就肯定是正确的结果,直接返回;

  • 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。
    这样的效率最高。

  • 第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。

  • 第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。
    这种场景,对应的就是MySQL正常关闭的情况。这时候,MySQL会把内存的脏页都flush到磁盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

InnoDB刷脏页的控制策略

首先,你要正确地告诉InnoDB所在主机的IO能力,这样InnoDB才能知道需要全力刷脏页的时候,可以刷多快。

这就要用到innodb_io_capacity这个参数了,它会告诉InnoDB你的磁盘能力。这个值我建议你设置成磁盘的IOPS。磁盘的IOPS可以通过fio这个工具来测试,下面的语句是我用来测试磁盘随机读写的命令:

如果你来设计策略控制刷脏页的速度,会参考哪些因素呢?

一个是脏页比例,一个是redo log写盘速度。数innodb_max_dirty_pages_pct是脏页比例上限,默认值是75%

InnoDB会根据当前的脏页比例(假设为M),算出一个范围在0到100之间的数字,计算这个数字的伪代码类似这样:

1
2
3
4
5
6
F1(M)
{
if M>=innodb_max_dirty_pages_pct then
return 100;
return 100*M/innodb_max_dirty_pages_pct;
}

InnoDB每次写入的日志都有一个序号,当前写入的序号跟checkpoint对应的序号之间的差值,我们假设为N。InnoDB会根据这个N算出一个范围在0到100之间的数字,这个计算公式可以记为F2(N)。F2(N)算法比较复杂,你只要知道N越大,算出来的值越大就好了。

根据上述算得的F1(M)和F2(N)两个值,取其中较大的值记为R,之后引擎就可以按照innodb_io_capacity定义的能力乘以R%来控制刷脏页的速度。

img

你就要合理地设置innodb_io_capacity的值,并且**平时要多关注脏页比例,不要让它经常接近75%**。

一旦一个查询请求需要在执行过程中先flush掉一个脏页时,这个查询就可能要比平时慢了。而MySQL中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。

在InnoDB中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为1的时候会有上述的“连坐”机制,值为0时表示不找邻居,自己刷自己的。

在MySQL 8.0中,innodb_flush_neighbors参数的默认值已经是0了。

为什么表数据删掉一半,表文件大小不变?

参数innodb_file_per_table

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数innodb_file_per_table控制的:

​ 这个参数设置为OFF表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;

​ 这个参数设置为ON表示的是,每个InnoDB表数据存储在一个以 .ibd为后缀的文件中。

将innodb_file_per_table设置为ON,是推荐做法,我们接下来的讨论都是基于这个设置展开的。

我们在删除整个表的时候,可以使用drop table命令回收表空间。但是,我们遇到的更多的删除数据的场景是删除某些行,这时就遇到了我们文章开头的问题:表中的数据被删除了,但是表空间却没有被回收。

数据删除流程

img

们要删掉R4这个记录,InnoDB引擎只会把R4这个记录标记为删除。如果之后要再插入一个ID在300和600之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

现在,你已经知道了InnoDB的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,会怎么样?

答案是,整个数据页就可以被复用了。

数据页的复用跟记录的复用是不同的。

记录的复用,只限于符合范围条件的数据。

R4这条记录被删除后,如果插入一个ID是400的行,可以直接复用这个空间。但如果插入的是一个ID是800的行,就不能复用这个位置了。

而当整个页从B+树里面摘掉以后,可以复用到任何位置。以图1为例,如果将数据页page A上的所有记录删除以后,page A会被标记为可复用。这时候如果要插入一条ID=50的记录需要使用新页的时候,page A是可以被复用的。

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用

delete命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过delete命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

实际上,不止是删除数据会造成空洞,插入数据也会。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。

img

可以看到,由于page A满了,再插入一个ID是550的数据时,就不得不再申请一个新的页面page B来保存数据了。页分裂完成后,page A的末尾就留下了空洞(注意:实际上,可能不止1个记录的位置是空洞)。

更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。

经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。

重建表,就可以达到这样的目的。

重建表

你可以新建一个与表A结构相同的表B,然后按照主键ID递增的顺序,把数据一行一行地从表A里读出来再插入到表B中。

于表B是新建的表,所以表A主键索引上的空洞,在表B中就都不存在了。显然地,表B的主键索引更紧凑,数据页的利用率也更高。如果我们把表B作为临时表,数据从表A导入表B的操作完成后,用表B替换A,从效果上看,就起到了收缩表A空间的作用。

你可以使用alter table A engine=InnoDB命令来重建表。在MySQL 5.5版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表B不需要你自己创建,MySQL会自动完成转存数据、交换表名、删除旧表的操作。

img

图3

显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表A的话,就会造成数据丢失。因此,在整个DDL过程中,表A中不能有更新。也就是说,这个DDL不是Online的。

MySQL 5.6版本开始引入的Online DDL,对这个操作流程做了优化。

  1. 建立一个临时文件,扫描表A主键的所有数据页;
  2. 用数据页中表A的记录生成B+树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对A的操作记录在一个日志文件(row log)中,对应的是图中state2的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表A相同的数据文件,对应的就是图中state3的状态;
  5. 用临时文件替换表A的数据文件。

img

图4

DDL之前是要拿MDL写锁的,这样还能叫Online DDL吗?

确实,图4的流程中,alter语句在启动的时候需要获取MDL写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。

alter语句在启动的时候需要获取MDL写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。

什么要退化呢?为了实现Online,MDL读锁不会阻塞增删改操作。

那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做DDL。

而对于一个大表来说,Online DDL最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个DDL过程来说,锁的时间非常短。对业务来说,就可以认为是Online的。

上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗IO和CPU资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用GitHub开源的gh-ost来做。

Online 和 inplace

我们把表A中的数据导出来的存放位置叫作tmp_table。这是一个临时表,是在server层创建的。

根据表A重建出来的数据是放在“tmp_file”里的,这个临时文件是InnoDB在内部创建出来的。整个DDL过程都在InnoDB内部完成。对于server层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。

所以,我现在问你,如果你有一个1TB的表,现在磁盘间是1.2TB,能不能做一个inplace的DDL呢?

答案是不能。因为,tmp_file也是要占用临时空间的。

1
alter table t engine=innodb,ALGORITHM=inplace;
1
alter table t engine=innodb,ALGORITHM=copy;

当你使用ALGORITHM=copy的时候,表示的是强制拷贝表,对应的流程就是图3的操作过程。

1
alter table t add FULLTEXT(field_name);

这个过程是inplace的,但会阻塞增删改操作,是非Online的。

  1. DDL过程如果是Online的,就一定是inplace的;
  2. 反过来未必,也就是说inplace的DDL,有可能不是Online的。截止到MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引(SPATIAL index)就属于这种情况。
  • 从MySQL 5.6版本开始,alter table t engine = InnoDB(也就是recreate)默认的就是上面图4的流程了;
  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了MDL读锁;
  • optimize table t 等于recreate+analyze。

什么时候使用alter table t engine=InnoDB会让一个表占用的空间反而变大。

  1. 就是这个表,本身就已经没有空洞的了,比如说刚刚做过一次重建表操作。
    1. 将表t重建一次;
    2. 插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
    3. 这种情况下,再重建一次表t,就可能会出现问题中的现象。

count(*)这么慢,我该怎么办?

count(*)的实现方式

  • MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count(*)的时候会直接返回这个数,效率很高;
  • 而InnoDB引擎就麻烦了,它执行count(*)的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数

这里是没有加where条件的,如果加了where 条件的话,MyISAM表也是不能返回得这么快的。

为什么InnoDB不跟MyISAM一样,也把数字存起来呢?

这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB表“应该返回多少行”也是不确定的。这里,我用一个算count(*)的例子来为你解释一下。

img

三个会话A、B、C会同时查询表t的总行数,但拿到的结果却不同。

这和InnoDB的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是MVCC来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于count(*)请求来说,InnoDB只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。

你知道的,InnoDB是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于count()这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL优化器会找到最小的那棵树来遍历。*在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。

用缓存系统保存计数

你可以用一个Redis服务来保存这个表的总行数。这个表每被插入一行Redis计数就加1,每被删除一行Redis计数就减1。这种方式下,读和更新操作都很快,但你再想一下这种方式存在什么问题吗?

没错,缓存系统可能会丢失更新。

Redis的数据不能永久地留在内存里,所以你会找一个地方把这个值定期地持久化存储起来。但即使这样,仍然可能丢失更新。试想如果刚刚在数据表中插入了一行,Redis中保存的值也加了1,然后Redis异常重启了,重启后你要从存储redis数据的地方把这个值读回来,而刚刚加1的这个计数操作却丢失了

比如,Redis异常重启以后,到数据库里面单独执行一次count(*)获取真实的行数,再把这个值写回到Redis里就可以了。异常重启毕竟不是经常出现的情况,这一次全表扫描的成本,还是可以接受的。

但是还是会有不一致的情况

img

在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以,我们说即使Redis正常工作,这个计数值还是逻辑上不精确的。

在数据库保存计数

img

我们来看下现在的执行结果。虽然会话B的读操作仍然是在T3执行的,但是因为这时候更新事务还没有提交,所以计数值加1这个操作对会话B还不可见。

因此,会话B看到的结果里, 查计数值和“最近100条记录”看到的结果,逻辑上就是一致的。

不同的count用法

这里,首先你要弄清楚count()的语义。count()是一个聚合函数,对于返回的结果集,一行行地判断,如果count函数的参数不是NULL,累计值就加1,否则不加。最后返回累计值

对于count(主键id)来说,InnoDB引擎会遍历整张表,把每一行的id值都取出来,返回给server层。server层拿到id后,判断是不可能为空的,就按行累加。

对于count(1)来说,InnoDB引擎遍历整张表,但不取值。server层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

单看这两个用法的差别的话,你能对比出来,count(1)执行得要比count(主键id)快。因为从引擎返回id会涉及到解析数据行,以及拷贝字段值的操作。

对于count(字段)来说

  1. 如果这个“字段”是定义为not null的话一行行地从记录里面读出这个字段,判断不能为null,按行累加
  2. 如果这个“字段”定义允许为null,那么执行的时候,判断到有可能是null,还要把值取出来再判断一下,不是null才累加

count(*)是例外

并不会把全部字段取出来,而是专门做了优化,不取值。count(*)肯定不是null的字段,按行累加。

按照效率排序的话,count(字段)<count(主键id)<count(1)≈count(*),所以我建议你,尽量使用count(*)。

日志和索引相关问题

日志相关问题

binlog(归档日志)和redo log(重做日志)配合崩溃恢复的时候,用的是反证法,说明了如果没有两阶段提交,会导致MySQL出现主备数据不一致等问题。

在两阶段提交的不同瞬间,MySQL如果发生异常重启,是怎么保证数据完整性的?

img

两个“commit”的概念

  • 他说的“commit语句”,是指MySQL语法中,用于提交一个事务的命令。一般跟begin/start transaction 配对使用。
  • 而我们图中用到的这个“commit步骤”,指的是事务提交过程中的一个小步骤,也是最后一步。当这个步骤执行完成后,这个事务就提交完成了。
  • “commit语句”执行的时候,会包含“commit 步骤”。

在两阶段提交的不同时刻,MySQL异常重启会出现什么现象

如果在图中时刻A的地方,也就是写入redo log 处于prepare阶段之后、写binlog之前,发生了崩溃(crash),由于此时binlog还没写,redo log也还没提交,所以崩溃恢复的时候,这个事务会回滚。这时候,binlog还没写,所以也不会传到备库。到这里,大家都可以理解。

主要集中在时刻B,也就是binlog写完,redo log还没commit前发生crash,那崩溃恢复的时候MySQL会怎么处理?

我们先来看一下崩溃恢复时的判断规则
  1. 如果redo log里面的事务是完整的,也就是已经有了commit标识,则直接提交;
  2. 如果redo log里面的事务只有完整的prepare,则判断对应的事务binlog是否存在并完整:
    a. 如果是,则提交事务;
    b. 否则,回滚事务。

MySQL怎么知道binlog是完整的?

一个事务的binlog是有完整格式的:

  • statement格式的binlog,最后会有COMMIT

  • row格式的binlog,最后会有一个XID event

  • 在MySQL 5.6.2版本以后,还引入了binlog-checksum参数,用来验证binlog内容的正确性。对于binlog日志由于磁盘原因,可能会在日志中间出错的情况,MySQL可以通过校验checksum的结果来发现。所以,MySQL还是有办法验证事务binlog的完整性的。

    redo log 和 binlog是怎么关联起来的?

    它们有一个共同的数据字段,叫XID。崩溃恢复的时候,会按顺序扫描redo log:

  • 如果碰到既有prepare、又有commit的redo log,就直接提交;

  • 如果碰到只有parepare、而没有commit的redo log,就拿着XID去binlog找对应的事务。有事务则提交,没有则会滚

处于prepare阶段的redo log加上完整binlog,重启就能恢复,MySQL为什么要这么设计?

在时刻B,也就是binlog写完以后MySQL发生崩溃,这时候binlog已经写入了,之后就会被从库(或者用这个binlog恢复出来的库)使用。

所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。

如果这样的话,为什么还要两阶段提交呢?干脆先redo log写完,再写binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑?

其实,两阶段提交是经典的分布式系统问题,并不是MySQL独有的。

如果必须要举一个场景,来说明这么做的必要性的话,那就是事务的持久性问题。

对于InnoDB引擎来说,如果redo log提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果redo log直接提交,然后binlog写入的时候失败,InnoDB又回滚不了,数据和binlog日志又不一致了。

两阶段提交就是为了给所有人一个机会,当每个人都说“我ok”的时候,再一起提交。

不引入两个日志,也就没有两阶段提交的必要了。只用binlog来支持崩溃恢复,又能支持归档,不就可以了?

这位同学的意思是,只保留binlog,然后可以把提交流程改成这样:… -> “数据更新到内存” -> “写 binlog” -> “提交事务”,是不是也可以提供崩溃恢复的能力?

不可以

InnoDB接入了MySQL后,发现既然binlog没有崩溃恢复的能力,那就用InnoDB原有的redo log好了。

img

og还是不能支持崩溃恢复的。我说一个不支持的点吧:binlog没有能力恢复“数据页”。

引擎内部事务2会回滚,然后应用binlog2可以补回来;但是对于事务1来说,系统已经认为提交完成了,不会再应用一次binlog1。

InnoDB引擎使用的是WAL技术,执行事务的时候,写完内存和日志,事务就算完成了。如果之后崩溃,要依赖于日志来恢复数据页。

那能不能反过来,只用redo log,不要binlog?

如果只从崩溃恢复的角度来讲是可以的。你可以把binlog关掉,这样就没有两阶段提交了,但系统依然是crash-safe的。

redo log是循环写,写到末尾是要回到开头继续写的。这样历史日志没法保留,redo log也就起不到归档的作用。

MySQL系统依赖于binlog。binlog作为MySQL一开始就有的功能,被用在了很多地方。其中,MySQL系统高可用的基础,就是binlog复制。

redo log一般设置多大?

如果是现在常见的几个TB的磁盘的话,就不要太小气了,直接将redo log设置为4个文件、每个文件1GB吧

正常运行中的实例,数据写入后的最终落盘,是从redo log更新过来的还是从buffer pool更新过来的呢?

redo log并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由redo log更新过去”的情况。

  1. 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与redo log毫无关系。
  2. 在崩溃恢复场景中,InnoDB如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让redo log更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。

redo log buffer是什么?是先修改内存,还是先写redo log文件?

在一个事务的更新过程中,日志是要写多次的。比如下面这个事务:

1
2
3
4
begin;
insert into t1 ...
insert into t2 ...
commit;

这个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先保存起来,但又不能在还没commit的时候就直接写到redo log文件里。

所以,redo log buffer就是一块内存,用来先存redo日志的。也就是说,在执行第一个insert的时候,数据的内存被修改了,redo log buffer也写入了日志。

但是,真正把日志写到redo log文件(文件名是 ib_logfile+数字),是在执行commit语句的时候做的。

order by”是怎么工作的?

全字段排序

在city字段上创建索引之后,我们用explain命令来看看这个语句的执行情况。

img

Extra这个字段中的“Using filesort”表示的就是需要排序,MySQL会给每个线程分配一块内存用于排序,称为sort_buffer。

img

图2 city字段的索引示意图

  1. 满足city=’杭州’条件的行,是从ID_X到ID_(X+N)的这些记录。
  2. 初始化sort_buffer,确定放入name、city、age这三个字段;
  3. 从索引city找到第一个满足city=’杭州’条件的主键id,也就是图中的ID_X;
  4. 到主键id索引取出整行,取name、city、age三个字段的值,存入sort_buffer中;
  5. 从索引city取下一个记录的主键id;
  6. 重复步骤3、4直到city的值不满足查询条件为止,对应的主键id也就是图中的ID_Y;
  7. sort_buffer中的数据按照字段name做快速排序;
  8. 按照排序结果取前1000行返回给客户端。

img

图中“按name排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数sort_buffer_size

sort_buffer_size,就是MySQL为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序

rowid排序

在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在sort_buffer和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么sort_buffer里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。

如果MySQL认为排序的单行长度太大会怎么做呢?
1
SET max_length_for_sort_data = 16;

max_length_for_sort_data,是MySQL中专门控制用于排序的行数据的长度的一个参数。它的意思是,如果单行的长度超过这个值,MySQL就认为单行太大,要换一个算法。

city、name、age 这三个字段的定义总长度是36,我把max_length_for_sort_data设置为16,我们再来看看计算过程有什么改变。

新的算法放入sort_buffer的字段,只有要排序的列(即name字段)和主键id。

  1. 初始化sort_buffer,确定放入两个字段,即name和id;
  2. 从索引city找到第一个满足city=’杭州’条件的主键id,也就是图中的ID_X;
  3. 到主键id索引取出整行,取name、id这两个字段,存入sort_buffer中;
  4. 从索引city取下一个记录的主键id;
  5. 重复步骤3、4直到不满足city=’杭州’条件为止,也就是图中的ID_Y;
  6. 对sort_buffer中的数据按照字段name进行排序;
  7. 遍历排序结果,取前1000行,并按照id的值回到原表中取出city、name和age三个字段返回给客户端。

这个执行流程的示意图如下,我把它称为rowid排序。

img

对比图3的全字段排序流程图你会发现,rowid排序多访问了一次表t的主键索引,就是步骤7。

最后的“结果集”是一个逻辑概念,实际上MySQL服务端从排序后的sort_buffer中依次取出id,然后到原表查到city、name和age这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的。

全字段排序 VS rowid排序

如果MySQL实在是担心排序内存太小,会影响排序效率,才会采用rowid排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

如果MySQL认为内存足够大,会优先选择全字段排序,把需要的字段都放到sort_buffer中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

如果内存够,就要多利用内存,尽量减少磁盘访问。

rowid排序会要求回表多造成磁盘读,因此不会被优先选择。

并不是所有的order by语句,都需要排序操作的。从上面分析的执行过程,我们可以看到,MySQL之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。

img

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足city=’杭州’的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要city的值是杭州,name的值就一定是有序的。

  1. 从索引(city,name)找到第一个满足city=’杭州’条件的主键id;
  2. 到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的一部分直接返回;
  3. 从索引(city,name)取下一个记录主键id;
  4. 重复步骤2、3,直到查到第1000条记录,或者是不满足city=’杭州’条件时循环结束。
  5. img

img

可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用explain的结果来印证一下。

如何正确地显示随机消息?

从一个单词表中随机选出三个单词

1
select word from words order by rand() limit 3;

这个语句的意思很直白,随机排序取前3个。虽然这个SQL语句写法很简单,但执行流程却有点复杂的。

img

Extra字段显示Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。

Extra的意思就是,需要临时表,并且需要在临时表上排序。

对于InnoDB表来说,执行全字段排序会减少磁盘访问,因此会被优先选择。

对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,根本不会导致多访问磁盘。优化器没有了这一层顾虑,那么它会优先考虑的,就是用于排序的行越小越好了,所以,MySQL这时就会选择rowid排序。

**MySQL的表是用什么方法来定位“一行数据”的?

如果把一个InnoDB表的主键删掉,是不是就没有主键,就没办法回表了?

如果你创建的表没有主键,或者把一个表的主键删掉了,那么InnoDB会自己生成一个长度为6字节的rowid来作为主键。

这也就是排序模式里面,rowid名字的来历。实际上它表示的是:每个引擎用来唯一标识数据行的信息。

  • 对于有主键的InnoDB表来说,这个rowid就是主键ID;
  • 对于没有主键的InnoDB表来说,这个rowid就是由系统生成的;
  • MEMORY引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个rowid其实就是数组的下标。

order by rand()使用了内存临时表,内存临时表排序的时候使用了rowid排序方法。

磁盘临时表

tmp_table_size这个配置限制了内存临时表的大小,默认值是16M。如果临时表大小超过了tmp_table_size,那么内存临时表就会转成磁盘临时表。

磁盘临时表使用的引擎默认是InnoDB,是由参数internal_tmp_disk_storage_engine控制的。

磁盘临时表的时候,对应的就是一个没有显式索引的InnoDB表的排序过程

我把tmp_table_size设置成1024,把sort_buffer_size设置成 32768, 把 max_length_for_sort_data 设置成16。

为什么这些SQL语句逻辑相同,性能却差异巨大?

对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

如果对字段做了函数计算,就用不上索引了,这是MySQL的规定。

对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

优化器并不是要放弃使用这个索引。

隐式类型转换

交易编号tradeid这个字段上,本来就有索引,但是explain的结果却显示,这条语句需要走全表扫描。你可能也发现了,tradeid的字段类型是varchar(32),而输入的参数却是整型,所以需要做类型转换。

数据类型转换的规则是什么?

这里有一个简单的方法,看 select “10” > 9的结果:

  1. 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是1;
  2. 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是0。

img

在MySQL中,字符串和数字做比较的话,是将字符串转换成数字

隐式字符编码转换

这两个表的字符集不同,一个是utf8,一个是utf8mb4,所以做表连接查询的时候用不上关联字段的索引。

1
select * from trade_detail where tradeid=$L2.tradeid.value; 

在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成utf8mb4,再跟L2做比较。

1
2
select * from trade_detail  where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; 
连接过程中要求在被驱动表的索引字段上加函数操作,是直接导致对被驱动表做全表扫描的原因。

每次你的业务代码升级时,把可能出现的、新的SQL语句explain一下,是一个很好的习惯。

1
2
3
4
5
6
mysql> CREATE TABLE `table_a` (
`id` int(11) NOT NULL,
`b` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `b` (`b`)
) ENGINE=InnoDB;

有100万行数据,其中有10万行数据的b的值是’1234567890’, 假设现在执行语句是这么写的:

1
select * from table_a where b='1234567890abcd';

最理想的情况是,MySQL看到字段b定义的是varchar(10),那肯定返回空呀。可惜,MySQL并没有这么做。

那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树b上并没有这个值,也很快就能返回空结果。

但实际上,MySQL也不是这么做的。

这条SQL语句的执行很慢,流程是这样的:

  1. 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是10,所以只截了前10个字节,就是’1234567890’进去做匹配;
  2. 这样满足条件的数据有10万行;
  3. 因为是select *, 所以要做10万次回表;
  4. 但是每次回表以后查出整行,到server层一判断,b的值都不是’1234567890abcd’;
  5. 返回结果是空。

为什么我只查一行的语句,也执行这么慢?

查询长时间不返回

1
select * from t where id=1;

一般碰到这种情况的话,大概率是表t被锁住了。接下来分析原因的时候,一般都是首先执行一下命

1
show processlist

令,看看当前语句处于什么状态。

等MDL锁

就是使用show processlist命令查看Waiting for table metadata lock的示意图。

img

这个状态表示的是,现在有一个线程正在表t上请求或者持有MDL写锁,把select语句堵住了

session A 通过lock table命令持有表t的MDL写锁,而session B的查询需要获取MDL读锁。所以,session B进入等待状态。

但是,由于在show processlist的结果里面,session A的Command列是“Sleep”,导致查找起来很不方便。不过有了performance_schema和sys系统库以后,就方便多了。(MySQL启动时需要设置performance_schema=on,相比于设置为off会有10%左右的性能损失)

通过查询sys.schema_table_lock_waits这张表,我们就可以直接找出造成阻塞的process id,把这个连接用kill 命令断开即可。

img

等flush

现在有一个线程正要对表t做flush操作。MySQL里面对表做flush操作的用法,一般有以下两个:

1
2
3
flush tables t with read lock;

flush tables with read lock;

这两个flush语句,如果指定表t的话,代表的是只关闭表t;如果没有指定具体的表名,则表示关闭MySQL里所有打开的表。

在session A中,我故意每行都调用一次sleep(1),这样这个语句默认要执行10万秒,在这期间表t一直是被session A“打开”着。然后,session B的flush tables t命令再要去关闭表t,就需要等session A的查询结束。这样,session C要再次查询的话,就会被flush 命令堵住了。

图7是这个复现步骤的show processlist结果。这个例子的排查也很简单,你看到这个show processlist的结果,肯定就知道应该怎么做了。

img

等行锁

1
select * from t where id=1 lock in share mode; 

img

由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的select语句就会被堵住。

img

显然,session A启动了事务,占有写锁,还不提交,是导致session B被堵住的原因。

这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果你用的是MySQL 5.7版本,可以通过sys.innodb_lock_waits 表查到。

1
select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G

查询慢

经过了重重封“锁”,我们再来看看一些查询慢的例子。

1
select * from t where c=50000 limit 1;

由于字段c上没有索引,这个语句只能走id主键顺序扫描,因此需要扫描5万行。

你可以看一下慢查询日志。注意,这里为了把所有语句记录到slow log里,我在连接后先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为0。

坏查询不一定是慢查询

1
select * from t where id=1;

img

虽然扫描行数是1,但执行时间却长达800毫秒。

img

如果我把这个slow log的截图再往下拉一点,你可以看到下一个语句,select * from t where id=1 lock in share mode,执行时扫描行数也是1行,执行时间是0.2毫秒。

看上去是不是更奇怪了?按理说lock in share mode还要加锁,时间应该更长才对啊。

你看到了,session A先用start transaction with consistent snapshot命令启动了一个事务,之后session B才开始执行update 语句。

session B执行完100万次update语句后,id=1这一行处于什么状态呢?你可以从图16中找到答案。img

img

session B更新完100万次,生成了100万个回滚日志(undo log)。

带lock in share mode的SQL语句,是当前读,因此会直接读到1000001这个结果,所以速度很快;而select * from t where id=1这个语句,是一致性读,因此需要从1000001开始,依次执行undo log,执行了100万次以后,才将1这个结果返回。

undo log里记录的其实是“把2改成1”,“把3改成2”这样的操作逻辑,画成减1的目的是方便你看

幻读是什么,幻读有什么问题?

img

在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。

面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。

因为这三个查询都是加了for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。

幻读有什么问题?

session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。

img

session B的第二条语句update t set c=5 where id=0,语义是“我把id=0、d=5这一行的c值,改成了5”。

由于在T1时刻,session A 还只是给id=5这一行加了行锁, 并没有给id=0这行加上锁。因此,session B在T2时刻,是可以执行这两条update语句的。这样,就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。

session C也是一样的道理,对id=1这一行的修改,也是破坏了Q1的加锁声明。

其次,是数据一致性的问题。

锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。

img

  1. 经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的;
  2. 经过T2时刻,id=0这一行变成(0,5,5);
  3. 经过T4时刻,表里面多了一行(1,5,5);
  4. 其他行跟这个执行序列无关,保持不变。

这些数据也没啥问题,但是我们再来看看这时候binlog里面的内容。

  1. T2时刻,session B事务提交,写入了两条语句;
  2. T4时刻,session C事务提交,写入了两条语句;
  3. T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。

我统一放到一起的话,就是这样的:

1
2
3
4
5
6
7
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/*所有d=5的行,d改成100*/

好,你应该看出问题了。这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。

也就是说,id=0和id=1这两行,发生了数据不一致。这个问题很严重,是不行的。

这个数据不一致到底是怎么引入的?

select * from t where d=5 for update这条语句只给d=5这一行,也就是id=5的这一行加锁”导致的。

img

由于session A把所有的行都加了写锁,所以session B在执行第一个update语句的时候就被锁住了。需要等到T6时刻session A提交以后,session B才能继续执行。

这样对于id=0这一行,在数据库里的最终结果还是 (0,5,5)。在binlog里面,执行序列是这样的:

1
2
3
4
5
6
7
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/*所有d=5的行,d改成100*/

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

但同时你也可以看到,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了id=1这一行的插入和更新呢?

原因很简单。在T3时刻,我们给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁。

也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。

到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。

接下来,我们再看看InnoDB怎么解决幻读的问题。

如何解决幻读?

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是**间隙锁(Gap Lock)**。

间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。

在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。

比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。

img

但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。

img

间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。也就是说,我们的表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。

img

  1. session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10);
  2. session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  3. session B 试图插入一行(9,9,9),被session A的间隙锁挡住了,只好进入等待;
  4. session A试图插入一行(9,9,9),被session B的间隙锁挡住了。

两个session进入互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。

间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的

今天和你分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。

他们公司就使用的是读提交隔离级别加binlog_format=row的组合。他曾问他们公司的DBA说,你为什么要这么配置。DBA直接答复说,因为大家都这么用呀。

为什么我只改一行的语句,锁这么多?

我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则1:加锁的基本单位是next-key lock。希望你还记得,next-key lock是前开后闭区间。
  2. 原则2:查找过程中访问到的对象才会加锁。
  3. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。
  4. 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
  5. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

等值查询间隙锁

由于表t中没有id=7的记录,所以用我们上面提到的加锁规则判断一下的话:

  1. 根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10];
  2. 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。

所以,session B要往这个间隙里面插入id=8的记录会被锁住,但是session C修改id=10这行是可以的。

非唯一索引等值锁

img

  1. 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock。
  2. 要注意c是普通索引,因此仅访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock。
  3. 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)。
  4. 根据原则2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。
  5. 锁是加在索引上的;同时,它给我们的指导是,如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将session A的查询语句改成select d from t where c=5 lock in share mode。你可以自己验证一下效果。

主键索引范围锁

1
2
mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;

id定义为int类型,这两个语句就是等价的吧?其实,它们并不完全等价。img

  1. 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。 根据优化1, 主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。
  2. 范围查找就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15]。

所以,session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。这样,session B和session C的结果你就能理解了。

非唯一索引范围锁

img

这次session A用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sesion A加的锁是,索引c上的(5,10] 和(10,15] 这两个next-key lock。

所以从结果上来看,sesson B要插入(8,8,8)的这个insert语句时就被堵住了。

这里需要扫描到c=15才停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要继续往后找了

唯一索引范围锁bug

img

session A是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了。

但是实现上,InnoDB会往前扫描到第一个不满足条件的行为止,也就是id=20。而且由于这是个范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上。

所以你看到了,session B要更新id=20这一行,是会被锁住的。同样地,session C要插入id=16的一行,也会被锁住。

照理说,这里锁住id=20这一行的行为,其实是没有必要的。因为扫描到id=15,就可以确定不用往后再找了。但实现上还是这么做了,因此我认为这是个bug。

非唯一索引上存在”等值”的例子

1
insert into t values(30,10,30);

img

可以看到,虽然有两个c=10,但是它们的主键值id是不同的(分别是10和30),因此这两个c=10的记录之间,也是有间隙的。

图中我画出了索引c上的主键id。为了跟间隙锁的开区间形式进行区别,我用(c=10,id=30)这样的形式,来表示索引上的一行。

现在,我们来看一下案例六。

这次我们用delete语句来验证。注意,delete语句加锁的逻辑,其实跟select … for update 是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

img

这时,session A在遍历的时候,先访问第一个c=10的记录。同样地,根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。

然后,session A向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。

也就是说,这个delete语句在索引c上的加锁范围,就是下图中蓝色区域覆盖的部分。

img

这个蓝色区域左右两边都是虚线,表示开区间,即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。

limit 语句加锁

img

这个例子里,session A的delete语句加了 limit 2。你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B的insert语句执行通过了,跟案例六的结果不同。

这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。

因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间

一个死锁的例子

是按照next-key lock的逻辑来分析的,因为这样分析比较方便。最后我们再看一个案例,目的是说明:next-key lock实际上是间隙锁和行锁加起来的结果。

img

  1. session A 启动事务后执行查询语句加lock in share mode,在索引c上加了next-key lock(5,10] 和间隙锁(10,15);

  2. session B 的update语句也要在索引c上加next-key lock(5,10] ,进入锁等待;

  3. 然后session A要再插入(8,8,8)这一行,被session B的间隙锁锁住。由于出现了死锁,InnoDB让session B回滚。

  4. 其实是这样的,session B的“加next-key lock(5,10] ”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。

    也就是说,我们在分析加锁规则的时候可以用next-key lock来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。

    MySQL有哪些“饮鸩止渴”提高性能的方法?

    短连接风暴

    第一种方法:先处理掉那些占着连接但是不工作的线程。

    max_connections的计算,不是看谁在running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过kill connection主动踢掉。这个行为跟事先设置wait_timeout的效果是一样的。设置wait_timeout参数表示的是,一个线程空闲wait_timeout这么多秒之后,就会被MySQL直接断开连接。

    第二种方法:减少连接过程的消耗。

    慢查询性能问题

    1. 索引没有设计好;
      1. 在备库B上执行 set sql_log_bin=off,也就是不写binlog,然后执行alter table 语句加上索引;
      2. 执行主备切换;
      3. 这时候主库是B,备库是A。在A上执行 set sql_log_bin=off,然后执行alter table 语句加上索引。
    2. SQL语句没写好;
    3. MySQL选错了索引。
      1. 上线前,在测试环境,把慢查询日志(slow log)打开,并且把long_query_time设置成0,确保每个语句都会被记录入慢查询日志;
      2. 在测试表里插入模拟线上的数据,做一遍回归测试;
      3. 观察慢查询日志里每类语句的输出,特别留意Rows_examined字段是否与预期一致。(我们在前面文章中已经多次用到过Rows_examined方法了,相信你已经动手尝试过了。如果还有不明白的,欢迎给我留言,我们一起讨论)。

    QPS突增问题

    1. 一种是由全新业务的bug导致的。假设你的DB运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
    2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的QPS就会变成0。
    3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的SQL语句直接重写成”select 1”返回。

    MySQL是怎么保证数据不丢的?

    binlog的写入机制

    binlog的写入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

    一个事务的binlog是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了binlog cache的保存问题。

    系统给binlog cache分配了一片内存,每个线程一个,参数 binlog_cache_size用于控制单个线程内binlog cache所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

    事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。状态如图1所示。

    img

    可以看到,每个线程有自己binlog cache,但是共用同一份binlog文件。

    • 图中的write,指的就是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快。
    • 图中的fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为fsync才占磁盘的IOPS。

    write 和fsync的时机,是由参数sync_binlog控制的:

    1. sync_binlog=0的时候,表示每次提交事务都只write,不fsync;
    2. sync_binlog=1的时候,表示每次提交事务都会执行fsync;
    3. sync_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync。

    因此,在出现IO瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,比较常见的是将其设置为100~1000中的某个数值。

    但是,将sync_binlog设置为N,对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志。

redo log的写入机制

redo log buffer里面的内容,是不是每次生成后都要直接持久化到磁盘呢?

​ 如果事务执行期间MySQL发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。

另外一个问题是,事务还没提交的时候,redo log buffer中的部分日志有没有可能被持久化到磁盘呢?

答案是,确实会有。

img

  1. 存在redo log buffer中,物理上是在MySQL进程内存中,就是图中的红色部分;
  2. 写到磁盘(write),但是没有持久化(fsync),物理上是在文件系统的page cache里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是hard disk,也就是图中的绿色部分。

日志写到redo log buffer是很快的,wirte到page cache也差不多,但是持久化到磁盘的速度就慢多了。

为了控制redo log的写入策略,InnoDB提供了innodb_flush_log_at_trx_commit参数,它有三种可能取值:

  1. 设置为0的时候,表示每次事务提交时都只是把redo log留在redo log buffer中;
  2. 设置为1的时候,表示每次事务提交时都将redo log直接持久化到磁盘;
  3. 设置为2的时候,表示每次事务提交时都只是把redo log写到page cache。

MySQL有哪些“饮鸩止渴”提高性能的方法?

短连接风暴

max_connections

先处理掉那些占着连接但是不工作的线程。

1
2
show processlist
kill connection + id

第二种方法:减少连接过程的消耗。

慢查询性能问题

  1. 索引没有设计好;
  2. SQL语句没写好;
  3. MySQL选错了索引。

QPS突增问题

MySQL是怎么保证数据不丢的?

binlog的写入机制

binlog的写入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

系统给binlog cache分配了一片内存,每个线程一个,参数 binlog_cache_size用于控制单个线程内binlog cache所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。状态如图1所示。

img

可以看到,每个线程有自己binlog cache,但是共用同一份binlog文件。

  • 图中的write,指的就是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快。
  • 图中的fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为fsync才占磁盘的IOPS。

write 和fsync的时机,是由参数sync_binlog控制的:

  1. sync_binlog=0的时候,表示每次提交事务都只write,不fsync;
  2. sync_binlog=1的时候,表示每次提交事务都会执行fsync;
  3. sync_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync。

将sync_binlog设置为N,对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志。

redo log的写入机制

img

  1. 存在redo log buffer中,物理上是在MySQL进程内存中,就是图中的红色部分;
  2. 写到磁盘(write),但是没有持久化(fsync),物理上是在文件系统的page cache里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是hard disk,也就是图中的绿色部分。

日志写到redo log buffer是很快的,wirte到page cache也差不多,但是持久化到磁盘的速度就慢多了。

为了控制redo log的写入策略,InnoDB提供了innodb_flush_log_at_trx_commit参数,它有三种可能取值:

  1. 设置为0的时候,表示每次事务提交时都只是把redo log留在redo log buffer中;
  2. 设置为1的时候,表示每次事务提交时都将redo log直接持久化到磁盘;
  3. 设置为2的时候,表示每次事务提交时都只是把redo log写到page cache。

MySQL是怎么保证主备一致的?

img

客户端的读写都直接访问节点A,而节点B是A的备库,只是将A的更新都同步过来,到本地执行。这样可以保持节点B和A的数据是相同的。

img

  1. 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
  2. 防止切换逻辑有bug,比如切换过程中出现双写,造成主备不一致;
  3. 可以用readonly状态,来判断节点的角色。
  4. 因为readonly设置对超级(super)权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

Mysql是如何保证高可用

img

图 1 MySQL主备切换流程–双M结构

  1. 主库A执行完成一个事务,写入binlog,我们把这个时刻记为T1;
  2. 之后传给备库B,我们把备库B接收完这个binlog的时刻记为T2;
  3. 备库B执行完成这个事务,我们把这个时刻记为T3。

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是T3-T1。

你可以在备库上执行show slave status命令,它的返回结果里面会显示seconds_behind_master,用于表示当前备库延迟了多少秒。

seconds_behind_master的计算方法是这样的:

  1. 每个事务的binlog 里面都有一个时间字段,用于记录主库上写入的时间;
  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到seconds_behind_master。

可以看到,其实seconds_behind_master这个参数计算的就是T3-T1。所以,我们可以用seconds_behind_master来作为主备延迟的值,这个值的时间精度是秒。

主备延迟的来源

首先,有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。

第二种常见的可能了,即备库的压力大。一般的想法是,主库既然提供了写能力,那么备库可以提供一些读能力。或者一些运营后台需要的分析语句,不能影响正常业务,所以只能在备库上跑。

一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力

  1. 通过binlog输出到外部系统,比如Hadoop这类系统,让外部系统提供统计类查询的能力。

其中,一主多从的方式大都会被采用。因为作为数据库系统,还必须保证有定期全量备份的能力。而从库,就很适合用来做备份。

这就是第三种可能了,即大事务。

*另一种典型的大事务场景,就是大表DDL。**这个场景,我在前面的文章中介绍过。处理方案就是,计划内的DDL,建议使用gh-ost方案

可靠性优先策略

在图1的双M结构下,从状态1到状态2切换的详细过程是这样的:

  1. 判断备库B现在的seconds_behind_master,如果小于某个值(比如5秒)继续下一步,否则持续重试这一步;
  2. 把主库A改成只读状态,即把readonly设置为true;
  3. 判断备库B的seconds_behind_master的值,直到这个值变成0为止;
  4. 把备库B改成可读写状态,也就是把readonly 设置为false;
  5. 把业务请求切到备库B。

这个切换流程,一般是由专门的HA系统来完成的,我们暂时称之为可靠性优先流程。

img

图中的SBM,是seconds_behind_master参数的简写。

这个切换流程中是有不可用时间的。因为在步骤2之后,主库A和备库B都处于readonly状态,也就是说这时系统处于不可写状态,直到步骤5完成后才能恢复。

在这个不可用状态中,比较耗费时间的是步骤3,可能需要耗费好几秒的时间。这也是为什么需要在步骤1先做判断,确保seconds_behind_master的值足够小。

备库为什么会延迟好几个小时?

img

图1 主备流程图

我们要关注的是图中黑色的两个箭头。一个箭头代表了客户端写入主库,另一箭头代表的是备库上sql_thread执行中转日志(relay log)。如果用箭头的粗细来代表并行度的话,那么真实情况就如图1所示,第一个箭头要明显粗于第二个箭头。

在主库上,影响并发度的原因就是各种锁了。由于InnoDB引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,它对业务并发度的支持还是很友好的。所以,你在性能测试的时候会发现,并发压测线程32就比单线程时,总体吞吐量高。

而日志在备库上的执行,就是图中备库上sql_thread更新数据(DATA)的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。

img

coordinator就是原来的sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了worker线程。而work线程的个数,就是由参数slave_parallel_workers决定的。根据我的经验,把这个值设置为8~16之间最好(32核物理机的情况),毕竟备库还有可能要提供读查询,不能把CPU都吃光了。

你需要先思考一个问题:事务能不能按照轮询的方式分发给各个worker,也就是第一个事务分给worker_1,第二个事务发给worker_2呢?

其实是不行的。因为,事务被分发给worker以后,不同的worker就独立执行了。但是,由于CPU的调度策略,很可能第二个事务最终比第一个事务先执行。而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题。

同一个事务的多个更新语句,能不能分给不同的worker来执行呢?

答案是,也不行。举个例子,一个事务更新了表t1和表t2中的各一行,如果这两条更新语句被分到不同worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。

coordinator在分发的时候,需要满足以下这两个基本要求:

  1. 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个worker中。
  2. 同一个事务不能被拆开,必须放到同一个worker中。

MySQL 5.5版本的并行复制策略

按表分发策略

按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个worker不会更新同一行。

img

可以看到,每个worker线程对应一个hash表,用于保存当前正在这个worker的“执行队列”里的事务所涉及的表。hash表的key是“库名.表名”,value是一个数字,表示队列中有多少个事务修改这个表。

有事务分配给worker时,事务里面涉及的表会被加到对应的hash表中。worker执行完成后,这个表会被从hash表中去掉。

可以看到,每个worker线程对应一个hash表,用于保存当前正在这个worker的“执行队列”里的事务所涉及的表。hash表的key是“库名.表名”,value是一个数字,表示队列中有多少个事务修改这个表。

主库出问题了,从库怎么办?

img

图1 一主多从基本结构

图中,虚线箭头表示的是主备关系,也就是A和A’互为主备, 从库B、C、D指向的是主库A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

img

图2 一主多从基本结构–主备切换

相比于一主一备的切换流程,一主多从结构在切换完成后,A’会成为新的主库,从库B、C、D也要改接到A’。正是由于多了从库B、C、D重新指向的这个过程,所以主备切换的复杂性也相应增加了。