hiveSql完成递归计算
-
- 基础数据
- 分析数据
- 初步思路
- 最终解决方案
- 代码
- 优缺点
- 总结
最近遇到一个业务数据的坑,涉及到递归的计算思想,但是需要用hivesql完成计算。经过同事的提点完成了,这里记录一下过程。
基础数据
先说下基础数据:
有用户ID,下单时间,卡类型。想要按照有效期分别为年卡:365天,月卡:31天,两年卡:730天计算出每个用户每一单的会员开始结束时间。PS:表中没有一个用户在同一天买多单的情况
用户ID | 下单时间 | 卡类型 |
---|---|---|
A | 2022-01-24 21:47:00 | 月卡 |
A | 2022-02-03 23:08:45 | 月卡 |
A | 2022-02-11 07:22:43 | 月卡 |
A | 2022-02-15 22:31:32 | 年卡 |
B | 2022-02-14 16:03:12 | 月卡 |
有会员数据,只记录了购买会员的下单时间,和会员卡的类型,下单用户。不知道会员的开始结束时间(会员有效时间),需要通过上述已知的条件计算出来。一开始以为只要算一下下单时间作为当前单的开始时间,再通过卡类型的有效期计算下结束时间就好(不同类型的卡有不同的有效期,例如月卡31天,年卡365天)。但是后来开始写才发现没那么简单~ 用户可以在自己还是上一单会员的有效期内提前购买会员。如果这种情况还是按照上述逻辑计算的话。上一单还没结束,当前单的下单时间就不是当前单的有效期开始时间了。例如:
用户 | 第N次买会员 | 卡类型 | 下单时间 | 计算开始时间 | 计算结束时间 |
---|---|---|---|---|---|
A | 1 | 月卡 | 2022-01-24 21:47:00 | 2022-01-24 21:47:00 | 2022-02-23 21:47:00 |
A | 2 | 月卡 | 2022-02-03 23:08:45 | 2022-02-03 23:08:45 | 2022-03-05 23:08:45 |
上表中用户A买了两单,但是第二单是在第一单还没结束买的。所以第二单的开始结束时间计算的就有问题了!
真实的第二单开始时间应该是第一单的结束时间,即2022-02-23号。结束日期是这个日期加上卡类型对应的有效期,即2022-03-25号。
分析数据
准对这种情况可以分析数据。
用户可以在自己还是会员有效期内再次购买,且可以不止购买一次。类似上述基础数据,用户A购买了4单,其中后三单都是在相对前一单没结束的情况下买的。按照上述的逻辑,这种情况,用户A的后3单都应该是相对前一单的结束时间。类似递归计算,每一单都需要先计算出前一单。
第N单 | 用户ID | 下单时间 | 卡类型 | 计算开始时间 | 计算结束时间 |
---|---|---|---|---|---|
1 | A | 2022-01-24 21:47:00 | 月卡 | 当前单的下单时间 | 当前单的下单时间+月卡有效天数31 |
2 | A | 2022-02-03 23:08:45 | 月卡 | 第1单的结束时间 | 第1单的结束时间+月卡效天数31 |
3 | A | 2022-02-11 07:22:43 | 月卡 | 第2单的结束时间 | 第2单的结束时间+月卡效天数31 |
4 | A | 2022-02-15 22:31:32 | 年卡 | 第3单的结束时间 | 第3单的结束时间+年卡效天数365 |
利用hiveSQL实现,到这里可以先思考下怎么做
初步思路
将上述表按照用户id分组,下单时间升序row_number()开窗后进行自关联。左表序号 = 右表序号 + 1,会得到将每个用户的前后单拉平的数据。判断前一单会员的结束时间和当前单下单时间的大小,知道下一单购买时,是否在上一单的有效期内。从而判断当前单的开始时间用当前单的下单时间还是上一单的结束时间。见表:
a.用户ID | a.下单时间 | a.结束时间 | a.卡类型 | a.序号 | b.下单时间 | b.卡类型 | b.序号 |
---|---|---|---|---|---|---|---|
A | 2022-01-24 | 2022-02-24 | 月卡 | 1 | \N | \N | \N |
A | 2022-02-03 | 2022-03-06 | 月卡 | 2 | 2022-01-24 | 月卡 | 1 |
A | 2022-02-11 | 2022-03-14 | 月卡 | 3 | 2022-02-03 | 月卡 | 2 |
A | 2022-02-15 | 2022-03-15 | 年卡 | 4 | 2022-02-11 | 月卡 | 3 |
可以看到上表中第二行数据是A用户的第二单(a.字段名)和第一单(b.字段名)拉平。其中a.结束时间是根据a.下单时间加上卡的对应有点天数计算的临时结束日期(目的是为了得到用户的第一单的结束日期),此时比较b.下单时间 和 a.结束时间即可知道用户的第二单是否是在第一单的有效期内下单。从而判断第二单的开始时间是第一单的结束时间 还是 第二单的下单时间。
仔细看发现此初步思路并不可行。因为是错一位关联的,可以看到上表中的第三行,应该需要用b.下单时间 和 用户第二单的真正会员结束时间做比较才对。此逻辑行不通。
最终解决方案
理一下思路,其实从一个用户角度出发,如果该用户购买了多单。每单可能是在上一单的有效期内购买(即续费),也有可能是在上一单有效期外购买(即复购)。
给用户的所有单按照时间升序排序后,如果是续费则为1,是复购则为0。有表如下:
第N单 | 复购0/续费1 |
---|---|
第一单 | - |
第二单 | 0 |
第三单 | 1 |
第四单 | 1 |
… | … |
第N单 | 0 |
即只需要找到除了第一单外每个连续续费前的第一个复购即可。按照这单复购连续递归计算出升序后的连续续费有效期。知道遇到下一个复购。即上表中的每个0管下面的连续一串的1。递归思路就在这里了。但是想了挺久没有想到怎么在已知用户的现有的所有订单数据的情况下计算出来(我感觉算不出来,如果有能算出来的思路课在下面的评论区 或者私信告诉我,我学习学习)。后来请教了大神同事。给了我一个思路。
先查询该表的所有数据最小下单时间作为第一天。因为表中没有一个用户在同一天买多单的情况。所以第一天出现的一定是用户的第一单。以此算下这些第一单的开始时间(即下单时间) 和 结束时间(下单时间 + 卡对应的有效天数)
写到表中,得结果表tableA,原表为tableB。再限制tableB中的下一天(即最小天的下一天,记为表tmp),tmp这些下单用户作为左表 左关联 tableA中每个用户的最大下单时间的那一单。判断tmp中的每个用户每一单的下单时间 和 该用户在今天之前的最后一单的结束时间。从而模拟出时间的流逝,每天用户下单 都和今天之前的最后一单做比较,判断是复购还是续费,从而对应的计算开始结束时间,再union all 今天之前的所有已经计算好的会员数据。就说这个思路真的挺牛,每单都能和自己的上一单已经算好的结束时间做关联!没明白的可以见下表,简单画一下一个用户的:
例如现在有用户A,下了很多单,有复购有续费的,最小下单时间取出来,最小下单则为该用户的第一单(时间是第一天):
用户 | 第N次买会员 | 卡类型 | 下单时间 | 计算开始时间 | 计算结束时间 |
---|---|---|---|---|---|
A | 1 | 月卡 | 2022-01-24 21:47:00 | 2022-01-24 21:47:00 | 2022-02-23 21:47:00 |
此时时间流逝,来到该用户第二单的那天,取出该数据 作为左表A 左关联这个用户在今天之前的最后一单表b,即上表中的第一单:
a.用户 | a.第N次买会员 | a.卡类型 | a.下单时间 | b.用户 | b.第N次买会员 | b.卡类型 | b.下单时间 | b.开始时间 | b.结束时间 |
---|---|---|---|---|---|---|---|---|---|
A | 2 | 月卡 | 2022-02-03 23:08:45 | A | 1 | 月卡 | 2022-01-24 21:47:00 | 2022-01-24 21:47:00 | 2022-02-23 21:47:00 |
此时第二单(当前单)就和已经算好了正确的开始 、 结束时间的前一单关联到一起了。判断当前单的下单时间和上一单的正确的结束时间的大小即可判断出当前单是续费还是复购。从而计算出当前单正确的开始时间结束时间。再和表b做union all操作,将该用户的两单写到结果表中。
用户 | 第N次买会员 | 卡类型 | 下单时间 | 计算开始时间 | 计算结束时间 |
---|---|---|---|---|---|
A | 1 | 月卡 | 2022-01-24 21:47:00 | 2022-01-24 21:47:00 | 2022-02-23 21:47:00 |
A | 2 | 月卡 | 2022-02-03 23:08:45 | 2022-02-23 21:47:00 | 2022-03-26 21:47:00 |
此时,时间继续流逝,来到该用户的第三单,同第二单一样。将第三单取出,左关联 前两单的最后一单(即第二单)。这样第三单右可以和已经计算好正确的开始结束时间的第二单关联上了。再计算第三单的开始结束时间,再union all以为第四单做准备。
代码
insert overwrite table 结果表
select
a.order_sn, -- 订单号
a.member_id, -- 用户ID
cast(a.pay_amount as double) as pay_amount, -- 支付金额
a.create_time, -- 订单创建时间
case when a.total_amount = 98 then '月卡'
when a.total_amount = 298 then '年卡'
else '两年卡' end as card_type, -- 卡类型
-- 会员开始时间
case when b.member_id is null or cast(a.payment_time as string) > cast(b.end_time as string) then a.payment_time else b.end_time end as start_time,
-- 会员结束时间
case when b.member_id is null or cast(a.payment_time as string) > cast(b.end_time as string) then
case when a.total_amount = 98 then dateadd(a.payment_time,31,'dd')
when a.total_amount = 298 then dateadd(a.payment_time,365,'dd')
else dateadd(a.payment_time,730,'dd') end
else
case when a.total_amount = 98 then dateadd(b.end_time,31,'dd')
when a.total_amount = 298 then dateadd(b.end_time,365,'dd')
else dateadd(b.end_time,730,'dd') end
end as end_time
from
(select * from 原表 where substr(create_time,1,10) = '${tdate}') a
left join
(select
*
from
(select
*,ROW_NUMBER() OVER(PARTITION BY member_id ORDER BY create_time desc) AS irank
from 结果表 where substr(create_time,1,10) < '${tdate}'
) t where irank = 1
) b
on a.member_id = b.member_id
union all
select * from 结果表 where substr(create_time,1,10) < '${tdate}';
优缺点
-
优点
能准确的计算出每单的开始结束时间,思路很棒。 -
缺点
当数据量很大时,即会员表开始时间在很久以前,这种思路只能是单线程跑下来,不能并行计算的,所以跑的会很慢。
总结
涛神(同事),牛x!!!