FinOps降本增效之SQL优化

FinOps降本增效之SQL优化

背景

在数据驱动大数据时代,数据处理愈发成为企业发展的重要组成部分。随着业务和数据的爆炸式增长,处理数据的成本也越来越高,好多公司提出了降本增效的目标。成本优化对企业经营的价值和意义非常重要。通过成本优化,企业可以降低生产和运营成本,提高利润率,增强企业的竞争力。通过SQL任务的优化,一方面可以帮助企业更快地获取数据,提高数据处理效率,减少资源浪费,从而降低成本,另一方面还可以提高数据的准确性和可靠性,减少错误率,提高数据分析的质量和精度,为企业决策提供更加可靠的依据。

受众群体

     具有SQL基础能力,并希望借鉴最优实践的数据开发工程狮、数据分析狮以及对SQL优化感兴趣的“狮”们。

“纸上谈兵”-理论篇

磨刀不误砍柴工,在讲解具体案例之前,先来理解几个概念。

表(Table)的本质

不管是Hive还是阿里云MaxCompute,它们的本质是将数据存储在分布式文件系统中。数据被组织成表的形式,表可以被分区和存储在不同的数据节点上,以实现数据的并行处理和高可用性。表的本质是分布式存储和处理数据的一种方式。用户可以通过SQL无差别地来查询和处理这些文件数据。

表的类型(Type)

按照数据的存储或组织方式,可以分为分区表和非分区表。将数据按照某个字段(如日期、区域等)进行分区存储,分区对应的其实就是一个子目录(ds=20230530)。

drwxrwxrwx -  0 2023-05-31 02:10 ../dev.db/test_a/ds=20230530
......
drwxrwxrwx  - 0 2023-06-04 17:29 ../dev.db/test_a/ds=20230603

而非分区表则将所有数据存储在同一个表级目录下(test_b)。

drwxrwxrwx -  0 2023-05-31 02:10 ../dev.db/test_b

分区表和非分区表对比

表类型优点
分区表查询时只需要扫描特定的分区,而不需要扫描整个表,因此查询速度更快。
分区表可以更方便地进行数据的备份、恢复和维护,因为只需要对特定的分区进行操作。
分区表可以支持更大的数据量,因为数据可以分散存储在多个分区中,而不是全部存储在同一个表中。
非分区表由于非分区表不需要额外的分区存储空间,因此相对于分区表来说,存储空间更小。

在设计表结构或者说建模的时候,如何选择表类型还是要根据实际业务形态及使用场景出发。

裁剪(Pruning)

分区裁剪(Partition Pruning)和列裁剪(Column Pruning)是两种优化技术,用于提高查询性能。分区裁剪是指在查询时,根据查询条件中指定的分区键的值,只查询相关的分区,而不是扫描所有的分区。这样可以大大减少查询的数据量,提高查询效率。列裁剪是指在查询时,只查询需要的列,而不是查询所有的列。这样可以减少网络传输的数据量,提高查询效率。

谓词下推(Predicate Pushdown)

谓词下推是指在查询时,将查询条件下推到数据源中进行过滤,而不是将所有数据都读入内存后再进行过滤。这样可以减少网络传输的数据量和内存的使用,提高查询效率。例如:select max(age) from user_tb where sex=‘m’ 那么谓词下推会将这个 sex=‘m’ 条件推到数据源中,只读取年龄大于18的数据,而不是读取所有数据后再进行过滤。Hive的优化器会自动完成谓词下推,无需手动干预。但是为了确保这个优化技术能够发挥作用,需要在查询时使用合适的查询条件.特别是左关联一个分区表的时候,需要特别注意。

查看执行计划(Explain)

在Hive中,执行计划是指查询将如何被执行的详细描述。它显示了Hive处理查询的步骤,包括操作执行的顺序、输入/输出格式、Reducer的数量以及使用的连接类型等。上面提到我们可以通过SQL无差别地分析和处理数据,无需感知底层数据是如何存储的以及SQL代码是如何转换为计算模型通过什么样的计算引擎执行的。但是理解执行计划对于优化Hive查询的性能非常重要,因为它可以帮助您识别潜在的瓶颈并相应地优化查询。作为数据分析师,虽然不需要深入理解底层的运行原理,但通过“explain [dependency] sql_code” 来查看数据输入表和分区是否符合预期,在案例篇中会稍加展开说明。

SQL复杂度

一般来说查询语句中包含的表、字段、条件、聚合函数等的数量和复杂度越高,输入的数据量越大查询的复杂度就越高。SQL复杂度往往与计算成本正相关,即复杂度越高成本越高。某种意义上可以定义为成本越高,SQL复杂度越高,下面的常见的SQL算子的复杂度矩阵。

SQL算子时间复杂度空间复杂度
JOINO(n^2)O(n)
GROUP BYO(nlogn)O(n)
ORDER BYO(nlogn)O(n)
DISTINCTO(nlogn)O(n)
UNIONO(nlogn)O(n)
ROW_NUMBERO(n)O(1)
HAVINGO(n)O(1)

实际情况可能会因为数据量、硬件配置、参数配置等因素而有所不同。

参数

参数对SQL执行的效果起到很大作用,尤其是遇到性能瓶颈的时候,一条简单的set 命令 就可以改变执行计划和执行效果。下面列出了Hive中的一些常用参数及简单说明,详细资料可以参阅Hive官方文档。(不同的环境参数默认配置可能略有差异)。

参数参数介绍
hive.vectorized.execution.enabled是否开启向量化模式,默认关闭
hive.auto.convert.join是否开启Mapjoin自动优化,默认关闭
hive.mapjoin.smalltable.filesize默认值25000000(25M)
hive.map.aggrMap端聚合,默认开启
hive.merge.mapfiles合并只有Map任务作业的输出文件,默认开启
hive.merge.mapredfiles合并MapReduce作业最终的输出文件,默认关闭
hive.merge.smallfiles.avgsize默认值25600000当输出文件小于该值时,启用MR任务合并小文件
hive.merge.size.per.task默认值256000000,合并和的文件大小,一般和hdfs块大小一致
hive,exec.parallel默认关闭,在资源充足的情况下可以有效节约运行时间
hive.exec.parallel.thread.number允许并行任务个数,默认值8
hive.optimize.correlation相关性优化,默认关闭
hive.optimize.skewjoin倾斜连接,默认关闭,开启后为倾斜键单独创建计划
hive.skewjoin.key默认值100000,如果相同key行数超过该值则认为该key为倾斜关联键

这些参数可以由运维人员通过配置文件全局设置,也可以在单个任务中手动灵活设置,任务中的配置参数会覆盖全局参数。

付费与计费模式

谈到成本,必然与计费和付费模式有关,就像我们用的手机套餐一样,一般分为按量付费和预付费(包月包年)。不同的消费群体会根据自己的消费水平和消费能力选择不同的付费方式。不同付费模式的比较。

付费模式计费模式特点
按量实际消耗的CU * 单价(1)单价高,灵活性高,支持弹性伸缩。
数据读取量 * SQL复杂度 * 单价(2)
包年包月一次性购买一定数量的CU单价低,灵活性低,如果没有合理的布局很容易造成资源浪费

通过SQL任务优化,对于按量模式可以提前释放资源,对于预付费模式,可以大大提高资源使用率。不管是采用何种模式,通过SQL优化都能起到降本增效的作用。通过SQL优化及调度任务的合理化布局,找到付费模式的最优解。

关于SQL优化的理论知识与技巧主要介绍以上8个点,下面我们进入干货区。

“知行合一”-案例篇

下面我们通过案例来具体感受一下SQL优化方法和效果。由于部分展示代码过程,某些篇幅用“…“省略,只保留关键信息。

案例0:简单地查看执行计划

假设现有如下一张用户信息表 user_info_pt(pt代表分区表)

select * from user_info_pt;
1001	张山	18	m	2023-06-01
1002	李士	20	f	2023-06-01
1003	王武	23	f	2023-06-01
1004	赵六	30	m	2023-06-01
1005	周琦	31	m	2023-06-01
1001	张山	18	m	2023-06-02
1002	李士	20	f	2023-06-02
1003	王武	23	f	2023-06-02
1004	赵六	30	m	2023-06-02
1005	周琦	31	m	2023-06-02
Time taken: 0.508 seconds, Fetched: 10 row(s)

通过查询2023年6月1号分区用户信息表中年龄大于18岁的用户信息的执行计划来看一下其大致庐山真面目。

explain
 select name,age,sex from user_info_pt a
 where dt='2023-06-01' and age>18;
......
Processor Tree:
  TableScan
    alias: a
    Statistics: Num rows: 10 Data size: 1870 Basic stats: COMPLETE Column stats: NONE
    Filter Operator
      predicate: ((dt = '2023-06-01') and (age > 18)) (type: boolean)
      Statistics: Num rows: 3 Data size: 561 Basic stats: COMPLETE Column stats: NONE
      Select Operator
        expressions: name (type: string), age (type: tinyint), sex (type: string)
        outputColumnNames: _col0, _col1, _col2
        Statistics: Num rows: 3 Data size: 561 Basic stats: COMPLETE Column stats: NONE
        ListSink

“TableScan: alias: a” 行表示扫描的表别名为"a",如果没有指定表别名,默认值为表名。

“Statistics" 行表示表的统计信息: 行数:10 数据大小:1870B

"predicate: ((dt = ‘2023-06-01’) and (age > 18))"行就是理论篇中提到的谓词下推,在读取表的时候只扫描"dt = ‘2023-06-01’"分区 并读取"age>18"的数据,而不是扫描所有分区,最后按"age>18"再过滤。

下面再来看一下 explain dependency的功能。

explain dependency
 select name,age,sex from user_info_pt a
 where dt='2023-06-01' and age>18;

{"input_tables":[{"tablename":"user_info_pt","tabletype":"MANAGED_TABLE"}],
"input_partitions":[{"partitionName":"user_info_pt@dt=2023-06-01"}]}

相比于 explain 和 explain dependency 结果要简单清楚得多,很容易看出输入表为“user_info_pt”及输入分区为“dt=2023-06-01”,符合我们的预期。

说明:不同的产品(比如Hive和MaxCompute),执行计划和执行计划的打印信息会不同,但底层的执行逻辑几乎是相同的。后面的案例,为了减少篇幅,会减少或省略执行计划的打印。


案例1:列裁剪优化

虽然在SQL代码规范中明确写明不要用select *, 但很多同学为了少打几个字段,贪图一时之方便,喜欢使用select *,而实际使用到的只是少数某几列。让我们通过具体的例子来体会一下具体的差异。

select * from order_info_pt 
where ds='20230601';

select  uid,order_no,crt_time
from    order_info_pt
where   ds = '20230601';
SQLNum rowsData size
select *1.08603256E85.17820324608E11
select 字段1.08603256E82.2589477248E10

通过执行计划可以看出读取Data size大约节约(5.17820324608E11 / 2.2589477248E10 ≈ 23 )倍,按照按量计费模式(2),成本相差约23倍。所以平时养成良好的SQL习惯,无形之中给企业带来降本增效的作用。


**案例2:合理的分区设计

一张模型执行日志表,不同业务人员每天都会根据不同的模型键(model_key)进行数据分析。

代码如下:

......
select  biz_key
        ......
        ,output_result
from    model_result_daily_pt_old
where   dt = '20230601'
and     model_key = 'KNATIVE_DZ_BR_SUB_MODEL_202210_V3_1MTH'
.....

优化之前model_result_daily_pt_old 是个以日期dt作为分区字段,通过执行计划看到数据行数和数据大小

Statistics: Num rows: 5.3117044E7, Data size: 1.59351132E10

后面我建议业务owner修改表结构,把model_key设计成二级分区

create table if not exists model_result_daily_pt(
  biz_key string comment '',
  ......
  output_result
).....
partitioned by (dt string,model_key string);

执行同样的SQL代码,读取的数据行数和数量如下:

Statistics: Num rows: 1552329.0, Data size: 3.104658E8

   优化后读取的数据缩小了1.59351132E10/3.104658E8 ≈ 50 多倍,执行时间也大大减少,大大提高效率和降低成本。

案例3:数据倾斜

数据倾斜指的是数据分布极不均匀,这里的数据往往指的是用于join 和 group by的字段,导致某个或某几个节点任务运行时间远远超过其他任务(因为同一个key_value会分发到同一个节点进行计算)这样的任务通常也被称作长尾任务。

select  ,task.uid
        ,usr.name
from    f_task_info_pt task
left join  d_user_pt as usr
on task.uid = usr.uid and usr.dt = '2023-06-01'
where task.dt = '2023-06-01';

通过任务的监控发现这个代码运行时间很长,然后代码只有一个join操作,基本可以断定是数据倾斜造成的。我们可以通过关联字段的统计信息验证一下:

SELECT  uid
        ,count(*) as cnt
from    d_user_pt
where   ds = '20230601'
group by  uid
having  cnt > 500 
-- 这个值可以设的大一点,这里为了显示效果才取的这么小
order by  cnt desc 
limit 10;

NULL	4319218
a005045dd732351	946
......
ac4e89562448b23	681

通过查询结果发现空值 有430万行之多,而其他uid值最高也就几百行,分布很不均匀。下面来看具体优化方法,

如果在业务上空值是没有意义的,那么可以直接过滤掉,如果空值行是有意义的那么可以通过在关联的时候加个随机数进行打散解决。代码如下

select  task.task_id
        ,task.uid
        ,usr.register_time
from    task_info task
left join  ${ld_loan}.dim_user as usr
on      coalesce(task.uid ,cast(rand() * 10000 as string)) = usr.uid
and     usr.ds = '20230601'
where   task.ds = '20230601';

优化后原本运行1个小时左右的任务,只要几分钟就执行结束。


案例4:增量合并代替全量计算

现有一张app登录日志表,记录每个用户每一次APP登录信息,每天有几千万人次登录量。表结构和样本数据如下

create table app_login_log_pt(
    uid bigint comment'用户id',
    login_at timestamp comment '登录时间',
    app_name string comment 'app名称'
)comment 'app登录日志表'
partitioned by (dt string comment '日期分区')
stored as orc;

select uid,login_at,app_name
from app_login_log_pt where dt='2023-06-01';
  
1001	2023-06-01 09:12:23	微信	2023-06-01
...
1001	2023-06-01 12:12:23	抖音	2023-06-01
...

某一天张三接到个需求,需要做一张表,查看每个用户每个APP最早登录时间和最近一次登录时间。看看张三是怎么做的:

insert  overwrite table app_login_info
select  uid
        ,app_name
        ,min(login_at) as first_login_at
        ,max(login_at) as atest_login_at
from    app_login_log_pt
where   dt <= '${yesterday}'
group by  uid,app_name

我相信很多刚步入数据开发和数据分析行业的工程师们写过这样类似的代码。可能在业务刚上线的时候这段代码很快就能跑出来,但是过一个月、一年…后发现任务越来越慢,甚至跑不出来。因为每次都要读取全量分区的数据,6月1号那天的数据已经高达2.12E12行,Data size: 4.4096E14 ≈ 410TB,太恐怖了。

insert overwrite table user_app_login_info_pt partition(dt='${today}')
select  coalesce(a.uid ,b.uid) as uid
       ,coalesce(a.app_name ,b.app_name) as app_name
     -- 如果昨天有首登时间为空,就取今天的,不然取昨天的
      ,coalesce(b.first_login_at ,a.first_login_at) as first_login_at
    -- 如果今天有登录,就取今天的,不然取昨天的
    ,coalesce(a.latest_login_at,b.latest_login_at) as latest_login_at
from    (
          -- 计算今天的首末次登录时间
            select  uid
                    ,app_name
                    ,min(login_at) as first_login_at
                    ,max(login_at) as latest_login_at
            from    app_login_log_pt
            where   dt = '${today}' -- 只取最新业务时间分区
            group by  uid,app_name
        ) a full join (
            select  uid
                    ,app_name
                    ,first_login_at
                    ,latest_login_at
            from    app_login_log_pt
            where   dt = '${yesterday}' -- 取昨天的累计结果
        ) b on a.uid = b.uid and a.app_name = b.app_name;

通过优化后,数据读取量减少了好几个数量级,处理时间节约一半以上。按照计费模式(2),成本也是指数级别的下降。在优化实践过程中好多类似张三的任务通过优化从几百下降到几块钱。


案例5:合并优化

所谓合并优化指的是,相同的逻辑可以通过合并减少重复读取数据源。

案例5.1

假设有一张表test_tb,存了a_val(语文成绩)、b_val(数学成绩)、c_val(英语成绩)三个数值,要显示成val(成绩),type(科目)的格式,我相信大多数同学都是这么写的:

select  a_val as val,'A' as type from test_tb 
union all 
select  b_val as val,'B' as type from test_tb 
union all 
select  c_val as val,'C' as type from test_tb

以上代码,重复读取了test_tb三次,下面通过改写只需要读一次。

select
  split(data,',')[0] as val,
  split(data,',')[1] as type
from test_tb a
lateral view  
explode(split(concat(coalesce(a_val,''),',','A','#',
      coalesce(b_val,''),',','B','#',
      coalesce(c_val,''),',','C'),'#')) t as data

案例5.2

先来看优化前的代码,读取model_execute_log_pt,对output_result 按不同的model_key进行分箱操作,重复了3次。大大提高了数据读取量和SQL复杂度。

select 
......
case when a.output_result>=0.40 then 'T1'
when a.output_result>=0.35 then 'T2'
......
when a.output_result>=0.20 then 'T6'
else 'T7' end as rul_grp,
......
from  model_execute_log_pt a
left join model_execute_detail_log_pt b
on a.id=b.result_id and b.dt='${yesterday}'
where a.dt='${yesterday}' 
and model_key = 'P_JY_NEW_CST_XGB'

union all

select 
......
case when a.output_result>=0.54 then 'T1'
when a.output_result>=0.46 then 'T2'
......
when a.output_result>=0.29 then 'T6'
else 'T7' end as rul_grp,
......

union all
select 
......
case when a.output_result>=0.44 then 'T1'
......
when a.output_result>=0.23 then 'T6'
else 'T7' end as rul_grp,
......

对于这样的代码,我们完全可以提炼出相同的逻辑进行合并。

select 
 a.uid,
 a.output_result,
 case when model_key='A' and a.output_result>=0.40 then 'T1'
      ......
      when model_key='A' and a.output_result>=0.20 then 'T6'
       
      when model_key= 'B' and a.output_result>=0.54 then 'T1'
      ......
      when model_key= 'B' and a.output_result>=0.29 then 'T6'
       
      when model_key= 'C' and a.output_result>=0.44 then 'T1'
      ......
      when model_key= 'C' and a.output_result>=0.23 then 'T6'
  else 'T7' end as rul_grp
......
from model_execute_log_pt
left join model_execute_detail_log_pt  b
on a.id=b.result_id and b.dt='${yesterday}'
where a.dt='${yesterday}' and 
a.model_key in ('A','B','C')

通过优化,减少了源表的读取次数,降低了SQL的复杂度,起到降本增效的作用,下表为优化前后的一个对比:

单表读取次数Join个数运行时间成本
优化前331小时x
优化后1110分钟x/3

案例6:count(distinct case when)优化

我们直接看优化前的代码,31个count(distinct case when ) 语句,平均需要消耗8个小时以上才能执行完成。

select
stg_typ,  
......
prd_typ,
count(distinct case when aft_dif=0 and lft_prc_amt<>0 then ord_no else null end) as ruc_bej,
count(distinct case when aft_dif=1 and lft_prc_amt<>0 then ord_no else null end) as rec_amt_1b,
......
count(distinct case when aft_dif=30 and lft_prc_amt<>0 then ord_no else null end) as rec_amt_30b
from
acct_no_zx_tb
where ovd_prd in ('M0_M1','M1_M1','M2+_M1')
group by stg_typ, ...... prd_typ

下面我们来看优化后的代码,优化后的代码平均执行时间缩短到30分钟左右。

select
  stg_typ,  
  ......
  prd_typ,
  count(case when aft_dif = 0 then ord_no else null end) as ruc_bej,
  count(case when aft_dif = 1 then ord_no else null end) as rec_amt_1b,
  ......
  count(case when aft_dif = 30 then ord_no else null end) as rec_amt_30b
from
  (
    select
      stg_typ,  
      ......
      prd_typ,
      row_number() over(partition by ord_no,stg_typ,......,prd_typ,aft_dif) as rnk
    from
      acct_no_zx_tb
    where
      lft_prc_amt <> 0
      and ovd_prd in ('M0_M1', 'M1_M1', 'M2+_M1')
  ) t1
where rnk = 1
group by stg_typ,......,prd_typ

优化后的代码在子查询中使用了row_number()函数,对每个订单按照一定的条件进行排序,并为每个订单分配一个行号。然后在外层查询中,只选择行号为1的记录,这样就可以保证每个订单只被统计一次。这样可以避免使用多个count distinct case when语句,使代码更加简洁和高效。同时,优化后的代码也对where条件进行了优化,只选择了lft_prc_amt不为0的记录,这样可以避免对不必要的记录进行统计,提高了查询效率。

总结

主要以降低数据输入和SQL复杂度为中心思想进行优化实践。我们可以通过列和分区的裁剪、合理的设计表结构、文件压缩格式、相同逻辑合并等方法来减少数据输入和重复读取,可以通过关联键的统计信息提前捕获数据倾斜,通过减少join、distinct聚合、排序来降低SQL复杂度。当然优化的案例和方法还有很多,根据笔者的经验总结如下:

存储优化

  1. 表格式: 选择合理的表格式,尽量杜绝使用文本格式。

  2. 合理设计分区:对于经常用于where过滤条件中的字段可以考虑设计成分区字段,如案例(2)。

  3. 裁剪:对于无用的字段在进入dwd层就过滤掉。

  4. 生命周期管理:根据实际场景合理设置表的生命周期,对于存量的表我们可以修改生命周期。根据表的使用热度,冷热分离等等。

计算优化

  1. 全局参数优化:自动Mapjoin优化,禁止暴力扫描分区表、禁止提交成本超过多少阈值的任务,当然这方面需要平台运维人员优化。

  2. 列裁剪:只选择分析需要的列,禁止使用select *,如案例(1)。

  3. 分区裁剪:左外连接(Left Join)的时候,左表的分区字段写在where语句中,右表的分区字段写在on语句中,这样就能通过谓词下推起到分区裁剪的作用。

  4. 数据倾斜:关联字段的分布不均匀容易引起数据倾斜,导致长尾计算,如按案例(3)。

  5. 增量合并代替全量计算:诸如计算最近、最早、最大、最小值的时候,我们可以用增量分区的数据聚合值和历史的聚合结果做比较得到最新的结果,如案例(4)。

  6. 降低SQL复杂度:减少关联 和 distinct去重,如案例(5)和案例(6),distinct 聚合很容易导致数据倾斜。

治理优化

  1. 任务和表下线:通过血缘关系和审计日志,下线任务和表。

  2. 降频:对于更新频率不高的任务可以降低调度频率,比如由每日改为每周、每月调度。

微信公众号版 FinOps降本增效之SQL优化 欢迎大家点击关注。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值