MySQL Join
- 背景介绍:t1,t2 表结构一致(字段 id,a,b —> PRIMARY KEY (
id
), KEYa
(a
) - Index Nested-Loop Join
-
栗子:select * from t1 straight_join t2 on (t1.a=t2.a);
- straight_join 让 MySQL 使用固定的连接方式执行查询(即这里 t1 会驱动表
-
Index Nested-Loop Join 算法的执行流程
-
从表 t1 中读入一行数据 R
-
从数据行 R 中,取出 a 字段到表 t2 里去查找
-
取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分
-
重复执行步骤 1 到 3,直到表 t1 的末尾循环结束
-
这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引
-
在这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索
-
-
结论
- 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好
- 如果使用 join 语句的话,需要让小表做驱动表
-
- Simple Nested-Loop Join
- 栗子:select * from t1 straight_join t2 on (t1.a=t2.b);
- 由于表 t2 的字段 b 上没有索引,因此每次到 t2 去匹配的时候,都要做一次全表扫描
- 这样算来,这个 SQL 请求就要扫描表 t2 多达 100 次,总共扫描 100*1000=10 万行
- 这种场景时,MySQL 并不会选择此算法,而是选择 Block Nested-Loop Join 算法
- Block Nested-Loop Join
-
Block Nested-Loop Join 算法的执行流程
-
把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存
-
扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回
-
如果 t1 需读入数据过大,join_buffer 一次性存放不下时
-
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k
- 一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少
-
如果放不下表 t1 的所有数据话,策略很简单,就是分段放
- 第一次匹配筛选完后,清空 join_buffer,重复原操作,直至全匹配完
-
-
-
结论
- 从时间复杂度,两个算法是一样的;但是后者判断都是内存操作,速度快很多,性能更好
- 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表
- 在 join_buffer_size 足够大的时候,选择两个表都一样
-
- 能不能用 Join ,如果使用,用哪张表作驱动表?
- 如果可以使用被驱动表的索引,join 语句还是有其优势的
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用
- 在使用 join 的时候,应该让小表做驱动表
- 小表:过滤查询条件后数据量,即计算参与 join 的各个字段的总数据量,数据量小的那张
Join 优化
-
Multi-Range Read 优化
-
目的:尽量使用顺序读盘
-
MRR 执行流程
-
根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中
-
将 read_rnd_buffer 中的 id 进行递增排序
- 由 read_rnd_buffer_size 参数控制大小,若满了则依然使用 “分段” 的作法
-
排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回
-
若需稳定地使用 MMR 优化,需要设置 set optimizer_switch=“mrr_cost_based=off”
- 官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR
-
explain 后 # Extra 字段多了 Using MRR,表示的是用上了 MRR 优化
- 使用上 MRR 后,得到的结果集也是按照主键 id 递增顺序的
-
-
MRR 能够提升性能的核心
- 根据二级索引叶子节点获取到批量的主键值后,进行对这批主键值排序,最后再批量回表查询
- nnoDB 查记录时是直接读整个页,然后再在页中找到需要的行记录(类似于数组预读的特性
-
-
Batched Key Access
-
MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA) 算法
-
Batched Key Access 流程
- 旧 NLJ 算法是从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join
- BKA 算法是一次性取出多个值存入 join_buffer 中,利用 MRR特性 进行匹配关联
- 同理,若 join_buffer 不够大,仍然使用“分段”的方式
-
使用BKA :set optimizer_switch=‘mrr=on,mrr_cost_based=off,batched_key_access=on’;
- 前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR
-
-
BNL 算法的性能问题
- 可能会多次扫描被驱动表,占用磁盘 IO 资源
- 影响只是暂时的,在语句执行结束后,对 IO 的影响也就结束了
- 判断 join 条件需要执行 M*N 次对比,如果是大表就会占用非常多的 CPU 资源
- 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率
- 如果被驱动表是一个大的冷数据表,多次扫描冷表且执行时间超过1秒时
- 冷表的数据量小于整个 Buffer Pool 的 3/8,把冷表的数据页移到 LRU 链表头部
- 如果这个冷表很大,就会出现业务正常访问的数据页,没有机会进入 young 区域
- 对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率
- 如果被驱动表是一个大的冷数据表,多次扫描冷表且执行时间超过1秒时
- 可能会多次扫描被驱动表,占用磁盘 IO 资源
-
BNL 转 BKA
-
总体思路:让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能
-
直接在被驱动表上建索引,这时就可以直接转成 BKA 算法(若业务需要且非低频使用
-
创建带有索引的临时表
-
大致思路
- 把表 t2 中满足条件的数据放在临时表 tmp_t 中
- 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引
- 让表 t1 和 tmp_t 做 join 操作
-
执行效果
- 整个过程执行时间的总和还不到1秒,相比于前面的1分11秒,性能得到了大幅提升
-
-
-
扩展 -hash join
- 用哈希表取代无需数组,查询匹配效率大幅度提升
- MySQL 的优化器和执行器一直被诟病的一个原因:不支持哈希 join
- MySQL 官方的 roadmap,也是迟迟没有把这个优化排上议程
临时表重名
-
临时表与内存表概念上的区别
- 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory
- 这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在
- 临时表,可以使用各种引擎类型(如果使用的是 Memory 引擎,则特性同上
- 如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的
- 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory
-
临时表的特性
- 临时表在使用上有以下几个特点
- 建表语法是 create temporary table …
- 一个临时表只能被创建它的 session 访问,对其他线程不可见(session 结束时自动删除
- 临时表可以与普通表同名
- session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表
- show tables 命令不显示临时表
- 临时表特别适合我们文章开头的 join 优化这种场景
- 不同 session 的临时表是可以重名的(可以支持 多个 session 同时执行 join 优化场景
- 不需要担心数据删除问题(session 结束时自动回收
- 临时表在使用上有以下几个特点
-
临时表的应用
-
由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中
- 其中,分库分表系统的跨库查询就是一个典型的使用场景
-
分库分表简图
-
一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上
-
在这个架构中,分区 key 的选择是以“减少跨库和跨表查询”为依据的
- 针对于每次查询都能使用上分区字段条件时,这是分库分表方案最欢迎的语句形式了
-
当对于查询条件里面没有用到分区字段 f 场景下的解决思路
-
第一种思路是,在 proxy 层的进程代码中实现排序
- 这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算
- 缺点
- 需要的开发工作量比较大(如果遇到复杂操作,对中间层的开发能力要求较高
- 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题
-
另一种思路就是,把各个分库拿到的数据汇总到一个 MySQL 实例的一个表中再做逻辑
-
跨库查询流程示意图
-
在实践中,我们往往会发现每个分库的计算量都不饱和
-
所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上
-
-
-
-
-
为什么临时表可以重名
- 栗子:create temporary table temp_t(id int primary key)engine=innodb;
- MySQL 要给这个 InnoDB 表创建一个 frm 文件保存表结构定义,还要有地方保存表数据
- 这个 frm 文件放在临时文件目录下(select @@tmpdir 命令,来显示实例的临时文件目录
- 文件名后缀是.frm,前缀是“#sql{进程 id}{线程 id} 序列号
- 关于表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式
- 在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件
- 从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据
- 这个 frm 文件放在临时文件目录下(select @@tmpdir 命令,来显示实例的临时文件目录
- MySQL 维护数据表时,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key
- 一个普通表的 table_def_key 的值是由“库名 + 表名”得到的
- 对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”
- 即不同 session 创建的临时表t1,它们的 table_def_key 不同,磁盘文件名也不同
- 在实现上,每个线程都维护了自己的临时表链表
- session 内操作表时,会先遍历链表,若未存在再去操作普通表
- session 结束时对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作
-
临时表和主备复制
- 问题:临时表只在线程内自己可以访问,DROP TEMPORARY TABLE 为什么要写到 binlog 里面
- 如果不记录临时表操作,则备份在执行 insert into t_normal select * from temp_t 会报错
- 不同 binlog row 格式下,主备复制对临时表操作同步的策略
- binlog 是 row 格式时,记录的是对应操作的数据(write_row event 记录插入一行数据(1,1)
- 只在 binlog_format=statment/mixed 的时候,binlog 中才会记录临时表的操作
- 主库退出时会自动删除临时表,但是备库同步线程是持续在运行的,所有需写入删除操作
- 另一个问题:DROP TABLE
t_normal
/* generated by server */ (为什么同步时要改成标准格式- drop table 命令是可以一次删除多个表的,若 binlog_format=row 时,可能导致同步线程停止
- 因为备库上并没有表 temp_t,drop table 命令记录 binlog 的时候,就必须对语句做改写
- drop table 命令是可以一次删除多个表的,若 binlog_format=row 时,可能导致同步线程停止
- 下一个问题:主库上不同的线程创建同名的临时表是没关系的,但是传到备库执行是怎么处理的
- MySQL 在记录 binlog 的时候,会把主库执行这个语句的线程 id 写到 binlog 中
- 在备库的应用线利用这个线程 id 来构造临时表的 table_def_key
- session A 临时表 t1,在备库的是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
- session B 临时表 t1,在备库的是:库名 +t1+“M 的 serverid”+“session B 的 thread_id”;
- 由于 table_def_key 不同,所以这两个表在备库的应用线程里面是不会冲突的
- 问题:临时表只在线程内自己可以访问,DROP TEMPORARY TABLE 为什么要写到 binlog 里面
内存临时表
-
union 执行流程
-
栗子:(select 1000 as f) union (select id from t1 order by id desc limit 2);
-
union 执行流程
- 执行第二个子查询,拿到数据试图插入临时表时需要校验唯一性约束
- 这个场景下的内存临时表起到了暂存数据的作用和通过临时表主键校验唯一性约束
-
union all 取代 union 后,不再依赖临时表,得到的结果直接作为结果集的一部分返回给客户端
-
-
group by 执行流程
-
栗子:select id%10 as m, count(*) as c from t1 group by m;
-
group by 的 explain 结果
- Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
- Using temporary,表示使用了临时表;
- Using filesort,表示需要排序;
-
group by 执行流程
- 创建内存临时表,表里有两个字段 m 和 c,主键是 m;
- 扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
- 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
- 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
- 遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端
-
若需求中不需要对结果进行排序,可以在 SQL 语句末尾增加 order by null
-
内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M
- 超过阈值后,内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB
-
-
group by 优化方法 – 索引
- 执行 group by 语句为什么需要临时表
- group by 的语义逻辑,是统计不同的值出现的个数
- 由于每一行的 id%100 的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果
- 如果可以确保输入的数据是有序的,那么计算 group by 的时候就只需要从左到右顺序扫描即可
- 在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新
- 栗子:alter table t1 add column z int generated always as(id % 100), add index(z);
- 利用索引z,即可实现不需要临时表和不需要文件排序(顺序读索引z 即可
- 在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新
- 执行 group by 语句为什么需要临时表
-
group by 优化方法 – 直接排序
-
背景:如果碰上不适合创建索引的场景且需要放到临时表上的数据量特别大时
- 原流程:先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表
- 优化:通过 SQL_BIG_RESULT 这个提示(hint)告诉优化器,数量很大,直接走磁盘临时表
- 磁盘临时表是 B+ 树存储,存储效率不如数组来得高(从磁盘空间考虑,直接用数组存吧
-
使用 SQL_BIG_RESULT 的执行流程图
- 初始化 sort_buffer,确定放入一个整型字段,记为 m;
- 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
- 扫描完成后,对 sort_buffer 的字段 m 做排序(若内存不够,则使用磁盘临时文件辅助排序
- 排序完成后,就得到了一个有序数组
- 根据有序数组,得到数组里面的不同值,以及每个值的出现次数,组装成结果集返回
-
-
MySQL 什么时候会使用内部临时表
-
如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的
- 否则就需要额外的内存,来保存中间结果
-
join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构
-
如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表
- union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数
-
-
针对 group by 使用的指导原则
- 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
- 尽量让 group by 过程用上表的索引( explain 结果里有没有 Using temporary 和 Using filesort
- 如果 group by 需要统计的数据量不大,尽量只使用内存临时表
- 可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表
- 如果数据量实在太大,使用 SQL_BIG_RESULT 提示(告诉优化器直接使用排序算法得到结果值
内存表影响
-
不建议在生产环境上使用内存表的主要原因
- 锁粒度问题
- 数据持久化问题
-
内存表的数据组织结构
- InnoDB 和 Memory 引擎的数据组织方式是不同的
- InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id,称为索引组织表
- Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,称为堆组织表
- 两个引擎的一些典型不同
- InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的
- 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值
- 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引
- InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
- InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同
- InnoDB 和 Memory 引擎的数据组织方式是不同的
-
hash 索引和 B-Tree 索引
- 内存表不仅支持 hash 索引同时也支持 B-Tree 索引
- 栗子:alter table t1 add index a_btree_index using btree (id);
- 内存表的优势是速度快,其中的一个原因就是 Memory 引擎支持 hash 索引
- 更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快
- 内存表不仅支持 hash 索引同时也支持 B-Tree 索引
-
内存表的锁
- 内存表不支持行锁,只支持表锁(即一个表内数据只支持串休更新
- 这里的表锁区别于 MDL 锁,是用来锁表内数据的(前者为表结构
- 跟行锁比起来,表锁对并发访问的支持不够好
- 内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好
- 内存表不支持行锁,只支持表锁(即一个表内数据只支持串休更新
-
数据持久性问题
-
数据放在内存中是内存表的优势,但也是一个劣势(数据库重启的时候所有的内存表都会被清空
-
由于 MySQL 担心主库重启之后,出现主备不一致,MySQL 在实现上做了这样一件事儿
-
在数据库重启之后,往 binlog 里面写入一行 DELETE FROM t1
-
如果此时使用双 M 结构的话
- 备库重启的时候,备库 binlog 里的 delete 语句就会传到主库(主库内存表的内容删除
- 主库再使用的时候就会发现,内存表数据突然被清空….
-
-
内存表并不适合在生产环境上作为普通数据表使用
- 如何看待 “内存表执行速度快” 这个观点
- 如果你的表更新量大,那么并发度是一个很重要的参考指标(行锁 >> 表锁
- 能放到内存表的数据量都不大,而且 InnoDB 有 InnoDB Buffer Pool 作读性能保障
- 建议把普通内存表都用 InnoDB 表来代替
-
例外:在数据量可控,不会耗费过多内存的情况下,你可以考虑使用内存表
- 数量量可控时,使用内存临时表的效果更好的原因
- 相比于 InnoDB 表,使用内存表不需要写磁盘,往表 temp_t 写数据的速度更快
- 索引 b 使用 hash 索引,查找的速度比 B-Tree 索引快
- 临时表数据只有 2000 行,占用的内存有限
- 数量量可控时,使用内存临时表的效果更好的原因
-
内存临时表刚好可以无视内存表的两个不足
- 临时表不会被其他线程访问,没有并发性的问题
- 临时表重启后也是需要删除的,清空数据这个问题不存在
- 备库的临时表也不会影响主库的用户线程
-
- 如何看待 “内存表执行速度快” 这个观点
-