HiveQL 进阶之以柔克刚 - 将简单语法运用到极致

前言

初衷

如何高效地使用 HiveQL ,将 HiveQL 运用到极致。

在大数据如此流行的今天,不只是专业的数据人员,需要经常地跟 SQL 打交道,即使是产品、运营等非技术伙伴,也会或多或少地使用过 SQL ,如何高效地发挥 SQL 的能力,继而发挥数据的能力,变得尤为重要。

HiveQL 发展到今天已经颇为成熟,作为一种 SQL 方言,其支持大多数查询语法,具有较为丰富的内置函数,同时还支持开窗函数、用户自定义函数、反射机制等诸多高级特性。面对一个复杂的数据场景,或许有人技术娴熟,选择使用 HiveQL 高级特性解决,如:编写用户自定义函数扩展 SQL 的数据处理能力;或许有人选择敬而远之,转向使用其他非 SQL 类型的解决方案。本文并不讨论不同方式的优劣,而是尝试独辟蹊径,不是强调偏僻的语法特性或是复杂的 UDF 实现,而是强调 通过灵活的、发散性的数据处理思维,就可以用最简单的语法,解决复杂的数据场景。

适合人群

不论是数据开发初学者还是资深人员,本篇文章或许都能有所帮助,不过更适合中级、高级读者阅读。

本篇文章重点介绍数据处理思维,并没有涉及到过多高阶的语法,同时为了避免主题发散,文中涉及的函数、语法特性等,不会花费篇幅进行专门的介绍,读者可以按自身情况自行了解。

内容结构

本篇文章将围绕数列生成、区间变换、排列组合、连续判别等主题进行介绍,并附以案例进行实际运用讲解。每个主题之间有轻微的前后依赖关系,依次阅读更佳。

提示信息

本篇文章涉及的 SQL 语句只使用到了 HiveQL 基本的语法特性,理论上可以在目前的主流版本中运行,同时特意注明,运行环境、兼容性等问题不在本篇文章关注范围内。

快速制造测试数据

生成用户访问日志表 visit_log ,每一行数据表示一条用户访问日志。该表将被用作下文各类场景的测试数据。

-- SQL - 1
with visit_log as (
    select stack (
        6,
        '2022-01-01', '101', '湖北', '武汉', 'Android',
        '2022-01-01', '102', '湖南', '长沙', 'IOS',
        '2022-01-01', '103', '四川', '成都', 'Windows',
        '2022-01-02', '101', '湖北', '孝感', 'Mac',
        '2022-01-02', '102', '湖南', '邵阳', 'Android',
        '2022-01-03', '101', '湖北', '武汉', 'IOS'
    ) as (dt, user_id, province, city, device_type)
)
select * from visit_log;

数列

数列是最常见的数据形式之一,实际数据开发场景中遇到的基本都是有限数列,也是本节将要重点介绍的内容。本节将从最简单的递增数列开始,找出一般方法并推广到更泛化的场景。

仙人指路

一个简单的递增数列

首先引出一个简单的递增整数数列场景:

  • 从数值 \( 0 \) 开始;
  • 之后的每个数值递增 \( 1 \) ;
  • 至数值 \( 3 \) 结束;
    如何生成满足以上三个条件的数列?即 \( [0,1,2,3] \) 。

实际上,生成该数列的方式有多种,此处介绍其中一种简单且通用的方案。

-- SQL - 2
select
    t.pos as a_n
from (
    select posexplode(split(space(3), space(1)))
) t;
a_n
0
1
2
3

通过上述 SQL 片段可得知,生成一个递增序列只需要三个步骤:
① 生成一个长度合适的数组,数组中的元素不需要具有实际含义;
② 通过 UDTF 函数 posexplode 对数组中的每个元素生成索引下标;
③ 取出每个元素的索引下标。
以上三个步骤可以推广至更一般的数列场景:等差数列、等比数列。下文将以此为基础,直接给出最终实现模板。

等差数列

若设首项 \( a_1 = a \) ,公差为 \( d \) ,则等差数列的通项公式为 \( a_n = a_1 + (n - 1)d \) 。
SQL 实现:

-- SQL - 3
select
    a_1 + t.pos * d as a_n
from (
    select posexplode(split(space(n - 1), space(1)))
) t;

等比数列

若设首项 \( a_1 = a \) ,公比为 \( r \) ,则等比数列的通项公式为 \( a_n = ar^{n-1} \) 。
SQL 实现:

-- SQL - 4
select
    a_1 * pow(r, t.pos) as a_n
from (
    select posexplode(split(space(n - 1), space(1)))
) t;

应用场景举例

如何还原任意维度组合下的维度列簇名称?

在多维分析场景下,可能会用到高阶聚合函数,如 cuberollupgrouping sets 等,可以针对不同的维度组合下的数据进行聚合统计。

场景描述

现有用户访问日志表 visit_log ,该表定义见 快速制造测试数据
假如针对省份 province , 城市 city, 设备类型 device_type 三个维度列,通过高阶聚合函数,统计得到了不同维度组合下的用户访问量。

  1. 如何知道一条统计结果是根据哪些维度列聚合出来的?
  2. 想要输出 聚合的维度列 的名称,用于下游的报表展示等场景,又该如何处理?
解决思路

可以借助 Hive 提供的 Grouping__ID 来实现,核心方法是对 Grouping__ID 进行逆向实现。 详细步骤如下:

一、准备好所有的 Grouping__ID 。
① 生成一个包含 \( 2^x \) 个数值的递增数列,每个数值表示一种 Grouping__ID ,其中 \( x \) 为所有维度列的数量, \( 2^x \) 为所有维度组合的数量。即
\( { 0, 1, 2, ..., 2^x - 1 } \)

② 将递增数列中的每个 Grouping__ID 转为 2 进制字符串,并展开该 2 进制字符串的每个比特位。例如
3 => { 0, 0, 0, 1, 1 }

二、准备好所有维度列的名称。
③ 生成一个字符串序列,依次保存每个维度列的名称,即
{ dim_col_1, dim_col_2, ..., dim_col_x }

三、将 Grouping__ID 映射到维度列名称。
④ 对于递增数列中的每个数值,将该数值的 2 进制的每个比特位与维度列的下标进行映射。例如
grouping__id:3 => { 0, 0, 0, 1, 1 }
维度列:{ dim_col_1, dim_col_2, dim_col_3, dim_col_4, dim_col_5 }
映射结果:{ 0:dim_col_1, 0:dim_col_2, 0:dim_col_3, 1:dim_col_4, 1:dim_col_5 }

⑤ 对递增数列中的每个数值进行聚合,输出所有比特位等于 0 的维度列。
dim_col_1,dim_col_2,dim_col_3

注意:不同版本的 Hive 之间, Grouping__ID 实现有差异,以上处理逻辑适用于 2.3.0 及之后的版本。 2.3.0 之前的版本基于上述步骤稍加修改即可,此处不再专门花费篇幅描述。

SQL 实现
-- SQL - 5
with group_dimension as (
    select -- 每种分组对应的维度字段
        gb.group_id, concat_ws(",", collect_list(case when gb.placeholder_bit = 0 then dim_col.val else null end)) as dimension_name
    from (
        select groups.pos as group_id, pe.*
        from (
            select posexplode(split(space(cast(pow(2, 3) as int) - 1), space(1)))
        ) groups -- 所有分组
        lateral view posexplode(split(lpad(conv(groups.pos,10,2), 3, "0"), '')) pe as placeholder_idx, placeholder_bit -- 每个分组的bit信息
    ) gb
    left join ( -- 所有维度字段
        select posexplode(split("省份,城市,设备类型", ','))
    ) dim_col on gb.placeholder_idx = dim_col.pos
    group by gb.group_id
)
select 
    group_dimension.dimension_name as dimension_name,
    province, city, device_type,
    visit_count
from (
    select
        grouping__id as group_id,
        province, city, device_type,
        count(1) as visit_count
    from visit_log b
    group by province, city, device_type
    GROUPING SETS(
        (province),
        (province, city),
        (province, city, device_type)
    )
) t
join group_dimension on t.group_id = group_dimension.group_id
order by dimension_name;
dimension_name province city device_type visit_count
省份 湖北 NULL NULL 3
省份 湖南 NULL NULL 2
省份 四川 NULL NULL 1
省份,城市 湖北 武汉 NULL 2
省份,城市 湖南 长沙 NULL 1
省份,城市 湖南 邵阳 NULL 1
省份,城市 湖北 孝感 NULL 1
省份,城市 四川 成都 NULL 1
省份,城市,设备类型 湖北 孝感 Mac 1
省份,城市,设备类型 湖南 长沙 IOS 1
省份,城市,设备类型 湖南 邵阳 Android 1
省份,城市,设备类型 四川 成都 Windows 1
省份,城市,设备类型 湖北 武汉 Android 1
省份,城市,设备类型 湖北 武汉 IOS 1

区间

相比于数列较多用于表示离散数据,区间往往用于描述连续的数据,虽然两者具有不同的数据特征,不过在实际应用中,数列与区间的处理具有较多相通性。本节将介绍一些常见的区间场景,并抽象出通用的解决方案。

二鬼拍门

区间分割

已知一个数值区间 \( [a,b] = \{ x | a \leq x \leq b \} \) ,如何将该区间均分成 \( n \) 段子区间?

该问题可以简化为数列问题,数列公式为 \( a_n = a_1 + (n - 1)d \) ,其中 \( a_1 = a \) , \( d = (b - a) / n \) :
① 生成一个长度为 \( n \) 的数组,数组中的元素不需要具有实际含义;
② 通过 UDTF 函数 posexplode 对数组中的每个元素生成索引下标;
③ 取出每个元素的索引下标,并进行数列公式计算,得出每个子区间的起始值与结束值。

SQL 实现:

-- SQL - 6
select
    a_1 + t.pos * d as sub_interval_start, -- 子区间起始值
    a_1 + (t.pos + 1) * d as sub_interval_end -- 子区间结束值
from (
    select posexplode(split(space(n - 1), space(1)))
) t;

区间交叉

已知两个日期区间存在交叉 ['2022-01-01', '2022-01-03'] 、 ['2022-01-02', '2022-01-04']

  1. 如何合并两个日期区间,并返回合并后的新区间?
  2. 如何知道哪些日期是交叉日期,并返回该日期交叉次数?

解决上述问题的方法有多种,此处介绍其中一种简单且通用的方案。
核心思路是结合数列生成、区间分割方法,先将日期区间分解为最小处理单元,即多个日期组成的数列,然后再基于日期粒度做统计。具体步骤如下:
① 获取每个日期区间包含的天数;
② 按日期区间包含的天数,将日期区间拆分为相应数量的递增日期序列;
③ 通过日期序列统计合并后的区间,交叉次数;

SQL 实现:

-- SQL - 7
with tbl as (
    select stack(
        2,
        '2022-01-01', '2022-01-03',
        '2022-01-02', '2022-01-04'
    ) as (date_start, date_end)
)
select 
    min(date_item) as date_start_merged, 
    max(date_item) as date_end_merged, 
    collect_set( -- 交叉日期计数
        case when date_item_cnt > 1 then concat(date_item, ':', date_item_cnt) else null end
    ) as overlap_date
from (
    select 
        -- 拆解后的单个日期
        date_add(date_start, pos) as date_item,
        -- 拆解后的单个日期出现的次数
        count(1) over(partition by date_add(date_start, pos)) as date_item_cnt
    from tbl
    lateral view posexplode(split(space(datediff(date_end, date_start)), space(1))) t as pos, val
) t;
date_start_merged date_end_merged overlap_date
2022-01-01 2022-01-04 ["2022-01-02:2","2022-01-03:2"]

增加点儿难度 !
如果有多个日期区间,且区间之间交叉状态未知,上述问题又该如何求解。即:

  1. 如何合并多个日期区间,并返回合并后的多个新区间?
  2. 如何知道哪些日期是交叉日期,并返回该日期交叉次数?

SQL 实现:

-- SQL - 8
with tbl as (
    select stack(
        5,
        '2022-01-01', '2022-01-03',
        '2022-01-02', '2022-01-04',
        '2022-01-06', '2022-01-08',
        '2022-01-08', '2022-01-08',
        '2022-01-07', '2022-01-10'
    ) as (date_start, date_end)
)
select
    min(date_item) as date_start_merged, 
    max(date_item) as date_end_merged,
    collect_set( -- 交叉日期计数
        case when date_item_cnt > 1 then concat(date_item, ':', date_item_cnt) else null end
    ) as overlap_date
from (
    select 
        -- 拆解后的单个日期
        date_add(date_start, pos) as date_item,
        -- 拆解后的单个日期出现的次数
        count(1) over(partition by date_add(date_start, pos)) as date_item_cnt,
        -- 对于拆解后的单个日期,重组为新区间的标记
        date_add(date_add(date_start, pos), 1 - dense_rank() over(order by date_add(date_start, pos))) as cont
    from tbl
    lateral view posexplode(split(space(datediff(date_end, date_start)), space(1))) t as pos, val
) t
group by cont;
date_start_merged date_end_merged overlap_date
2022-01-01 2022-01-04 ["2022-01-02:2","2022-01-03:2"]
2022-01-06 2022-01-10 ["2022-01-07:2","2022-01-08:3"]

应用场景举例

如何按任意时段统计时间区间数据?

场景描述

现有用户还款计划表 user_repayment ,该表内的一条数据,表示用户在指定日期区间内 [date_start, date_end] ,每天还款 repayment 元。

-- SQL - 9
with user_repayment as (
    select stack(
        3,
        '101', '2022-01-01', '2022-01-15', 10,
        '102', '2022-01-05', '2022-01-20', 20,
        '103', '2022-01-10', '2022-01-25', 30
    ) as (user_id, date_start, date_end, repayment)
)
select * from user_repayment;

如何统计某个时段内,每天所有用户的应还款总额?

解决思路

核心思路是将日期区间转换为日期序列,再按日期序列进行汇总统计。

SQL 实现
-- SQL - 10
select 
    date_item as day, 
    sum(repayment) as total_repayment
from (
    select 
        date_add(date_start, pos) as date_item,
        repayment
    from user_repayment
    lateral view posexplode(split(space(datediff(date_end, date_start)), space(1))) t as pos, val
) t
where date_item >= '2022-01-15' and date_item <= '2022-01-16'
group by date_item
order by date_item;
day total_repayment
2022-01-15 60
2022-01-16 50

排列组合

排列组合是针对离散数据常用的数据组织方法,实际应用场景中又以组合更为常见,本节将分别介绍排列、组合的实现方法,并结合实例着重介绍通过组合对数据的处理。

双马饮泉

排列

已知字符序列 [ 'A', 'B', 'C' ] ,每次从该序列中可重复地选取出 2 个字符,如何获取到所有的排列?

-- SQL - 11
select 
    concat(val1, val2) as perm
from (select split('A,B,C', ',') as characters) dummy
lateral view explode(characters) t1 as val1
lateral view explode(characters) t2 as val2;
perm
AA
AB
AC
BA
BB
BC
CA
CB
CC

整体实现比较简单。

组合

已知字符序列 [ 'A', 'B', 'C' ] ,每次从该序列中可重复地选取出 2 个字符,如何获取到所有的组合?

-- SQL - 12
select 
    concat(least(val1, val2), greatest(val1, val2)) as comb
from (select split('A,B,C', ',') as characters) dummy
lateral view explode(characters) t1 as val1
lateral view explode(characters) t2 as val2
group by least(val1, val2), greatest(val1, val2);
comb
AA
AB
AC
BB
BC
CC

整体实现比较简单。

应用场景举例

如何对比统计所有组合?

场景描述

现有用户访问日志表 visit_log ,该表定义见 快速制造测试数据
如何按省份两两建立对比组,按对比组展示省份的用户访问量?

对比组 省份 用户访问量
湖北-湖南 湖北 xxx
湖北-湖南 湖南 xxx
解决思路

核心思路是从所有省份列表中不重复地取出 2 个省份,生成所有的组合结果,然后关联 visit_log 表分组统计结果。

SQL 实现
-- SQL - 13
select
    combs.province_comb,
    log.province,
    count(1) as visit_count
from visit_log log
join ( -- 所有对比组
    select 
        concat(least(val1, val2), '-', greatest(val1, val2)) as province_comb,
        least(val1, val2) as province_1, greatest(val1, val2) as province_2
    from (
        select collect_set(province) as provinces
        from visit_log
    ) dummy
    lateral view explode(provinces) t1 as val1
    lateral view explode(provinces) t2 as val2
    where val1 <> val2
    group by least(val1, val2), greatest(val1, val2)
) combs on 1 = 1
where log.province in (combs.province_1, combs.province_2)
group by combs.province_comb, log.province
order by combs.province_comb, log.province;
对比组 省份 用户访问量
四川-湖北 四川 1
四川-湖北 湖北 3
四川-湖南 四川 1
四川-湖南 湖南 2
湖北-湖南 湖北 3
湖北-湖南 湖南 2

连续

本节主要介绍连续性问题,重点描述了连续活跃场景。对于静态类型的连续活跃、动态类型的连续活跃,分别阐述了不同的实现方案。
本节内容直接贴近具体的应用,大部分篇幅以 SQL 内容为主。

静态连续活跃场景统计

场景描述

现有用户访问日志表 visit_log ,该表定义见 快速制造测试数据
如何获取连续登录大于或等于 2 天的用户?

上述问题在分析连续性时,获取连续性的结果以超过固定阈值为准,可归类为 连续活跃大于 N 天的静态连续活跃场景统计

SQL 实现

基于相邻日期差实现( lag / lead 版)
-- SQL - 14
select user_id
from (
    select 
        *,
        lag(dt, 2 - 1) over(partition by user_id order by dt) as lag_dt
    from (select dt, user_id from visit_log group by dt, user_id) t0
) t1
where datediff(dt, lag_dt) + 1 = 2
group by user_id;
user_id
101
102

整体实现比较简单。

基于相邻日期差实现(排序版)
-- SQL - 15
select user_id
from (
    select *, 
        dense_rank() over(partition by user_id order by dt) as dr
    from visit_log
) t1
where datediff(dt, date_add(dt, 1 - dr)) + 1 = 2
group by user_id;
user_id
101
102

整体实现比较简单。

基于连续活跃天数实现
-- SQL - 16
select user_id
from (
    select 
        *,
        -- 连续活跃天数
        count(distinct dt) 
            over(partition by user_id, cont) as cont_days
    from (
        select 
            *, 
            date_add(dt, 1 - dense_rank() 
                over(partition by user_id order by dt)) as cont
        from visit_log
    ) t1
) t2
where cont_days >= 2
group by user_id;
user_id
101
102

可以视作 基于相邻日期差实现(排序版) 的衍生版本,该实现能获取到更多信息,如连续活跃天数。

基于连续活跃区间实现
-- SQL - 17
select user_id
from (
    select 
        user_id, cont, 
        -- 连续活跃区间
        min(dt) as cont_date_start, max(dt) as cont_date_end
    from (
        select 
            *, 
            date_add(dt, 1 - dense_rank() 
                over(partition by user_id order by dt)) as cont
        from visit_log
    ) t1
    group by user_id, cont
) t2
where datediff(cont_date_end, cont_date_start) + 1 >= 2
group by user_id;
user_id
101
102

可以视作 基于相邻日期差实现(排序版) 的衍生版本,该实现能获取到更多信息,如连续活跃区间。

动态连续活跃场景统计

场景描述

现有用户访问日志表 visit_log ,该表定义见 快速制造测试数据
如何获取最长的 2 个连续活跃,输出用户、最长连续活跃天数、最长连续活跃日期区间?

上述问题在分析连续性时,获取连续性的结果不是且无法与固定的阈值作比较,而是各自以最长连续活跃作为动态阈值,可归类为 动态连续活跃场景统计

SQL 实现

基于 静态连续活跃场景统计 的思路进行扩展即可,此处直接给出最终 SQL :

-- SQL - 18
select
    user_id, 
    -- 最长连续活跃天数
    datediff(max(dt), min(dt)) + 1 as cont_days,
    -- 最长连续活跃日期区间
    min(dt) as cont_date_start, max(dt) as cont_date_end
from (
    select 
        *, 
        date_add(dt, 1 - dense_rank() 
            over(partition by user_id order by dt)) as cont
    from visit_log
) t1
group by user_id, cont
order by cont_days desc
limit 2;
user_id cont_days cont_date_start cont_date_end
101 3 2022-01-01 2022-01-03
102 2 2022-01-01 2022-01-02

结语

通过灵活的、散发性的数据处理思维,就可以用最简单的语法,解决复杂的数据场景 是本篇文章贯穿全文的思想。文中针对数列生成、区间变换、排列组合、连续判别等常见的场景,给出了相对通用的解决方案,并结合实例进行了实际运用的讲解。

本篇文章尝试独辟蹊径,强调灵活的数据处理思维,希望能让读者觉得眼前一亮,更希望真的能给读者产生帮助。同时毕竟个人能力有限,思路不一定是最优的,甚至可能出现错误,欢迎提出意见或建议。为了便于交流探讨,文中的每个 SQL 都标记了编号,可以直接在评论区 @SQL编号 沟通。

你可能感兴趣的:(hive大数据sql)