这篇内容是我读完林晓斌老师的《MySQL实战45讲》的一个总结,第一次粗读了一遍,第二次精读并写了这篇总结文档。
林晓斌老师的这个专栏,质量很高,感觉把以前的很多疑问都串起来了,看完对MySQL有了一个整体的认知,值得推荐!
总结中也包含了自己学习时的一些思考,都有用高亮块标注。不过csdn不支持语雀的高亮块,显示成了:::info格式。
SQL 的执行流程
Mysql 的架构 Server 层、存储引擎层
SQL 执行路径上,一般描述都是简化模型四件套 +缓存 也就是<font style="color:rgb(59, 67, 81);">连接器、分析器、优化器、执行器、缓存</font>
对于 Server 层按功能模块 还可以分为:<font style="color:rgb(59, 67, 81);">系统管理控制工具、连接池、SQL 接口、解析器、优化器、执行器、 缓存</font>
差异项:SQL 接口和系统管理控制工具
- 系统管理控制工具 :这指的是MySQL Server本身的管理工具集,如备份恢复(
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">mysqldump</font>)、集群管理、配置管理等。它不属于SQL语句处理的核心数据通路,而是支撑整个系统运行的周边工具。 - SQL 接口 :(贯穿各层,但贴近连接器)
- 负责接收客户端发送的各种SQL命令(DML, DDL, 存储过程调用等),并将最终结果返回给客户端。它是MySQL与客户端通信的协议端点,可以看作是连接器之后、分析器之前的入口/出口。
- 命令分发与简单 SQL 的直接处理,连接器类似迎宾员、SQL 接口类似点餐员,要一杯水可以直接满足,要其他菜品则分发到后厨处理
SQL 的查询过程
8.0 已移除弊大于利的查询缓存
:::info
Query Cache 查询缓存 5.7.20 开始标记删除,8.0.3 查询缓存被正式移除
Server 层的权限缓存、表定义缓存(Table Definition Cache)仍然保留
:::
查询缓存以查询语句作为 Key,查询结果作为 value,key 对应 sql 中任何一张表的更新,都会是的查询失效。
所以高并发、读写混合的场景下,缓存命中率极低,频繁的失效和重建反而带来额外的性能损耗
分析器、优化器、执行器
分析器
- **词法分析:**解析 SQL 中的关键字,判断 SQL 类型,映射表明字段、等查询参数。
- 语法分析:词法分析结束进行语法分析,判断 SQL 是否满足 Mysql 语法,是否是可执行的
优化器:确定 SQL 的执行方案
- 决定使用哪个索引!存在 join 时决定各个表的链接顺序
执行器:进行执行前的权限验证,调用存储引擎的接口获取数据
MySql 的日志系统
redo log、undo log 、Binary Log
MySQL 三大日志深度解析:Redo Log、Undo Log 与 Binary Log 的协同之道
redo log 和 binary log 的两阶段提交
- t1:客户端 commit
- t2:向 innoDB 发送 prepard 请求, innod redolog 刷盘,事务状态=prepard
- t3:server 尝试写 binlog
- t4:更新事务状态为 commited
- 崩溃恢复时,只要 binlog 写成功了,就可以恢复正确的状态。
事务如何互相隔离
undo log 保存了啥
- 事务 id
- 主键
- 数据修改前的旧值
- 回滚指针 指向更早一条的 undo 记录,形成 undo 链
- 操作类型
undo log 什么时候删除
在不需要的时候才会删除,也就是所有的事务的快照启动时间之前的 undo log 会被删除!就是当系统里没有比这个回滚日志更早的 read-view 的时候。
undo log 实现的 快照读
Mysql 中每条记录更新的时候,都会同步记录一条 undo log,当前事务查询时,会沿着 undo log 数据链,查询到快照创建时的数据版本。
不同隔离级别的快照什么时候创建
:::color1
begin /start transaction 时事务不会在启动的时候创建,而是在执行到第一个操作 InnoDB 表的语句的时候才真正的启动。
如果希望马上启动事务,则可以使用 START TRANSACTION with consistent SNAPSHOT
:::
**可重复读:**第一个 InnoDb 语句时确定一致性视图
**读已提交:**每条 SQL 执行的时候都创建一个新的视图快照
快照在 MVCC 里如何工作的
一致性视图 consistend read view 用来支持 RC 和 RR 的隔离级别
它没有物理结构,他的作用只是事务执行期间用来定义"我能看到什么数据"
- 每个事务有个唯一的事务 id
transaction_id、每行数据更新也有个新的数据版本,这个数据版本会记录这个更新事物的 idrow trx_id - innoDB 给每个事务构造了一个数组,保存事务启动瞬间,当前正在活跃的所有事务 id
- 数组的事务 id 最小值,作为低水位,系统的最大事务 id+1 记录为高水位 用这两个事务标记值就组成了当前事务的一致性视图
- 如果事务查找到数据 发现 row trx id 小于低水位事务 id,则数据在事务开始前提交,数据可见
- 如果row trx id 超过高水位,则说明数据是当前事务之后提交的,数据不可见,需要从 undo log 链往前计算到可见的版本
- 如果 row trx id 在高低水位之间,如果 trx_id 在数组中,则说明是启动后提交的,数据不可见。如果 trx_id 不在数组中,则说明是开始前就已经提交的事务,数据可见。
数据更新时的当前读
- 更新数据时都是先读后写,这个读只能读当前的值!成为当前读 current read
- 如果查询数据时使用 FOR UPDATE 或者 FOR SHARE / LOCK IN SAHRE MODE(老版本) 也是当前读
- 如果更新数据也使用快照读,就是去改历史数据了,这样更新就丢失了,所以必须使用当前读!
索引
索引的出现其实就是为了提高数据查询的效率,就像书的目录一样
索引维护
页分裂: B+树索引为了维护索引的有序性,所以会在插入新值的时候做必要的维护!除了逻辑上挪动前后数据,有时候甚至要沈琴要给新的数据页 然后挪动部分数据过去
**页合并:**当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程
从索引上看主键递增的优势:每一次插入都是追加操作不涉及挪动其他记录和页分裂
主键字段越小越好:主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间就越小,搜索就越快。
最左前缀原则、索引覆盖、索引合并和索引下推
全局锁和表锁
MySQL 里面的锁可以分为全局锁、表级锁、行锁三类
全局锁
全局锁是对整个数据库实例施加读锁,命令是 Flush tables with read lock 缩写为 FTWRL可以让整个库的实例 处于只读状态。所有的数据更新语句、DDL 语句、事务提交语句都会被阻塞。
典型用法 :全库逻辑备份时获取一致性视图,但是不推荐使用因为会导致整个数据库无法写入,业务停摆。
**替代全局所得一致性视图:**如果数据库所有表都是 InnoDB 引擎,也就是支持可重复读的隔离级别,可以使用 mysqldump --single-transaction 来实现一致性视图。如果存在 MyISAM 这种存储引擎的表,如果可以转换成 InnoDB 则推荐转换,否则就只能使用全库表的读锁 <font style="color:rgb(17, 17, 51);background-color:rgba(175, 184, 193, 0.2);">--lock-all-tables </font>来保证一致性了,这会导致无法写入。
:::color1
Mysql 5.6 开始,InnoDB 也支持全文索引了,MySQL 8 更是全面优化过,MyISAM 基本要被淘汰了,因为官方推荐 Innodb 是通用首选,社区和 oracle 维护重心也全在 InnoDB
:::
表级锁
表级锁可以分为普通表锁,和元数据锁 meta data lock (MDL)
表锁
表锁是没有更细粒度锁之前的无奈之举,对于 InnoDB 引擎来说,有更细粒度的行锁、间隙锁一般不会使用表锁来控制并发。
可以通过 lock tables ... read/write 来施加读锁、写锁。
- read 读锁,是一个共享锁,write 锁是一个排他锁
- 使用 lock tables 之后,当前会话只能访问显示锁定的表。例如 LOCK TABLES orders READ 锁定了订单表,再去 SELECT * FROM customer 表就会报错。
元数据锁 meta data lock
DML 的共享锁:分为共享写锁和读锁。SR/SW 不需要显式的使用,在访问一个表的时候会被自动的加上,用来保证增删改查过程中,表的结构定义不会发生改变。
MDL X 锁:写锁和读锁互斥。每次要对表结构做变更操作的时候,就会施加 MDL 写锁。
- 5.5 引入的 MDL 锁,在 5.7 之前的版本中,有写锁优先策略,一个等待中的 DML 写锁会阻塞后续的 DML 读锁和写锁。这一操作是为了防止 DDL 写操作一致获取不到 DML 锁被饿死。
- 5.7 版本优化:不会无脑阻塞后面的 DML 读锁,而是会智能插队,更公平。
安全的变更表结构
- 提前 kill 长事务,避免 ddl 语句长时间获取不到 DML 锁
- 8.0 版本可以使用 ALTER TABLE 命令使用 NOWAIT 和 WAIT N 参数,一定时间获取不到锁就退出
- 老版本 Mysql 可以设置会话的锁等待超时时间,实现了类似的效果
SET SESSION lock_wait_timeout = 30
行锁功过
两阶段锁协议
在 InnoDB 事务中,行锁在需要的时候加上,会在事务结束时才释放。
死锁&死锁检测
如果两个事务中,事务 A 事务 B 互相持有对方所需的数据的行锁,则会造成死锁!
出现死锁怎么办
- 配置会话的锁申请超时时间 innodb_lock_wait_timeout 事务超时回滚,锁被释放,死锁解开。但是超时时间无法真正判断是否死锁,断了会误伤,长了则等待时间无法接受。
- 配置主动死锁检测,配置
<font style="color:rgb(59, 67, 81);"> innodb_deadlock_detect=on</font>会造成额外的性能损失,检测到死锁时会主动回滚某个事务,让其他事务得以执行。
增加行锁的并发思路
- 将更新一行数据拆解为更新多行数据,比如每个事务都需要频繁账户总额,只有一条总额记录则并发事务会被这行记录行锁阻塞。可以将总额拆分成 10 条记录,那么并发度可以提升 10 倍。
普通索引和唯一索引
change buffer 对二级索引的优化
Change Buffer 是 InnoDB 的一种优化结构,用于在非唯一二级索引页未被缓存到 Buffer Pool 时,将对该索引页的 INSERT、UPDATE、DELETE 操作(即"变更")延迟写入,暂存于系统表空间或独立表空间中,避免立即读取冷页造成的随机 I/O
工作场景分析:
- 更新条件不涉及二级索引,但更新字段影响二级索引。
- 如果受影响的二级索引页不在内存,变更会缓存在 Change Buffer
- 变更会在后续该索引页被访问时合并(包括查询、后台合并等)
- 更新条件涉及二级索引查询
- 查询条件涉及的索引页会被加载到内存
- 这些页的变更会直接应用,不使用 Change Buffer
- 更新可能影响其他索引页(如索引项位置改变),这些页的变更仍可能用 Change Buffer
- 示例 :
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">UPDATE t SET age=12 WHERE age=15</font>(age索引页在内存,但新的age=12可能属于另一个索引页)
- 唯一索引的特殊性
- 唯一索引不能使用 Change Buffer
- 根本原因:需要立即验证唯一性约束,必须加载索引页到内存检查
- 索引页已在内存,直接更新,无需缓冲
唯一索引和普通索引的选择
如业务上能保证唯一,此时使用唯一索引和普通索引都能满足业务需求的情况下,我们怎么选?
因为有 change buffer 的存在,所以二级索引是否使用唯一索引的场景很明显了。如果二级索引经常更新,并且不会更新后立即查询,那使用普通二级索引是更优的选择
MySQL 为啥会选错索引
优化器选择索引的目的,就是使用最小的代价执行语句
数据库中,扫描行数 往往是影响代价的因素之一,但不是唯一的因素,优化器还会结合是否使用临时表 、是否排序等综合判断
优化器怎么判断扫描行数?
- 基数是一个索引上不同值的个数,基数 cardinality 越大区分度越好。可以使用 show index 查看索引的基数。mysql 通过采样统计获得基数,因为精确统计代价太高,innoDb 采集 n 个数据页统计每页的基数平均值*总页数。而且会在变更数据超过 1/M 的时候重新统计,N 和 M 由 innodb_stats_presistent 配置的 on /off 决定,on 的时候,n=20,m=10 off 时,n=8 m=16
- 如果短时间大量删除插入数据可能导致优化器对扫描行数预估不准确导致选错索引,可以通过
<font style="color:rgb(59, 67, 81);">analyze table t</font>来重新统计索引
优化器选错索引怎么办
方法 1:使用 force index 强制选择一个索引。缺点,代码跟数据库索引名称耦合!
方法 2:通过语句修改,引导 Mysql 使用期望的索引。
方法 3:删掉被错误使用的索引
怎么给字符串加索引
目标:找到区分度最大的部分作为开头,并且要设定好长度!利用好前缀索引
- 直接创建完整索引,这样可能比较占用空间;
- 创建前缀索引(限定索引长度),节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
- 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
- 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。
举例:身份证号保存
- 前缀区分度不大,后缀区分度不大!采用倒序保存,只需要后 6 位就可以提供足够的区分度。
- 使用 hash 原理,比如每次额外保存 crc32 这个函数得出的 4 校验码,这样索引长度只有 4 位。不过后面需要唯一确定记录时,除了比较 crc32 的值还需要比较身份证号。
MySQL 为啥抖一下
Buffer pool 刷脏导致的抖动
Mysql 的 buffer pool 刷盘时可能就会抖一下,刷盘的场景
- redo log 满了,需要向前推进检查点,所以 redoLog 设置小了,会限制系统的能力
- buffer pool 内存不足,需要新的内存也
- 系统空闲的时候,提前刷入脏页
- 系统正常关闭的时候,刷入脏页
InnoDB 刷脏如何控制
- 告诉 InnoDB 服务器的 IO 能力,通过**innodb_io_capacity **告诉 InnoDB。这个值可以通过 fio 工具进行测试这样最准确。
- innodb_max_dirty_pages_pct 字段控制脏页比例,InnoDB 会结合日志序号和 checkpoint 徐好的差值,和脏页比例,决定百分之多少的innodb_io_capacity 的速度进行刷脏
- innodb_flush_neighbors 是否刷相邻页,如果刷脏页的时候发现旁边的页也是脏页就会一起刷。这在机械硬盘时代很有用,但是对于固态硬盘时代已经意义不大了,只刷自己反而能更快完成刷脏操作。8.0 已经默认设置为 0 了
表空间回收,为啥删掉一半数据表文件大小不变
表数据放在哪儿?innodb_file_per_table
innodb_file_per_table 设置成 OFF 则表数据放在系统共享表空间,也就是跟数据字典放一块儿
设置成 ON 则一个标单独一个 ibd 文件,默认为 ON,这样更容易管理,而且 drop 表时,这个文件也会被立即删除。
删除插入数据造成数据空洞
- 某行数据删除后,在数据页中只是被标记删除,可以在下次重用。某页数据都被删除了,数据页也是被标记为可复用。所以表文件表现在磁盘上的大小是不会变的!
- 如果随机插入数据,可能造成索引页分裂,分裂之后每页数据都没存满就产生了许多数据空洞了。
重建表消除空洞
- 可以使用 alter table A engine=InnoDB 命令来重建表,在 5.6 之前,这个操作会阻塞原表的更新。
- 5.6 之后 引入** Online DDL**,重建表的过程中,会记录重建表过程中的更新到日志文件 row log 重建完成后追加重放,就可以得到最新的表。而不用阻塞表的操作
- 5.6 之前的命令可以看成
"alter table t engine=innodb,ALGORITHM=copy;"5.6 之后的命令可以看成alter table t engine=innodb,ALGORITHM=inplace;这个 inplace 原地操作是 Online 的因为阻塞操作的时间非常端。
获取表行数 count() 慢怎么办
MyISAM 在表中保存了总行数,所以 count(*)会直接返回,但是 InnoDb 需要一行行统计。
show table status 会返回一个 TABLE_ROWS 能用吗?不能用因为是采样估算出来的,官方说误差能达到 40% 50%
用一张表保存所有表的计数
- 如果使用 redis 保存表计数,由于不属于一个系统没法儿保证数据的一致性和可见性
- 单独使用一张表保存计数,则可以在同一个事务内进行提交修改,保证准确性。
count(1) 、count(id)、count(*)、count(字段) 的差别
- count(1) 遍历查出所有的行,每行直接返回 1 不需要解析结果
- count(id 主键) 遍历查出所有的行,将 id 返回 server 层按行累加
- count(普通字段) 如果字段定义 NOT NULL 则一行行读取这个字段,进行累加,如果字段允许为 null 则还需要额外判断值是否为空,不为空则累加
- count(*) 不需要取出字段,直接按行累加
- 按照效率排序的话,count(字段)<count(主键id)<count(1)≈count(),所以我建议 你,尽量使用count()
Order BY 如何工作的
全字段排序
sort buffer:每个 mysql 的客户端线程,都会被分配到一块儿内存用于排序。称为 sort buffer
全字段排序就是,将查询结果的所有字段 和所有行存入 sort buffer 后面使用快速排序算法。最后从有序的结果中取出指定数量的结果进行返回
sort buffer 的大小可以通过 sort_buffer_size 字段来进行配置。sort buffer 容量如果不够,可能排序就没法儿都在内存中完成了。如果数据量太大,Mysql 会使用磁盘临时文件来辅助排序,通过 OPTIMIZER_TRACE 的结果,可以从 number_of_tmp_files 字段中看到是否使用了临时文件。外部排序一般使用**归并排序算法,**在每个临时文件排序完成后最后合并成一个有序的大文件。
:::color1
sort_buffer_size 一般默认 256kb,是一个会话级的内存参数。如果盲目调大全局值,可能导致系统内存耗尽!
如果有大结果集排序需求,或者是数据仓库批处理任务,推荐在会话中临时调大
SET SESSION sort_buffer_size = 4 * 1024 * 1024; -- 4MB
:::
RowId 排序
如果一行数据的字段长度太大,sort buffer 为了尽可能在内存中完成排序,还会使用另一种排序方法!就是将排序字段和主键 id 存入 sort buffer 最后再回表。
这个参数由<font style="color:rgb(59, 67, 81);">max_length_for_sort_data</font> 控制,默认是 1024 字节
索引排序
如果排序字段有索引,那么排序在查询数据的时候就完成,就不需要进行额外排序了
如何正确的显示随机值
order by rand() 原理
- 会读取所有涉及的数据行
- 将查询返回的结果行,每一行调用 rand()函数生成一个随机值
- 将(行数据,随机数)存入**临时表,**这里如果临时表太大超过 tmp_table_size的配置,还会使用磁盘临时表。
- 对临时表进行排序。这里一般使用 rowid 排序方法,因为内存临时表回表的代价很小。而且 mysql 在排序要求的结果数量很少的时候 会使用优先队列排序算法,类似堆排序只维护结果要求的结果数量的顺序。
其他随机排序方法
算法 1 id 范围内,随机 id 抽取一行
假设 id 范围 m~n,则从 m~n 随机取一个 id 假设为 x,随后查询的时候使用 WHERE id>=x LIMIT 1
缺点:数据如果不是均匀的,那概率就不平均了
算法 2 随机取第 n 条数据
假设数据范围内有 n 条数据,则随机抽取第 x 条,查询时 使用 LIMIT x,1
优点:概率更平衡,但是代价比算法 1 大,涉及到总行数扫描 C +后续读取时 X+1 行数据读取,但是比 rand() 函数还是更划算的。况且这个随机处理逻辑可以放在业务中实现
看似相同但性能差距巨大的 SQL
对索引字段使用函数导致索引失效
mysql> select count(*) from tradelog where month(t_modified)=7;
对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。即使有些函数不会改变索引值的有序性,但是 mysql 并不会特殊判断处理这些场景。
隐式转换导致索引失效
plsql
mysql> select * from tradelog where tradeid=110717;
此处tradeid 是 varchar 类型的,输入参数是整形,就触发了隐式的类型转换,导致索引失效。
隐式转换的规则
- 如果一方是字符串,另一方会被转换为字符串
- 一方是浮点数,另一方容易被转换成浮点数
- 字符串和浮点数比较时,mysql 通常会将字符串转换为数字。比如刚刚的例子
:::color1
推荐显式的进行类型转换,不确定时使用 EXPLAIN 进行检查
:::
当心字符集不一致导致的隐式转换
两张表链接的字段,假设两张表的链接字段字符集不一致,就会涉及字符集的隐式转换,子集向超集转换。索引也会失效。
幻读和间隙锁
幻读是在可重复读隔离级别才需要解决的问题,所以 mysql 的可重复读隔离级别是如何解决幻读的?
- mysql 有基于 mvcc 的当前读,对于当前读使用快照保证了可重复读
- 但是对于当前读,如果只有行锁是没法儿解决幻读问题的,于是 mysql 引入了间隙锁 gap-lock 间隙锁+行锁就是 next-key-lock
next-key-lock 解决了当前读场景下的幻读
- gap-lock 间隙锁,锁的就是两个值之间的空隙
- 跟间隙锁存在冲突关系的,是"往这个间隙中插入一个记录"也称为插入意向锁。间隙锁之间是不存在冲突的。
- 间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。
- next-key-lock 解决了当前读场景下的幻读,不过也提升了死锁的概率
- 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的
:::color1
Mysql 的 RR 到底有没有解决幻读?
在实际开发中,可以认为MySQL的RR通过MVCC解决了快照读的幻读,通过Next-Key Lock解决了当前读的幻读。但如果事务中先快照读判断业务逻辑,再基于此逻辑进行写入,其他事务的插入可能破坏业务约束,这就是官方说的"半幻读"。
要完全避免,应在事务开始时用SELECT ... FOR UPDATE进行加锁读,或使用Serializable隔离级别
:::
读已提交没有间隙锁
- 读已提交场景没有间隙锁,一般只有行锁。
- 但是如果使用读已提交,则** binlog 格式必须使用 row** 因为事务进行中只对已存在的行数据加锁,可以出现新的符合更新条件的数据。此时事务提交就无法确认事务更新的数据范围了。
加锁规则总结
饮鸩止渴,临时提高性能的办法
短链接数量过多的场景
处理 1:先处理掉那些占着连接但是不工作的线程。可以使用 kill connection,主动踢除空闲线程。踢除空闲线程时,如果线程存在事务可能是有损的,会导致事务回滚。如果没有事务则是无损的,优先踢除不存在事务的 sleep 线程。
处理 2:减少链接过程的消耗,链接数据库的过程中涉及登录和权限判断。紧急情况可以使重启数据库并使用- skip-grant-tables 参数。整个 Mysql 会跳过所有权限验证,不过风险极高。并且 8.0 开启这个参数后,会仅限本地客户端链接。
数据库查询性能问题
Mysql 中引发性能问题的慢查询,有三种可能
- 没有设置合理的索引
- SQL 语句没写好,导致索引失效
- MySQL 选错了索引
解决没有设置合理的索引问题:在 5.6 之后都可以 Online DDL 所以直接 alter table 增加索引。如果存在主备环境则可以关闭备库的 binlog 之后增加索引,主备切换后再在原来的主库上增加索引,这个方案很古老。实际上更推荐使用 gh-ost 方案更稳妥
SQL 语句没写好:则紧急更新 SQL 语句
MySQL 选错了索引:可以使用 force index 临时解决这个问题。
QPS 突增导致的性能问题
场景 1:QPS 突增由新上线功能导致
- 临时下线这个功能,或者数据库白名单紧急禁用该功能服务关联的链接。
MySQL 如何保证数据不丢失
通过 WAL Write Ahead Log 机制,只要 redo log 和 binlog 写入成功数据就不会丢失
MySQL 三大日志深度解析:Redo Log、Undo Log 与 Binary Log 的协同之道
binlog 的写入机制
- 一个事务的 binlog 是不能被拆开的,无论事务多大都要确保一次性写入。
- 每个线程都有一个 binlog cache 内存,超出这个内存大小还会暂存到磁盘
- 事务提交时,执行器会将 binlog cache 的完整事务日志写入 binlog,并清空这块缓存。
- 每个线程有自己的 binlog cache 共用一份 binlog 文件!
- write 过程:事务提交时 binlog 会被写入到 binlog file 的系统缓存
- fsync 过程:等到一定条件会执行 fsync 将 binlog file 的系统缓存持久化到磁盘,这时候才回产生磁盘 IO
- binlog 持久化的时机,通过
**<font style="color:rgb(59, 67, 81);">sync_binlog</font>**参数控制- sync_binlog=0 每次事务提交都只写入 binlog 缓存(write),不持久化到磁盘。依赖系统内存刷盘
- sync_binlog=1 每次事务提交都会 fsync
- sync_binlog=N(N>1)表示每次提交事务都会 write 但是积累 N 个事务后才回 fsync 持久化
- sync_binlog 可以提升性能,在业务中一般不推荐设置成 0 而是 100~1000,风险就是可能会丢失最近 N 个事务的 binlog 日志
redolog 的写入机制
**redolog 的写入存在 3 种状态 **
- 存在于 redolog buffer 中
- 写入到磁盘缓存 page cache,但是没持久化 fsync
- 持久化到磁盘完成
**redolog 的持久化策略由 **innodb_flush_log_at_trx_commit 控制
- innodb_flush_log_at_trx_commit=0 每次提交都只写入 redolog buffer
- innodb_flush_log_at_trx_commit=1 每次事务提交都 fsync 持久化到磁盘
- innodb_flush_log_at_trx_commit=2 每次事务提交都写入到系统磁盘缓存 page cache 但是不持久化
- InnoDB 有个后台线程,会固定每秒会将 redolog buffer 写入系统磁盘缓存 page cache 再调用 fsync 持久化到磁盘。
- redo log buffer 占用空间快到了
<font style="color:rgb(59, 67, 81);">innodb_log_buffer_size</font>一半的时候也会触发 write 写入到磁盘缓存 - 事务 A 提交需要持久化时,未提交的事物的 redolog 一样会被持久化 这是数据库的 STEAL 机制
- 如果开启了 binlog,那么 redo log 在 fsync 的时候只要保证 prepare 的日志被 fsync 就可以了,真正 commit 的时候只会写入系统磁盘缓存 page cache
:::color1
innodb_log_buffer_size = 16M # 控制Redo Log Buffer的大小
设置太小 → 频繁刷盘,性能下降
设置太大 → 内存浪费,崩溃恢复可能更久
在保证不频繁等待 (innodb_log_waits ≈ 0) 的前提下,使用尽可能小的Buffer Size
所需_buffer大小 ≈ 刷盘间隔 × 每秒Redo生成速率
:::
redo log 的组提交结减少 IO 操作
1000个事务同时提交时,InnoDB如何避免1000次磁盘写入?答案是:组提交(Group Commit)
组提交是InnoDB将多个事务的Redo Log刷盘操作合并为一次磁盘写入的优化技术。在高并发场景下,它可以将数百次I/O合并为几次,极大提升吞吐量。
redo log 组提交流程:
- 事务 1 提交时,要求刷盘,但是事务 1 不会立即刷盘,而是等待一定时间
- 事务 1 开始组队,成为队伍 Leader
- 事务 2 到达,要求刷盘
- 事务 3 到达要求刷盘
- 事务 1(Leader) 等到刷盘超时,将队伍中所有 redolog 刷盘,三个事务同时收到刷盘成功结果
- 在 5.5 版本及之前,binlog 没有组提交,所以当
sync_binlog=1时整体性能仍然被 binlog 的串行限制,但是 5.6 开始 Mysql 优化了 binlog 的组提交。
:::color1
由于组提交的存在, innodb_flush_log_at_trx_commit =1 的性能可以得到很大的提升
:::
5.6 开始 binlog 也可以一起组提交
原本 binlog 的写入和 fsync 都是在 redolog 的 prepare 写入之后的,如果 sync_binlog=1,那么 binlog 的 write 和 fsync 阶段都是串行的。
MySQL 5.6 引入 binlog group commit 后,新增了Leader-Follower 模式,将多个 binlog 写入请求合成一批统一写入和刷盘。形成**"联合组提交"**
这么操作下来,相当于将多个prepare 阶段的 redolog 的 binlog 刷盘请求,汇总在一起了,而且形成了更多的 prepare 阶段的事务。
阶段 1:Flush Stage(写入 binlog 到 OS 缓存)
- 第一个到达的事务成为 Leader,获取 LOCK_flush。
- Leader 收集所有已到达(或短时间内到达)的待提交事务(包括自己),形成一个批次。
- Leader 将这一批事务的 binlog 全部写入(write)到 binlog 文件的 OS page cache。
- 完成后,立即释放 LOCK_flush(锁持有时间极短)。
- 其他事务(Follower)在此阶段不执行 write,只等待被 Leader 处理。
阶段 2:Sync Stage(持久化到磁盘)
- Leader 执行 一次 fsync(),将刚才写入的所有 binlog 刷到磁盘(是否真刷取决于 sync_binlog)。
- 此阶段不持有任何 binlog 锁,后续批次的 Flush Stage 可并行开始!
阶段 3:Commit Stage(通知引擎提交)
- Leader 依次通知 InnoDB(或其他引擎)提交本批次中的每个事务。
- InnoDB 将 redo log 标记为 COMMIT(完成两阶段提交)。
- Follower 事务在此时被唤醒,向客户端返回"提交成功"
可以通过参数 binlog 组提交的效果
- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
:::info
当 MySQL 开启 binlog 时,由于两阶段提交的要求,所有事务的 redo log 会被"阻塞"在 PREPARE 状态,直到 binlog 完成组提交的 Sync 阶段。随后,binlog 的 Commit Stage 会批量触发 InnoDB 提交这些事务,从而使得 redo log 的持久化也呈现出"组提交"的行为。
****换句话说:binlog 的组提交机制,实际上"调度"了 redo log 的提交时机,使其与 binlog 批次对齐,实现联合组提交(coordinated group commit)。
:::
IO 瓶颈优化
- 设置 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。这样做的风险是,主机掉电的时候会丢数据。设置成 2 和设置成 0 性能差不多,优点是能避免 MySQL 重启导致的丢数据,所以更推荐。
- 在 binlog 和 redolog 的组提交的优化下,将 sync_binlog=1 和innodb_flush_log_at_trx_commit =1 的性能已经得到了大幅度的提升
MySQL 主备如何同步数据
主备基本原理
备库设置 Read only 防止误操作,备库监听主库的更新实现数据同步
- 备库 B 通过 change master 命令设置主库的链接信息,以及从哪个位置开始请求 binlog ,这个位置信息包含文件名和日志偏移量
- 备库 B 执行 start slave 命令,备库会启动两个线程。io_thread 和 Sql thread。io_thread 负责与主库建立链接
- 主库 A 校验 B 库的链接后,根据备库 B 发送过来的位置,读取 binlog 发送给 B
- 备库 B 收到 binlog 写入本地文件,称为中转日志 relay log
- sql_thread 读取中转日志,解析命令执行。sql_thread 早期是单线程的,后面演化成了多线程。
binlog 的三种格式
**statement 格式:**第一行记录 GTID、然后在 BEGIN 和 commit 之间是 SQL 的原文。commit 还会携带二阶段提交的全局事务 xid。在某些场景下,statement 可能会发出警告警告这条 SQL 是 unsafe 的,比如使用了 limit 。
**ROW 格式:**row 格式里不记录 SQL 的原文,而是记录对哪张表的哪个数据执行了何种操作。
**mixed 格式:**为了解决 statement 格式可能导致主备不一致的问题,但是又不想 row 格式一样占用大量空间,mysql 有个折中方案,就是 mixed 格式!MySQL 如果判断 statement 不安全,就会用 row 格式否则就会用 statement 格式。
推荐使用 row 格式,因为对于数据恢复方面 row 格式的好处很多,对于 delete 的数据会记录被删除的整行的数据。对于 insert 可以直接定位插入的那一行,对于更新操作则会记录更新前的数据状态。MariaDB 的Flashback工具就是基于介绍的原理来回滚数据的
**binlog 推荐使用专用工具解析:**insert into t values(10,10, now()); 在 mixed 格式日志里也是安全的,因为事务开始时会 SET TIMESTAMP,但是如果直接拷贝 SQL 执行,而不是使用专用 binlog 工具。那执行就会出错!
双主结构如何解决循环复制问题
**双主结构:**生产环境上,很多时候会使用双主结构,这样主备切换的时候就不需要修改主备关系了,只需要客户端切换链接节点,并将其中一个设置 read only 即可。
**循环复制问题:**双主结构 A B,他们互相监听对方的 binlog ,如果不做处理的话,一个 SQL 在 A 节点执行,同步到 B 节点,B 节点又生成了 binlog 发送到 A 则会出现循环复制的问题。
binlog 的 server id 解决循环复制问题:
- 两个库的 server id 必须不同,如果相同则无法设置主备关系
- 备库接收到 binlog 重放后,生成的 binlog 保留原始的 server id
- 每个库收到 binlog 后,会判断 server id,如果 server id 和自己相同,则直接丢弃
MySQL 如何进行主备切换
主备切换条件,主备同步延迟 seconds_behind_master
**主备切换的场景:**软件升级、主库机器下线,或者主库掉电
**seconds_behind_master:**可以使用 show slave status 查看备库与主库的延迟
主备延迟最常见的原因是备库消费 relay log 的速度跟不上主库 binlog 生产的速度
产生主备延迟的几种原因
- 备库没有和主库对称部署,使用了配置低的机器
- 备库用来执行数据统计分析任务,导致备库 cpu 压力大影响同步速度。这个时候推荐将 binlog 推给大数据或者多分几个从库分担压力。
- 从库执行大事务耗时过长,造成延迟。
- 备库并行复制能力也很重要
:::color1
seconds_behind_master 的值是由备库当前执行的 binlog 事务中的时间和当前机器的时间,结合主备服务器时间差值计算出来的。
:::
主备切换-可靠性优先策略
- 判断备库 B 当前延迟是否小于某个值,比如 5s 满足条件则进行下一步,否则则重试
- 主库 A 改成只读
- 等待备库 B 的延迟为 0
- 将备库 B 改成可读写状态
- 客户端链接到备库 B,此时备库 B 切换为主库
存在不可用时间,也就是两个库都是只读的状态时,所以要判断延迟在一定范围内才开始切换。否则禁用了主库,然后等延迟恢复等半小时是不可接受的。
主备切换-可用性优先策略
直接最早执行步骤 4 和步骤 5,也就是直接开放备库读写,客户端链接备库再去设置原来的主库停止写入。
一般不会使用这种策略,因为妥妥的会产生数据不一致的问题!
但是某些场景不得不用,比如主库掉电,为了保证服务的可用性,已经别无选择了。
MySQL 如何解决备库同步速度问题(备库并行复制)
单线程 sql_thread 为什么会延迟
- 主库可以同时接收多个客户端的事务请求,并发度的限制在于各种锁,所以在多线程场景下吞吐量肯定高于单线程
- mysql 版本 5.6 之前,从库的 relay log 处理线程,sql_thread 只有单线程,因此在主库并发度高的时候,就会出现严重的主备延迟,甚至出现备库永远追不上主库的问题。
MySQL 解决单线程复制的思路
要提升并发度,就要将执行事务的线程数量进行提升,mysql 新增 worker 线程来执行更新数据的任务,原来的 sql_thread 变为 coordinator,只负责读取中转日志并且分发事务。
分发事务的基本要求
- 不能造成更新覆盖,也就是原本存在并发冲突的更新事务必须到同一个 worker 中串行执行。
- 同一个事务不能拆开,只能在同一个 worker 中执行
博主 5.5 版本自己写的分发策略-按表分发
- 每个 worker 保存一个 hash 表,key 是事务的表名,value 是事务的数量。
- 分发事务时,根据事务涉及的表进行分发。
- 如果事务涉及的表跟所有 worker 都不冲突,则交给最空闲的 worker 执行
- 如果跟超过 1 个 worker 冲突,则 coordinator 线程等待,等待冲突的 worker 只剩 1 个或执行完毕
- 如果只跟 1 个 worker 冲突,则将事务交由该 worker 执行
优点:适用于事务平均分布在各个表之间的场景
缺点:如果大量事务都集中在 1 张表,那就只有 1 个 worker 执行,跟单线程复制是一样的
博主按行分发策略
- 对比按表分发,key 变成了 库名+表名+唯一键的值
- 有时候 id 相同还不行,还得考虑唯一键的场景,不同事物之间保证唯一键的唯一性
- 这个方案要成功
- 要能从 binlog 解析出表名、主键、唯一键
- 必须有主键
- 不能有外键,因为级联更新不会记录在 binlog 中
- 问题:
- hash key 太分散,太耗费内存。
- 耗费 cpu 计算资源
- 博主优化策略
- 单个事务超过 n 行,就退化成单线程模式。
MySQL 5.6 的按库并行
mysql 官方在 5.6 退出了按库并行的并行复制方案,跟博主的按表分发方案类似,只是力度更粗
缺陷:一个库一个线程,如果热点集中在一个 DB 里,那这个策略就没效果了。
如果要使用这个策略,相当于得 相同热度的表均分到不同 db 里才有效果,所以这个策略用的并不多
MariaDB 利用组提交的并行复制策略
redo log 在优化 fsync 的磁盘 io 时用到了组提交的策略,能在一组事务里提交说明他们之间一定是不存在冲突的,因为事务没提交存在冲突的其他事务还在等待提交后事务锁释放。MariaDB 的并行复制策略就是依据这一原理实现的并行复制。
MariaDB 的实现流程
- 在一组里提交的事务有一个相同的 commit_id,下一组就是 commit_id+1
- commit_id 也写入到 binlog 中
- 备库分发事务的时候,根据 commit_id,将一个 commit_id 中的事务分发到多个 worker 执行
- 一个 commit_id 的事务执行完成后,再分发下一个 commit_id 的事务
优点:对原系统改造非常少,并且实现了相当高的并发度。
缺点:仍然赶不上主库的并发度,因为主库不同的 commit_id 之间是存在并行的,但是 MariaDB 的方法,在不同的 commit_id 之间的执行其实是完全串行的。在上一个 commit_id 组事务执行期间,下一组不能开始。
5.7 的并行复制策略
在 MariaDB 实现并行复制后,官方也提供了类似的功能,由参数 slave-parallel-type 来控制并行复制策略
- 配置为
slave-parallel-type=DATABASE表示使用 5.6 的按库并行策略 - 配置为
slave-parallel-type=LOGICAL_CLOCK表示的就是类似于 MariaDB 的策略,不过官方针对并行度进行了优化。
官方LOGICAL_CLOCK 策略的优化
- 执行状态的事务不可以并行同步!
- MariaDB 的思路是处于 Commit 状态的事务可以并行,但是其实处于 prepare 阶段的事务就可以开始并行了。
- binlog 组提交的两个参数
<font style="color:rgb(59, 67, 81);">binlog_group_commit_sync_delay 和binlog_group_commit_sync_no_delay_count</font>可以用来减少 binlog 的写盘次数,在 5.7 的并行策略里,他们可以用来制造更多同时处于 prepare 阶段的事务,这样就增加了备库复制的并行度了。 - 这两个参数相当于可以让主库执行的慢些,备库执行的快些,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。
MySQL 5.7.22 的并行复制策略 WRITESET(写集合)
2018 年 4 月份发布的 MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制。
新增了一个binlog-transaction-dependency-tracking来控制如何启用这个新策略
- COMMIT_ORDER 表示的就是前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
- WRITESET 表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行
- WRITESET_SESSION 是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。
官方的**WRITESET 和按行分发很类似,但是官方的实现存在很大的优势**
- WRITESET 是在主库生成时就计算好修改了哪些数据行,并写入到 binlog,这样从库不需要再重新计算。
- 不依赖 binlog 的内容,不需要扫一遍所有的 binlog 才能决定如何分发。并且支持 statement 格式
- 缺点同样存在,依然无法并行处理没有主键 和存在外键约束的场景,此时会退化成单线程模型
主备切换时如何确认 binlog 点位
一主多从架构主备切换

主库 A 掉电后,A'成为新的主库,从库 B、C、D 也要改接到 A'
此时从库 B、C、D 将从 A'获取 binlog,那他们如何找到同步的点位呢?
基于点位的主备切换
节点切换一般使用 change master 命令,这个命令里,除了主库的链接信息外,还需要明确主库的文件名和偏移量 ,<font style="color:rgb(59, 67, 81);">MASTER_LOG_FILE 和 MASTER_LOG_POS</font>这个就是常说的同步点位。
同步点位怎么找?这个点位一般很难取到精确值,只能取到一个大概的位置!核心思路就是找到一个靠前的位置,然后跳过 n 条事务。
- 等待 A'刷完所有的 relay log 中继日志
- A'执行 show master status 得到 A'上最新的 File 和 Position
- 找到原主库 A 崩溃时刻 T,解析 A'的 File 找到 T 时刻的点位
mysqlbinlog File --stop-datetime=T --start-datetime=T - 这个从 A'找到的点位,只是一个不精确的点位,所以切换任务时要先自动跳过可能出现的错误
- 出错时就主动跳过 1 个事务,直到没有错误出现
set global sql_slave_skip_counter=1; start slave; - 跳过指定错误
<font style="color:rgb(59, 67, 81);">slave_skip_errors</font>
- 出错时就主动跳过 1 个事务,直到没有错误出现
这种基于点位跳过事务或者跳过错误的方法,虽然可以重新建立主备关系,但是这两种操作都很复杂且容易出错!所以 mysql 引入 GTID 彻底解决了这一问题
5.6 GTID 集合记录执行过的事务
GTID 全称是 Global Transaction Identifier 也就是全局事务标识。由两部分组成 <font style="color:rgb(59, 67, 81);">GTID=server_uuid:transaction_id</font>
- server_uuid:是每个 Mysql 服务首次启动时生成的,生成后除非手动修改后面是不会再变的。
- transaction_id:这个事务 id 和 sql 执行过程中的事务 id 不是一回事,这是事务提交后写入 binlog 才有的一个初始值是 1 的序号,后面每次提交就会+1
开启 GTID 模式 :只需要启动实例时增加参数 gtid_mode=on 和 enforce_gtid_consistency=on
GTID 的两种生成方式
在 GTID 模式下,每个 binlog 事务都会跟着一个 GTID,这个 GTID 的生成有两种方式,使用哪种方式取决于 session 变量 gtid_next 的值

- 如果 gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。
- 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT='server_uuid:gno'。
- 把这个 GTID 加入本实例的 GTID 集合。
- 指定一个值 gtid_next 是一个指定的 gtid 值,比如通过 set gitd_next ='current_gtid'
- 如果 current_gtid 已存在,那这个 binlog 接下来事务会被系统忽略
- 如果 current_gtid 不存在于 gtid 集合,那这个 current_gtid 就会被分配给接下来要执行的事务
- gtid 重复是会报错的,如果手动指定了 gtid_next 那么执行下一个事务前,就需要将 gtid_next 更新成一个新的,或者设置成 automatic
每个 MySQL 实例都维护了一个 GTID 集合,用来对应"这个实例执行过的所有事务"。
:::color1
GTID 大部分时间都是有序且连续的,所以保存 GTID 集合,不是保存集合中的每个值,而是集合的开始和结束节点
举例:GTID 集合=【【1200】,201,【203999】】
即使主库删掉了 Binlog,也会保留删除之前的 GTID
:::
基于 GTID 的主备切换
一般主库挂掉后,需要比较所有的从库中,哪个库的 binlog 点位最新,或者 GTID 最大才把谁切成主库。
将备库 B 切换到新主库 A'的过程 master_auto_position=1 表示使用 GTID 作为主备关系的协议,就不需要找头疼的 MASTER_LOG_FILE 和 MASTER_LOG_POS
plain
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1
后面切换过程中,B 将它执行过的 GTID 发给 A',A'计算出自身 GTID 集合和 B 发来的 GTID 集合的差集
- 正常情况:A'包含 B 所有的 GTID,A'找到第一个 B 没有的 GTID 开始发给 B
- 异常情况:A'未包含 B 所有的 GTID,说明 B'数据更新,或者 A'删除了部分 GTID,服务器会直接抛出错误!
读写分离如何解决读延迟问题
读写分离的两种方案
方案 1 客户端负载均衡:主节点写入,n 个从节点由客户端自主自主决定如何进行负载均衡
- 优点:性能更好,问题排查方便
- 后端一般会通过注册中心例如 Zookeeper 来维护节点信息,所以客户端逻辑也并不会过于复杂。
方案 2 中间件代理:客户端不直接连接 Mysql 节点,而是通过中间件代理层 proxy 来决定如何进行请求的分发路由。
- 优点:客户端无感
- 缺点:存在一定的性能损失,并且维护难度更高。
- 常见的读写分离中间件代理,MySQL Router(官方) 、ProxySQL(最流行的开源方案)云服务厂商的 RDS 读写分离版
读写分离从库读到过期数据 "过期读"
不论哪种架构,主库和从库之间都存在一定的延迟,如果写入后立即读取,可能就读取到了同步之前的状态。这种状态博主称之为"过期读"
- 强制走主库方案;
- sleep 方案;
- 判断主备无延迟方案;
- 配合 semi-sync 方案;
- 等主库位点方案;
- 等 GTID 方案。
强制主库方案(简单粗暴但可拓展性下降)
- 对于需要立即获取结果的请求,强制转发到主库。对于不需要立即获取结果的,则转发到从库
- 这个方案看起来啥也没做,但是其实是用的最多的。
- 缺点:对于大量所有查询都不能是过期读的时候,从库就没法儿分担压力了,即使增加从库,也拓展不了数据库的吞吐量。
Sleep 方案
- 具体方案内容大概就是,执行一条 SELECT SLEEP(1) 命令,预设从库延迟在 1 秒内,sleep 一下有很大概率拿到最新数据。但是对用户体验很不友好,每次都 sleep 看起来也不靠谱
- 靠谱一点的优化:某些修改&变更场景中,修改完成后由前端变更页面数据,而不是通过重新查询来获取最新数据。
判断主备延迟方案(不够精确)
判断主备当前有无延迟有 3 种方案
- 使用 show slave status 命令,查看
seconds_behind_master参数的值。只不过单位是秒,不够精确。 - 使用 binlog 点位判断延迟。
- Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
- Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。
- 如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。
- slave 上判断收到的 gtid 集合和已执行的 gtid 集合的是否一致,如果一致则说明收到的日志已经同步完成
:::color1
**潜在问题:**事务很多时,可能迟迟等不到判定从库点位完全执行完毕的时候。
:::
:::color1
这个方法的缺陷就是,都是从库使用已经收到的 binlog 和已经执行的 binlog 进度来对比
获取到的是从库收到与执行进度之前的差距!
但是如果 binlog 还未发送到从库,在发送阶段存在延迟,那这个方法就无能为力
:::
semi-sync 半同步提交+主备延迟判断
半同步复制流程:事务提交时,主库发送 binlog 给从库,从库收到 binlog 后,返回 ack 确认收到,主库再返回客户端事务完成!
半同步提交+主备延迟判断方案
- 半同步提交,用来保证事务完成后 binlog 被发送到从库。
- 主备延迟判断,用来保证收到的 binlog 都被执行完了
:::color1
缺陷:
- 在持续延迟的情况下,可能出现过度等待的问题。
- 半同步提交,如果存在多个从库,也没法儿保证读请求会被转发到这个提交成功的从库。
:::
等主库点位方案
select master_pos_wait(file, pos[, timeout]); 命令是从库执行,传入主库 binlog 文件和点位,返回从命令执行开始到从库同步到 file 和 pos 位置执行的事务数量。timeout 可选执行的超时时间!
命令异常则返回 null、等待超时则返回-1、命令执行时从库超过这个点位了,则返回 0
依赖这个命令,那我们可以在从库中选择已经同步完写入事务的从库执行查询逻辑
- 事务在主库提交,执行 show master status 获得最新的 file 和 Position
- 选定一个从库,执行上面的 select master_pos 如果返回>=0 说明事务已经被应用到该从库了。
- 如果从库>=0 在从库执行,否则则在主库执行。
等 GTID 方案
如果开启了 GTID 同步方式,那 gtid 也有个类似的方案SELECT wait_for_executed_gtid_set(gtid_set,1) 等待一定时间,如果从库执行到了这个 GTID 对应的事务记录,则返回 0 否则则返回 1
在等主库点位的方案中,还需要额外查一下事务提交后最新的日志点位,而 5.7.6 之后,mysql 已经允许在更新类事务完成后,直接返回事务的 GTID 了,这样也可以少一次查询。
流程:
- trx1 事务更新完成,响应中直接获取到这个事务的 GTID=gtid1
- 选定一个从库执执行 SELECT wait_for_executed_gtid_set(gtid1,1)
- 如果返回 0 则可以在这个从库执行查询语句
- 这个从库未命中的话,直接到主库还是换其他从库,还是重试一次则看业务具体实现逻辑了。
如何判断主库当前是否可用
SELECT 1 为什么不行
SELECT 1 并不能保证数据库状态一定正常,只能保证主库进程仍然可以正常接受请求。
<font style="color:rgb(59, 67, 81);">innodb_thread_concurrency</font>并发线程数限制,机器的 cpu 核心数有限,配置过多的并发线程数 会导致上下文切换过于频繁。默认=0 标识不限制,但是一般配置 64~128 之间。注意!并发线程数不等于并发连接数,空闲状态、等待锁状态的链接线程并不会占用并发连接数。- 如果innodb_thread_concurrency 满了,新线程想执行数据库操作就会被阻塞了!SELECT 1 不涉及数据库操作,所以不会被阻塞所以无法体现当前并发线程数状态!
查表判断 为啥还不行?
select 1 无法判断并发线程数打满,那我用一个专门的表来检测,SELECT * FROM mysql.health_check 总可以了吧?
但是这样还是会有问题,比如面对磁盘写满的情况!这时候数据库还是可以正常读取数据的,只是没法儿更新写入了!
更新判断 总可以了吧?
可以使用类似的表结构,保存 id、t_modified,id 使用 Mysql 实例的 serverId,t_modified 保存最后一次检测的时间。这样即使双主结构( Active-Standby 双 M 架构)下,binlog 互相同步也不会出问题。
sql
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();
更新判断是一个比较常用的方案,但是依然会有一些问题!比如判定慢问题!
一般来说,HA 检测可用的逻辑,执行更新判断,如果 update 语句 n 秒没有返回,就认为系统不可用,需要进行主备切换了!但是 IO 100%的场景下,我们这条 update 语句所需的资源很小,所以还是有可能很快就提交了,导致检测认为系统正常。这就是只用单条 SQL 采样判断存在的局限性!
使用内部统计
mysql 5.6 以后提供了 performance_schema 库,提供了很多性能监控信息
比如 file_summary_by_event_name 表就包含了每次 IO 请求的时间!

打开所有的 performance_schema 性能会下降 10%,所以可以只打开自己需要的项。
举例:MAX_TIMER 相关字段判断是否出现异常
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;
误删数据怎么搞?只能跑路了吗?
误删行
- 通过类似 Flashback 工具,通过闪回将数据恢复。不过要确保 binlog_format=row 并且 binlog_row_image=FULL 。flashback 的原理,是修改 binlog 内容,拿回原库重放
- flashback 工具的原理,比如误插入数据,则 binlog 对应的事件是 write_rows event 将其改成 Delete_rows event 再执行
- delete 误删数据,则将 delete_rows event 改成 write_rows_event
- update 语句,binlog 记录了修改前后的数据,只需要对调前后数据进行更新即
- 误操作了多条数据,则需要倒序重放。
- 不建议在主库执行这些操作,建议恢复出一个备份再执行,否则容易造成二次破坏
- 预防:将
**sql_safe_updates**参数设置为 ON 这样 update/delete 没有 where 条件时就会报错
:::color1
delete 操作删除数据很慢,从性能考虑整表删除时推荐使用
truncate/drope table 和 drope database
不过 delete 操作会生成 binlog,truncate/drop 操作则只会在 binlog 中记录 statement 即使 binlog_format=row
:::
误删库/表
核心思想,使用全量备份加增量日志来恢复这张表,最后重新导入主库
- 取最近的一次全量备份,用备份恢复出一个临时库
- 取全量备份之后的 binlog 日志开始同步
- 跳过 drop/truncate 导致误删的语句,其他全部应用到临时库
- 如果没开 gtid,则先设置-stop-position,应用到误操作前一句的日志,后面再用-start-position 接着应用误操作之后的日志。
- 如果开了 gtid,则只要找到误操作命令的 gtid 直接加到备库的 gtid 集合来跳过 binlog 中的误删语句
- 为了加速数据恢复,使用 mysqlbinlog 命令时,可以 加-database 参数,来指定误删表所在的库
- 缺点:速度慢,binlog 重放用不上主从同步的并行复制技术,只能单线程同步
**加速恢复的方法:**在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库
- 在 start slave 之前,先通过执行 change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表;
- 这样做也可以用上并行复制技术,来加速整个数据恢复过程。
延迟复制备库来控制恢复时间
如果数据库特别大,全量备份的间隔可能会很久,比如半个月一次全量备份,后面可能应用 binlog 来恢复的时间得按天计算,这是完全不可接受的。
Mysql 5.6 引入了**延迟复制备库,**这是一种特殊的备库,使用命令 过 CHANGE MASTER TO MASTER_DELAY = N 可以实现备库与主库保持 N 秒的延迟,假设将这个延迟设置为 3600 秒,那误删表 1 小时之内,停止这个备库的同步,跳过误删命令后,只需要 最多 1 小时就可以恢复被误删的数据了。
最后再提取这个标导入主库即可。
如何预防误删库/表
- 账号分离,开发人员只给 DML 权限不给 truncate&drop 权限。就算要执行 DDL 命令,也要通过管理系统来执行
- 删除操作按 sop 执行,例如
- 删除数据表之前,先将标改成特殊的名称,比如 to_be_delete,后面无业务异常再执行删除。
- 删除操作只能对特定后缀的表执行。
rm 删除了整个实例
如果有高可用集群方案,整个实例删除是最不怕的,HA 高可用系统会选出新的主库,整个集群仍然可以正常提供服务。
不过为了防止机房整体下线,备份最好跨城市保存。
执行 Kill query/connection 为啥杀不掉线程
kill query 和 kill connection 命令
kill query 线程 id表示停止正在执行的语句kill connection 线程 id表示断开线程的连接,断开前同样会停止其正在执行的语句,connection 可以
kill 命令干了啥?
- kill 命令肯定不可能直接不管不顾终止线程,肯定该释放的锁得释放,该灰姑娘的操作得回滚
- kill 命令告诉执行线程,这条语句不需要继续执行,可以终止了
- 执行
kill query thread_id_B命令- 将线程thread_id_B的 运行状态改成 THD::KILL_QUERY (将变量 killed 赋值为 THD::KILL_QUERY)
- 给thread_id_B 发送一个中断信号,因为线程有可能处于锁等待,这个时候如果不中断等待,线程是不会进行状态判断,也就不会知道需要执行终止操作了。
- 正常锁等待、sleep 状态是可以被正常中断的,但是如果是等待并发执行上线,这时候就没法儿被中断了,只会等到获得了执行权限才会进行状态判断。
- 执行
kill connection thread_id_B会立即断开客户端的连接,并且将 线程状态(killed 标志位)设置为 KILL_CONNECTION- 执行了 KILL connection,但是因为线程处于特殊状态没有被唤醒,或者执行了大事务需要较长耗时来回滚,show processlist 的状态就会显示为 killed,即使客户端已经断开了连接。
- 如果一个线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。
:::color1
kill connection 或者 kill query 会将线程的 killed 标志位
设置为 **killed = THD::KILL_QUERY** 或者** **killed = THD::KILL_CONNECTION ** **
在 SHOW PROCESSLIST 的 State 列中,只要线程被标记为 killed 且尚未完成退出/回滚等后续动作,通常统一显示为:Killed
:::
客户端误解:表多的库连接起来就慢
这是由于客户端会提供本地库名和表名补全的功能这个功能会导致客户端连接后执行一些操作
假设客户端连接到 db1:
- 执行 show database
- 切换到 db1 库,执行 show tables
- 将结果构建成一个本地的哈希表,这也是最花时间的一步
可以通过 连接参数 -A 关闭这个自动补全功能,-q 也可以
客户端误解:-quick 参数(简写 -q) 其实是让客户端加速
你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能
MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:
- 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是mysql_store_result 方法。
- 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。
mysql 默认使用第一种,如果使用-quick 参数就会使用第二种,这种方式会根据客户端本地处理速度来返回,因此客户端处理的慢,那服务端发送就会阻塞让服务端变慢
** quick 参数真实作用是让客户端加速**
- 跳过表名自动补全
- mysql_store_result 需要使用客户端本地内存来缓存结果,结果太大可能影响本地机器性能
- 不会把执行命令记录到本地命令历史
查询大表,会耗尽数据库内存吗?
全表扫描会撑爆 server 层内存吗?
假设有一个 200G 的表 db1.t 客户端执行 SELECT * FROM db1.t 时没有任何过滤,所以每一行都会存在结果集中。这个结果集并不会撑爆内存,因为服务端不需要保存一个完整的结果集。
取数据和发数据流程如下
- 获取一行,写入到 net_buffer 中,这块儿内存大小由
net_buffer_length控制,默认 16K - 重复获取行,直到 net_buffer 写满,调用网络接口发送出去。
- 如果发送成功,清空 net_bufer 循环上面的操作。
- 如果发送函数返回 EAGAIN 或者 WSAEWOULDBLOCK 就表示本地网络栈 socket send buffer 写满了,进入等待流程。直到网络栈重新可写,再继续发送。
所以一个查询传输数据的时候,最多占用的内存就是 net_buffer_length 这么大,并不会一下子读出整个表 200G 的数据撑爆内存。
也就是说 mysql 是边读边发的,这样会带来一个问题,如果客户端处理能力缓慢会导致 Mysql 服务端结果发不出去,造成这个事务执行时间变长,这个时候 show processlist 可以看到线程状态是** Seding to client**
关于客户端参数 mysql_use_result 和 mysql_store_result
如果客户端使用 -quick 参数,客户端就会使用 mysql_use_result 方法,变成读一行处理一行再去取一行,这个时候如果业务处理的慢或者数据量大,就会导致发送阻塞,并且事务要很长时间才结束。
所以不是特殊情况,都应该使用 mysql_store_result将查询结果保存到客户端本地。但是如果数据量非常大,可能撑爆客户端内存,那使用 mysql_use_result 也是可行的。
Sending data 状态和 Sending to client 的区别
与"Sending to client"长相很类似的一个状态是"Sending data"
有时候网络没什么问题,但是可以看到 sending data 状态,为什么 sending data 这么耗时呢?
- mysql 查询语句进入执行阶段后,状态会被设置为 sending data
- 发送执行结果列相关信息(meta data) 给客户端
- 执行查询语句流程
- 将状态设置为空串
也就是说 sending data 代表的含义可能仅仅是正在执行中,比如再执行 FOR UPDATE 查询等待锁得时候,state 就显示的 sending data

buffer pool 分代管理降低全表扫描带来的影响
内存中的数据页是放在 buffer pool 中的,放在 buffer pool 中的内存页,除了起到保存更新结果,配合 redo log 顺序写入来避免随机写入的作用。更重要的一个作用,就是加速查询!
buffer pool 加速查询的重要指标就是 命中率 可以使用命令 <font style="color:rgb(59, 67, 81);">show engine innodb status</font> 查看命中率 <font style="color:rgb(59, 67, 81);">buffer pool hit rate</font> ,一个健康的线上系统,应该在 99% 以上
buffer pool 使用的是 LRU (Least Recently Used, LRU) 最近最少使用算法来维护的缓存,算法的核心就是最久未被访问的最先淘汰!
如果是普通的 LRU 算法,那一次全表扫描需要读取大量的磁盘页进入内存,淘汰掉很多热点的数据页了,会导致命中率急剧下降!
mysql 的 innodb 引入了分代思想来优化这种场景,innoDB 上按照 5:3 划分了 young 区和 old 区
- 每次访问到的页移到 young 区
- 从 old 区的尾部淘汰旧数据,每次读取到的新数据放在 old 区的头部
- old 区的数据页,存活超过 1 秒,就移动到整个链表头部,短与 1 秒位置不变。这个参数由 innodb_old_blocks_time 控制,默认 1000ms
有了这个分代策略之后,即使执行全表扫描,快速的淘汰和读取都是发生在 old 区,不会导致 young 区被覆盖,保证了正常业务的 buffer pool 命中率
JOIN 到底能不能用?
JOIN 的执行过程,NLJ
t1 和 t2 的字段 a 都有索引,这里使用 straight_join 是直接指定驱动表,避免 mysql 的自动选择驱动表影响分析过程。straight_join 只影响链接顺序,不影响链接类型,它本质上还是一个内连接。
针对执行语句 select * from t1 straight_join t2 on (t1.a=t2.a); 进行分析

- 被驱动的表 t2 的字段 a 上有索引,mysql 用上了这条索引
- 从 t1 读取一行数据 R
- 从数据 R 中取出 a 字段的值到表 t2 上查找
- 取出 t2 中满足条件的行,跟 R 组成新的一行作为结果集的一部分。
- 重复步骤 2~3,一直到表 1 循环结束
这个遍历表 t1 根据 t1 数据去 t2 中查找满足条件的记录,类似我们写程序时嵌套查询类似,并且可以用上被驱动表的索引。这种形式我们称之为 "Index Nested-Loop Join" 简称 NLJ 嵌套循环连接。
无索引 JOIN 时的 Simple Nested-Loop Join
假设执行语句改成 select * from t1 straight_join t2 on (t1.a=t2.b); 数据表 t2 的 b 字段是没有索引的,继续执行嵌套循环链接查询,当然也是可以查出数据的。
此时这个算法称为**" Simple Nested-Loop Join",**但是没有索引 t2 的扫描次数会爆炸,变成 t1 行数*t2 的行数。
不过可以放心,Mysql 没有使用这个笨重的 Simple Nested_Loop Join 算法,而是使用了另一个叫做 Block Nested-Loop Join 的算法简称 BNL
BNL 算法: 无可用索引时 Join 的 Block Nested-Loop Join
针对select * from t1 straight_join t2 on (t1.a=t2.b); 对于被驱动表上没有可用的索引时,算法流程如下
- 将表 t1 的数据读入线程 join_buffer 内存中,此时 Select * 会将整个 t1 表放入内存
- 扫描表 t2 把表 t2 的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的作为结果集的一部分返回。
:::color1
可以看到为了避免每次取一次 t1 数据,就全表扫描 t2,mysql 是将 t1 记录全部取出到缓存,然后再遍历扫描 t2 时,依次和内存中的 t1 进行对比。扫描次数就变成了 t1.rows+t2.rows ,虽然比较次数还是 t1.rows * t2.rows,但是比较过程是内存操作,会很快。
:::
问题:join_buffer 放不下 t1 所有数据怎么办
- mysql 对此早有办法,办法就是分段处理。join_buffer 的大小可以通过
join_buffer_size来设定 - 假设内存不足,扫描 t1 时,假设读取 100 行满了,则会执行第二步,开始扫描 t2 和已存在数据进行比较
- 清空 join_buffer 后,再接着扫描 t1,接着全表扫描 t2 进行比较。
:::color1
这个算法原理从名字可以看出来 "分块儿 join"
假设驱动表行数是 N、分成 K 段才能完成算法流程,被驱动表 t2 是 m 行。
这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)
则:扫描行数是 N+λNM; 内存判断次数是 N*M 次。
所以:如果 join_buffer_size 适当调大,可以优化 join 执行扫描行数,提升速度。
:::
能不能使用 JOIN
- 如果被连接的字段有索引,能用到 Index Nested-Loop Join 算法,其实是没问题的。
- 如果被链接的字段没有索引,只能用到 Block Nested-Loop Join 算法,则被驱动表要扫描很多次,尽量不要使用。
- 可以看 explain 结果中,Extra 字段中,有没有出现"Block Nested Loop"来判断是否使用了 BNL
为什么要用小表驱动?
- 如果是 NLJ,应该选择小表做驱动
- 如果是 BNL 则跟 join_buffer_size 的大小有关,如果 join_buffer_size 足够大,那大小表驱动都一样。如果是 join_buffer_Size 不够大,则应该使用小表做驱动,而且 join_buffer_size 往往是不够大的。
**明确什么是小表:**在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是"小表",应该作为驱动表
MySQL 对 JOIN 的优化
Multi-Range Read (MRR)优化

一个小问题,mysql 查询到二级索引数据后,server 层回表查询的时候是一行行的去搜索聚簇索引还是批量搜索聚簇索引?
答:
- 主键索引是一棵 B+数,只能根据一个 id 进行定位,所以回表也是一行行搜索主键的。
- 如果回表的多个 id 是无序的,那每次查询都是随机查就没法儿利用到聚簇索引上按照主键顺序组织的优势了。
- 这就是 MRR 算法的优化思路,希望多个主键查询时,按照主键的顺序进行查询,达到类似顺序读的效果提升读取性能。
- 根据二级索引,定位到满足条件的记录,将二级索引中的主键值存入 **read_rnd_buffer **
- 将 read_rnd_buffer 中的 id 递增排序
- 使用排序后的 id 数组,一次到主键索引中查询记录,作为结果返回。
- **read_rnd_buffer **的大小由
read_rnd_buffer_size控制如果放满了,就会先将其中 id 排序进行查询,在清空进行下次循环。
- 如果要稳定使用 MRR 优化,需要设置
set optimizer_switch="mrr_cost_based=off"因为官方优化器策略,判断消耗时倾向于不使用 MRR,所以把这个倾向策略关闭,就会默认使用 MRR 了
:::color1
MRR 的核心原理是通过 "先收集后排序再批量" 的机制优化回表过程:先在二级索引上收集所有满足条件的主键 ID 并放入缓冲区,接着对这些主键进行排序,最后按主键顺序批量回表读取数据行------这种优化在机械硬盘上效果尤为显著,能将大量随机寻道转为顺序读取,提升性能数倍;在固态硬盘上虽然随机访问较快,但顺序读取仍能减少 IO 合并与调度开销,整体仍有稳定增益
:::
5.6 版本的对 NLJ 算法的优化 Batched Key Access (BKA)算法
join_buffer 在原始的 NLJ 算法中并没有起到什么作用,只是在 BNL 算法里用来暂存驱动表的数据。
在 BKA 算法中,就利用到了 join_buffer 来优化查询。
原始的 NLJ 算法中,驱动表一行行的取出关联字段的值,到被驱动表中进行索引查询。这样是用不上 MRR 的优势的。
**BKA 的核心思想:**一次性从驱动表中取出多行驱动表的数据,放到内存中,这块儿内存就是之前 NLJ 未利用到的 join_buffer。从多行数据中,取出被驱动表关联的字段,随后被驱动表回表时就可以利用到 MRR 优化将被驱动表的回表随机 I/O 转为顺序 I/O,减少回表的随机读取。
对于大数据量/机械硬盘实例的的 NLJ 查询优化明显。
启用 BKA
sql
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
其中,前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR。
BNL 算法的性能问题
BNL 会带来什么问题: BNL 会全表扫描被驱动表,而且如果 join_buffer 不够大,可能会多次全表扫描,那结合 buffer pool 的 LRU 队列,会产生什么问题呢?
- 大量访问冷数据时,buffer_pool 会去磁盘加载冷数据到 old 区,如果存活没有超过 1 秒,且没有二次访问,那就不会对 buffer_pool 的 yung 区造成影响 (old 区占 buffer_pool 3/8)
- 问题 1:如果 old 区够大,能完全装下冷表数据,并且这个 BNL 的 SQL 执行时间超过 1 秒,old 区的冷表数据,会被移动到 young 区,导致 buffer_pool 的 LRU young 区都不是真正的热点数据
- 问题 2:如果 old 区不够大,不能完全装下冷表数据,那冷表数据会在 old 区快速循环读入和淘汰,并且导致 old 区原本可以进入 yung 区的正常业务数据被挤占淘汰。
所以大表 join 带来的坏处除了需要大量的 IO,但是 IO 的挤占是临时的,SQL 结束影响也就结束了,但是对 buffer_pool 的影响是持续的,后面很长一段时间的请求都会收到影响。
优化:
- 为了减少这种影响,可以增加 join_buffer_size 的大小,减少被驱动表被循环访问的次数。
- 如果 explain 显示,链接是 BNL 算法的,则必须考虑将其**转换成 BKA **算法,常见做法就是给链接字段增加索引。
BNL 转 BKA Batched Key Access
BNL 转 BKA 的常见做法是将连接字段加上索引
但是有些不适合在被驱动表上加索引时我们应该怎么做呢?比如下面这个场景,被驱动表过滤后结果集很小
sql
t1有1000行,t2有100w行,t2过滤后剩余2000行
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;
- 此时 BNL 算法执行,选用 t1 作为驱动表,即使 join_buffer 能完全放下,那扫描行数:t1=1000 t2=100W,内存比较次数 1000*100W=10 亿次 这个判断的工作量会很大(耗时 1 分 11 秒)
- 如果创建索引,这个索引如果不常用的话会很浪费,不创建的话要比较 10 亿次。
- 可以使用临时表来处理这种场景
临时表 BNL 转 BKA
- 将 t2 满足条件的结果创建临时表 tmp_t
- 为了让 join 使用 BKA 算法,给临时表链接字段用上索引
- 让表 t 和临时表 join,执行结果不到 1 秒
sql
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);
hash join
如果上面的例子中,join_buffer 如果维护的是一个 hash 表,那么就不用每次都遍历 1000 次,而是只需要执行 100W 次 hash 查找就好了。
Mysql 在 8.0.18 版本中引入了这个优化特性,会对较小的表(作为构建表)在 join_buffer 中构建哈希表,后面只需要对探测表每一行计算指定字段的哈希值,在哈希表中查找匹配项即可,而不是每一行都需要遍历。
mysql 启用 hash join 的条件
- 等值连接 使用=操作符
- 没有合适的索引可用
- 至少有一个链接条件可以完全放在内存中
- 优化器认为 Hash join 比 NLJ 更高效
:::color1
在 MySQL 8.0.18 版本中,Hash Join 和 BNL 算法共存,在 8.0.20 版本开始,BNL 算法已经被移除,Hash Join 成为了默认替代无索引的等值链接算法。
:::
临时表
为什么要用临时表将 BNL 优化成 BKA 算法
临时表的特性
- 建表语法
create temporary table ... - 临时表只能被创建它的 session 访问,对其他线程不可见
- 临时表可以与普通表同名,存在同名表的时候 shwo create 和增删改查操作时 优先访问临时表。
- show tables 命令不显示临时表。
可以看出临时表特别适合上一节说到的 JOIN 优化场景
- 不同 session 的临时表可以重名,多个 session 并发执行时,不需要担心表名重复
- 不需要担心数据删除问题,普通表如果执行过程中断开,或者异常重启,则需要专门清理中间数据。
临时表的应用场景
由于不用担心不同线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。
特别是分库分表系统的跨库查询,就是一个典型的使用场景。
分库分表跨库查询场景中,比如大表 huge_table 被拆分到 1024 个表,分布到 32 个库上,一般分库分表系统都有一个中间 proxy 层,也有一些方案是应用层直连所有库。
在 proxy 层架构中直接确定库和表的场景 :分区 key 一般是以减少跨库跨表查询为依据,如果大部分的查询都包含某个字段条件,比如 f 字段,那就会按照 f 字段做分区的依据。比如 select v from huge_table where f=n 在这个场景,直接根据 f 确定具体的表是最好不过了。
在查询条件不是分库 key 的场景:
select v from ht where k >= M order by t_modified desc limit 100;如果查询用的是字段 k,k 不是分库分表的键,那 proxy 就要查询所有的实例中的表,然后统一处理。这时候有两种方案
- 在 proxy 中间件的内存中处理,缺点就是 proxy 的复杂性提升,对 proxy 层压力大,容易成为瓶颈
- 将各个分库的数据,选一个 Mysql 实例进行汇总,然后在这个汇总实例上进行逻辑操作。
为啥临时表可以重命名
临时表在磁盘中的保存
临时表创建过程分析 create temporary table temp_t(id int primary key)engine=innodb;
- 执行这个语句,mysql 会给这个 innoDB 表创建一个 frm 文件保存表结构定义
- frm 文件放在临时文件目录下,文件名后缀是 .frm 前缀是**"#sql{进程 id}序列号**"
- 关于临时表数据的保存
- 5.6 之前,mysql 会在临时文件目录下创建一个相同前缀、以 ibd 后缀的文件来保存数据
- 5.7 开始,mysql 引入了一个临时文件表空间,用来存放临时文件的数据,就没有 ibd 文件了。
临时表在内存中也不会和已有表重名
MySQL 维护数据表,除了磁盘中有物理文件,内存中也有一套机制来区分不同的表,每个表都有一个 tab_def_key
- 一个普通表的 table_def_key 是由 库名+表名 得到的,所以同一个库下创建两个同名的普通表,会有雨 def_table_key 已存在,导致无法创建成功。
- 一个临时表,table_def_key 是在 库名+表名的基础上,又加入了 server_id+thread_id 这样 session A 和 sessionB 的同名临时表就可以共存了
线程使用临时表链表记录自身的临时表
- 每个线程都维护了自己的临时表链表,每次 session 内操作表的时候,会先遍历链表检查是否存在临时表。如果有临时表则优先操作临时表,没有临时表再操作普通表。
- session 结束时,会遍历链表,对每个临时表执行
DROP TEMPORARY TABLE +表名的操作、 - binlog 也会记录这个
DROP TEMPORARY TABLE的操作
临时表和主备复制
为啥要往从库同步临时表?
按正常想法来说,临时表只存在于某个会话周期内,按理说不需要往备库同步对吧?
- 但是 binlog 是有一个 statement 格式,语句上是分不出来你操作的是临时表 t 还是普通表 t,如果临时表没有被同步过去,到时候从库就要报错了。或者你 SQL 中同时用到了临时表和普通表,这个语句同步到从库也是会报错的。
- 如果 binlog 是 row 格式,那么临时表相关的语句就不会记录到 binlog 中了
主备同步要手动删除临时表?
- 主库的会话结束时,会自动执行 DROP TEMPORARY TABLE 操作,删除 session 的临时表链表中的临时表。但是在主备同步时,备库同步线程是一直在持续执行的,不会自动删除,而且这个 SQL 也不会被自动同步到备库
- 所以开发人员用完了临时表,需要手动添加删除临时表语句,防止备库的临时表泄露,或者备库出现临时表冲突或者产生数据不一致。
一个冷门知识点,为啥 mysql 要重写 drop table 命令?
- 正常的 create table 和 alter table mysql 都会原样记录,空格都不会修改。但是 drop table 则是会被改写的
DROP TABLEt_normal/* generated by server */改写后还会有 generated by server 的标记 - mysql 的 drop table 命令可以一次删除多个表,比如
主库上执行 "drop table t_normal, temp_t"在 binlog 为 row 时,由于备库并没有这个临时表,所以必须要改写这个命令,才能同步到备库执行不然备库同步报错会导致同步线程停止。
备库是由同一个同步线程执行同步的,为啥同名临时表不会冲突?
问题:假设主库两个 session 都创建了临时表 t1,这个临时表创建语句都会被传导备库上,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个 create 语句两次。(即使开了多线程复制,也可能被分配到从库的同一个 worker 中执行)。那么,这会不会导致同步线程报错 ?
- 主库在记录 binlog 的时候,会把主库执行这个线程的 id 写入到 binlog,备库是能拿到每个执行语句的主库线程 id 的。
- 构建临时表的时候,会使用这个线程 id 来构建临时表的 table_def_key,备库的 table_def_key 就是
:库名 +t1+"M 的 serverid"+"session A 的 thread_id" - 由于 table_def_key 不同,所以这两个表在备库的应用线程里面是不会冲突的。
什么时候会使用内部临时表
内存临时表通常是指 MySQL 内部为了优化查询自动创建的临时表,可以使 memory 引擎或者 TempTable 引擎。MySQL 会自动根据情况在内存和磁盘之间转换。显示创建的指定 MEMORY 引擎的临时表只是其一种形式。
常见的使用内部临时表的场景 GROUP BY、DISTINCT、 UNION(不包括 UNION ALL) 操作(隐式包含去重操作)、物化的 CNT(共用表达式)等等 EXPLAIN 查看 Extra 列,出现 "Using temporary" 就是用了临时表
内部临时表存在哪儿?
union 的隐式 DISTINCT 执行流程
示例语句 (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。
UNION ALL 不去重
如果使用的是 UNION ALL 就没有了去重的意义,会直接执行两个子查询将结果发给客户端。也就不会用到临时表了。
group BY 的执行流程
group by 也是常见的会用到内部临时表的场景,示例语句 select id%10 as m, count(*) as c from t1 group by m;

explain 结果可以看到:using index 说明使用了索引覆盖,不需要回表。using temporary 说明使用了临时表,using filesort 表示需要排序
- 创建内存临时表 表里有字段 m 和 c 主键是 m
- 扫描表 t1 的索引啊,依次取出叶子节点上的 id 值,计算 id%10 的结果,记录为 x
- 如果临时表中没有 x,就插入一行就流 (x,1)
- 如果表中有主键 x 的行,则将字段 c+1
- 遍历完成后,根据字段 m 排序,返回得到的结果集。
**注意:**旧版本MySQL的GROUP BY会隐式排序,用ORDER BY NULL可优化性能;但MySQL 8.0+已取消这个行为,现在不需要了。
**注意: **如果系统使用内存临时表时,超过tmp_table_size 的大小,就会转为磁盘临时表,磁盘临时表默认的存储引擎是 InnoDB 如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。
group by 优化方法 - 索引
不论使用内存临时表,还是磁盘临时表,group by 都会构建一个带唯一索引的表,执行代价都比较高。如果数据量大,执行就会很慢。
**为啥 group by 需要临时表?**还是参考上一个例子,因为需要统计不同值出现的个数,并且值的出现是无需的,所以需要一个临时表来统计结果。
如果数据有序 group by 还需要临时表吗?

- 当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
- 当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);
按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。mysql 的索引正好就是这样组织的。
mysql 5.7 支持的 generated column 机制,实现列数据的关联更新
可以实现更新字段 id 时自动更新 一个其他字段 alter table t1 add column z int generated always as(id % 100), add index(z);
这样在对被索引字段执行 group by 时就可以不使用临时表了

group by 优化方法-大数据场景直接排序
group by 如果用到临时表,每次都会按照先放内存临时表,容量不够时再转磁盘表。那我们在预知数据量特别大的场景时,就可以直接走磁盘临时表
可以使用 SQL_BIG_RESULT 这个提示,就可以告诉优化器这个语句涉及的数据很大,直接使用磁盘临时表
select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;
MySQL 的优化器一看,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。
- 初始化 sort_buffer,确定放入一个整型字段,记为 m;
- 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
- 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
- 排序完成后,就得到了一个有序数组。
- 根据有序数组,得到数组里面的不同值,以及每个值的出现次数。这一步的逻辑,你已经从前面的图 10 中了解过了。
MySQL 什么时候会使用内部临时表?
- 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
- join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
- 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。
内存临时表的核心配置
- 内存临时表的核心配置是
tmp_table_size,但它仅限制系统内部临时表的内存使用,用户显式创建的临时表不受此参数约束。 - 系统内部临时表默认使用** Memory 引擎(MySQL 5.7)**或更高效的 TempTable 引擎(MySQL 8.0+) ,当数据量超过 tmp_table_size 和 max_heap_table_size 中的较小值时,会自动转为磁盘临时表(默认 InnoDB 引擎)。
- 而用户临时表则按创建时指定的引擎独立管理:使用 MEMORY 引擎时受 max_heap_table_size 限制,使用 InnoDB 引擎时则直接使用磁盘临时表空间。对于大数据量的临时操作,建议用户显式创建 InnoDB 临时表以避免内存溢出。
- 所有InnoDB临时表 (无论用户显式创建或系统自动转换)均共享相同的存储机制:数据写入独立的临时表空间文件(如ibtmp1) ,使用专用内存缓存区域(非Buffer Pool普通缓存),并享有会话级隔离和快速空间回收。与普通表的本质区别在于:临时表绕过Buffer Pool的复杂管理、无需undo/redo日志,专注于会话生命周期内的高效操作。
- 内部临时表由MySQL自动创建,使用专用内存池(MySQL 8.0+的TempTable引擎)存储查询中间结果,查询结束立即销毁。当数据超内存时转为InnoDB磁盘临时表。用户临时表则使用通用内存或磁盘(可指定引擎),会话结束才释放。两者在生命周期和管理上独立,但使用InnoDB磁盘存储时会共享底层机制。
InnoDB 够用了吗?我们还需要 Memory 引擎吗?
为什么相同的插入顺序,innoDB 和 memory 默认顺序不一样?
sql
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);

如上所示两张引擎不同的表,插入相同的数据,执行 Select * 时 Memory 引擎的 t1 表,0 在最后,而 t2 则 0 在最前。
不同的索引组织方式
innodb 引擎的 t2 的索引 是我们很熟悉的 B+树
memory 引擎不同,它默认是 hash 索引 ,所以它的主键和索引是分开存放的。内存表的数据部分以数组的方式 单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,可以看到索引上的 key 并不是有序的。

:::color1
memory 引擎使用固定长度的数组来保存数据,所以不能使用 B+数这种数据和索引组织在一块儿的方式组织索引,默认使用 hash 索引,也可以使用 B 树索引。
:::
这两个引擎的典型不同
- InnoDB 的数据由于 B+数聚簇索引组织数据,所以永远有序
- 插入数据时,Innodb 只能在固定的位置插入新值,而内存表找到数据空位就可以插入新值
- 数据发生变化时,innodb 只需要修改主键索引,而内存表需要修改所有索引(因为每个索引都保存的是数据行内存中的位置)
- InnoDb 主键索引需要查找 1 次才能取到行数据,普通索引则包括一次回表所以需要两次。而 memory 引擎不管什么索引都只需要亿次查找,因为所以有锁都保存的是数据的位置。
- innodb 支持变长数据类型,不同记录的长度可能不同;内存表不支持 blob 和 text 字段,并且即使定义了 varchar(N),实际上也会当作 char(n) 也就是固定长度字符串来保存,因为要保持每行数据长度相同。
- memory 使用 hash 索引的话,就不支持范围查询了
hash 索引和 B-Tree 索引
内存表也是支持 B-Tree 索引的,例如可以通过这个方式建立索引
alter table t1 add index a_btree_index using btree(id);

:::color1
Memory 引擎支持 B-Tree 的原因
- 主要原因避免 hash 索引在大数据量下的查询速度退化
- 次要原因是可以支持优先的排序和范围查找
- min() /max()快速查找
- order by limit 优化
- 某些有序操作的支持
:::
内存表的锁
内存表只支持表锁
内存表不支持行锁,只支持表锁,因此只要一张表有更新,这个表上的其他读写操作都会被阻塞。
内存表的数据持久性问题和主从同步
内存表重启后数据清空,表结构保留
数据放在内存中,是内存表得以快速查询的优势,但是也会带来一些问题,比如持久化!内存表的表定义在重启时不会丢失,但是所有内存数据都是会丢失的。
正常情况下,实例重启,数据丢失也就丢了,但是在 Mysql 的高可用架构下,这个问题就会变得复杂起来了
M-S 主从架构下备库清空内存表导致同步停止
- 业务正常访问主库
- 备库业务升级,设备重启,备库内存表数据被清空
- 主库更新内存表数据,备库发生报错,导致同步停止
MM 双主结构,备库重启导致主库内存表被清空
双主结构,这里是指 两个节点互为主从,然后单节点写入
mysql 主库重启后,为了防止主备不一致还额外会执行内存表清空的操作 DELETE FROM t1 传递给从库。
这在双主的高可用架构中,从节点重启后,主节点会收到从节点发来的 DELETE FROM t1 导致正常读写的主库,突然丢失内存表的所有数据。
一般情况更推荐使用普通的 innoDB 表而不是内存表
- 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB 支持行锁,并发度比内存表好;
- 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读 QPS 很高并且数据量不大的表,即使是使用 InnoDB,数据也是都会缓存在 InnoDB Buffer Pool 里的。因此,使用 InnoDB 表的读性能也不会差。
用户的临时表可以使用 memory 引擎内存表
- 临时表不会被其他线程访问,没有并发性的问题;
- 临时表重启后也是需要删除的,清空数据这个问题不存在;
- 备库的临时表也不会影响主库的用户线程。
而且创建 memory 引擎内存表,不涉及磁盘的写入,比 innoDB 引擎的临时表速度更快。并且 memory 的 hash 索引在等值查询时,比 B+Tree 索引的查找速度也更快。
所以创建临时表时,更推荐使用 memory 引擎,当然支持范围查找就还是要创建 innodb 的临时表
自增主键为啥不是连续的?
自增主键的自增值保存在哪儿?
看起来在表结构定义中? 不对!
shwo create table 可以看到,表定义里面有 AUTO_INCREMENT=2,表示下一次插入数据时会使用这个自增值 id=2。
表结构保存在.frm 的文件中,但是并不会保存自增值。
不同引擎的自增值保存策略不同
- MyISAM 引擎的自增值保存在数据文件中
- InnoDB 引擎的自增值,实际上保存在了内存中!实际上到了 8.0 版本之后,,才有了**自增值持久化的能力,**也就是发生重启,表的自增值可以恢复到 MySQL 重启前的值。
- mysql5.7 及其之前的版本,自增值保存在内存里,并没有持久化。每次重启都会根据表中已存在的数据 找到当前的最大值 max(id) 最后将 max(id)+1 当做 AUTO_INCREMENT
自增值的修改机制
如果 id 被定义为 AUTO_INCREMENT
- 插入 id 时指定 id 为 0、null,就会把表当前的 AUTO_INCREMENT 值设置为自增 id 的值。
- 如果插入数据时 id 字段置顶了具体的值 X,就使用语句里指定的值。
- X<AUTO_INCREMENT 那么这个表的自增值不变
- 如果 X>= AUTO_INCREMENT 那么会把AUTO_INCREMENT 的值修改为新的自增值
- 新的自增值生成算法是:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 x 的值,作为新的自增值。auto_increment_offset 和auto_increment_increment 是两个系统参数,分别表示初始值和默认步长,默认都是 1。
:::color1
一般来说 auto_increment_offset 和auto_increment_increment 保持默认都是 1 即可,但是在某些双写的场景下,可能会修改这个配置,使用不同的初始值,并且auto_increment_increment=2 这样两个库写入的数据就不会有主键冲突了。
:::
自增值的修改时机,为什么不会保证连续
使用这个命令来分析自增的插入 insert into t values(null, 1, 1);
- 执行器调用 InnoDB 引擎接口写入一行,传入这一行的值是 (0,1,1)
- InnoDB 发现没有指定自增 id 的值,获取表 t 当前的自增值 2
- 将传入的行值改成(2,1,1)
- 将表的 AUTO_INCREMENT 自增值,改成 3
- 继续执行插入数据操作,假设已经存在 c=1 的操作,所以报主键冲突,语句返回。
自增值不连续的场景
- 自增值遇到插入冲突导致执行失败后,自增值并不会回溯,所以自增值不一定连续
- 自增值在事务执行阶段被获取且累加后,事务回滚并不会带着自增值一起进行回滚 ,所以自增值不一定是连续的。
如果要连续,这个自增值就会成为瓶颈,数据表每个语句的插入都需要持有这个自增值的锁,否则一旦出现回滚就不连续了。维持自增值完全连续需要的性能代价是巨大的。
自增锁的优化
获取自增 id 的锁,并不是一个事务锁,而是每次申请完就立马释放,允许其他并发的事务进行申请。但是在 5.1 版本之前并不是这样的!
mysql 5.0 版本时候的自增锁 是语句级别
那时候的自增锁是语句级别,也就是说如果一个语句申请了一个表自增锁,这个锁会等语句结束后才释放,很明显这会影响并发度。
mysql 5.1.22 引入了一个新策略,新增了一个参数 innodb_automic_lock_mode 默认值是 1
- 这个参数为 0 时表示沿用 5.0 版本的策略,语句执行后释放
- 这个参数设置为 1 时,
- insert 语句 自增锁申请之后马上释放
- 类似 insert select 这样的批量语句还是会等待语句结束后释放
- 这个参数设置为 2 时,所有的自增锁都是申请后立即释放。
为什么 innodb_automic_lock_mode 默认不是 2?
答案是为了 binlog=statement 时的安全!
因为 binlog 如果是 statement 格式,那么假设在执行 INSERT SELECT 时有其他事务在执行,也用到了自增锁,但是 binlog 上,事务永远是有先后的,此时无论是 INSERT SELECT 在先,还是 其他占用自增锁的事务在先,INSERT SELECT 这个语句插入的数据的自增 id 永远都是连续的。这就出现了主从数据不一致了。
建议
- 在生产上,尤其是有 insert ... select 这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row. 这样做,既能提升并发性,又不会出现数据一致性问题。
- 需要注意的是,我这里说的批量插入数据,包含的语句类型是 insert ... select、replace ... select 和 load data 语句。
mysql 对申请自增 id 的优化
- insert 多个 values,会精确计算需要分配的自增 id 一次性分配
- 批量插入数据没法儿明确数据个数时的策略
- 第一次申请分配 1 个
- 第二次申请后,如果还是这个语句会分配 2 个
- 第三次申请,还是这个语句则会分配 4 个
- 以此类推,同一个语句每次去申请申请到的自增 id 都是上一次的两倍。这也是导致自增 id 不连续的第三种原因
自增值的主从一致
:::color1
在使用 <font style="color:rgb(6, 10, 38);">STATEMENT</font> 格式的 binlog 时,MySQL 通过在 binlog 中记录 <font style="color:rgb(6, 10, 38);">SET INSERT_ID=N</font> 的方式,显式指定每条包含自增主键的 INSERT 语句所使用的起始自增值,从而确保从库重放 SQL 时生成与主库完全相同的自增 ID,保障主从数据一致。
然而,这种机制依赖于自增值分配的可预测性和确定性。因此,MySQL 官方明确要求:当 **<font style="color:rgb(6, 10, 38);">binlog_format=STATEMENT</font>**(或 **<font style="color:rgb(6, 10, 38);">MIXED</font>**模式下实际使用语句复制时), **<font style="color:rgb(6, 10, 38);">innodb_autoinc_lock_mode</font>**必须设置为 0(传统模式)或 1(连续模式),禁止使用 2(交错模式) 。因为 <font style="color:rgb(6, 10, 38);">lock_mode=2</font> 会导致并发插入时自增值分配不可预测,在基于语句的复制下无法保证主从一致性,尤其在批量插入(如 <font style="color:rgb(6, 10, 38);">INSERT ... SELECT</font> 或多行 <font style="color:rgb(6, 10, 38);">INSERT</font>)场景中风险更高。
:::
自增 id 不连续的三种场景
- insert 遇到主键冲突插入失败
- 事务执行回滚
- 批量插入的翻倍分配的策略
INSERT 语句为啥这么多锁?
insert 语句本身是个轻量的操作,但是仅限于普通的 insert 语句。类似 insert from select 这种**特殊情况,**执行过程中还会给其他资源加锁。或者在无法申请到自增 id 以后立马释放锁
sql
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 select 语句的锁
假设可重复读隔离级别下,binlog_format=statement 执行 insert into t2(c,d) select c,d from t
mysql 会对表 t 的所有行和间隙都加锁!
因为 mysql 还是处于 binlog 的日志一致性考虑,假设事务 1:insert into t2(c,d) select c,d from t 执行时,有另一个事务2 往 t 中插入一行,但是他们的事务写到 binlog 顺序是不确定的,这就没法儿保证从库执行时,insert 语句插入的数据跟主库一致了。
insert 自表插入
insert ... select 的时候,对目标表也不是锁全表,而是只锁住需要访问的资源。
假设事务 1 执行 insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
这个事务 1 加锁范围就是表 t 索引 c 上的 (3,4] 和 (4,supremum] 这两个 next-key lock,以及主键索引上 id=4 这一行。
insert 自表插入会全表扫描
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。
也就是说,这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。
至于使用临时表,是因为这种一边读取一边更新,如果不用临时表有可能读到自己刚插入的数据,可能导致跟执行的语义不符。
可以使用临时表优化加锁范围
sql
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 唯一键冲突
表 t c 字段值 1、2、3、4、5
**insert 唯一键冲突会施加 next-kay-log (gap-lock + record-lock(读锁)) **

这是在可重复读隔离级别下执行的,可以看到 sessionA 发生唯一键冲突后,session A 持有索引 c 上的 (5,10] 共享 next-key lock(读锁)。导致 sessionB 尝试插入 9 也被锁住了。
至于为什么加锁,官方可能是为了防止这一行被别的事务删掉,作者也不太清楚。
insert 唯一键冲突导致死锁的经典场景
在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回。

- 在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(如果你的印象模糊了,可以回顾下[第 21 篇文章]介绍的加锁规则)。
- 在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,session C 也在索引 c 上,c=5 这一个记录上,加了读锁。
- T3 时刻,session A 回滚,sessionB 和 SessionC 都试图继续执行插入操作,都想要加上写锁,然后二者互相等待对方的行锁(读锁)于是出现了死锁。
insert into ... on duplicate key update
正常的 insert 语句碰到唯一键异常,就会报错,但是可以使用 on duplicate key update,来决定报错后改写为更新操作。这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。
上面的例子改写成,insert into t values(11,10,10) on duplicate key update d=100;就会给索引 c 上 (5,10] 加一个排他的 next-key lock(写锁)。如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。
案例:
现在表 t 里面已经有了 (1,1,1) 和 (2,2,2) 这两行,所以 id、和唯一键 c 都会冲突。
主键 id 是先判断的,MySQL 认为这个语句跟 id=2 这一行冲突,所以修改的是 id=2 的行
:::color1
执行这条语句的 affected rows 返回的是 2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功了,update 计数加了 1, insert 计数也加了 1。
:::
怎么最快的复制一张表
如果对原表的加锁范围可控且不大,可以直接简单的使用 insert select 语句实现。
但是为了避免对原标加锁,更稳妥的方法都是先将数据写入到外部文件,在写回目标表。
mysqldump 方法
一种方法是,使用 mysqldump 命令将数据导出成一组 INSERT 语句。你可以使用下面的命令:
sql
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 的作用是,导出数据时不需要枷锁,而是使用快照读
- -add-locks =0 表示在输出的文件中,不增加 LOCK TABLES t WRITE; 来对表施加写锁
- -no-create-info 意思是不要导出表结构
- -set-grid-purged=off 表示不需要导出 GTID 相关信息
- -result-file 就是指定输出文件路径了。

可以看到,一条 INSERT 语句里面会包含多个 value 对,这是为了后续用这个文件来写入数据的时候,执行速度可以更快。
如果你希望生成的文件中一条 INSERT 语句只插入一行数据的话,可以在执行 mysqldump 命令时,加上参数--skip-extended-insert。
然后,你可以通过下面这条命令,将这些 INSERT 语句放到 db2 库里去执行。
mysql -h127.0.0.1 -P13000 -uroot db2 -e "source /client_tmp/t.sql"
source 并不是一条 SQL 语句,而是一个客户端命令。mysql 客户端执行这个命令的流程是这样的:
- 打开文件,默认以分号为结尾读取一条条的 SQL 语句;
- 将 SQL 语句发送到服务端执行。
也就是说,服务端执行的并不是这个"source t.sql"语句,而是 INSERT 语句。所以,不论是在慢查询日志(slow log),还是在 binlog,记录的都是这些要被真正执行的 INSERT 语句。导出 CSV 文件
导出 CVS 文件
mysql 提供了方法将查询结果到处到服务端本地目录
select * from db1.t where a>900 into outfile '/server_tmp/t.csv';
- 这条语句会将结果保存在服务端,如果使用客户端链接执行,需要到服务端目录下去找这个文件。
- into outfile 指定了文件的生成位置 ,位置收到 secure_file_priv 配置的限制。
- secure_file_priv 为空表示不限制文件生成位置,这是不安全的设置
- 如果设置路径字符串,生成的文件就只能放在这个目录及其子目录
- 设置为 NULL 表示禁止这个 mysql 实例上执行 SELECT ...INTO OUTFILE 操作了
- 得到 cvs 文件后可以用命令导入到目标表
load data infile '/server_tmp/t.csv' into table db2.t;
在主备同步的时候,主库如果执行 load data infile,备库怎么办呢?
答案:主库执行完后,会把 cvs 文件内容也写到 binlog 中,备库后续会解析 binlog 中的 cvs 文件,写到本地临时目录,然后再执行 load Data 语句,最后就可以实现一致性了。
物理拷贝法
我们能直接拷贝数据表的.frm 文件和 ibd 文件吗?
答案是不行,因为一个 innodb 表除了这俩文件,还需要在数据字典中注册。
MySQL5.6 提供了可传输表空间(transportable tablespace) ,可以实现导出+导入表空间实现物理拷贝的功能
- 执行 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 相同的数据。
grant 之后需要 flush privileges 吗?
grant 语句是用来给用户赋权的,那赋权之后到底要不要 flush privileges 呢?
grant 语句和 flush privileges 语句做了啥?
举例:create user 'ua'@'%' identified by 'pa';
这条语句的逻辑是创建一个用户 'ua'@'%',密码是 pa。注意,在 MySQL 里面,用户名 (user)+ 地址 (host) 才表示一个用户,因此 ua@ip1 和 ua@ip2 代表的是两个不同的用户。
- 磁盘上,往 mysql.user 中插入了一行记录,但是没指定权限,所以这行记录上表示权限的字段值都是 N
- 内存里,往数组 acl_user 中插入一个 acl_user 对象,这个对象的 access 字段值为 0
全局权限
全局权限作用在整个 Mysql 实例,权限信息保存在 mysql.user 表里
赋予所有权限的写法:grant all privileges on *.* to 'ua'@'%' with grant option;
- 磁盘上,将 mysql.user 表中,用户 'ua'@'%'这一行所有表示权限的字段值都设置为 'Y'
- 内存里,将数组 acl_user 中找到这个用户对象,将 access 值(权限位)设置为二进制的全 1
在这个 grant 命令执行完成后,如果有新的客户端使用用户名 ua 登录成功,MySQL 会为新连接维护一个线程对象,然后从 acl_users 数组里查到这个用户的权限,并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句,所有关于全局权限的判断,都直接使用线程对象内部保存的权限位。
基于上面的分析我们可以知道:
- grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
- 对于一个已经存在的连接,它的全局权限不受 grant 命令的影响。
收回所有权限的案例 revoke all privileges on . from 'ua'@'%';
- 磁盘上,将 mysql.user 表里,用户'ua'@'%'这一行的所有表示权限的字段的值都修改为"N";
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 的值修改为 0。
DB 权限
除了全局权限,MySQL 也支持库级别的权限定义。如果要让用户 ua 拥有库 db1 的所有权限,可以执行下面这条命令:grant all privileges on db1.* to 'ua'@'%' with grant option;
- 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为"Y";
- 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为"全 1"。
每次需要判断一个用户对一个数据库读写权限的时候,都需要遍历一次 acl_dbs 数组,根据 user、host 和 db 找到匹配的对象,然后根据对象的权限位来判断。
也就是说,grant 修改 db 权限的时候,是同时对磁盘和内存生效的。
- 全局权限即使回收了,不会影响已有的线程链接。因为这个权限信息会被缓存到线程对象中,revoke 操作不影响这个线程对象。
- DB 权限修改后,已有的链接就会立即收到影响,因为每次都会通过 acl_dbs 全局对象来判断权限。
- 如果某个会话,某个库的权限被收回,但是他已经处于这个 DB 中,它还是有权限操作这个库。因为权限信息,在 use datebase 的时候缓存到会话变量中了。
表权限和列权限
除了 db 级别的权限外,MySQL 支持更细粒度的表权限和列权限。
其中,表权限定义存放在表** mysql.tables_priv** 中,列权限定义存放在表 **mysql.columns_priv 中。这两类权限,组合起来存放在内存的 hash 结构 column_priv_hash **中。
赋权命令如下
sql
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;
跟 db 权限类似,这两个权限每次 grant 的时候都会修改数据表,也会同步修改内存中的 hash 结构。因此,对这两类权限的操作,也会马上影响到已经存在的连接。
flush privileges 使用场景
- 正常情况下,赋权操作是立即生效的,正常不需要再执行亿次 flush privileges 了。
- flush privileges 命令会清空 acl_users 数组,然后从 mysql.user 表中读取数据重新加载,重新构造一个 acl_users 数组。也就是说,以数据表中的数据为准,会将全局权限内存数组重新加载一遍。
- 同样地,对于 db 权限、表权限和列权限,MySQL 也做了这样的处理。
- 也就是说,如果内存的权限数据和磁盘数据表相同的话,不需要执行 flush privileges。而如果我们都是用 grant/revoke 语句来执行的话,内存和数据表本来就是保持同步更新的。
当内存和数据表权限不一致用 flush privileges 来重建内存数据达到一致状态。
这种不一致往往是由不规范的操作导致的,比如直接用 DML 语句操作系统权限表。
要不要使用分区表
分区表是什么?
sql
CREATE TABLE `t` (
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
KEY (`ftime`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
insert into t values('2017-4-1',1),('2018-4-1',1);
这两行记录,会按照分区落在两个分区上

可以看到分区表包含了一个.frm 文件,和四个.ibd 文件。
所以对于引擎层来说这是 4 个表,对于 server 层来说这是 1 个表
手动分区和分区表有什么区别?
手动分区和分区表性能差距不大:比如按照年份来分表,我们就分别创建普通表 t_2017、t_2018、t_2019 等等。手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新。在性能上,这和分区表并没有实质的差别。
差距主要在 Server 层:
- 分区表和手工分表,一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表。因此,从引擎层看,这两种方式也是没有差别的。
- 其实这两个方案的区别,主要是在 server 层上 。从 server 层看,我们就不得不提到分区表一个被广为诟病的问题:打开表的行为。
MyISAM 引擎一个分区数量过多时的典型错误

- 第一次访问一个分区表的时候,MySQL 会把每个分区访问一遍
- 如果分区表的分区数很多,比如超过 1000 个,Mysql 启动时默认的 open_files_limit 是 1024 个,就会在访问这个表的时候由于需要打开所有文件,导致超出 open_files_limit 限制而报错
- 使用 MyISAM 引擎会存在这个问题,Innodb 不会。因为 MyISAM 的分区实现机制 是**通用分区机制 generic partitioning,**每次访问分区都由 server 层控制。这个通用分区策略,是最早的分区策略,所以实现很粗糙,有严重的性能问题。
MySQL 5.7.9 引入了**本地分区机制,native partitioning ,**这个策略是在 InnoDB 内部管理决定打开哪些分区
从 MySQL5.7.17 开始,MyISAM 分区表,就标记为弃用了,一直到 MySQL 8.0 版本,就不允许创建 MyISAM 分区表了,只允许创建实现了本地分区策略引擎的分区表,目前只有 InnoDB 和 NDB 这俩引擎支持
分区表 server 层行为
从** server 层看分区表的话,一个分区表就是一个表**,一个分区表有多个分区,也是共用一个 MDL 锁


所以这也是 DBA 同学常说的,分区表做 DDL 的时候影响更大,如果是普通分表起码 truncate 一张表的时候不会跟另一张表的查询语句出现 MDL 锁冲突
小结
- MySQL 第一次打开分区表的时候需要访问所有的分区(通用分区机制下)
- server 层认为这是同一张表,会共用一个 MDL 锁
- 在引擎层,认为这是不同的表,因此** MDL 锁之后的执行过程**,会根据分区表规则,只访问必要的分区。
分区表的应用场景
分区表的优势
分区表的最大优势是对业务透明 ,使用分区表的代码更简洁 ,而且分区表可以方便的清理历史数据
如果一项业务跑的时间足够长,往往就会有根据时间删除历史数据的需求。这时候,按照时间分区的分区表,就可以直接通过 alter table t drop partition ...这个语法删掉分区,从而删掉过期的历史数据。
这个 alter table t drop partition ...操作是直接删除分区文件,效果跟 drop 普通表类似。与使用 delete 语句删除数据相比,优势是速度快、对系统影响小。
MySQL 支持分区划分方式:除了 range 范围分区,mysql 还支持 hash 分区、list 分区(一个分区对应一个值列表) 等分区方法
自增 ID 用完了怎么办?
表中的自增主键用完了怎么办?
表定义的自增值达到上限后的逻辑是:再申请下一个 id 时,得到的值保持不变。
后续的插入会由于主键冲突而无法插入
自增 id 如果使用 int 类型的话,默认最大值是 2^32 -1 也就是 4294967295 42 亿左右,如果使用无符号的 bigint 那这个值 (2^64 -1 = **<font style="color:rgb(6, 10, 38);">18,446,744,073,709,551,615</font>**)非常大,是很难达到这个上限的。
如果没定义主键,innoDB 隐式的 row_id 用完了怎么办?
如果你创建的 InnoDB 表没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id 。InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys.row_id 值作为要插入数据的 row_id,然后把 dict_sys.row_id 的值加 1。
实际上,在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)。但是,InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,这样写到数据表中时只放了最后 6 个字节,所以 row_id 能写到数据表中的值,就有两个特征
- ow_id 写入表中的值范围,是从 0 到 2^48-1;
- 当 dict_sys.row_id=2^48时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0
- 也就是说,写入表的 row_id 是从 0 开始到 2^48-1。达到上限后,下一个值就是 0,然后继续循环。
关联 binlog 和 redolog 的 xid 用完了呢?
xid 是用来关联 binlog 和 redolog:redo log 和 binlog 相配合的时候,提到了它们有一个共同的字段叫作 Xid。它在 MySQL 中是用来对应事务的
Xid 来自 mysql 的全局变量 global_query_id: MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid。
global_query_id 是一个纯内存变量,重启之后就清零了。所以你就知道了,在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。但是 mysql 重启后会新开一个 binlog 文件,所以单个 binlog 文件中的 Xid 一定是唯一的。
global_query_id 定义的长度是 8 个字节,这个自增值的上限是 2^64-1 ,达到上限后就会继续从 0 开始计数!所以理论上是有可能出现一个 binlog 中存在相同的 XID 的,但是这个2^64-1 太大了,大到这种情况值存在于理论上。
:::color1
崩溃恢复时因为 binlog 是追加写入的,所以即使出现 2 个相同的 xid,也可以区分,哪个适用于崩溃恢复。
:::
:::color1
AI 的说法是 :**XID = [16B server_uuid] + [8B 递增 trans_id] **
trans_id 来自 MySQL Server 层的全局原子计数器 transaction_counter,每次事务提交时递增。不过用完了都是从头开始。并且不进行持久化。
:::
Innodb 内部递增的事务 id trx_id 用完了呢?
Xid 是由 server 层维护的。InnoDB 内部使用 Xid,就是为了能够在 InnoDB 事务和 server 之间做关联。但是,InnoDB 自己的 trx_id,是另外维护的
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。
InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。
对于正在执行的事务,可以从** information_schema.innodb_trx** 表中看到事务的 trx_id。

可以看到,只执行读取操作时显示的 trx_id 是一个非常大的值,T4 时刻则是 1289
- T1 时刻 trx_id 的值是 0,这个很大的数只是用来展示的
- 这个数字由系统临时计算出来的,算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48
- 这个算法可以保证
- 同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的
- 如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的 trx_id 就是不同的
- 显示值里面加上 2^48,目的是要保证只读事务显示的 trx_id 值比较大。
- 直到 T3 执行插入操作时,InnoDB 才真正的分配了 trx_id 也就是 1289 (除了修改,如果使用了 FOR UPDATE 也不算只读事务)
为什么看起来 trx_id 是跳着增加的?
- 关于 update 操作为啥 trx_id 不止增加了 1,因为 update/delete 设计删除旧数据,这个操作会把数据放到 purge 队列等待后续物理删除,这个操作也会把 max_trx_id+1
- InnoDB 后台操作,比如索引信息统计,也会启动内部事务这些操作也会增加 max_trx_id
只读事务不分配 trx_id 的好处
- 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的,因为它不修改数据。所以,在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id。
- 另一个好处是,可以减少 trx_id 的申请次数。在 InnoDB 里,即使你只是执行一个普通的 select 语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请 trx_id,就大大减少了并发事务申请 trx_id 的锁冲突。
trx_id 会达到 2^48-1 的上限吗?
- max_trx_id 会持久化存储,重启也不会重置为 0,那么从理论上讲,只要一个 MySQL 服务跑得足够久,就可能出现 max_trx_id 达到 2^48-1 的上限,然后从 0 开始的情况。
- 假设一个 MySQL 实例的 TPS 是每秒 50 万,持续这个压力的话,在 17.8 年后,就会出现这个情况。如果 TPS 更高,这个年限自然也就更短了。但是,从 MySQL 的真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过**,这个 bug 是只要 MySQL 实例服务时间够长,就会必然出现的**。
trx_id 达到上限的脏读 bug
- 假设时刻 1 trx_id 达到了上限,那在这个上限后启动的事务 T1 的低水位事务 id 就是最大值 2^48-1 了
- 时刻 2 如果有其他事务 T2 执行操作,被分配的 trx_id 就会归零
- 此时事务 T1 就可以读到 T2 未提交的数据,发生了脏读
thread_id 用完了呢
thread 是 mysql 中最常见的自增 id,show prcesslist 第一列就是 thread_id
- thread_id 的逻辑很好理解:系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量。
- thread_id_counter 定义的大小是 4 个字节,因此达到 2^32-1 后,它就会重置为 0,然后继续增加。但是,你不会在 show processlist 里看到两个相同的 thread_id。
- 因为 MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 的时候,如果线程数组已经有了这个 id 就获取下一个 id 进行判断