MySQL实战45讲
MySQL实战45讲
00开篇词 这一次,让我们一起来搞懂MySQL

01 基础架构:一条SQL查询语句是如何执行的?
mysql> select * from T where ID=10; 
连接器
mysql -h$ip -P$port -u$user -p - 如果用户名或密码不对,你就会收到一个”Access denied for user”的错误,然后客户端程序结束执行。
- 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。

- 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
- 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
查询缓存
mysql> select SQL_CACHE * from T where ID=10; 分析器
mysql> elect * from t where ID=1;
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'elect * from t where ID=1' at line 1 优化器
mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20; - 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于 20。
- 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是否等于 10。
执行器
mysql> select * from T where ID=10;
ERROR 1142 (42000): SELECT command denied to user 'b'@'localhost' for table 'T' - 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
小结
02 日志系统:一条SQL更新语句是如何执行的?
mysql> create table T(ID int primary key, c int); mysql> update T set c=c+1 where ID=2; 
重要的日志模块:redo log
- 一种做法是直接把账本翻出来,把这次赊的账加上去或者扣除掉;
- 另一种做法是先在粉板上记下这次的账,等打烊以后再把账本翻出来核算。

重要的日志模块:binlog
- redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
- redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
- redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
- 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
- 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
- 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

两阶段提交
- 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
- 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。
- 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
- 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。
小结
03 事务隔离:为什么你改了我还看不见?
隔离性与隔离级别
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1); 
- 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
- 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
- 若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
- 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。
mysql> show variables like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+ 事务隔离的实现

事务的启动方式
- 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
- set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60 小结
上期问题时间
04 深入浅出索引(上)
索引的常见模型



InnoDB 的索引模型
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB; 
- 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
- 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
索引维护
你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。
- 只有一个索引;
- 该索引必须是唯一索引。
小结
alter table T drop index k;
alter table T add index(k); alter table T drop primary key;
alter table T add primary key(id); 上期问题时间
- 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
- 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
- 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)
- 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
- Percona 的 pt-kill 这个工具不错,推荐使用;
- 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
- 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
05 深入浅出索引(下)
mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;
insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg'); 
- 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
- 再到 ID 索引树查到 ID=300 对应的 R3;
- 在 k 索引树取下一个值 k=5,取得 ID=500;
- 再回到 ID 索引树查到 ID=500 对应的 R4;
- 在 k 索引树取下一个值 k=6,不满足条件,循环结束。
覆盖索引
备注:关于如何查看扫描行数的问题,我将会在第 16 文章《如何正确地显示随机消息?》中,和你详细讨论。
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; 

小结
CREATE TABLE `geek` (
`a` int(11) NOT NULL,
`b` int(11) NOT NULL,
`c` int(11) NOT NULL,
`d` int(11) NOT NULL,
PRIMARY KEY (`a`,`b`),
KEY `c` (`c`),
KEY `ca` (`c`,`a`),
KEY `cb` (`c`,`b`)
) ENGINE=InnoDB; select * from geek where c=N order by a limit 1;
select * from geek where c=N order by b limit 1; 上期问题时间
06 全局锁和表锁 :给表加个字段怎么有这么多阻碍?
全局锁
- 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
- 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。

备注:如果你对事务隔离级别的概念不是很清晰的话,可以再回顾一下第 3 篇文章[《事务隔离:为什么你改了我还看不见?》]中的相关内容。
- 一是,在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。
- 二是,在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
表级锁
- 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
- 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
备注:这里的实验环境是 MySQL 5.6。

ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ... 小结
- 要么是你的系统现在还在用 MyISAM 这类不支持事务的引擎,那要安排升级换引擎;
- 要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把 lock tables 和 unlock tables 改成 begin 和 commit,问题就解决了。
上期问题时间
@浪里白条 帮大家总结了复习要点; @约书亚 的问题里提到了 MRR 优化; @HwangZHen 留言言简意赅。
07 行锁功过:怎么减少行锁对性能的影响?
从两阶段锁说起

- 从顾客 A 账户余额中扣除电影票价;
- 给影院 B 的账户余额增加这张电影票价;
- 记录一条交易日志。
死锁和死锁检测

- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
- 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
小结
- 第一种,直接执行 delete from T limit 10000;
- 第二种,在一个连接中循环执行 20 次 delete from T limit 500;
- 第三种,在 20 个连接中同时执行 delete from T limit 500。
上期问题时间
Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */ - 如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
- 如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
- 如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
- 从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。
@Aurora 给了最接近的答案; @echo_陈 问了一个好问题; @壹笙☞漂泊 做了很好的总结。
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); 
- 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
- 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
“快照”在 MVCC 里是怎么工作的?


- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分,那就包括两种情况 a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见; b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
- 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
- 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
- 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

- 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
- 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
- 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
- (1,3) 还没提交,属于情况 1,不可见;
- (1,2) 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见;
- (1,1) 是在视图数组创建之前提交的,可见。
更新逻辑

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

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

- (1,3) 还没提交,属于情况 1,不可见;
- (1,2) 提交了,属于情况 3,可见。
小结
- 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
- 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4); 
上期问题时间
@Tony Du 的评论,详细而且准确。 @Knight²º¹⁸ 提到了如果可以加上特定条件,将这 10000 行天然分开,可以考虑第三种。是的,实际上在操作的时候我也建议你尽量拿到 ID 再删除。 @荒漠甘泉 提了一个不错的问题,大家需要区分行锁、MDL 锁和表锁的区别。对 InnoDB 表更新一行,可能过了 MDL 关,却被挡在行锁阶段。
09 普通索引和唯一索引,应该怎么选择?
select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz'; 
查询过程
- 对于普通索引来说,查找到满足条件的第一个记录 (5,500) 后,需要查找下一个记录,直到碰到第一个不满足 k=5 条件的记录。
- 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。
更新过程
- 对于唯一索引来说,找到 3 和 5 之间的位置,判断到没有冲突,插入这个值,语句执行结束;
- 对于普通索引来说,找到 3 和 5 之间的位置,插入这个值,语句执行结束。
- 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;
- 对于普通索引来说,则是将更新记录在 change buffer,语句执行就结束了。
change buffer 的使用场景
索引选择和实践
change buffer 和 redo log
备注:这里,你可以再回顾下第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中的相关内容。
mysql> insert into t(id,k) values(id1,k1),(id2,k2); 
- Page 1 在内存中,直接更新内存;
- Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息
- 将上述两个动作记入 redo log 中(图中 3 和 4)。

- 读 Page 1 的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL 之后如果读数据,是不是一定要读盘,是不是一定要从 redo log 里面把数据更新以后才可以返回?其实是不用的。你可以看一下图 3 的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
- 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。
小结
- 首先,业务正确性优先。咱们这篇文章的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。
- 然后,在一些“归档库”的场景,你是可以考虑使用唯一索引的。比如,线上数据只需要保留半年,然后历史数据保存在归档库。这时候,归档数据已经是确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。
上期问题时间


@某、人、@夏日雨、@周巘、@李金刚 等同学提了一个很好的问题,就是我们今天答案的 session B’ 的情况; @justin 提到了提交和未提交版本的区别对待,@倪大人 提到了读提交和当前读的区别,都是经过了思考后提出的好问题,大家可以去留言区看看。
10 MySQL为什么有时候会选错索引?
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `b` (`b`)
) ENGINE=InnoDB; 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(); 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*/ - 第一句,是将慢查询日志的阈值设置为 0,表示这个线程接下来的语句都会被记录入慢查询日志中;
- 第二句,Q1 是 session B 原来的查询;
- 第三句,Q2 是加了 force index(a) 来和 session B 原来的查询语句执行情况对比。

优化器的逻辑

- 设置为 on 的时候,表示统计信息会持久化存储。这时,默认的 N 是 20,M 是 10。
- 设置为 off 的时候,表示统计信息只存储在内存中。这时,默认的 N 是 8,M 是 16。


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; 
- 扫描行数的估计值依然不准确;
- 这个例子里 MySQL 又选错了索引。
索引选择异常和处理


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; 
小结
上期问题时间
- 从磁盘读入数据页到内存(老版本的数据页);
- 从 change buffer 里找出这个数据页的 change buffer 记录 (可能有多个),依次应用,得到新版数据页;
- 写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。
@某、人 把 02 篇的 redo log 更新细节和 change buffer 的更新串了起来; @Ivan 回复了其他同学的问题,并联系到 Checkpoint 机制; @约书亚 问到了 merge 和 redolog 的关系。
11 怎么给字符串字段加索引?
mysql> create table SUser(
ID bigint unsigned primary key,
email varchar(64),
...
)engine=innodb; mysql> select f1, f2 from SUser where email='xxx'; mysql> alter table SUser add index index1(email);
或
mysql> alter table SUser add index index2(email(6)); 

select id,name,email from SUser where email='zhangssxyz@xxx.com'; - 从 index1 索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得 ID2 的值;
- 到主键上查到主键值是 ID2 的行,判断 email 的值是正确的,将这行记录加入结果集;
- 取 index1 索引树上刚刚查到的位置的下一条记录,发现已经不满足 email=‘zhangssxyz@xxx.com’的条件了,循环结束。
- 从 index2 索引树找到满足索引值是’zhangs’的记录,找到的第一个是 ID1;
- 到主键上查到主键值是 ID1 的行,判断出 email 的值不是’zhangssxyz@xxx.com’,这行记录丢弃;
- 取 index2 上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出 ID2,再到 ID 索引上取整行然后判断,这次值对了,将这行记录加入结果集;
- 重复上一步,直到在 idxe2 上取到的值不是’zhangs’时,循环结束。
mysql> select count(distinct email) as L from SUser; mysql> select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser; 前缀索引对覆盖索引的影响
select id,email from SUser where email='zhangssxyz@xxx.com'; select id,name,email from SUser where email='zhangssxyz@xxx.com'; 其他方式
mysql> select field_list from t where id_card = reverse('input_id_card_string'); mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc); mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string' - 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。
- 在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32() 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。
- 从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。
小结
- 直接创建完整索引,这样可能比较占用空间;
- 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
- 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
- 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。
上期问题时间

@斜面镜子 Bill 的评论最接近答案; @某、人 做了两个很不错的对照试验; @ye7zi 等几位同学很认真的验证,赞态度。大家的机器如果 IO 能力比较差的话,做这个验证的时候,可以把 innodb_flush_log_at_trx_commit 和 sync_binlog 都设置成 0。
12 为什么我的MySQL会“抖”一下?
你的 SQL 语句为什么变“慢”了

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

- 第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。 这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。 你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿 redo log 出来应用不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态:
- 一种是内存里存在,内存里就肯定是正确的结果,直接返回;
- 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。 这样的效率最高。
- 第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。 这种场景,对应的就是 MySQL 认为系统“空闲”的时候。当然,MySQL“这家酒店”的生意好起来可是会很快就能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。
- 第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。 这种场景,对应的就是 MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。
- 第一种是,还没有使用的;
- 第二种是,使用了并且是干净页;
- 第三种是,使用了并且是脏页。
- 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
- 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的。
InnoDB 刷脏页的控制策略
fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest F1(M)
{
if M>=innodb_max_dirty_pages_pct then
return 100;
return 100*M/innodb_max_dirty_pages_pct;
} 
mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
select @a/@b; 小结
上期问题时间
@lttzzlll ,提到了用整型存“四位年份 + 五位编号”的方法; 由于整个学号的值超过了 int 上限,@老杨同志 也提到了用 8 个字节的 bigint 来存的方法。
13 为什么表数据删掉一半,表文件大小不变?
参数 innodb_file_per_table
- 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
- 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。
数据删除流程


重建表

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

Online 和 inplace
alter table t engine=innodb,ALGORITHM=inplace; alter table t engine=innodb,ALGORITHM=copy; alter table t add FULLTEXT(field_name); - DDL 过程如果是 Online 的,就一定是 inplace 的;
- 反过来未必,也就是说 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。
小结
- 一个表 t 文件大小为 1TB;
- 对这个表执行 alter table t engine=InnoDB;
- 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。
上期问题时间

@某、人 给了一个形象的描述,而且提到了,在这种情况下,连 change buffer 的优化也失效了。因为 checkpoint 一直要往前推,这个操作就会触发 merge 操作,然后又进一步地触发刷脏页操作; 有几个同学提到了内存淘汰脏页,对应的 redo log 的操作,这个我们会在后面的文章中展开,大家可以先看一下 @melon 同学的描述了解一下; @算不出流源 提到了“动态平衡”,其实只要出现了这种“平衡”,意味着本应该后台的操作,就已经影响了业务应用,属于有损失的平衡。
14 count()这么慢,我该怎么办?
count(*) 的实现方式
- MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;
- 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。
- 会话 A 先启动事务并查询一次表的总行数;
- 会话 B 启动事务,插入一行后记录后,查询表的总行数;
- 会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。

备注:如果你对 MVCC 记忆模糊了,可以再回顾下第 3 篇文章[《事务隔离:为什么你改了我还看不见?》]和第 8 篇文章[《事务到底是隔离的还是不隔离的?》]中的相关内容。
- MyISAM 表虽然 count(*) 很快,但是不支持事务;
- show table status 命令虽然返回很快,但是不准确;
- InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。
用缓存系统保存计数
- 一种是,查到的 100 行结果里面有最新插入记录,而 Redis 的计数里还没加 1;
- 另一种是,查到的 100 行结果里没有最新插入的记录,而 Redis 的计数里已经加了 1。


在数据库保存计数
备注:关于 InnoDB 的崩溃恢复,你可以再回顾一下第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中的相关内容。

不同的 count 用法
- server 层要什么就给什么;
- InnoDB 只给必要的值;
- 现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。
- 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
- 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。
小结
上期问题时间
- 将表 t 重建一次;
- 插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
- 这种情况下,再重建一次表 t,就可能会出现问题中的现象。
@W_T 等同学提到了数据表本身紧凑的情况; @undifined 提了一个好问题, @帆帆帆帆帆帆帆帆 同学回答了这个问题; @陈飞 @郜 @wang chen wen 都提了很不错的问题,大家可以去看看。
15 答疑文章(一):日志和索引相关问题
日志相关问题

- 他说的“commit 语句”,是指 MySQL 语法中,用于提交一个事务的命令。一般跟 begin/start transaction 配对使用。
- 而我们图中用到的这个“commit 步骤”,指的是事务提交过程中的一个小步骤,也是最后一步。当这个步骤执行完成后,这个事务就提交完成了。
- “commit 语句”执行的时候,会包含“commit 步骤”。
- 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
- 如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整: a. 如果是,则提交事务; b. 否则,回滚事务。
追问 1:MySQL 怎么知道 binlog 是完整的?
- statement 格式的 binlog,最后会有 COMMIT;
- row 格式的 binlog,最后会有一个 XID event。
追问 2:redo log 和 binlog 是怎么关联起来的?
- 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
- 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。
追问 3:处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢复,MySQL 为什么要这么设计?
追问 4:如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑?
追问 5:不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来支持崩溃恢复,又能支持归档,不就可以了?

追问 6:那能不能反过来,只用 redo log,不要 binlog?
追问 7:redo log 一般设置多大?
追问 8:正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?
- 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系。
- 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。
追问 9:redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?
begin;
insert into t1 ...
insert into t2 ...
commit; 业务设计问题
业务上有这样的需求,A、B 两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是 like 表,一个是 friend 表,like 表有 user_id、liker_id 两个字段,我设置为复合唯一索引即 uk_user_id_liker_id。语句执行逻辑是这样的:
以 A 关注 B 为例: 第一步,先查询对方有没有关注自己(B 有没有关注 A) select * from like where user_id = B and liker_id = A;
如果有,则成为好友 insert into friend;
没有,则只是单向关注关系 insert into like;
但是如果 A、B 同时关注对方,会出现不会成为好友的情况。因为上面第 1 步,双方都没关注对方。第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在 MySQL 锁层面有没有办法处理?
CREATE TABLE `like` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`liker_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`)
) ENGINE=InnoDB;
CREATE TABLE `friend` (
id` int(11) NOT NULL AUTO_INCREMENT,
`friend_1_id` int(11) NOT NULL,
`firned_2_id` int(11) NOT NULL,
UNIQUE KEY `uk_friend` (`friend_1_id`,`firned_2_id`)
PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
值是 1 的时候,表示 user_id 关注 liker_id; 值是 2 的时候,表示 liker_id 关注 user_id; 值是 3 的时候,表示互相关注。
mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1;
select relation_ship from `like` where user_id=A and liker_id=B;
/* 代码中判断返回的 relation_ship,
如果是 1,事务结束,执行 commit
如果是 3,则执行下面这两个语句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(A,B);
commit; mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2;
select relation_ship from `like` where user_id=B and liker_id=A;
/* 代码中判断返回的 relation_ship,
如果是 2,事务结束,执行 commit
如果是 3,则执行下面这两个语句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(B,A);
commit; 小结
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL primary key auto_increment,
`a` int(11) DEFAULT NULL
) ENGINE=InnoDB;
insert into t values(1,2); mysql> update t set a=2 where id=1; 
- 更新都是先读后写的,MySQL 读出数据,发现 a 的值本来就是 2,不更新,直接返回,执行结束;
- MySQL 调用了 InnoDB 引擎提供的“修改为 (1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
- InnoDB 认真执行了“把这个值修改成 (1,2)“这个操作,该加锁的加锁,该更新的更新。
上期问题时间
- insert into 数据表;
- update 计数表,计数值加 1。
并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。 知识点在[《行锁功过:怎么减少行锁对性能的影响?》] 因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度。
CREATE TABLE `rows_stat` (
`table_name` varchar(64) NOT NULL,
`row_count` int(10) unsigned NOT NULL,
PRIMARY KEY (`table_name`)
) ENGINE=InnoDB; @北天魔狼、@斜面镜子 Bil 和 @Bin 等同学,都给出了正确答案; @果然如此 同学提了一个好问题,虽然引入事务,避免看到”业务上还没提交的更新”,但是 Redis 的计数被提前看到了。核心原因还是两个系统,不支持一致性视图; @ 帆帆帆帆帆帆帆帆 同学的问题提醒了大家,count(id) 也是可以走普通索引得到的。
16 “order by”是怎么工作的?
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`city` varchar(16) NOT NULL,
`name` varchar(16) NOT NULL,
`age` int(11) NOT NULL,
`addr` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `city` (`city`)
) ENGINE=InnoDB; select city,name,age from t where city='杭州' order by name limit 1000 ; 全字段排序


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

/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* @a 保存 Innodb_rows_read 的初始值 */
select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
/* @b 保存 Innodb_rows_read 的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 计算 Innodb_rows_read 差值 */
select @b-@a; 
rowid 排序
SET max_length_for_sort_data = 16; - 初始化 sort_buffer,确定放入两个字段,即 name 和 id;
- 从索引 city 找到第一个满足 city=‘杭州’条件的主键 id,也就是图中的 ID_X;
- 到主键 id 索引取出整行,取 name、id 这两个字段,存入 sort_buffer 中;
- 从索引 city 取下一个记录的主键 id;
- 重复步骤 3、4 直到不满足 city=‘杭州’条件为止,也就是图中的 ID_Y;
- 对 sort_buffer 中的数据按照字段 name 进行排序;
- 遍历排序结果,取前 1000 行,并按照 id 的值回到原表中取出 city、name 和 age 三个字段返回给客户端。


- sort_mode 变成了 ,表示参与排序的只有 name 和 id 这两个字段。
- number_of_tmp_files 变成 10 了,是因为这时候参与排序的行数虽然仍然是 4000 行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了。
全字段排序 VS rowid 排序
alter table t add index city_user(city, name); 
- 从索引 (city,name) 找到第一个满足 city=‘杭州’条件的主键 id;
- 到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;
- 从索引 (city,name) 取下一个记录主键 id;
- 重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city=‘杭州’条件时循环结束。


alter table t add index city_user_age(city, name, age); - 从索引 (city,name,age) 找到第一个满足 city=‘杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
- 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
- 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city=‘杭州’条件时循环结束。


小结
mysql> select * from t where city in ('杭州'," 苏州 ") order by name limit 100; 上期问题时间




@Gavin 、@melon、@阿建 等同学提到了锁验证法; @郭江伟 同学提到了两个点,都非常好,有去实际验证。结论是这样的: 第一,hexdump 看出来没改应该是 WAL 机制生效了,要过一会儿,或者把库 shutdown 看看。 第二,binlog 没写是 MySQL Server 层知道行的值没变,所以故意不写的,这个是在 row 格式下的策略。你可以把 binlog_format 改成 statement 再验证下。
17 如何正确地显示随机消息?
mysql> CREATE TABLE `words` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`word` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=0;
while i<10000 do
insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
set i=i+1;
end while;
end;;
delimiter ;
call idata(); 内存临时表
mysql> select word from words order by rand() limit 3; 


- 创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,为了后面描述方便,记为字段 R,第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。
- 从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。
- 现在临时表有 10000 行数据了,接下来你要在这个没有索引的内存临时表上,按照字段 R 排序。
- 初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。
- 从内存临时表中一行一行地取出 R 值和位置信息(我后面会和你解释这里为什么是“位置信息”),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。
- 在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
- 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 word 值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。
## Query_time: 0.900376 Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3; 
- 对于有主键的 InnoDB 表来说,这个 rowid 就是主键 ID;
- 对于没有主键的 InnoDB 表来说,这个 rowid 就是由系统生成的;
- MEMORY 引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个 rowid 其实就是数组的下标。
磁盘临时表
set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* 执行语句 */
select word from words order by rand() limit 3;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G 
- 对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;
- 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
- 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。

select city,name,age from t where city='杭州' order by name limit 1000 ; 随机排序方法
- 取得这个表的主键 id 的最大值 M 和最小值 N;
- 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
- 取不小于 X 的第一个 ID 的行。
mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1; - 取得整个表的行数,并记为 C。
- 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
- 再用 limit Y,1 取得一行。
mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt; - 取得整个表的行数,记为 C;
- 根据相同的随机方法得到 Y1、Y2、Y3;
- 再执行三个 limit Y, 1 语句得到三行数据。
mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; // 在应用代码里面取 Y1、Y2、Y3 值,拼出 SQL 后执行
select * from t limit @Y2,1;
select * from t limit @Y3,1; 小结
上期问题时间
- 执行 select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为 100 的内存数组 A 保存结果。
- 执行 select * from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组 B。
- 现在 A 和 B 是两个有序数组,然后你可以用归并排序的思想,得到 name 最小的前 100 值,就是我们需要的结果了。
select * from t where city=" 杭州 " order by name limit 10100; select * from t where city=" 苏州 " order by name limit 10100。 select id,name from t where city=" 杭州 " order by name limit 10100; select id,name from t where city=" 苏州 " order by name limit 10100。 评论区很多同学都提到不能排序,说明各位对索引的存储都理解对了。 @峰 同学提到了归并排序,是我们这个问题解法的核心思想; @老杨同志 的回答中提到了“从业务上砍掉功能”,这个也确实是在业务设计中可以考虑的一个方向; @某、人 帮忙回答了 @发条橙子同学的问题,尤其是对问题一的回答,非常精彩。
18 为什么这些SQL语句逻辑相同,性能却差异巨大?
案例一:条件字段函数操作
mysql> CREATE TABLE `tradelog` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`operator` int(11) DEFAULT NULL,
`t_modified` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`),
KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; mysql> select count(*) from tradelog where month(t_modified)=7; 

mysql> select count(*) from tradelog where
-> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
-> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or
-> (t_modified >= '2018-7-1' and t_modified<'2018-8-1'); 案例二:隐式类型转换
mysql> select * from tradelog where tradeid=110717; - 数据类型转换的规则是什么?
- 为什么有数据类型转换,就需要走全索引扫描?
- 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是 1;
- 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是 0。

mysql> select * from tradelog where tradeid=110717; mysql> select * from tradelog where CAST(tradid AS signed int) = 110717; select * from tradelog where id="83126"; 案例三:隐式字符编码转换
mysql> CREATE TABLE `trade_detail` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
`step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now());
insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit'); mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /* 语句 Q1*/ - 第一行显示优化器会先在交易记录表 tradelog 上查到 id=2 的行,这个步骤用上了主键索引,rows=1 表示只扫描一行;
- 第二行 key=NULL,表示没有用上交易详情表 trade_detail 上的 tradeid 索引,进行了全表扫描。

- 第 1 步,是根据 id 在 tradelog 表里找到 L2 这一行;
- 第 2 步,是从 L2 中取出 tradeid 字段的值;
- 第 3 步,是根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 的结果里面第二行的 key=NULL 表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断 tradeid 的值是否匹配。
mysql> select * from trade_detail where tradeid=$L2.tradeid.value; 这个设定很好理解,utf8mb4 是 utf8 的超集。类似地,在程序设计语言里面,做自动类型转换的时候,为了避免数据在转换过程中由于截断导致数据错误,也都是“按数据长度增加的方向”进行转换的。
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; mysql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4; 
select operator from tradelog where traideid =$R4.tradeid.value; select operator from tradelog where traideid =CONVERT($R4.tradeid.value USING utf8mb4); select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; -
比较常见的优化方法是,把 trade_detail 表上的 tradeid 字段的字符集也改成 utf8mb4,这样就没有字符集转换的问题了。
alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null;
-
如果能够修改字段的字符集的话,是最好不过了。但如果数据量比较大, 或者业务上暂时不能做这个 DDL 的话,那就只能采用修改 SQL 语句的方法了。
mysql> select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;

小结
上期问题时间
mysql> select * from t limit N, M-N+1; @老杨同志 提出了重新整理的方法、@雪中鼠 [悠闲] 提到了用 rowid 的方法,是类似的思路,就是让表里面保存一个无空洞的自增值,这样就可以用我们的随机算法 1 来实现; @吴宇晨 提到了拿到第一个值以后,用 id 迭代往下找的方案,利用了主键索引的有序性。
19 为什么我只查一行的语句,也执行这么慢?
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i,i);
set i=i+1;
end while;
end;;
delimiter ;
call idata(); 第一类:查询长时间不返回
mysql> select * from t where id=1; 
等 MDL 锁



等 flush
mysql> select * from information_schema.processlist where id=1; 
flush tables t with read lock;
flush tables with read lock; 

等行锁
mysql> select * from t where id=1 lock in share mode; 

mysql> select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G 
第二类:查询慢
mysql> select * from t where c=50000 limit 1; 
mysql> select * from t where id=1; 




小结
begin;
select * from t where c=5 for update;
commit; 上期问题时间
mysql> CREATE TABLE `table_a` (
`id` int(11) NOT NULL,
`b` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `b` (`b`)
) ENGINE=InnoDB; mysql> select * from table_a where b='1234567890abcd'; - 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是 10,所以只截了前 10 个字节,就是’1234567890’进去做匹配;
- 这样满足条件的数据有 10 万行;
- 因为是 select *, 所以要做 10 万次回表;
- 但是每次回表以后查出整行,到 server 层一判断,b 的值都不是’1234567890abcd’;
- 返回结果是空。
@赖阿甘 提到了等号顺序问题,时间上 MySQL 优化器执行过程中,where 条件部分, a=b 和 b=a 的写法是一样的。 @沙漠里的骆驼 提到了一个常见的问题。相同的模板语句,但是匹配行数不同,语句执行时间相差很大。这种情况,在语句里面有 order by 这样的操作时会更明显。 @Justin 回答了我们正文中的问题,如果 id 的类型是整数,传入的参数类型是字符串的时候,可以用上索引。
20 幻读是什么,幻读有什么问题?
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25); begin;
select * from t where d=5 for update;
commit; 幻读是什么?

- Q1 只返回 id=5 这一行;
- 在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
- 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。
- 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
- 上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
幻读有什么问题?


- 经过 T1 时刻,id=5 这一行变成 (5,5,100),当然这个结果最终是在 T6 时刻正式提交的 ;
- 经过 T2 时刻,id=0 这一行变成 (0,5,5);
- 经过 T4 时刻,表里面多了一行 (1,5,5);
- 其他行跟这个执行序列无关,保持不变。
- T2 时刻,session B 事务提交,写入了两条语句;
- T4 时刻,session C 事务提交,写入了两条语句;
- T6 时刻,session A 事务提交,写入了 update t set d=100 where d=5 这条语句。
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*/ 
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)*/ 如何解决幻读?



备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把 next-key lock 记为前开后闭区间。
begin;
select * from t where id=N for update;
/* 如果行不存在 */
insert into t values(N,N,N);
/* 如果行存在 */
update t set d=N set id=N;
commit; 
- session A 执行 select … for update 语句,由于 id=9 这一行并不存在,因此会加上间隙锁 (5,10);
- session B 执行 select … for update 语句,同样会加上间隙锁 (5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
- session B 试图插入一行 (9,9,9),被 session A 的间隙锁挡住了,只好进入等待;
- session A 试图插入一行 (9,9,9),被 session B 的间隙锁挡住了。
小结

上期问题时间
@薛畅 、@张永志同学给出了正确答案。而且提到了在读提交隔离级别下,是只有行锁的。 @帆帆帆帆帆帆帆帆、@欧阳成 对上期的例子做了验证,需要说明一下,需要在启动配置里面增加 performance_schema=on,才能用上这个功能,performance_schema 库里的表才有数据。
21 为什么我只改一行的语句,锁这么多?
- MySQL 后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。
- 如果大家在验证中有发现 bad case 的话,请提出来,我会再补充进这篇文章,使得一起学习本专栏的所有同学都能受益。
- 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
- 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
- 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25); 案例一:等值查询间隙锁

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

- 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5] 加上 next-key lock。
- 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10] 加 next-key lock。
- 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
- 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。
案例三:主键索引范围锁
mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update; 
- 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
- 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。
案例四:非唯一索引范围锁

案例五:唯一索引范围锁 bug

案例六:非唯一索引上存在”等值”的例子
mysql> insert into t values(30,10,30); 


案例七:limit 语句加锁


案例八:一个死锁的例子

- session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock(5,10] 和间隙锁 (10,15);
- session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待;
- 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。
小结

上期问题时间
-
@令狐少侠 说,以前一直认为间隙锁只在二级索引上有。现在你知道了,有间隙的地方就可能有间隙锁。
-
@浪里白条 同学问,如果是 varchar 类型,加锁规则是什么样的。 回答:实际上在判断间隙的时候,varchar 和 int 是一样的,排好序以后,相邻两个值之间就有间隙。
-
有几位同学提到说,上一篇文章自己验证的结果跟案例一不同,就是在 session A 执行完这两个语句:
begin; select * from t where d=5 for update; /Q1/
@ 某、人 、@郭江伟 两位同学尝试分析了上期问题,并给了有启发性的解答。
22 MySQL有哪些“饮鸩止渴”提高性能的方法?
短连接风暴



慢查询性能问题
- 索引没有设计好;
- SQL 语句没写好;
- MySQL 选错了索引。
- 在备库 B 上执行 set sql_log_bin=off,也就是不写 binlog,然后执行 alter table 语句加上索引;
- 执行主备切换;
- 这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。
mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1");
call query_rewrite.flush_rewrite_rules(); 
- 上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志;
- 在测试表里插入模拟线上的数据,做一遍回归测试;
- 观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。(我们在前面文章中已经多次用到过 Rows_examined 方法了,相信你已经动手尝试过了。如果还有不明白的,欢迎给我留言,我们一起讨论)。
QPS 突增问题
- 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
- 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
- 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成”select 1”返回。
- 如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
- 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。
小结
上期问题时间

- 由于是 order by c desc,第一个要定位的是索引 c 上“最右边的”c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]。
- 在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 session B 的 insert 语句的原因。
- 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。
- 索引 c 上 (5, 25);
- 主键索引上 id=15、20 两个行锁。
@HuaMax 给出了正确的解释。
@Justin 同学提了个好问题,<= 到底是间隙锁还是行锁?其实,这个问题,你要跟“执行过程”配合起来分析。在 InnoDB 要去找“第一个值”的时候,是按照等值去找的,用的是等值判断的规则;找到第一个值以后,要在索引内找“下一个值”,对应于我们规则中说的范围查找。
@信信 提了一个不错的问题,要知道最终的加锁是根据实际执行情况来的。所以,如果一个 select * from … for update 语句,优化器决定使用全表扫描,那么就会把主键索引上 next-key lock 全加上。
@nero 同学的问题,提示我需要提醒大家注意,“有行”才会加行锁。如果查询条件没有命中行,那就加 next-key lock。当然,等值判断的时候,需要加上优化 2(即:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。)。
@小李子、@发条橙子同学,都提了很好的问题,这期高质量评论很多,你也都可以去看看。
23 MySQL是怎么保证数据不丢的?
binlog 的写入机制

- 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
- 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
redo log 的写入机制

- 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
- 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
- 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。
- 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
- 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
- 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。
- **一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。**注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
- **另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。**假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

- trx1 是第一个到达的,会被选为这组的 leader;
- 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
- trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
- 这时候 trx2 和 trx3 就可以直接返回了。

- 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
- 调用 fsync 持久化。

- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
- redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
- 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
- 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
- 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。
小结
- 如果客户端收到事务成功的消息,事务就一定持久化了;
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
- 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。
上期问题时间
- 其中第 3 个问题,“如果一个数据库是被客户端的压力打满导致无法响应的,重启数据库是没用的。”,说明他很好地思考了。 这个问题是因为重启之后,业务请求还会再发。而且由于是重启,buffer pool 被清空,可能会导致语句执行得更慢。
- 他提到的第 4 个问题也很典型。有时候一个表上会出现多个单字段索引(而且往往这是因为运维工程师对索引原理不够清晰做的设计),这样就可能出现优化器选择索引合并算法的现象。但实际上,索引合并算法的效率并不好。而通过将其中的一个索引改成联合索引的方法,是一个很好的应对方案。
@Max 同学提到一个很好的例子:客户端程序的连接器,连接完成后会做一些诸如 show columns 的操作,在短连接模式下这个影响就非常大了。 这个提醒我们,在 review 项目的时候,不止要 review 我们自己业务的代码,也要 review 连接器的行为。一般做法就是在测试环境,把 general_log 打开,用业务行为触发连接,然后通过 general log 分析连接器的行为。
@Manjusaka 同学的留言中,第二点提得非常好:如果你的数据库请求模式直接对应于客户请求,这往往是一个危险的设计。因为客户行为不可控,可能突然因为你们公司的一个运营推广,压力暴增,这样很容易把数据库打挂。 在设计模型里面设计一层,专门负责管理请求和数据库服务资源,对于比较重要和大流量的业务,是一个好的设计方向。
@Vincent 同学提了一个好问题,用文中提到的 DDL 方案,会导致 binlog 里面少了这个 DDL 语句,后续影响备份恢复的功能。由于需要另一个知识点(主备同步协议),我放在后面的文章中说明。
24 MySQL是怎么保证主备一致的?

- 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
- 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
- 可以用 readonly 状态,来判断节点的角色。

- 在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
- 在备库 B 上执行 start slave 命令,这时候备库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
- 主库 A 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。
- 备库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
- sql_thread 读取中转日志,解析出日志里的命令,并执行。
binlog 的三种格式对比
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `t_modified`(`t_modified`)
) ENGINE=InnoDB;
insert into t values(1,1,'2018-11-13');
insert into t values(2,2,'2018-11-12');
insert into t values(3,3,'2018-11-11');
insert into t values(4,4,'2018-11-10');
insert into t values(5,5,'2018-11-09'); mysql> delete from t /*comment*/ where a>=4 and t_modified<='2018-11-10' limit 1; mysql> show binlog events in 'master.000001'; 
- 第一行 SET @@SESSION.GTID_NEXT=‘ANONYMOUS’你可以先忽略,后面文章我们会在介绍主备切换的时候再提到;
- 第二行是一个 BEGIN,跟第四行的 commit 对应,表示中间是一个事务;
- 第三行就是真实执行的语句了。可以看到,在真实执行的 delete 命令之前,还有一个“use ‘test’”命令。这条命令不是我们主动执行的,而是 MySQL 根据当前要操作的表所在的数据库,自行添加的。这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到 test 库的表 t。 use ‘test’命令之后的 delete 语句,就是我们输入的 SQL 原文了。可以看到,binlog“忠实”地记录了 SQL 命令,甚至连注释也一并记录了。
- 最后一行是一个 COMMIT。你可以看到里面写着 xid=61。你还记得这个 XID 是做什么用的吗?如果记忆模糊了,可以再回顾一下[第 15 篇文章]中的相关内容。

- 如果 delete 语句使用的是索引 a,那么会根据索引 a 找到第一个满足条件的行,也就是说删除的是 a=4 这一行;
- 但如果使用的是索引 t_modified,那么删除的就是 t_modified=‘2018-11-09’也就是 a=5 这一行。

- Table_map event,用于说明接下来要操作的表是 test 库的表 t;
- Delete_rows event,用于定义删除的行为。
mysqlbinlog -vv data/master.000001 --start-position=8900; 
- server id 1,表示这个事务是在 server_id=1 的这个库上执行的。
- 每个 event 都有 CRC32 的值,这是因为我把参数 binlog_checksum 设置成了 CRC32。
- Table_map event 跟在图 5 中看到的相同,显示了接下来要打开的表,map 到数字 226。现在我们这条 SQL 语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的 Table_map event、都会 map 到一个单独的数字,用于区分对不同表的操作。
- 我们在 mysqlbinlog 的命令中,使用了 -vv 参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4 这些值)。
- binlog_row_image 的默认配置是 FULL,因此 Delete_event 里面,包含了删掉的行的所有字段的值。如果把 binlog_row_image 设置为 MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录 id=4 这个信息。
- 最后的 Xid event,用于表示事务被正确地提交了。
为什么会有 mixed 格式的 binlog?
- 因为有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。
- 但 row 格式的缺点是,很占空间。比如你用一个 delete 语句删掉 10 万行数据,用 statement 的话就是一个 SQL 语句被记录到 binlog 中,占用几十个字节的空间。但如果用 row 格式的 binlog,就要把这 10 万条记录都写到 binlog 中。这样做,不仅会占用更大的空间,同时写 binlog 也要耗费 IO 资源,影响执行速度。
- 所以,MySQL 就取了个折中方案,也就是有了 mixed 格式的 binlog。mixed 格式的意思是,MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。
mysql> insert into t values(10,10, now()); 

mysqlbinlog master.000001 --start-position=2738 --stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd; 循环复制问题

- 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
- 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
- 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。
- 从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;
- 传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;
- 再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。
小结
上期问题时间
- 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
- 备库延迟,为了让备库尽快赶上主库。@永恒记忆和 @Second Sight 提到了这个场景。
- 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。
- 批量导入数据的时候。
@way 同学提到了一个有趣的现象,由于从库设置了 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 导致一直延迟的情况。我们在主库设置这两个参数,是为了减少 binlog 的写盘压力。备库这么设置,尤其在“快要追上”的时候,就反而会受这两个参数的拖累。一般追主备就用“非双 1”(追上记得改回来)。
@一大只 同学验证了在 sync_binlog=0 的情况下,设置 sync_delay 和 sync_no_delay_count 的现象,点赞这种发现边界的意识和手动验证的好习惯。是这样的:sync_delay 和 sync_no_delay_count 的逻辑先走,因此该等还是会等。等到满足了这两个条件之一,就进入 sync_binlog 阶段。这时候如果判断 sync_binlog=0,就直接跳过,还是不调 fsync。
@锅子 同学提到,设置 sync_binlog=0 的时候,还是可以看到 binlog 文件马上做了修改。这个是对的,我们说“写到了 page cache”,就是文件系统的 page cache。而你用 ls 命令看到的就是文件系统返回的结果。
25 MySQL是怎么保证高可用的?

主备延迟
- 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
- 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;
- 备库 B 执行完成这个事务,我们把这个时刻记为 T3。
- 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
- 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。
主备延迟的来源
- 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
- 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。
备注:这里需要说明一下,从库和备库在概念上其实差不多。在我们这个专栏里,为了方便描述,我把会在 HA 过程中被选成新主库的,称为备库,其他的称为从库。
可靠性优先策略
- 判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
- 把主库 A 改成只读状态,即把 readonly 设置为 true;
- 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;
- 把备库 B 改成可读写状态,也就是把 readonly 设置为 false;
- 把业务请求切到备库 B。

可用性优先策略
mysql> CREATE TABLE `t` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`c` int(11) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(c) values(1),(2),(3); insert into t(c) values(4);
insert into t(c) values(5); 
- 步骤 2 中,主库 A 执行完 insert 语句,插入了一行数据(4,4),之后开始进行主备切换。
- 步骤 3 中,由于主备之间有 5 秒的延迟,所以备库 B 还没来得及应用“插入 c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。
- 步骤 4 中,备库 B 插入了一行数据(4,5),并且把这个 binlog 发给主库 A。
- 步骤 5 中,备库 B 执行“插入 c=4”这个中转日志,插入了一行数据(5,4)。而直接在备库 B 执行的“插入 c=5”这个语句,传到主库 A,就插入了一行新数据(5,5)。

- 使用 row 格式的 binlog 时,数据不一致的问题更容易被发现。而使用 mixed 或者 statement 格式的 binlog 时,数据很可能悄悄地就不一致了。如果你过了很久才发现数据不一致的问题,很可能这时的数据不一致已经不可查,或者连带造成了更多的数据逻辑不一致。
- 主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,我都建议你使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。
- 有一个库的作用是记录操作日志。这时候,如果数据不一致可以通过 binlog 来修补,而这个短暂的不一致也不会引发业务问题。
- 同时,业务系统依赖于这个日志写入逻辑,如果这个库不可写,会导致线上的业务操作无法执行。

小结

上期问题时间

stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
start slave; stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=();
start slave; @一大只、@HuaMax 同学提到了第一个复现方法;
@Jonh 同学提到了 IGNORE_SERVER_IDS 这个解决方法;
@React 提到,如果主备设置不同的步长,备库是不是可以设置为可读写。我的建议是,只要这个节点设计内就不会有业务直接在上面执行更新,就建议设置为 readonly。
26 备库为什么会延迟好几个小时?


- 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
- 同一个事务不能被拆开,必须放到同一个 worker 中。
MySQL 5.5 版本的并行复制策略
按表分发策略

- 由于事务 T 中涉及修改表 t1,而 worker_1 队列中有事务在修改表 t1,事务 T 和队列中的某个事务要修改同一个表的数据,这种情况我们说事务 T 和 worker_1 是冲突的。
- 按照这个逻辑,顺序判断事务 T 和每个 worker 队列的冲突关系,会发现事务 T 跟 worker_2 也冲突。
- 事务 T 跟多于一个 worker 冲突,coordinator 线程就进入等待。
- 每个 worker 继续执行,同时修改 hash_table。假设 hash_table_2 里面涉及到修改表 t3 的事务先执行完成,就会从 hash_table_2 中把 db1.t3 这一项去掉。
- 这样 coordinator 会发现跟事务 T 冲突的 worker 只有 worker_1 了,因此就把它分配给 worker_1。
- coordinator 继续读下一个中转日志,继续分配事务。
- 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
- 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
- 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。
按行分发策略
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `a` (`a`)
) ENGINE=InnoDB;
insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5); 
- key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
- key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表 a=2 的行。
- key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表 a=1 的行。
- 要能够从 binlog 里面解析出表名、主键值和唯一索引的值。也就是说,主库的 binlog 格式必须是 row;
- 表必须有主键;
- 不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。
- 耗费内存。比如一个语句要删除 100 万行数据,这时候 hash 表就要记录 100 万个项。
- 耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的。
- coordinator 暂时先 hold 住这个事务;
- 等待所有 worker 都执行完成,变成空队列;
- coordinator 直接执行这个事务;
- 恢复并行模式。
MySQL 5.6 版本的并行复制策略
- 构造 hash 值的时候很快,只需要库名;而且一个实例上 DB 数也不会很多,不会出现需要构造 100 万个项这种情况。
- 不要求 binlog 的格式。因为 statement 格式的 binlog 也可以很容易拿到库名。
MariaDB 的并行复制策略
- 能够在同一组里提交的事务,一定不会修改同一行;
- 主库上可以并行执行的事务,备库上也一定是可以并行执行的。
- 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
- commit_id 直接写到 binlog 里面;
- 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
- 这一组全部执行完成后,coordinator 再去取下一批。


MySQL 5.7 的并行复制策略
- 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
- 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

- 同时处于 prepare 状态的事务,在备库执行时是可以并行的;
- 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。
- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
MySQL 5.7.22 的并行复制策略
- COMMIT_ORDER,表示的就是前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
- WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
- WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。
- writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
- 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
- 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。
小结
- 如果你是 DBA,就需要根据不同的业务场景,选择不同的策略;
- 如果是你业务开发人员,也希望你能从中获取灵感用到平时的开发工作中。
上期问题时间
-
一种是大事务(包括大表 DDL、一个事务操作很多行);
-
还有一种情况比较隐蔽,就是备库起了一个长事务,比如
begin; select * from t limit 1;
@易翔 、 @万勇、@老杨同志 等同学的回复都提到了我们上面说的场景;
@Max 同学提了一个很不错的问题。主备关系里面,备库主动连接,之后的 binlog 发送是主库主动推送的。之所以这么设计也是为了效率和实时性考虑,毕竟靠备库轮询,会有时间差。
27 主库出问题了,从库怎么办?


基于位点的主备切换
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
MASTER_LOG_FILE=$master_log_name
MASTER_LOG_POS=$master_log_pos - MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。
- 最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。
-
等待新主库 A’把中转日志(relay log)全部同步完成;
-
在 A’上执行 show master status 命令,得到当前 A’上最新的 File 和 Position;
-
取原主库 A 故障的时刻 T;
-
用 mysqlbinlog 工具解析 A’的 File,得到 T 时刻的位点。
mysqlbinlog File —stop-datetime=T —start-datetime=T

- 在从库 B 上,由于同步了 binlog, R 这一行已经存在;
- 在新主库 A’上, R 这一行也已经存在,日志是写在 123 这个位置之后的;
- 我们在从库 B 上执行 change master 命令,指向 A’的 File 文件的 123 位置,就会把插入 R 这一行数据的 binlog 又同步到从库 B 去执行。
set global sql_slave_skip_counter=1;
start slave; - 1062 错误是插入数据时唯一键冲突;
- 1032 错误是删除数据时找不到行。
GTID
GTID=server_uuid:gno - server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;
- gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。
GTID=source_id:transaction_id - 如果 gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。 a. 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’; b. 把这个 GTID 加入本实例的 GTID 集合。
- 如果 gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next=‘current_gtid’指定为 current_gtid,那么就有两种可能: a. 如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略; b. 如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t values(1,1); 
insert into t values(1,1); set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10';
begin;
commit;
set gtid_next=automatic;
start slave; 
基于 GTID 的主备切换
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1 - 实例 B 指定主库 A’,基于主备协议建立连接。
- 实例 B 把 set_b 发给主库 A’。
- 实例 A’算出 set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GITD 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。 a. 如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误; b. 如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B;
- 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。
GTID 和在线 DDL
-
在实例 X 上执行 stop slave。
-
在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。
-
执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。
-
到实例 X 上执行以下语句序列:
set GTID_NEXT=“server_uuid_of_Y:gno”; begin; commit; set gtid_next=automatic; start slave;
- 接下来,执行完主备切换,然后照着上述流程再执行一遍即可。
小结
上期问题时间
@慧鑫 coming 问了一个好问题,对同一行作更新的几个事务,如果 commit_id 相同,是不是在备库并行执行的时候会导致数据不一致?这个问题的答案是更新同一行的事务是不可能同时进入 commit 状态的。 @老杨同志 对这个问题给出了更详细的回答,大家可以去看一下。
28 读写分离有哪些坑?


- 客户端直连方案,因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。 你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。
- 带 proxy 的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。
- 强制走主库方案;
- sleep 方案;
- 判断主备无延迟方案;
- 配合 semi-sync 方案;
- 等主库位点方案;
- 等 GTID 方案。
强制走主库方案
- 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
- 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。
Sleep 方案
- 如果这个查询请求本来 0.5 秒就可以在从库上拿到正确结果,也会等 1 秒;
- 如果延迟超过 1 秒,还是会出现过期读。
判断主备无延迟方案

- Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
- Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。
- Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。
- Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
- Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。
- 主库执行完成,写入 binlog,并反馈给客户端;
- binlog 被从主库发送给备库,备库收到;
- 在备库执行 binlog 完成。

- trx1 和 trx2 已经传到从库,并且已经执行完成了;
- trx3 在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。
配合 semi-sync
- 事务提交的时候,主库把 binlog 发给从库;
- 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
- 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。
- 如果查询是落在这个响应了 ack 的从库上,是能够确保读到最新数据;
- 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。

- 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
- 在持续延迟的情况下,可能出现过度等待的问题。
等主库位点方案
select master_pos_wait(file, pos[, timeout]); - 它是在从库执行的;
- 参数 file 和 pos 指的是主库上的文件名和位置;
- timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。
- 如果执行期间,备库同步线程发生异常,则返回 NULL;
- 如果等待超过 N 秒,就返回 -1;
- 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0。
- trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position;
- 选定一个从库执行查询语句;
- 在从库上执行 select master_pos_wait(File, Position, 1);
- 如果返回值是 >=0 的正整数,则在这个从库执行查询语句;
- 否则,到主库执行查询语句。

GTID 方案
select wait_for_executed_gtid_set(gtid_set, 1); - 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
- 超时返回 1。
- trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
- 选定一个从库执行查询语句;
- 在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);
- 如果返回值是 0,则在这个从库执行查询语句;
- 否则,到主库执行查询语句。



小结
上期问题时间
- 如果业务允许主从不一致的情况,那么可以在主库上先执行 show global variables like ‘gtid_purged’,得到主库已经删除的 GTID 集合,假设是 gtid_purged1;然后先在从库上执行 reset master,再执行 set global gtid_purged =‘gtid_purged1’;最后执行 start slave,就会从主库现存的 binlog 开始同步。binlog 缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。
- 如果需要主从数据一致的话,最好还是通过重新搭建从库来做。
- 如果有其他的从库保留有全量的 binlog 的话,可以把新的从库先接到这个保留了全量 binlog 的从库,追上日志以后,如果有需要,再接回主库。
- 如果 binlog 有备份的情况,可以先在从库上应用缺失的 binlog,然后再执行 start slave。
@悟空 同学级联实验,验证了 seconds_behind_master 的计算逻辑。
@_CountingStars 问了一个好问题:MySQL 是怎么快速定位 binlog 里面的某一个 GTID 位置的?答案是,在 binlog 文件头部的 Previous_gtids 可以解决这个问题。
@王朋飞 同学问了一个好问题,sql_slave_skip_counter 跳过的是一个 event,由于 MySQL 总不能执行一半的事务,所以既然跳过了一个 event,就会跳到这个事务的末尾,因此 set global sql_slave_skip_counter=1;start slave 是可以跳过整个事务的。
29 如何判断一个数据库是不是出问题了?
select 1 判断
set global innodb_thread_concurrency=3;
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t values(1,1) 
- 线程 1 执行 begin; update t set c=c+1 where id=1, 启动了事务 trx1, 然后保持这个状态。这时候,线程处于空闲状态,不算在并发线程里面。
- 线程 2 到线程 129 都执行 update t set c=c+1 where id=1; 由于等行锁,进入等待状态。这样就有 128 个线程处于等待状态;
- 如果处于锁等待状态的线程计数不减一,InnoDB 就会认为线程数用满了,会阻止其他语句进入引擎执行,这样线程 1 不能提交事务。而另外的 128 个线程又处于锁等待状态,整个系统就堵住了。

查表判断
mysql> select * from mysql.health_check; 更新判断
mysql> update mysql.health_check set t_modified=now(); mysql> CREATE TABLE `health_check` (
`id` int(11) NOT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
/* 检测命令 */
insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now(); 内部统计

mysql> update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%'; mysql> select event_name,MAX_TIMER_WAIT FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000; mysql> truncate table performance_schema.file_summary_by_event_name; 小结
上期问题时间
@曾剑、@max 同学提到的备库先做,再切主库的方法也是可以的。
30 答疑文章(二):用动态的观点看加锁
- 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
- 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
- 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25); 不等号条件里的等值查询
begin;
select * from t where id>9 and id<12 order by id desc for update; 
- 首先这个查询语句的语义是 order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个 id<12 的值”。
- 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到 id=12 的这个值,只是最终没找到,但找到了 (10,15) 这个间隙。
- 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到 id=5 这一行,所以会加一个 next-key lock (0,5]。
等值查询的过程
begin;
select id from t where c in(5,20,10) lock in share mode; 
select id from t where c in(5,20,10) order by c desc for update; 怎么看死锁?

- 这个结果分成三部分:
- (1) TRANSACTION,是第一个事务的信息;
- (2) TRANSACTION,是第二个事务的信息;
- WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
- 第一个事务的信息中:
- WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;
- index c of table
test.t,说明在等的是表 t 的索引 c 上面的锁; - lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
- Record lock 说明这是一个记录锁;
- n_fields 2 表示这个记录是两列,也就是字段 c 和主键字段 id;
- 0: len 4; hex 0000000a; asc ;; 是第一个字段,也就是 c。值是十六进制 a,也就是 10;
- 1: len 4; hex 0000000a; asc ;; 是第二个字段,也就是主键 id,值也是 10;
- 这两行里面的 asc 表示的是,接下来要打印出值里面的“可打印字符”,但 10 不是可打印字符,因此就显示空格。
- 第一个事务信息就只显示出了等锁的状态,在等待 (c=10,id=10) 这一行的锁。
- 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
- 第二个事务显示的信息要多一些:
- “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
- index c of table
test.t表示锁是在表 t 的索引 c 上; - hex 0000000a 和 hex 00000014 表示这个事务持有 c=10 和 c=20 这两个记录锁;
- WAITING FOR THIS LOCK TO BE GRANTED,表示在等 (c=5,id=5) 这个记录锁。
- “lock in share mode”的这条语句,持有 c=5 的记录锁,在等 c=10 的锁;
- “for update”这个语句,持有 c=20 和 c=10 的记录锁,在等 c=5 的记录锁。
- 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
- 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以 InnoDB 选择了回滚成本更小的 lock in share mode 语句,来回滚。
怎么看锁等待?


- index PRIMARY of table
test.t,表示这个语句被锁住是因为表 t 主键上的某个锁。 - lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
- insert intention 表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。
- gap before rec 表示这是一个间隙锁,而不是记录锁。
- 那么这个 gap 是在哪个记录之前的呢?接下来的 0~4 这 5 行的内容就是这个记录的信息。
- n_fields 5 也表示了,这一个记录有 5 列:
- 0: len 4; hex 0000000f; asc ;; 第一列是主键 id 字段,十六进制 f 就是 id=15。所以,这时我们就知道了,这个间隙就是 id=15 之前的,因为 id=10 已经不存在了,它表示的就是 (5,15)。
- 1: len 6; hex 000000000513; asc ;; 第二列是长度为 6 字节的事务 id,表示最后修改这一行的是 trx id 为 1299 的事务。
- 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为 7 字节的回滚段信息。可以看到,这里的 acs 后面有显示内容 (% 和 4),这是因为刚好这个字节是可打印字符。
- 后面两列是 c 和 d 的值,都是 15。
- session A 执行完 select 语句后,什么都没做,但它加锁的范围突然“变大”了;
- 第 21 篇文章的课后思考题,当我们执行 select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到 c=10 的时候,要把 (5, 10] 锁起来。
update 的例子

注意:根据 c>5 查到的第一个记录是 c=10,因此不会加 (0,5] 这个 next-key lock。
- 插入 (c=1, id=5) 这个记录;
- 删除 (c=5, id=5) 这个记录。

- 插入 (c=5, id=5) 这个记录;
- 删除 (c=1, id=5) 这个记录。
小结
上期问题时间
- @老杨同志 回答得很详细。他的主要思路就是关于服务状态和服务质量的监控。其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。
- @Ryoma 同学,提到服务中使用了 healthCheck 来检测,其实跟我们文中提到的 select 1 的模式类似。
- @强哥 同学,按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。
31 误删数据后除了跑路,还能怎么办?
- 使用 delete 语句误删数据行;
- 使用 drop table 或者 truncate table 语句误删数据表;
- 使用 drop database 语句误删数据库;
- 使用 rm 命令误删整个 MySQL 实例。
误删行
- 对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;
- 同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;
- 而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。
(A)delete ...
(B)insert ...
(C)update ... (reverse C)update ...
(reverse B)delete ...
(reverse A)insert ... - 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
- 代码上线前,必须经过 SQL 审计。
误删库 / 表
- 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
- 用备份恢复出一个临时库;
- 从日志备份里面,取出凌晨 0 点之后的日志;
- 把这些日志,除了误删除数据的语句外,全部应用到临时库。

- 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用 mysqlbinlog 命令时,加上一个–database 参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其他库日志的情况。
- 在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:
- 如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用–stop-position 参数执行到误操作之前的日志,然后再用–start-position 从误操作之后的日志继续执行;
- 如果实例使用了 GTID 模式,就方便多了。假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。
- 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是 mysqlbinlog 工具并不能指定只解析一个表的日志;
- 用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。我们在[第 26 篇文章]中介绍的那些并行复制的方法,在这里都用不上。
- 在 start slave 之前,先通过执行 change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表;
- 这样做也可以用上并行复制技术,来加速整个数据恢复过程。

- 从备份系统下载 master.000005 和 master.000006 这两个文件,放到备库的日志目录下;
- 打开日志目录下的 master.index 文件,在文件开头加入两行,内容分别是 “./master.000005”和“./master.000006”;
- 重启备库,目的是要让备库重新识别这两个日志文件;
- 现在这个备库上就有了临时库需要的所有 binlog 了,建立主备关系,就可以正常同步了。
- 虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。
- 而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。
延迟复制备库
预防误删库 / 表的方法
- 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
- 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。
- 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
- 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。
rm 删除数据
小结
上期问题时间
begin;
select * from t where id>1 for update; 

@老杨同志 给出了正确的分析和 SQL 语句验证方法; @库淘淘 指出了 show engine innodb status 验证结论。
32 为什么还有kill不掉的语句?

收到 kill 以后,线程做什么?
其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。
- 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
- 给 session B 的执行线程发一个信号。
- 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;
- 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
- 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。

- sesssion C 执行的时候被堵住了;
- 但是 session D 执行的 kill query C 命令却没什么效果,
- 直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”,
- 但是这时候,如果在 session E 中执行 show processlist,你就能看到下面这个图。

- 把 12 号线程状态设置为 KILL_CONNECTION;
- 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示。
如果一个线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。 - 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
- 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
- DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。
另外两个关于客户端的误解

- 执行 show databases;
- 切到 db1 库,执行 show tables;
- 把这两个命令的结果用于构建一个本地的哈希表。
- 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是 mysql_store_result 方法。
- 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。
- 第一点,就是前面提到的,跳过表名自动补全功能。
- 第二点,mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;
- 第三点,是不会把执行命令记录到本地的命令历史文件。
小结
上期问题时间
33 我查这么多数据,会不会把数据库内存打爆?
全表扫描对 server 层的影响
mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file - 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
- 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
- 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
- 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

- 一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G;
- socket send buffer 也不可能达到 200G(默认定义 /proc/sys/net/core/wmem_default),如果 socket send buffer 被写满,就会暂停读数据的流程。

- MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
- 然后,发送执行结果的列相关的信息(meta data) 给客户端;
- 再继续执行语句的流程;
- 执行完成后,把状态设置成空字符串。


全表扫描对 InnoDB 的影响


- 在图 6 的状态 1 里,链表头部是 P1,表示 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
- 这时候有一个读请求访问 P3,因此变成状态 2,P3 被移到最前面;
- 状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部。
- 从效果上看,就是最久没有被访问的数据页 Pm,被淘汰了。

- 图 7 中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。
- 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处。
- 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:
- 若这个数据页在 LRU 链表中存在的时间超过了 1 秒,就把它移动到链表头部;
- 如果这个数据页在 LRU 链表中存在的时间短于 1 秒,位置保持不变。1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。
- 扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
- 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;
- 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。
小结
上期问题时间
@HuaMax 的回答中提到了对其他线程的影响; @夹心面包 @Ryoma @曾剑 同学提到了重启后依然继续做回滚操作的逻辑。
34 到底可不可以使用join?
- 我们 DBA 不让使用 join,使用 join 有什么问题呢?
- 如果有两个大小不同的表做 join,应该用哪个表做驱动表呢?
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
create table t1 like t2;
insert into t1 (select * from t2 where id<=100) Index Nested-Loop Join
select * from t1 straight_join t2 on (t1.a=t2.a); 
- 从表 t1 中读入一行数据 R;
- 从数据行 R 中,取出 a 字段到表 t2 里去查找;
- 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
- 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

- 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
- 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
- 所以,整个执行流程,总扫描行数是 200。
- 执行
select * from t1,查出表 t1 的所有数据,这里有 100 行; - 循环遍历这 100 行数据:
- 从每一行 R 取出字段 a 的值 $R.a;
- 执行
select * from t2 where a=$R.a; - 把返回的结果和 R 构成结果集的一行。
如果你没觉得这个影响有那么“显然”, 可以这么理解:N 扩大 1000 倍的话,扫描行数就会扩大 1000 倍;而 M 扩大 1000 倍,扫描行数扩大不到 10 倍。
- 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;
- 如果使用 join 语句的话,需要让小表做驱动表。
Simple Nested-Loop Join
select * from t1 straight_join t2 on (t1.a=t2.b); Block Nested-Loop Join
- 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
- 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。


- 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
- 内存中的判断次数是 M*N。
select * from t1 straight_join t2 on (t1.a=t2.b); - 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
- 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
- 清空 join_buffer;
- 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。

- 扫描行数是 N+λ*N*M;
- 内存判断 N*M 次。
- 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
- 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。
- 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
- 如果是 Block Nested-Loop Join 算法:
- 在 join_buffer_size 足够大的时候,是一样的;
- 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。
select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50; select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100; - 表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
- 表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。
小结
- 如果可以使用被驱动表的索引,join 语句还是有其优势的;
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
- 在使用 join 的时候,应该让小表做驱动表。
上期问题时间
- 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;
- 当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀。
@老杨同志 提到了更新之间会互相等锁的问题。同一个事务,更新之后要尽快提交,不要做没必要的查询,尤其是不要执行需要返回大量数据的查询; @长杰 同学提到了 undo 表空间变大,db 服务堵塞,服务端磁盘空间不足的例子。
35 join语句怎么优化?
create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, 1001-i, i);
set i=i+1;
end while;
set i=1;
while(i<=1000000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata(); Multi-Range Read 优化
select * from t1 where a>=1 and a<=100; 
- 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
- 将 read_rnd_buffer 中的 id 进行递增排序;
- 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。
set optimizer_switch="mrr_cost_based=off"。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)

Batched Key Access


set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; BNL 算法的性能问题
- 可能会多次扫描被驱动表,占用磁盘 IO 资源;
- 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
- 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。
BNL 转 BKA
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000; - 把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行,join_buffer_size 默认值是 256k,可以完全存入。
- 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比,
- 如果不满足 t1.b=t2.b,则跳过;
- 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于 [1,2000] 的条件,如果是,就作为结果集的一部分返回,否则跳过。


- 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
- 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引;
- 让表 t1 和 tmp_t 做 join 操作。
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b); 
- 执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是 100 万。
- 之后的 join 语句,扫描表 t1,这里的扫描行数是 1000;join 比较过程中,做了 1000 次带索引的查询。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。
扩展 -hash join
select * from t1;取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构,比如 C++ 里的 set、PHP 的 dict 这样的数据结构。select * from t2 where b>=1 and b<=2000;获取表 t2 中满足条件的 2000 行数据。- 把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。
小结
- BKA 优化是 MySQL 已经内置支持的,建议你默认使用;
- BNL 算法效率低,建议你都尽量转成 BKA 算法。优化的方向就是给被驱动表的关联字段加上索引;
- 基于临时表的改进方案,对于能够提前过滤出小数据的 join 语句来说,效果还是很好的;
- MySQL 目前的版本还不支持 hash join,但你可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
create table t2 like t1;
create table t3 like t2;
insert into ... // 初始化三张表的数据 select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z; 上期问题时间
- @长杰 和 @老杨同志 提到了数据量小于 old 区域内存的情况;
- @Zzz 同学,很认真地看了其他同学的评论,并且提了一个很深的问题。对被驱动表数据量大于 Buffer Pool 的场景,做了很细致的推演和分析。
36 为什么临时表可以重名?
create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b); - 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。
- 而临时表,可以使用各种引擎类型 。如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用 Memory 引擎。
临时表的特性

- 建表语法是 create temporary table …。
- 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
- 临时表可以与普通表同名。
- session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
- show tables 命令不显示临时表。
- 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
- 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。
临时表的应用

select v from ht where f=N; select v from ht where k >= M order by t_modified desc limit 100; - 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
- 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。
-
在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
-
在各个分库上执行
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
-
把分库执行的结果插入到 temp_ht 表中;
-
执行
select v from temp_ht order by t_modified desc limit 100;

为什么临时表可以重名?
create temporary table temp_t(id int primary key)engine=innodb; - 在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件;
- 而从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据。因此,我们就不需要再创建 ibd 文件了。

- 一个普通表的 table_def_key 的值是由“库名 + 表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
- 而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。
临时表和主备复制
create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/ DROP TABLE `t_normal` /* generated by server */ DROP TABLE `t_normal` /* generated by server */ 
- session A 的临时表 t1,在备库的 table_def_key 就是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
- session B 的临时表 t1,在备库的 table_def_key 就是 :库名 +t1+“M 的 serverid”+“session B 的 thread_id”。
小结

上期问题时间
select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z; - 如果驱动表是 t1,则连接顺序是 t1->t2->t3,要在被驱动表字段创建上索引,也就是 t2.a 和 t3.b 上创建索引;
- 如果驱动表是 t3,则连接顺序是 t3->t2->t1,需要在 t2.b 和 t1.a 上创建索引。
@库淘淘 做了实验验证; @poppy 同学做了很不错的分析; @dzkk 同学在评论中介绍了 MariaDB 支持的 hash join,大家可以了解一下; @老杨同志提了一个好问题,如果语句使用了索引 a,结果还要对 a 排序,就不用 MRR 优化了,否则回表完还要增加额外的排序过程,得不偿失。
37 什么时候会使用内部临时表?
union 执行流程
create table t1(id int primary key, a int, b int, index(a));
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata(); (select 1000 as f) union (select id from t1 order by id desc limit 2); 
- 第二行的 key=PRIMARY,说明第二个子句用到了索引 id。
- 第三行的 Extra 字段,表示在对子查询的结果集做 union 的时候,使用了临时表 (Using temporary)。
- 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段。
- 执行第一个子查询,得到 1000 这个值,并存入临时表中。
- 执行第二个子查询:
- 拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
- 取到第二行 id=999,插入临时表成功。
- 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。


group by 执行流程
select id%10 as m, count(*) as c from t1 group by m; 
- Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
- Using temporary,表示使用了临时表;
- Using filesort,表示需要排序。
- 创建内存临时表,表里有两个字段 m 和 c,主键是 m;
- 扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
- 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
- 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
- 遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端。



select id%10 as m, count(*) as c from t1 group by m order by null; 
set tmp_table_size=1024;
select id%100 as m, count(*) as c from t1 group by m order by null limit 10; 
group by 优化方法 – 索引

- 当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
- 当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);
alter table t1 add column z int generated always as(id % 100), add index(z); select z, count(*) as c from t1 group by z; 
group by 优化方法 – 直接排序
select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m; - 初始化 sort_buffer,确定放入一个整型字段,记为 m;
- 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
- 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
- 排序完成后,就得到了一个有序数组。


- 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
- join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
- 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。
小结
- 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
- 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
- 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
- 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。
上期问题时间
@poppy 同学,通过执行语句的报错现象推测了这个实现过程。
38 都说InnoDB好,那还要不要使用Memory引擎?
内存表的数据组织结构
create table t1(id int primary key, c int) engine=Memory;
create table t2(id int primary key, c int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0); 


- InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
- 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。
- InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
- 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值;
- 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引;
- InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
- InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。
delete from t1 where id=5;
insert into t1 values(10,10);
select * from t1; select * from t1 where id<5; hash 索引和 B-Tree 索引
alter table t1 add index a_btree_index using btree (id); 

- 锁粒度问题;
- 数据持久化问题。
内存表的锁


数据持久性问题

- 业务正常访问主库;
- 备库硬件升级,备库重启,内存表 t1 内容被清空;
- 备库重启后,客户端发送一条 update 语句,修改表 t1 的数据行,这时备库应用线程就会报错“找不到要更新的行”。

- 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB 支持行锁,并发度比内存表好;
- 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读 QPS 很高并且数据量不大的表,即使是使用 InnoDB,数据也是都会缓存在 InnoDB Buffer Pool 里的。因此,使用 InnoDB 表的读性能也不会差。
- 临时表不会被其他线程访问,没有并发性的问题;
- 临时表重启后也是需要删除的,清空数据这个问题不存在;
- 备库的临时表也不会影响主库的用户线程。
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b); - 相比于 InnoDB 表,使用内存表不需要写磁盘,往表 temp_t 的写数据的速度更快;
- 索引 b 使用 hash 索引,查找的速度比 B-Tree 索引快;
- 临时表数据只有 2000 行,占用的内存有限。
create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b); 
小结
上期问题时间
@老杨同志、@poppy、@长杰 这三位同学给出了正确答案,春节期间还持续保持跟进学习,给你们点赞。
39 自增主键为什么不是连续的?
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB; 自增值保存在哪儿?

- MyISAM 引擎的自增值保存在数据文件中。
- InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:
- 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。 举例来说,如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,我们删除 id=10 的行,AUTO_INCREMENT 还是 11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
- 在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。
自增值修改机制
- 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
- 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。
- 如果 X,那么这个表的自增值不变;
- 如果 X≥Y,就需要把当前自增值修改为新的自增值。
备注:在一些场景下,使用的就不全是默认值。比如,双 M 的主备结构里要求双写的时候,我们就可能会设置成 auto_increment_increment=2,让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数,避免两个库生成的主键发生冲突。
- 如果准备插入的值 >= 当前自增值,新的自增值就是“准备插入的值 +1”;
- 否则,自增值不变。
自增值的修改时机
insert into t values(null, 1, 1); - 执行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
- InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
- 将传入的行的值改成 (2,1,1);
- 将表的自增值改成 3;
- 继续执行插入数据操作,由于已经存在 c=1 的记录,所以报 Duplicate key error,语句返回。


insert into t values(null,1,1);
begin;
insert into t values(null,2,2);
rollback;
insert into t values(null,2,2);
// 插入的行是 (3,2,2) - 假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,之后继续执行。
- 事务 B 正确提交了,但事务 A 出现了唯一键冲突。
- 如果允许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现这样的情况:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
- 接下来,继续执行的其他事务就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。
- 每次申请 id 之前,先判断表里面是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的成本很高。因为,本来申请 id 是一个很快的操作,现在还要再去主键索引树上判断 id 是否存在。
- 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。
自增锁的优化
- 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
- 这个参数的值被设置为 1 时:
- 普通 insert 语句,自增锁在申请之后就马上释放;
- 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
- 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁。

- session B 先插入了两个记录,(1,1,1)、(2,2,2);
- 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
- 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。
- 一种思路是,让原库的批量插入数据语句,固定生成连续的 id 值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
- 另一种思路是,在 binlog 里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是 innodb_autoinc_lock_mode 设置为 2,同时 binlog_format 设置为 row。
- 语句执行过程中,第一次申请自增 id,会分配 1 个;
- 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
- 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
- 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
insert into t2(c,d) select c,d from t;
insert into t2 values(null, 5,5); 小结
上期问题时间
set sql_log_bin=off;
alter table tbl_name engine=innodb; 大家在春节期间还坚持看专栏,并且深入地思考和回复,给大家点赞。 @长杰 同学提到的将数据保存到 InnoDB 表用来持久化,也是一个方法。不过,我还是建议釜底抽薪,直接修改备库的内存表的引擎。 @老杨同志 提到的是主库异常重启的场景,这时候是不会报主备不一致的,因为主库重启的时候写了 delete from tbl_name,主备的内存表都清空了。
40 insert语句的锁为什么这么多?
insert … select 语句
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t insert into t2(c,d) select c,d from t; 
insert into t values(-1,-1,-1);
insert into t2(c,d) select c,d from t; insert 循环写入
insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1); 
insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1); 


- 创建临时表,表里有两个字段 c 和 d。
- 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
- 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。
create temporary table temp_t(c int,d int) engine=memory;
insert into temp_t (select c+1, d from t force index(c) order by c desc limit 1);
insert into t select * from temp_t;
drop table temp_t; insert 唯一键冲突

备注:这个 bug,是我在写这篇文章查阅文档时发现的,已经发给官方并被 verified 了。

- 在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(如果你的印象模糊了,可以回顾下[第 21 篇文章]介绍的加锁规则)。
- 在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,session C 也在索引 c 上,c=5 这一个记录上,加了读锁。
- T3 时刻,session A 回滚。这时候,session B 和 session C 都试图继续执行插入操作,都要加上写锁。两个 session 都要等待对方的行锁,所以就出现了死锁。

insert into … on duplicate key update
insert into t values(11,10,10) on duplicate key update d=100; 
小结
上期问题时间
@长杰 同学回答得非常准确。
41 怎么最快地复制一张表?
create database db1;
use db1;
create table t(id int primary key, a int, b int, index(a))engine=innodb;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t values(i,i,i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
create database db2;
create table db2.t like db1.t mysqldump 方法
mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql - –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
- –add-locks 设置为 0,表示在输出的文件结果里,不增加” LOCK TABLES
tWRITE;” ; - –no-create-info 的意思是,不需要导出表结构;
- –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
- –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。

mysql -h127.0.0.1 -P13000 -uroot db2 -e "source /client_tmp/t.sql" - 打开文件,默认以分号为结尾读取一条条的 SQL 语句;
- 将 SQL 语句发送到服务端执行。
导出 CSV 文件
select * from db1.t where a>900 into outfile '/server_tmp/t.csv'; - 这条语句会将结果保存在服务端。如果你执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器的临时目录下是不会生成 t.csv 文件的。
- into outfile 指定了文件的生成位置(/server_tmp/),这个位置必须受参数 secure_file_priv 的限制。参数 secure_file_priv 的可选值和作用分别是:
- 如果设置为 empty,表示不限制文件生成的位置,这是不安全的设置;
- 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
- 如果设置为 NULL,就表示禁止在这个 MySQL 实例上执行 select … into outfile 操作。
- 这条命令不会帮你覆盖文件,因此你需要确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。
- 这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。
load data infile '/server_tmp/t.csv' into table db2.t; - 打开文件 /server_tmp/t.csv,以制表符 (\t) 作为字段间的分隔符,以换行符(\n)作为记录之间的分隔符,进行数据读取;
- 启动事务。
- 判断每一行的字段数与表 db2.t 是否相同:
- 若不相同,则直接报错,事务回滚;
- 若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
- 重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。
- 主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中。
- 往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE
db2.t。 - 把这个 binlog 日志传到备库。
- 备库的 apply 线程在执行这个事务日志时: a. 先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中; b. 再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。

- 不加“local”,是读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
- 加上“local”,读取的是客户端的文件,只要 mysql 客户端有访问这个文件的权限即可。这时候,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。
mysqldump -h$host -P$port -u$user ---single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv 物理拷贝方法
- 执行 create table r like t,创建一个相同表结构的空表;
- 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
- 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
- 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
- 执行 unlock tables,这时候 t.cfg 文件会被删除;
- 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

- 在第 3 步执行完 flsuh table 命令之后,db1.t 整个表处于只读状态,直到执行 unlock tables 命令后才释放读锁;
- 在执行 import tablespace 的时候,为了让文件里的表空间 id 和数据字典中的一致,会修改 r.ibd 的表空间 id。而这个表空间 id 存在于每一个数据页中。因此,如果是一个很大的文件(比如 TB 级别),每个数据页都需要修改,所以你会看到这个 import 语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import 语句的耗时是非常短的。
小结
- 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
- 必须是全表拷贝,不能只拷贝部分数据;
- 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
- 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
- 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
- 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。
上期问题时间
42 grant之后要跟着flush privileges吗?
create user 'ua'@'%' identified by 'pa'; - 磁盘上,往 mysql.user 表里插入一行,由于没有指定权限,所以这行数据上所有表示权限的字段的值都是 N;
- 内存里,往数组 acl_users 里插入一个 acl_user 对象,这个对象的 access 字段值为 0。

全局权限
grant all privileges on *.* to 'ua'@'%' with grant option; - 磁盘上,将 mysql.user 表里,用户’ua’@’%‘这一行的所有表示权限的字段的值都修改为‘Y’;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 1”。
- grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
- 对于一个已经存在的连接,它的全局权限不受 grant 命令的影响。
revoke all privileges on *.* from 'ua'@'%'; - 磁盘上,将 mysql.user 表里,用户’ua’@’%‘这一行的所有表示权限的字段的值都修改为“N”;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 的值修改为 0。
db 权限
grant all privileges on db1.* to 'ua'@'%' with grant option; - 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为“Y”;
- 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为“全 1”。


表权限和列权限
create table db1.t1(id int, a int);
grant all privileges on db1.t1 to 'ua'@'%' with grant option;
GRANT SELECT(id), INSERT (id,a) ON mydb.mytbl TO 'ua'@'%' with grant option; flush privileges 使用场景


- T4 时刻给用户 ua 赋权限失败,因为 mysql.user 表中找不到这行记录;
- 而 T5 时刻要重新创建这个用户也不行,因为在做内存判断的时候,会认为这个用户还存在。
小结
grant super on *.* to 'ua'@'%' identified by 'pa'; - 如果用户’ua’@’%‘不存在,就创建这个用户,密码是 pa;
- 如果用户 ua 已经存在,就将密码修改成 pa。
上期问题时间
mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd @poppy 、@库淘淘 两位同学提到了第一个场景; @王显伟 @lionetes 两位同学帮忙回答了 @undifined 同学的疑问,拷贝出来的文件要确保 MySQL 进程可以读。
直播回顾 林晓斌:我的 MySQL 心路历程
- 我和 MySQL 打交道的经历;
- 你为什么要了解数据库原理;
- 我建议的 MySQL 学习路径;
- DBA 的修炼之道。
我的经历
以丰富的经历进入百度
开始尝试看源码解决问题
混社区分享经验
为什么要了解数据库原理?
了解原理能帮你更好地定位问题
了解原理能让你更巧妙地解决问题
看得懂源码让你有更多的方法
MySQL 学习路径

DBA 的修炼
DBA 和开发工程师有什么相同点?
DBA 有前途吗?
有哪些比较好的习惯和提高 SQL 效率的方法?
看源码需要什么技术?
怎么学习 C、C++?
学数据库要保持什么心态?
作者: OnlyWaitY 发表日期:2023 年 11 月 1 日