背景
当前业务中存在一个接口耗时比较高的功能模块(这里简称:【查询面板】)。其中最高耗时可达 50s +,造成该功能几乎无法使用,严重影响用户使用的体验。这里将记录如何将查询时延从 50s + 降低到 10s 内的过程
场景
目前所有表都使用 TDSQL (分布式 MySQLhttps://cloud.tencent.com/document/product/557/8765)来存储,由多个分片组成。
查询时主要用到了 2 张表:
广播表:在每个分片都存在完整的数据集
分区表:即:TDSQL-MySQL 中的一级分区,通过对分区键进行 hash 之后,dml 操作将发往分区键对应的分片中执行。如下图所示:
需要注意的是:在查询分区表时,需要指定明确的分区键(等值查询),否则会将所有请求发往所有的分片执行完之后,在 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,则借助磁盘文件来进行排序
优化点:
- 超过 max_length_for_sort_data(4KB)大小就会导致走 rowid 排序,需要二次回表查其他字段,
- 超过 sort_buffer_size (2M)则需要通过文件排序
目前通过查询 ad_panel 表信息,一行大小 2KB, 因此会走全字段排序,但是由于查出来的数据量已经超过 sort_buffer_size 因此会走
假设每次通过 group by 查询 5W 行数据,每行 2KB,那么一次需要对 50000*2/1024=97M 数据排序
优化方向:
建立二级索引,依赖二级索引有序的特性。因为分区表,且分区键 range 查询导致无法使用索引所以放弃该种方式
根据 rowid 排序思想,将排序列与数据列分两次查询。具体为:
第一次查询 id (8 byte)+ 排序列(4 byte ) 大小 12 byte ,此时有 50000*12/1024/1024=0.57M 数据排序
第二次根据查询的 id 回表查(业务上实现)
最后优化点:
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\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 (?)
未来方向
目前主要的问题:
- 分区键存在 range 查询导致索引失效,所以造成扫全表
- join 和 排序让广播表和分片表进行关联,不是同一类型表存在分片的 ID 不一致
所以一方面需要重新根据业务主体定义分区键,如果按 game_id 分区,另一方面将配置表需要过滤列冗余到宽表,避免 join