一道SQL题的前世此生

点击上方“超哥的杂货铺”,轻松关注javascript

640?wx_fmt=gif

来看一道SQL题目:

注:如下讨论核心在于解释原理,所涉及到的数据和表结构均为虚构。本文代码较多,若是看不清楚,能够在后台回复“SQL”获取本文PDF版本。java

假设某APP场景下,有如下数据需求:shell

表1,新增用户表,t_new_userapp

字段:uid 用户id,reg_date新增日期函数

表2,收藏行为表,t_favorite_act测试

字段:uid 用户id,fav_date 收藏日期优化

表3,加购物车行为,下面简称“加购”,t_add_cart_actui

字段:uid 用户id,add_date 加购日期spa

表4,新安装用户表(包括前文的新增用户,还包含卸载重装的用户),3d

t_new_install_user

字段,uid 用户id,install_date 安装日期

目标:求2019-07-01新增用户数和新装用户数以及当日新增用户和新装用户在当天,接下来三天,接下来七天产生收藏行为和加购物车行为的人数(请确保你理解了需求)。输出格式以下:

640?wx_fmt=png 点击图片查看细节

题目中涉及到了两类用户两种行为三种日期,直观的想法是能够化整为零,先缩小范围,看一种用户一个日期的行为,而后类推解决。(好比先看下新增用户三日收藏用户数,加购用户数)。咱们看一下这种思路的SQL写法。

提取2019-07-01新增用户数,三日收藏用户数,三日加购用户数的代码以下:

select a.reg_date, count(distinct a.uid) as new_usr_cnt, 
       count(distinct b.uid) as 3day_fav_usr_cnt,--3日收藏用户数 
       count(distinct c.uid) as 3day_add_cart_usr_cnt--3日加购用户数
from
(
    select reg_date, uid 
    from t_new_user
    where reg_date = '2019-07-01'
    group by reg_date, uid 
) a 
left join 
(
    select fav_date, uid 
    from t_favorite_act
    where fav_date between '2019-07-01' and date_add('2019-07-01', 2)
    group by fav_date, uid
) b 
on a.uid = b.uid
left join
(
    select add_date, uid 
    from t_add_cart_act
    where add_date between '2019-07-01'and date_add('2019-07-01', 2)
    group by add_date, uid
) c
on a.uid = c.uid
group by a.reg_date

提取2019-07-01新装用户数(包含新增),三日收藏用户数,三日加购用户数的代码以下(注意和以前的差异)

select a.reg_date, count(distinct a.uid) as new_install_usr_cnt, 
       count(distinct b.uid) as new_install_3day_fav_usr_cnt, --3日收藏用户数
       count(distinct c.uid) as new_install_3day_add_cart_usr_cnt--3日加购用户数
from
(
    select reg_date as new_date, uid 
    from t_new_user
    where reg_date = '2019-07-01'

    union all 

    select install_date as new_date, uid
    from t_new_install_user
    where install_date = '2019-07-01'
) a 
left join 
(
    select fav_date, uid 
    from t_favorite_act
    where fav_date between '2019-07-01' and date_add('2019-07-01', 2)
    group by fav_date, uid
) b 
on a.uid = b.uid
left join
(
    select add_date, uid 
    from t_add_cart_act
    where add_date between '2019-07-01'and date_add('2019-07-01', 2)
    group by add_date, uid
) c
on a.uid = c.uid
group by a.reg_date

按照这种思路,咱们须要写好多段的SQL(须要修改日期相关的代码):新增用户当日,新增用户3日,新增用户7日,新装用户当日,新装用户3日,新装用户7日。分别运行以后6次,通过整理就能获得最终的结果。也能够外面载嵌套一个left join,设法把这些小段的SQL合并起来(能够思考一下怎么作),这样减小运行的次数,可是代码会很是长,并且有不少重复的部分。显然,这样作不够“明智”。

有没有更简单一点的写法呢?答案固然是确定的。

变式1:新装和新增合并起来写

有没有发现“同一个日期”,如上面的“三日内行为”,对于新装用户和新增用户的代码,b表和c表的大段SQL是同样的!并且后面的代码包含了前面的。由于都要从这两个表里取收藏和加购行为,并且日期还同样。若是按照上面的写法,将新增用户和新装用户两段用日期关联,放到一块儿执行,会重复扫描b表和c表。显然这样在必定程度上会影响效率。考虑到大段代码的重复性,咱们能够巧妙的合并一下,以下:

select a.reg_date, 
       count(distinct case when a.type='new_user' then a.uid else null end) as new_usr_cnt,
       count(distinct case when a.type='new_user' then b.uid else null end) as fav_usr_cnt,
       count(distinct case when a.type='new_user' then c.uid else null end) as add_cart_usr_cnt,
       count(distinct a.uid) as new_install_usr_cnt, 
       count(distinct b.uid) as new_install_3day_fav_usr_cnt, 
       count(distinct c.uid) as new_install_3day_add_cart_usr_cnt
from
(
    select reg_date as new_date, uid, 'new_user' as type
    from t_new_user
    where reg_date = '2019-07-01'

    union all 

    select install_date as new_date, uid, 'reinstall' as type
    from t_new_install_user
    where install_date = '2019-07-01'

) a 
left join 
(
    select fav_date, uid 
    from t_favorite_act
    where fav_date between '2019-07-01' and date_add('2019-07-01', 2)
    group by fav_date, uid 
) b 
on a.uid = b.uid
left join
(
    select add_date, uid 
    from t_add_cart_act
    where add_date between '2019-07-01'and date_add('2019-07-01', 2)
    group by add_date, uid
) c
on a.uid = c.uid
group by a.reg_date

这样是把新增和新装的合并起来了,可是当日,三日,七日仍是须要分开写。还有办法优化吗?固然能够!(想象一下,若是需求突然增长了五日,十日,十五日怎么办?)(题外话,其实新装表是包含新增表的,若是作表的时候能用一个标识区分安装时是不是首次,可能会更合理一些。

变式2:巧用datediff

datediff函数能够求两个日期的时间差。虽然目前的需求是求当日,三日,七日,也就是日期差分别是0,2,6(注意不是1,3,7)的状况。若是咱们能求出全部的时间差diff_day,再对diff_day进行判断,不只能处理当前的需求,就是再来几个别的不一样的日期,那也能够只用一个case when就解决了,代码以下,显然比原来更清晰简洁。

select
    a.reg_date,
        count(distinct case when fav_datediff = 0 and a.type='new_user' then a.uid) as new_install_user_current_day,
    count(distinct case when fav_datediff = 0 and a.type='new_user' then b.uid else null end) as new_user_current_day_fav,
    count(distinct case when fav_datediff > 0 and fav_datediff <= 2 and a.type='new_user' then b.uid else null end) as new_user_3_day_fav,
    count(distinct case when fav_datediff > 0 and fav_datediff <= 6 and a.type='new_user' then b.uid else null end) as new_user_7_day_fav,
    count(distinct case when add_datediff = 0 and a.type='new_user' then c.uid else null end) as new_user_current_day_add,
    count(distinct case when add_datediff > 0 and add_datediff <= 2 and a.type='new_user' then c.uid else null end) as new_user_3_day_add,
    count(distinct case when add_datediff > 0 and add_datediff <= 6 and a.type='new_user' then c.uid else null end) as new_user_7_day_add,
    count(distinct case when fav_datediff = 0 then a.uid) as new_install_user_current_day,
    count(distinct case when fav_datediff = 0 then b.uid else null end) as new_install_user_current_day_fav,
    count(distinct case when fav_datediff > 0 and fav_datediff <= 2 then b.uid else null end) as new_install_user_3_day_fav,
    count(distinct case when fav_datediff > 0 and fav_datediff <= 6 then b.uid else null end) as new_install_user_7_day_fav,
    count(distinct case when add_datediff = 0 and then c.uid else null end) as new_install_user_current_day_add,
    count(distinct case when add_datediff > 0 and add_datediff <= 2 then c.uid else null end) as new_install_user_3_day_add,
    count(distinct case when add_datediff > 0 and add_datediff <= 6 then c.uid else null end) as new_install_user_7_day_add
from
(
    select  a.reg_date, a.uid, a.type, b.uid, c.uid,
    datediff(b.fav_date, a.reg_date) as fav_datediff, 
    datediff(b.add_date, a.reg_date) as add_datediff, 
    from
    (
        select reg_date as new_date, uid, 'new_user' as type
        from t_new_user
        where reg_date = '2019-07-01'

        union all 

        select install_date as new_date , uid, 'reinstall' as type
        from t_new_install_user
        where install_date = '2019-07-01'

    ) a 
    left join 
    (
        select fav_date, uid 
        from t_favorite_act
        where fav_date between '2019-07-01' and date_add('2019-07-01', 7)
        --注意这里要取能知足datediff=6的日期范围
        group by fav_date, uid 
    ) b 
    on a.uid = b.uid
    left join
    (
        select add_date, uid 
        from t_add_cart_act
        where add_date between '2019-07-01'and date_add('2019-07-01', 7)
        --注意这里要取能知足datediff=6的日期范围
        group by add_date, uid
    ) c
    on a.uid = c.uid
) d 

看完上面的代码,是否是感受豁然开朗了许多?咱们巧妙的将以前的代码缩减而且减小了冗余。对比一开始的想法,不只思路更加清晰,代码量也精简很多,并且更便捷,执行的效率也更高。不过咱们继续来思考这端代码,看有没有哪些细节是可能会忽略的。

变式3:若是不是2019-07-01怎么办?

若是是别的固定的日期,固然只须要复制一下代码,改变一下日期就行了。但工做中会有这样的场景,不只仅只是临时取一个数据,而是要开发报表,这须要让写好的SQL根据不一样的日期变量,天天执行一下,得到相应的数据。这里有几个问题。SQL的定时执行一般须要依赖于shell脚本,咱们须要把日期做为一个变量,它须要天天都自动更新成最新的时间(一般会有一天延迟,所以最新的日期就是当前日期昨天的时间)。另外一个是数据更新的问题。咱们用实例来讲明,假设今天是0809,那咱们应该能获得0808以及以前的数据。对于0802以及以前的数据,它的当日,三日,七日的转化状况已经固定了,不会随着时间进一步更新。对于0806以及以前的数据,它的当日和三日转化也已经肯定。而0803-0808这些天,他们的七日转化数据尚未“到位”,0806-0808,他们的三日转化数据也尚未“到位”,由于时间周期还没到。咱们能够选择将当前最新的数据呈现出来(例如0808的数据,当日,三日,七日是同样的,由于只有当日的数据),也能够选择若是日期还没到能够计算数据的时候,在相应的数据置为0。还有一个须要考虑的点:当最新的一天任务执行的时候,都伴随着有一些天的计算时间获得了知足(原来的1天变成2天,2天变成3天,以此类推),因此就须要同时更新当前时间及以前七天的数据。在调度任务的代码里须要有相应的处理逻辑。例如天天写入的时候都须要删除以前七天,写进最新七天等。(这里使用的是不置0的方式)。具体在这里就不展开了。

咱们继续讨论。

变式4:留存计算

回到咱们开篇的问题,有没有以为,需求的逻辑和留存很像。留存一般是指以前有过某种行为,后续依然有。但这里先后两种行为是不同的。其实能够看作是另外一种意义上的留存。所以上面的思路也能够用来计算留存。拿第二天留存来举例,咱们算一下0801-0807的第二天留存。这里额外须要一张活跃表。t_active_user(uid 用户id,act_date 活跃日期)

--次留
select a.reg_date, count(distinct a.uid) new_user, count(distinct b.uid) act_user
from
(
    select reg_date, uid
    from t_new_user
    where reg_date >='20190801' and  reg_date <='20190807' 
    group by 
    reg_date, uid --每日每组新增用户
) a
left join 
(
    select act_date, uid
    from t_active_user
    where act_date >='20190801' and  act_date <='20190808'--至少要到0808才能保证0807有留存数据
    group by act_date, uid  --每日每组日活用户
) b 
on a.uid = b.uid
where datediff(b.act_date, a.reg_date) = 1
group by a.reg_date

你认为这段SQL有问题吗?(咱们把留存的分子分母分开来计算,先不作相除)

乍一看可能不以为有什么问题。但运行以后会发现new_user和act_user的结果是同样的。问题出在where条件已经把没有不符合条件的活跃用户过滤掉了。正确的写法应该将判断条件写到count函数中(能够仔细思考一下),以下:

--次留
select a.reg_date, count(distinct a.uid) new_user, 
count(distinct case when datediff(b.act_date, a.reg_date) = 1 then b.uid else null end) act_user
from
(
    select reg_date, uid
    from t_new_user
    where reg_date >='20190801' and  reg_date <='20190807' 
    group by 
    reg_date, uid
) a
left join 
(
    select act_date, uid
    from t_active_user
    where act_date >='20190801' and  act_date <='20190808'--至少要到0808才能保证0807有留存数据
    group by act_date, uid 
) b 
on a.uid = b.uid
group by a.reg_date

一样的,7日留存也能够在此基础上稍加改动获得。但在多日留存的时候,依然也要考虑前面说的数据更新的问题。

select a.reg_date, count(distinct a.uid) new_user, 
count(distinct case when datediff(b.act_date, a.reg_date) = 1 then b.uid else null end) 2day_act_user,
count(distinct case when datediff(b.act_date, a.reg_date) = 6 then b.uid else null end) 7day_act_user
from
(
    select reg_date, uid
    from t_new_user
    where reg_date >='20190801' and  reg_date <='20190802'--有些天还不能计算7留的数据
    group by 
    reg_date, uid 
) a
left join 
(
    select act_date, uid
    from t_active_user
    where act_date >='20190801' and  act_date <='20190808'--至少要到0808才能保证0802有7日留存数据
    group by act_date, uid 
) b 
on a.uid = b.uid
group by a.reg_date

关于活跃留存的计算,还可能存在活跃表是全量表的状况,即每一个最新分区有历史每一天的活跃用户。那写法可能又有不一样了。咱们可使用下面的方法计算留存。累计活跃表:t_user_active_accu(uid 用户id,act_date 活跃日期)。这种状况咱们依然能够请datediff帮助咱们计算留存。只须要按照日期差进行分组,统计各个日期差的活跃用户数,最后将相同日期差的用户数求和就能算出“留存的用户”了。以下面代码计算了20190801的留存。

select 
    sum(case when datediff=0 then user_cnt else 0 end),
    sum(case when datediff=1 then user_cnt else 0 end)
from
(
    select datediff, count(distinct uid) as user_cnt
    from
    (
        select uid, datediff(act_date,'2019-08-01') as datediff
        from t_user_active_accu
        where ds = '20190808'--ds表示分区,取一个最新的便可
        and datediff('2019-08-01', act_date) >=0 
    ) a 
    group by datediff
) b 

上面两种计算留存的方式都比较好理解,使用了datediff函数,但须要注意使用的场景,一种是增量表,一种是全量表。

总结

本文咱们从一道SQL题目出发,讨论了使用union all 和datediff简化代码的过程,以及由此衍生出来的相关问题。因为表是虚构的,代码并未通过彻底测试,主要在于理解其中的技巧和原理。但愿对读到这里的你有所帮助。若有问题欢迎指出,也欢迎交流讨论~

640?wx_fmt=png

640?wx_fmt=jpeg

以清净心看世界;

用欢喜心过生活。

超哥的杂货铺,你值得拥有~

长按二维码关注咱们