记一次 MySQL 的查询优化

背景

当前业务中存在一个接口耗时比较高的功能模块(这里简称:【查询面板】)。其中最高耗时可达 50s +,造成该功能几乎无法使用,严重影响用户使用的体验。这里将记录如何将查询时延从 50s + 降低到 10s 内的过程

场景

目前所有表都使用 TDSQL (分布式 MySQLhttps://cloud.tencent.com/document/product/557/8765)来存储,由多个分片组成。

查询时主要用到了 2 张表:

image.png

广播表:在每个分片都存在完整的数据集

分区表:即:TDSQL-MySQL 中的一级分区,通过对分区键进行 hash 之后,dml 操作将发往分区键对应的分片中执行。如下图所示:

image.png

需要注意的是:在查询分区表时,需要指定明确的分区键(等值查询),否则会将所有请求发往所有的分片执行完之后,在 proxy 按原始 sql 语义进行对应处理再返回给客户端

对应表结构如下(已省略部分字段)

CREATE TABLE `ad_conf`
(
    `id`                   bigint          NOT NULL AUTO_INCREMENT,
    `med_account_id`       bigint unsigned NOT NULL COMMENT '媒体账户id',
    `creative_id`          bigint unsigned NOT NULL DEFAULT '0' COMMENT '创意id',
    `title`                varchar(1024)   NOT NULL DEFAULT '' COMMENT '标题',
    `status`               varchar(50)     NOT NULL DEFAULT '' COMMENT '创意状态',
    `opt_status`           varchar(50)     NOT NULL DEFAULT '' COMMENT '创意操作状态',
    `isdelete`             tinyint(1)      NOT NULL DEFAULT '2' COMMENT '是否已删除。1:是,2否',
    `creative_create_time` datetime                 DEFAULT NULL COMMENT '媒体侧创意创建时间, 格式: yyyy-mm-dd',
    `creative_modify_time` datetime                 DEFAULT NULL COMMENT '媒体侧创意修改时间, 格式:yyyy-mm-dd',
    PRIMARY KEY (`id`),
    KEY `creative_id_index` (`creative_id`),
    constraint account_creative unique (med_account_id, creative_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci shardkey=noshardkey_allset;
  
  CREATE TABLE `ad_panel`
(
    `id`               int         NOT NULL AUTO_INCREMENT,
    `statis_date`      int         NOT NULL COMMENT '统计日期,格式:yyyyMMdd',
    `med_account_id`   bigint      NOT NULL COMMENT '媒体账号 ID',
    `med_account_name` varchar(60) NOT NULL COMMENT '媒体账号名称',
    `game_id`          int         NOT NULL DEFAULT '0' COMMENT 'O2游戏ID',
    `game_name`        varchar(64) NOT NULL DEFAULT '' COMMENT '游戏名称',
    `agent_id`         int         NOT NULL DEFAULT '-1' COMMENT '代投方ID, 0 - 未知',
    `agent_name`       varchar(32) NOT NULL DEFAULT '' COMMENT '代投方名称',
    `med_id`           int         NOT NULL DEFAULT '-1' COMMENT '媒体ID',
    `med_name`         varchar(64) NOT NULL DEFAULT '' COMMENT '媒体名称',
    `plat_id`          bigint      NOT NULL DEFAULT '0' COMMENT '平台ID ( 1:Android,2:IOS)',
    `plat_name`        varchar(128)         DEFAULT '' COMMENT '平台名称',
    `ad_id`            bigint      NOT NULL COMMENT '广告计划id',
    `cost`             bigint               DEFAULT '0' COMMENT '消费,单位: 分',
    `shows`            int                  DEFAULT '0' COMMENT '展现',
  -- 省略 300+ 字段
    `click`            int                  DEFAULT '0' COMMENT '点击',
    `cpm`              bigint               DEFAULT '0' COMMENT '千次展现消费(头条接口:avg_show_cost),单位: 分',
    `cpc`              bigint               DEFAULT '0' COMMENT '平均点击价格(头条接口:avg_click_cost),单位: 分',
    PRIMARY KEY (`id`),
    UNIQUE KEY `date_creative_unique_index` (`statis_date`, `med_account_id`, `ad_id`),
    KEY `index_creative_id` (`ad_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci shardkey=statis_date;

查询过程分析

这里需要过滤 ad_conf 表筛选出对应的【广告配置】,以及获取到该【广告配置】在 ad_panel 表保存的【效果数据】,另外还可以根据所有效果数据的字段进行排序,比如:按消耗、曝光、ROI、CPA、LTV 等。

注意:

ad_panel 表只保存了 ad_conf 配置表有效果数据的信息,【然而如果没有效果数据的配置,则只会存在 ad_conf 表】

阶段一:直接 join

直接 join , 由于 ad_panel 是分区表,使用关联的字段 ad_id 无法使用索引,导致 ad_conf 的结果集在 ad_panel 中进行全表扫描

select l.*, sum(r.cost) as sum_cost, r.*
from ad_conf l
         left join ad_panel r on l.ad_id = r.ad_id and r.game_id = ?
    and r.statis_date >= ? and r.statis_date <= ?
where l.status = ?
  and l.med_account_id in ?
  and l.status = ?
  and l.isdelete = ?
group by l.ad_id
order by sum_cost desc, l.ad_id desc;

阶段二:聚合函数下推

如果 ad_panel 与 ad_conf 直接 join 造成全表扫描在数据量小的情况没什么问题,但如果 ad_panel 数据量达到 50w 之后也会非常慢,这里做了一个优化点,将聚合下推到子查询中,减少 left join 时扫描 ad_panel 的数据行。

select l.*, r.*
from ad_conf l
         left join (select sum(cost) as sum_cost, ad_id as ad_id_ext, *
                    from ad_panel
                    where statis_date >= ?
                      and statis_date <= ?
                      and game_id = ?
                    group by ad_id) r
                   on l.ad_id = r.ad_id_ext and r.statis_date >= ? and r.statis_date <= ?
where l.status = ?
  and l.med_account_id in ?
  and l.status = ?
  and l.isdelete = ?
order by sum_cost desc, l.ad_id desc;

可以通过对比两条 sql 行数的差距来看能否优化目的

select count(1)
from (
         select *
         from ad_panel
         where statis_date >= ?
           and statis_date <= ?
           and game_id = ?
         group by ad_id
     ) a ;
     
select count(1)
from (
         select *
         from ad_panel
         where statis_date >= ?
           and statis_date <= ?
           and game_id = ?
     ) a ;

阶段三:减少 join 时产生临时文件

通过阶段二在通过提前 group by 之后减少了 join 时所需数据量, 但当 group by 减少的数据量与【阶段一】相同时,速度上也会受到影响。

此时可以根据 SQL 执行的过程来分析

1. join

join 是在 join_buffer 内存做的,如果匹配的数据量比较小或者 join_buffer 设置的比较大,速度也不会太慢。但是,如果 join 的数据量比较大时,mysql 会采用在硬盘上创建临时表的方式进行多张表的关联匹配,这种显然效率就极低,本来磁盘的 IO 就不快,还要关联。

优化点:

降低 join 时的数据量

2. order by

order by 排序,分为全字段排序和 rowid 排序。它是拿 max_length_for_sort_data (4KB)和结果行数据长度对比,如果结果行数据长度超过 max_length_for_sort_data 这个值,就会走 rowid 排序,相反,则走全字段排序。

  • rowid 排序:仅在 sort buffer 中保存排序的字段和主键id,当完成排序后再通过主键 ID 回表查询其他列
  • 全字段排序:在 sort buffer 中保存所有需要查询的列,排序完成后直接返回,不需要二次回表

排序时借助 sort_buffer 来完成

  • 如果要排序的数据小于 sort_buffer_size,排序在 sort_buffer 内存中完成
  • 如果要排序的数据大于 sort_buffer_size,则借助磁盘文件来进行排序

优化点:

  1. 超过 max_length_for_sort_data(4KB)大小就会导致走 rowid 排序,需要二次回表查其他字段,
  2. 超过 sort_buffer_size (2M)则需要通过文件排序

目前通过查询 ad_panel 表信息,一行大小 2KB, 因此会走全字段排序,但是由于查出来的数据量已经超过 sort_buffer_size 因此会走

假设每次通过 group by 查询 5W 行数据,每行 2KB,那么一次需要对 50000*2/1024=97M 数据排序

优化方向:

  1. 建立二级索引,依赖二级索引有序的特性。因为分区表,且分区键 range 查询导致无法使用索引所以放弃该种方式

  2. 根据 rowid 排序思想,将排序列与数据列分两次查询。具体为:

  3. 第一次查询 id (8 byte)+ 排序列(4 byte ) 大小 12 byte ,此时有 50000*12/1024/1024=0.57M 数据排序

  4. 第二次根据查询的 id 回表查(业务上实现)

image.png
image.png

最后优化点:

1. 排序字段在左表

select l.ad_id, l.*
from task_log.ad_conf l
where l.status = ?
  and l.med_account_id in ?
  and l.status = ?
  and l.isdelete = ?
order by l.creative_create_time desc, l.ad_id desc;

2. 排序字段在右表

select l.ad_id, l.*
from task_log.ad_conf l
         left join (select sum(cost) as sum_cost, ad_id as ad_id_ext, *
                    from task_log.ad_panel
                    where statis_date >= ?
                      and statis_date <= ?
                      and game_id = ?
                    group by ad_id) r
                   on l.ad_id = r.ad_id_ext and r.statis_date >= ? and r.statis_date <= ?
where l.status = ?
  and l.med_account_id in ?
  and l.status = ?
  and l.isdelete = ?
order by sum_cost desc, l.ad_id desc;
  1. 通过 1\2 中的 ad_id 回表查询(此时转为单表查询),再通过 ad_id 在业务层去拼接
select ad_id as ad_id_ext, *
from task_log.ad_panel
where statis_date >= ?
  and statis_date <= ?
  and game_id = ?
  and ad_id in (?)

未来方向

目前主要的问题:

  1. 分区键存在 range 查询导致索引失效,所以造成扫全表
  2. join 和 排序让广播表和分片表进行关联,不是同一类型表存在分片的 ID 不一致

所以一方面需要重新根据业务主体定义分区键,如果按 game_id 分区,另一方面将配置表需要过滤列冗余到宽表,避免 join

你可能感兴趣的:(记一次 MySQL 的查询优化)