原文地址:
1.Hive SQL复杂场景实现(1) —— 连续发单天数
https://blog.csdn.net/Adrian_Wang/article/details/89791948
至今在数据分析岗摸爬滚打已有一年,尚且不敢说自己挖掘洞见的本事提升多少。但实打实的与SQL打了一年的交道,接触过各种各样的业务场景,完成过各种千奇百怪的需求,自我感觉在sql编程上也颇有体会。
相信接触过SQL的人都明白知道其非常容易上手,作为一个结构化查询语言,其在数据提取上给人们提供了非常大的便捷。然而在考虑到开发成本和计算的复杂度的情况下,并非所有的提数需求都适合用sql来实现,也并非sql能够在各种业务场景下提供数据提取的最优解。有些时候hive streaming、spark、甚至简单的python脚本都能把sql难以实现的逻辑变得得心应手。即便如此,探究sql在一些复杂场景下的实现还是对锻炼逻辑思维很有帮助。
最近我会专门开辟一个专栏来分享这一年来我所遇到的一些比较复杂的业务场景,并力求通过纯sql的方法实现,给出我能想到的所有解法与大家分享。这也算是见证和记录自己一年来sql编程的心路历程了吧。
毕竟我的目标是data scientist呀
————————————————
数据运营人员常常会需要查找活跃用户名单,而活跃用户很多情况下被定义为连续在线或发单n天及以上的用户。一方面我们可以根据n的值直接进行筛选;更具一般性地,就要求我们去求取每个用户某段时间内的最大连续在线或者发单天数了。
SQL求连续在线天数是一个非常经典的问题,该问题在不考虑计算成本下有非常多的解法。该问题也是我在面试实习生时最喜欢深入问的一个问题,在引导一个候选人去完成这个问题的过程中可以看出其对sql的理解深度以及其思维是否灵敏。
该问题的最大难点在于如何判断日期与日期间是否连续,那这就要涉及到处理行与行之间的关系了。说到这对SQL比较熟悉的同学应该就会反应出使用join或者窗口函数来处理了。
假设我们有19年一月份每日用户发单数据存储于订单表order_base:
user_id | order_id | create_time |
---|---|---|
234520012 | 1231512416323 | 2019-01-02 12:21:11 |
123149908 | 2412298719221 | 2019-01-04 01:11:34 |
… | … | … |
本方法比较tricky。连续的时间以为着这些时间点与某一个特定时间点的时间差也是连续的,从下表可以直观理解这一点:
日期 | 特定日期 | 日期差d |
2019-01-01 | 2019-01-01 | 0 |
2019-01-02 | 2019-01-01 | 1 |
2019-01-04 | 2019-01-01 | 3 |
2019-01-05 | 2019-01-01 | 4 |
2019-01-06 | 2019-01-01 | 5 |
那么我们对该日期差d进行个排序,如果连续的话,d与序号的差值应该是相同的,如下表:
日期 | 特定日期 | 日期差d | 序号r | 日期差d与序号r的差值 |
2019-01-01 | 2019-01-01 | 0 | 0 | 0 |
2019-01-02 | 2019-01-01 | 1 | 1 | 0 |
2019-01-04 | 2019-01-01 | 3 | 2 | 1 |
2019-01-05 | 2019-01-01 | 4 | 3 | 1 |
2019-01-06 | 2019-01-01 | 5 | 4 | 1 |
这样答案就显而易见了,只需要对上面这个子查询的最后一列进行分组统计行数,变得到了每次连续的天数,再取连续天数的最大值,便是我们想要的答案。
select
user_id,
max(date_cnt) as max_continuation_date_cnt
from
(
select
user_id,
d-d_ranking as d_group, -- 连续日期的组标记
count(1) as date_cnt
from
(
select
user_id,
d, --与标记日期的日期差
row_number() over(partition by user_id order by d) as d_ranking --与标记日期的日期差的排序
from
(
select
user_id,
datediff(create_date,'2019-01-01') as d --与标记日期的日期差
from
(
select
user_id,
to_date(create_time) as create_date
from
order_base
group by
user_id,
date(create_time)
)a -- 在这一层获取用户的发单日期并去重
)b --这一层获取与标记日期的日期差
)c --获取连续日期的排序
group by
user_id,
d-d_ranking
)d -- 获取每一个连续日期组的连续天数
group by
user_id
假设我们不需要知道用户最大的连续天数,只需要知道某个用户是否出现连续n天(假设n为3)登录的行为。那这里首先给出一种完全不考虑计算复杂度的解法,使用纯join关联去实现该问题。
整体思路是去获得同一个用户的发单日期对,看每一个发单日期的n天内是否有n个发单日期。
select
user_id
from
(
select
user_id,
to_date(create_time) as create_date
from
order_base
group by
user_id,
date(create_time)
)a -- 在这一层获取用户的发单日期并去重
left join
(
select
user_id,
to_date(create_time) as create_date
from
order_base
group by
user_id,
to_date(create_time)
)b -- 与a完全相同的逻辑,为了得到日期与日期间的关联
on
a.user_id = b.user_id --仅使用user_id进行关联,获取同一个用户发单日期间的笛卡尔积
where
a.create_date <= b.create_date
and date_add(a.create_date,3) > b.create_date --以a的日期为基准,保留从a.create_date开始的3天内发单日期
group by
user_id,
a.create_date
having
count(1) = 3 --如果从a.create_date开始的3天内都有发单,则应该有3条记录
该方法容易理解,但其最大的弊端在于关联时造成的笛卡尔积大大增加了计算的复杂度。在较小的数据集上可以考虑该方法,但实际生产环境下意义并不大。
这里 使用到了 HIVE 窗口函数 LAG LEAD, 不熟悉的同学请参考 我的文章 :
1. HIVE_HIVE函数_窗口函数_LAG()/LEAD() 详解
https://blog.csdn.net/u010003835/article/details/106739353
最后介绍一个最为直观,也是计算成本最小的方法。假设我们需要求连续登陆n天(假设n为7)及以上的用户,那么对于一个存在该行为的用户,他去重和排序后的发单日期信息中,必存在某一天,往前回溯(往后推)6条记录的日期,等于该日期减6(加6)。这么说可能不太好理解,但相信你看了以下代码便能很快明白我在说什么:
select
user_id
from
(
select
user_id,
create_date,
lag(create_date,6,null) over(partition by user_id order by create_date) as last_6_row -- 按时间排序后6行之前的那一条记录
(
select
user_id,
to_date(create_time) as create_date
from
order_base
group by
user_id,
date(create_time)
)a -- 在这一层获取用户的发单日期并去重
)b --获取6行之前的那一条记录
where
datediff(create_date,last_6_row) = 6
group by
user_id
如我在介绍问题背景的时候所说,处理日期间的连续性就需要将行与行之间进行关联,而sql提供的解决方案是join和窗口函数。恰恰sql的优势便在于刻画这种行数据间的关系,该问题场景能够帮助我们更深入地理解SQL的这一特性。