SQL大宝剑-已燃尽所有SQL的理解

作者:京东物流 向往

一、背景

从事数据开发将近四年,过程中有大量任务交接或阅读同事代码的场景。在这些场景中发现有些SQL读起来赏心悦目,可以一目了然地了解业务逻辑,一些复杂的业务需求实现方法也可以做到简洁优雅,同时在性能上也有良好表现。而有些SQL读起来非常艰难,时常要跨越几百行寻找WHERE条件或者关联字段,甚至充斥着大量相同的子查询命名,除了作者可能少有人能快速看懂。 在这里插入图片描述

为此,基于个人经验、理解与实践,我总结了一些方法和技巧,能让SQL尽量变得优雅,即兼顾代码可读性和执行性能两方面的提升。



二、方法与技巧

1.子查询与谓词下推

很多同事在写关联逻辑时,习惯于直接将原表关联,随后在最下方用一大段WHERE语句进行条件过滤,如下示例:

1// -------------------- Bad Codes ------------------------ 2 3SELECT 4 f1.pin, 5 c1.site_id, 6 c2.site_name 7FROM 8 fdm.fdm1 AS f1 9LEFT JOIN cdm.cdm1 AS c1 10ON 11 f1.erp = lower(c1.account_number) 12LEFT JOIN cdm.cdm2 AS c2 13ON 14 c1.site_id = c2.site_code 15WHERE 16 f1.start_date <= '""" + start_date + """' 17 AND f1.end_date > '""" + start_date + """' 18 AND f1.status = 1 19 AND c1.dt = '""" + start_date + """' 20 AND c2.yn = 1 21GROUP BY 22 f1.pin, 23 c1.site_id, 24 c2.site_name

这段SQL主要有两个问题:

1.cdm1和cdm2的条件写在LEFT JOIN之后,因为cdm1和cdm2是NULL补充表(NULL 补充表: 右表被称为 NULL 补充表,意味着它的存在是为了补充左表中可能缺失的值。即使在右表中没有与左表匹配的行,左表中的行仍然会被返回,右表的相关列会填充为 NULL),那么19和20行无法进行谓词下推,这会导致关联时fdm1和cdm1,cdm2先进行全表关联,再按照WHERE条件过滤分区。如果cdm1是每天全量的表,先关联全表所扫描的数据量可想而知是相当大的。

2.全表关联时没有对关联键进行NULL值处理,如果相关表的对应字段存在大量NULL值,会引起数据倾斜。



第一个问题涉及SQL的谓词下推,即写条件时,应该在不影响结果的情况下,尽量将过滤条件下推到join之前进行(“下推”指将条件推到靠近数据源的位置而不是SQL语句的方位)。谓词下推后,过滤条件在map端执行,减少了map端的输出,降低了数据在集群上传输的量,节约了集群的资源,也可以提升任务的性能。

对于常用的INNER JOIN和LEFT OUTER JOIN,谓词下推规则如下:

INNER JOINLEFT OUTER JOIN
左表右表左表右表
ON条件下推下推不下推下推
WHERE条件下推下推下推不下推

如果使用上述示例的写法,主要关注的是LEFT OUTER JOIN时WHERE语句里的条件是否会引起谓词不下推。如果不想记这些看起来很复杂的规则怎么办?可以如下所示直接使用子查询:

1// -------------------- Good Codes 👍🏻------------------------ 2 3SELECT 4 f1.pin, 5 c1.site_id, 6 c2.site_name 7FROM 8 ( 9 SELECT erp, pin FROM fdm.fdm1 WHERE dp = 'ACTIVE' AND status = 1 10 ) 11 f1 12LEFT JOIN 13 ( 14 SELECT 15 site_id, 16 lower(account_number) AS account_number 17 FROM 18 cdm.cdm1 19 WHERE 20 dt = '""" + start_date + """' 21 ) 22 c1 23ON 24 f1.erp = c1.account_number 25LEFT JOIN 26 ( 27 SELECT site_code, site_name FROM cdm.cdm2 WHERE yn = 1 28 ) 29 c2 30ON 31 c1.site_id = c2.site_code 32GROUP BY 33 f1.pin, 34 c1.site_id, 35 c2.site_name

将原来WHERE语句里的各个条件下推到每个表的子查询中,可以先过滤掉不必要的行,提升关联效率。同时可读性大大提高,能清晰地看出每个来源表都取了哪些数据。还有一些其它细节,比如BDP平台的fdm拉链表,大部分业务场景下,都可以用dp='ACTIVE'代替start_date <= '""" + start_date + """' AND end_date > '""" + start_date + """'。同时注意列裁剪问题,尽量少用SELECT * FROM,只选取必要的列以减少内存开销。



2.去重难题

为了保证数据粒度的准确,几乎所有的SQL脚本编写时,都要考虑去重问题。常见的方法有:

•GROUP BY

•DISTINCT

•ROW_NUMBER开窗

•COLLECT_SET



1)GROUP BY 真的更好吗?

我们经常能在各种大数据技术分享中看到去重时推荐使用GROUP BY代替DISTINCT的观点。不可否认,数据量达到一定程度,去重字段枚举值也很复杂时,GROUP BY确实在性能上更优秀,同时可以避免数据倾斜。但具体情况具体分析,比如下面两段SQL涉及的业务场景:

1// --------- Good Codes 👍🏻-------- 2select 3 count(distinct ulp_base_age) 4from 5 app.app1 6where 7 dt = sysdate(-1) 8 9// --------- Bad Codes -------- 10select 11 count(ulp_base_age) 12from 13 ( 14 select 15 ulp_base_age 16 from 17 app.app1 18 where 19 dt = sysdate(-1) 20 group by 21 ulp_base_age 22 ) t



底表app1为零售用户画像表,数据量20亿。如果要统计用户年龄分段的枚举值数量,哪一段的执行效率更高呢?先说结论:看到20亿的数据量,可能大家会认为第二段使用GROUP BY的SQL执行更快,但经过HIVE查询多次测试,第一段的平均执行时长约3分钟,第二段的平均执行时长约5分钟。



为了解释这个现象,我们需要了解GROUP BY和DISTINCT的原理。首先,ulp_base_age的枚举值只有7个,从MapReduce角度来看,在Map阶段,每个Map会对ulp_base_age去重。由于ulp_base_age枚举值有限,因而每个Map得到的ulp_base_age也有限,最终得到reduce的数据量也就是map数量*ulp_base_age枚举值的个数,这实质上造成了资源的浪费。而DISTINCT命令会在内存中构建一个hashtable,查找去重的时间复杂度是O(1);GROUP BY在不同版本间变动比较大,有的版本会用构建hashtable的形式去重,有的版本会通过排序的方式, 排序的最优时间复杂度无法到O(1)。另外,第一种方式(GROUP BY)不仅需要进行 shuffle,还需要在每个分区中进行聚合操作,会消耗更多的磁盘网络I/O资源。



这告诉我们,不要过度优化代码,要考虑到数据集的具体情况!!



2)最大化利用分桶

在面对更复杂的数据集时,去重也需要更巧妙的方法。假设有一个数据量极大的页面埋点数据集,其部分数据如下所示:

click_dtpin
2024-12-16a
2024-12-16a
2024-12-16a
2024-12-16bb
2024-12-16bb
2024-12-16ccc
2024-12-16ccc
2024-12-16dddd
2024-12-16eee
2024-12-16eeee

如果要统计天维度的UV,如果使用:

1// -------------------- Bad Codes -------------------- 2select 3 click_dt, 4 count(distinct pin)as uv 5from 6 log_table 7group by 8 click_dt;

那么假设有五个分桶,其使用情况会如下所示:

在这里插入图片描述

可以看到所有数据都被分配到了同一个桶里,其它桶都闲置,明显造成效率低下。优化代码如下:

1// -------------------- Good Codes 👍🏻 -------------------- 2SELECT 3 click_dt, 4 size(collect_set(pin)) AS uv 5FROM 6 ( 7 SELECT click_dt, pin FROM log_table GROUP BY click_dt, pin 8 ) 9 tmp 10GROUP BY 11 click_dt;

此时桶的使用情况如下:

在这里插入图片描述

此时充分利用了五个桶,实现了并行操作,单个桶的负担大大下降。但如果此时第二步的结果集太大,还是容易造成OOM的问题。面对海量数据集,代码还可以继续优化:

1// ------------------- Even Better Codes 👍🏻👍🏻👍🏻 ------------------- 2SELECT 3 click_dt, 4 SUM(uv_tmp) AS uv 5FROM 6 ( 7 SELECT 8 len_pin, 9 click_dt, 10 size(collect_set(pin)) AS uv_tmp 11 FROM 12 ( 13 SELECT click_dt, pin, LENGTH(pin) AS len_pin FROM log_table 14 ) 15 log_table_tmp 16 GROUP BY 17 len_pin, 18 click_dt 19 ) 20 tmp 21GROUP BY 22 click_dt

在聚合维度中加上pin的长度作为新维度,此时桶的使用如下:

在这里插入图片描述

此处使用pin的长度来预聚合pin,用一种分桶分步预聚合的方法,较为巧妙地把一个集合去重问题最终转化为相加问题,避开了单个jvm去重承受过大压力,面对海量数据集较为实用。具体开发场景中,也可以使用首末字母等有共性的属性来预聚合。



3.充分使用平台工具

由于曾经长期手动修改时间参数来回刷数据,操作繁琐的同时还容易出错。回刷涉及上下游表时,工作量更是巨大。直到我发现了平台的任务补录功能,并且可以通过Python和SQL的交互来实现灵活控制脚本里的时间参数来达到补录时的时间控制,才解决这个痛点。



比如在任务调度的py脚本里,可以利用sys.argv来控制时间参数。sys.argv的第一个元素是默认的,内容为脚本名称。而通过判断sys.argv的长度,可以在SQL内容之前使用如下Python代码来设置参数:

1if len(sys.argv) == 1: 2 # BDP不传参数的情况下使用,仅适用于BDP线上调度 3 curday = ht.oneday(0) 4 today = datetime.datetime.strptime(curday, '%Y-%m-%d') 5 start_date = str((today + datetime.timedelta(days=-1)).strftime("%Y-%m-%d"))[0:10] 6 end_date = str(today)[0:10] 7 last31Day = start_date 8elif len(sys.argv) == 2: 9 # BDP线上调度使用 配合BDP参数 ${fmt(add(NTIME(),-1,'day'),'yyyy-MM-dd')} 10 end_date = str(datetime.datetime.strptime(sys.argv[1], "%Y-%m-%d"))[0:10] 11 start_date = str( 12 (datetime.datetime.strptime(end_date, "%Y-%m-%d")).replace(day=1))[0:10] 13 last31Day = (datetime.datetime.strptime(end_date, "%Y-%m-%d") + 14 datetime.timedelta(days=-30)).strftime("%Y-%m-%d") 15elif len(sys.argv) == 3: 16 # 回刷使用,直接调用python脚本,并且需要传递两个日期参数,开始日期,结束日期 17 start_date = str(datetime.datetime.strptime(sys.argv[1], "%Y-%m-%d"))[0:10] 18 end_date = str(datetime.datetime.strptime(sys.argv[2], "%Y-%m-%d"))[0:10] 19else: 20 print('parameter error') 21 sys.exit(1)

同时需要在SQL内容后使用如下Python代码:

1if (len(sys.argv) == 1) | (len(sys.argv) == 2): 2 ht.exec_sql( 3 schema_name='app', 4 # 补数调度 5 # sql=showsql.format(htYDay_B=start_date, htYDay=end_date), 6 # 批量调度 7 sql=showsql.format(htYDay_B=start_date, htYDay=end_date), 8 table_name='app1', 9 exec_engine='spark', 10 spark_resource_level='high', 11 retry_with_hive=False, 12 spark_args=[ 13 '--conf spark.sql.hive.mergeFiles=true', 14 '--conf spark.sql.adaptive.enabled=true', 15 '--conf spark.sql.adaptive.repartition.enabled=true', 16 '--conf spark.sql.adaptive.join.enabled=true', 17 '--conf spark.sql.adaptive.skewedJoin.enabled=true', 18 '--conf spark.hadoop.hive.exec.orc.split.strategy=ETL', 19 '--conf spark.sql.shuffle.partitions=1200', 20 '--conf spark.driver.maxResultSize=8g', 21 '--conf spark.executor.memory=32g' 22 ]) 23elif len(sys.argv) == 3: 24 ht.exec_sql( 25 schema_name='app', 26 # 补数调度 27 sql=showsql.format(htYDay_B=start_date, htYDay=end_date), 28 # 批量调度 29 # sql=showsql.format(htYDay_B=last31Day, htYDay=end_date), 30 table_name='app1', 31 exec_engine='spark', 32 spark_resource_level='high', 33 retry_with_hive=False, 34 spark_args=[ 35 '--conf spark.sql.hive.mergeFiles=true', 36 '--conf spark.sql.adaptive.enabled=true', 37 '--conf spark.sql.adaptive.repartition.enabled=true', 38 '--conf spark.sql.adaptive.join.enabled=true', 39 '--conf spark.sql.adaptive.skewedJoin.enabled=true', 40 '--conf spark.hadoop.hive.exec.orc.split.strategy=ETL', 41 '--conf spark.sql.shuffle.partitions=1200', 42 '--conf spark.driver.maxResultSize=8g', 43 '--conf spark.executor.memory=32g' 44 ]) 45else: 46 print('parameter error') 47 sys.exit(1)

IF的第一个分支的作用是线上调度任务不配置参数时,可以将昨天的日期和今天的日期赋值给htYDay_B和htYDay;第二个分支则是线上脚本配置${fmt(add(NTIME(),-1,'day'),'yyyy-MM-dd')}等参数时,可以根据该参数计算并赋值htYDay_B和htYDay;第三个分支是任务补录时使用,通过上传时间范围的开始时间和结束时间,直接赋值htYDay_B和htYDay,来控制脚本中取数时间范围。



数据开发工程师往往会每天面对海量的业务需求,理解业务需求、数据探查会花费掉研发人员大量的时间,而日常开发有时会忽略写出的代码是否优雅和高效。如果对SQL优化的原理有一定认知,积累了足够的经验,或许能做到编写代码时下意识地写出更优雅的SQL代码。



参考文章: https://www.jhelp.net/p/foafP0Vuwt7Qaa12

点赞
收藏

评论区

加载中...

相关推荐

SQL性能优化策略之索引优化方法

「数仓宝贝库」,带你学数据!导读:SQL优化是优化工作中经常会涉及的问题,由于早期的开发人员往往只关注于SQL功能的实现,而忽略了性能。特别是复杂的SQL,上线之后很少修改,一旦出现问题,即使是当初的开发人员自己也很难理清其中的业务逻辑,需要花费大量的时间去理解代码之间的关系,最终可能还是感觉无从下手。因此开发人员前期应做好代码注释,避免编写过于复杂的SQ

SQL性能优化策略之索引优化方法

「数仓宝贝库」,带你学数据!导读:SQL优化是优化工作中经常会涉及的问题,由于早期的开发人员往往只关注于SQL功能的实现,而忽略了性能。特别是复杂的SQL,上线之后很少修改,一旦出现问题,即使是当初的开发人员自己也很难理清其中的业务逻辑,需要花费大量的时间去理解代码之间的关系,最终可能还是感觉无从下手。因此开发人员前期应做好代码注释,避免编写过于复杂的SQL

BATJ解决千万级别数据之MySQL 的 SQL 优化大总结

引用在数据库运维过程中,优化SQL是DBA团队的日常任务。例行SQL优化,不仅可以提高程序性能,还能减低线上故障的概率。目前常用的SQL优化方式包括但不限于:业务层优化、SQL逻辑优化、索引优化等。其中索引优化通常通过调整索引或新增索引从而达到SQL优化的目的。索引优化往往可以在短时间内产生非常巨大的效果。

MybatisPlus知识详解以及用十数个例子完成MybatisPlus的入门到进阶

1\.MybatisPlus1.1业务需求Mybatis缺点:1.Mybatis操作数据库的过程中,需要编辑大量的sql语句.无论该sql是否复杂或者简单.2.Mybatis操作时在xml配置文件中需要额外记忆单独的标签.需求:能否实现单表操作的CRUD的全自动的实现.能否实现

Django框架详细介绍

一、概述    ORM(ObjectRelationalMapping),全称:对象关系映射,简单的说就是通过创建类、实例化出对象的方法,使得类、对象、对象的属性能够和数据库中的表、记录、字段意义对应。  ORM只是一种工具,避免了开发人员在开发过程中不用反复地编写大量复杂的SQL语句,而可以专注于业务逻辑上的开发,提高开发效率,但是OR

MYSQL查询A表中不存在于B表中的所有符合条件的数据

在开发过程中,总有一些需求是需要查看在A表中ID不存在于B表中的ID的情况:下面有三种方法可以实现这一需求:第一种:使用Notin方法通过子查询的结果集来做过滤:selectfromAwhere11ANDA.IDnotin(selectIDfromB)这种情况最常见也是最容易理解的逻辑SQL代码,