MySQL学习(四)Join 等开发常用的操作 --- 2019年2月

时间:2022-12-16 06:28:08

1、查数据太多不会把内存用光

  InnoDB 的数据是保存在主键索引上,然后索引树分割保存在数据页上,数据页存在内存中/磁盘。change buffer 就是先把修改操作记录,然后读数据的时候,内存没有就从磁盘拿,把 change buffer 的改动应用的从磁盘读取的数据页上,返回给请求。并且此时数据页变成脏页,又会刷脏页。

  全盘扫描实际上就是扫描表的主键索引。查到符合条件的就放在结果集,然后返回给客户端。

  结果集:

  一块内存 net_buffer 写入符合条件的结果,重复获取写入到 net_buffer(默认大小16K) 直到写满,调用网络接口发出。发送成功就清空 net_buffer,然后继续获取下一行继续写到 net_buffer 中。

  然后发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,表示本地网络栈(socket send buffer) 写满了,进入等待,直到本地栈重新可写,再继续发送。客户端去读 socket receive buffer 的内容,不堵塞的话,socket send buffer 就会继续把数据发送给 socket receive buffer (服务端的网络栈) 。

  这个流程就是 "边读边发",所以并不会200G的表就会用到200G的内存。 如果客户端接收的很慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。

  show processlist 如果看到 State 的值一直处于 " Sending to client" ,就表示服务端的 socket receive buffer 写满了(网络端查询拿数据多/拿数据慢,所以写满),net_buffer 就会放满并且还有查询结果没写入到 net_buffer 中。如果想减少处于这个状态的线程,将 net_buffer_length 参数设置为一个更大的值也是可以的。

  如果客户端使用 -quick 参数,会使用 mysql_use_result 方法。 这个方法是读一行处理一行。如果有一个业务逻辑比较复杂,每读一行数据都要处理的逻辑很慢,就会导致客户端要过很久才会去取下一行数据。

  对于正常的线上业务,一个查询返回的结果不会太多(常常如此),建议使用 mysql_store_result

  这个接口,直接把查询结果保存到本地内存。

  Sending data 的意思只是 正在执行 sql 语句,并不是发送数据。

  全表扫描对 InnoDB 的影响

  WAL 机制,InnoDB 的 redo log pool buffer 内存,是保存更新的动作,配合 redo log,避免了随机写盘。

  内存的数据页是在 Buffer Pool(BP) 中管理的,在 WAL 机制里,Buffer Pool 还起到加速更新的作用。还有更重要的作用,加速查询。一般修改都保存在内存数据页,还没有落盘,此时查询,就会先查询内存(不用查磁盘),得到最新数据就返回了。

  Buffer Pool 对查询的加速效果,依赖一个重要的指标:内存命中率。

  执行命令: show engine innodb status 可以看到 Buffer pool hit rate 就是内存命中率,稳定服务的线上系统,内存命中率在99%以上。

  InnoDB Buffer Pool 的大小由参数 innodb_buffer_pool_size 决定。如果 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。

InnoDB 引擎之前用的 LRU(最少使用淘汰)算法,然后进行了改进。把 LRU 链表分为了 young 和 old 区域。5/8 是 young 区域,靠近链表尾部的 3/8 就是 old 区域。

  全盘扫描过程中,新插入的数据页是放在 old 区域开头,该数据页第一次和最后一次访问的时间间隔不超过1秒,就继续留在 old 区域, 超过1秒,就移动到 young 区域开头。 再扫描之后的数据,该数据页不会被访问到,所以很快被淘汰。

  这个策略最大收益是全盘扫描的时候,虽然用到了 Buffer Pool,但是 young 区域完全没影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

2、怎么使用 join

  select * from t1 straight_join t2 on (t1.a=t2.a);

  当使用 join 的时候,一个 SQL 语句的流程如下:

  1)、从表 t1 中读入一行数据 R;

  2)、从数据行 R 中,取出 on 条件里的 a 字段到表 t2 里去查找;

  3)、取出表 t2 中满足条件的行,跟 R 组成一行,做为结果集的一部分;

  4)、重复执行步骤1、3,知道表 t1 的末尾循环结束。

  Join 中,对 t1 表(驱动表)做了全盘扫描,去 t2 (被驱动表)查找走的是树搜索过程,所以只需要找到值符合的行就可以。

  算法:假如被搜索的行数是M,每次在被驱动表查一行数据,要先搜索索引A,再查主键索引。在被驱动表查一行的时间复杂度是 2*log2M。驱动表是N行,整个过程的复杂度是:N + N*2*log2M。

  所以 N 越小越好。

  注意事项:以小表驱动大表(小表:两个表按照各自的条件过滤,过滤完后,计算参与join的各个字段的总数据量,数据量小的那个表,就是"小表")。

  注意事项: t2 的 a 字段是 t2 表中的索引(用上被驱动表的索引,on 后的条件字段是 索引),并且称之为 "Index Nested-Loop Join"。

  如果 t2 表中的 a 字段不是索引,那么每次去 t2 表匹配就要做一次全盘扫描,复杂度就是 M*N。

  当然MySQL也没有用这个方法,而是用的 "Block Nested-Loop Join"方法,简称BNL 。

  BNL 流程如下:

  1)、把 t1 表的数据读入线程内存 join_buffer 中,扫描整个表的N行;

  2)、扫描表 t2 ,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足条件就作为结果集返回。

  这个算法里,两个表都做了一次全盘扫描,扫描总行数:M+N,内存中判断次数 M*N。

  join_buffer 大小由参数 join_buffer_size 设定,默认值 256K,如果放不下驱动表的整个表的数据,那就分段放。就是一部分满足条件返回后,清空 join_buffer 后继续放。但是 join_buffer_size 的越大,可以放入的行越多,分段就越少,对驱动表的全盘扫描次数就越少。

  总结:可以使用 Index Nested-Loop Join 算法(用上被驱动表的索引)是可以用 join的,使用Block Nested-Loop Join 算法,的话,join 就尽量不要用。

就是看 explain 结果的 Extra 字段,出现"Block Nested Loop"或"Index Nested Loop"。

  

3、join 语句的优化

  Multi-Range Read(MRR),这个优化主要的目的是尽量使用 顺序读 盘。

  如果只是按照索引 a 的值的递增顺序查,那么按主键id的回表就不一定是顺序查,可能是随机访问,性能就会相对较差。

  大多数的数据都是按照主键递增顺序插入到 主键索引的 B+ 树上的,如果是按照主键的递增顺序查询,对磁盘的读就比较接近顺序读,可以提升性能。

  所以根据条件是普通索引 a 去查询数据,语句的执行流程优化:

  1)、根据索引 a ,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中;

  2)、将 read_rnd_buffer 中的 id 进行递增排序;

  3)、排序后的 id 数据,依次到主键 id 索引中查询记录,并作为结果返回。

  read_rnd_buffer 的大小是由 read_rnd_buffer_size 控制,执行步骤一,当放满了索引 a 的值,就执行第二三步骤,然后清空 read_rnd_buffer,重新执行步骤一。

  如果想使用 MRR 优化,就得设置 set optimizer_switch="mrr_cost_based=off"。

  使用了 MRR 优化,当 explain SQL 语句时,看 Extra 关键字段,会有一个Using MRR。

  主要是在索引 a 做条件的时候,是一个范围查询。所以才会有排序,提现顺序性的优势。

  引入Batched Key Access 算法

  MySQL 5.6版本后开始引入的该 BKA 算法,是对 NLJ 的优化。

  NLJ 的执行逻辑是从驱动表 t1 表一行行取出 a 的值,然后去被驱动表 t2 去做 join。

  原理类似 MRR 的原理,也是从驱动表 t1 里面取多行数据,去被驱动表 t2 去匹配。并且驱动表 t1 的数据是存在临时内存 join_buffer 里的。

  如果要执行 BKA 算法,先设置:set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

  BNL 算法的性能问题

  大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。(会导致 Buffer Pool 的热数据被淘汰,影响内存命中率)

  BNL 转 BKA

  如果一个 SQL 语句是一个低频的 SQL 语句,为被驱动表创建一个索引就浪费。 但是不创建索引,走 BNL 算法,如果被驱动表数据量很大,也会很慢。

可以考虑使用临时表,流程:

  1)、把被驱动表 t2 中满足条件的数据放在临时表 tmp_t 中;

  2)、为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 上加上索引;

  3)、让驱动表 t1 和 tmp_t 做 join 操作。

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

4、临时表

  临时表只存在于创建该临时表的线程里,对其他线程不可见。所以临时表之间可以重名。

  分库分表的跨库查询一般就是使用临时表。

  分库分表:把一个逻辑上的大表,分散到不同的数据库实例上。比如将一个大表 ht ,按字段 a,拆分成1024个分表,然后分布到 32 个数据库实例上。一般情况在分库分表系统都有一个中间层 proxy。

  当 SQL 语句包含了 a 字段的等值条件,那么就用 f 做分区 key。 这样能迅速定位数据在哪个分表里,发送该 SQL 去该表做查询。

  但是如果 SQL 语句有另一个条件索引 k ,并且有 order by 和 limit 条件。 SQL 语句只能到所有的分区去查找满足条件的所有行,然后做统一 order by 的操作。

  有两种思路,思路一

  在 proxy 层的进程代码中实现排序。从分库中拿到数据后,直接在内存中参与计算。但是缺点也比较明显:需要的开发工作量发,如果涉及到复杂的操作,比如 group by 和 join 这样的操作,对中间层的开发能力要求较高。并且对 proxy 层的压力较大,尤其很容易出现内存不够用和 CPU 瓶颈问题。

  另一种思路

  把分库拿到的数据汇总到一个 MySQL 实例的一个表中,然后再该表做逻辑操作。

  在实践中我们会直接把临时表放在32个分库的某一个上。

  因为创建临时表在 MySQL 5.7之后是在内存的临时表空间,命名规则是进程号+线程号+表名,所以不存在不同的线程之间的表重名的问题。

  在 session 结束的时候,对链表的每个临时表执行 drop temporary+表名 ,并且记录到 binlog 中。(记录到 binlog 是涉及到了主备复制)

5、使用内部临时表

  union 语句是把两个查询的子集的并集作为结果集返回,一般是要在内存中划出临时内存表存该2个子查询的结果集。(去重)

  union all 就不需要去重,所以也不需要临时表,只需要将2个子查询的结果集合并返回。

  group by 执行流程

  group by 也是使用了临时表,以什么分组。

  临时表的大小由 tmp_table_size 参数控制,默认16M。如果数据大于了该参数的值,那就会把内存临时表转成磁盘临时表。。磁盘临时表默认使用的也是 InnoDB 引擎。

  group by 逻辑都需要构造一个带唯一索引的表,执行代价比较高。

  group by 优化方法 -- 索引

  当数据是根据 group by 的字段为索引,排好序。 那么 SQL 语句就不需要临时表,也不需要排序了。

  group by 优化方法 -- 直接索引

  如果遇上不好做索引的场景,那就让 group by 直接用磁盘临时表。临时表是 B+ 数存储,存储效率不如数组高,那就使用数据来存。

  如果对 group by 语句的结果没有排序要求,要在语句后面加上 order by null

  尽量让 group bu 过程用上表的索引。

  尽量使用临时表,或者调大 tmp_table_size 参数。

  如果数据量实在太大,那就使用 SQL_BIG_RESULT 这个提示,告诉优化器直接选择排序算法得到 group by 的结果。

  InnoDB 表的数据都是放在主键索引上的,主键索引就是B+树。 在 select * 的时候,就会从叶子节点从左往右扫描。

  不建议在生成环境中使用内存表:锁粒度问题、数据持久化问题。

  内存表不支持行锁,只支持表锁。一张表只要有更新,就会堵住其他所有这个表的读写操作。

  数据放内存中,当数据库重启的时候,所有的内存表会被清空。

6、自增主键为什么不是连续的

  自增主键可以让主键索引尽量的保持递增顺序插入,避免页分裂,因此索引更紧凑。

  当前插入的主键为 null 的时候,下一行插入的数据的主键是上一行的主键值+2,所以主键可能不是连续的。

  表的结构定义放在后缀名为 .frm 的文件中,但是并不会保存自增值。

  MySQL 8.0 版本,自增值的变更记录再 redo log 中,重启的时候恢复重启之前的值。MySQL 5.7 及之前的版本,自增值保存在内存,重启后第一次打开表,回去找自增值的最大值 max(id),然后将 max(id) +1 作为这个表的当前自增值。

  如果字段 id 定义为 AUTO_INCREMENT,插入一行数据,如果主键 id 为0或者null 的时候,就把这个表当前的 AUTO_INCREMENT 值填到自增字段。如果插入数据时 id 制定了具体的值,就直接使用该值。

  在插入过程中,就算出现错误(唯一索引冲突等),自增值增加了,也不会改回去。所以就会产生自增值的 空洞 。

  所以 唯一索引冲突是导致自增主键 id 不连续的第一种原因

  事务回滚也会产生类似的现象,这就是第二种原因。

  不允许自增值回退,保证了递增,但是不能保证连续。

  自增锁并不是一个事务锁,而是每次申请完就马上释放,以便允许其他事务再申请。

7、复制表

  mysqldump 方法

  使用 mysqldump 命令将数据导出成一组 INSERT 语句,把结果输出到临时文件。此时一条 INSERT 语句里面会包含多个 value 对,执行速度会更快。

  导出 CSV 文件

  直接将结果导出成 .csv 文件。然后使用 load data 命令将数据导入到目标表中。

  物理拷贝方法

8、grant 之后要跟着 flush privileges

  grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。所以规范地使用 grant 和 revoke 语句,是不需要加上 flush privileges 语句的。

flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能不一致的情况下再使用。

  自增ID达到上限后的逻辑是:再申请下一个ID时,得到的值保持不变。如果键是唯一,就可能报主键冲突错误。

全局扩展

  如果用 left join 的话,左边的表不一定就是驱动表,如果需要 left join 的语义,就不能把被驱动表的字段放在 where 条件里做等值判断或不等值判断,必须都写在 on 里面。

该文章只是笔记,记录的是我自己大概的总结。

如果想看完整的知识点,可以去 极客时间app 上找 MySQL实战45讲 --- 林晓斌 老师的课程。

链接:https://time.geekbang.org/column/139