在 SQL 中计算滚动聚合的最有效方法是什么?

What's the most efficient way to calculate a rolling aggregate in SQL?

我有一个数据集,其中包括一堆客户和他们拥有 "stay." 的日期范围,例如:

| ClientID | DateStart | DateEnd |
+----------+-----------+---------+
| 1        | Jan 1     | Jan 31  | (datediff = 30)
| 1        | Apr 4     | May 4   | (datediff = 30)
| 2        | Jan 3     | Feb 27  | (datediff = 55)
| 3        | Jan 1     | Jan 7   | (datediff = 6)
| 3        | Jan 10    | Jan 17  | (datediff = 6)
| 3        | Jan 20    | Jan 27  | (datediff = 6)
| 3        | Feb 1     | Feb 7   | (datediff = 6)
| 3        | Feb 10    | Feb 17  | (datediff = 6)
| 3        | Feb 20    | Feb 27  | (datediff = 6)

我的最终目标是能够确定客户在过去 X 时间超过 N 晚阈值的日期。假设过去 90 天中有 30 天。我还需要知道他们何时超过门槛。用例:酒店住宿和 VIP 身份。

所以我想生成这样的 table:

| ClientID | VIPStart  | VIPEnd  |
+----------+-----------+---------+
| 1        | Jan 31    | Apr 2   |
| 1        | May 4     | Jul 5   |
| 2        | Feb 3     | Apr 28  |
| 3        | Feb 17    | Apr 11  |
(Forgive me if the dates are slightly off, I'm doing this in my head)

理想情况下,我想生成一个视图,因为我需要经常引用它。

我想知道生成它的最有效方法是什么?假设我有成千上万的客户和数十万次入住。

到目前为止,我一直采用的方法是使用包含参数的 SQL 语句:截至 {?Date},谁拥有 VIP 身份,谁没有。我通过计算 DATEADD(day,-90,{?Date}),然后排除超出范围的记录,然后截断较早延伸的 DateStarts 和较晚延伸的 DateEnds,然后计算 DATEDIFF(day,DateStart,DateEnd) 用于使用调整后的 DateStartDateEnd,然后从 {?Date} 开始为每个客户获得结果 DATEDIFF()SUM()。它有效,但它并不漂亮。它给了我一个时间点快照;我要历史。

生成 table 个日期然后对每个日期都使用上述方法似乎效率有点低。

我考虑的另一个选择是将原始数据转换成分解的 table,每条记录对应一个晚上,这样我就可以更容易地计算它。像这样:

| ClientID | StayDate  |
+----------+-----------+
| 1        | Jan 1     |
| 1        | Jan 2     |
| 1        | Jan 3     |
| 1        | Jan 4     |
etc.

然后我可以添加一个列来计算过去 90 天内的天数,这将帮助我完成大部分工作。

但我不确定如何在视图中执行此操作。我有一个执行此操作的代码片段:

WITH DaysTally AS (
    SELECT MAX(DATEDIFF(day, DateStart, DateEnd)) - 1 AS Tally
    FROM Stays
UNION ALL
    SELECT Tally - 1 AS Expr1
    FROM DaysTally AS DaysTally_1
    WHERE  (Tally - 1 >= 0))

SELECT t.ClientID,
        DATEADD(day, c.Tally, t.DateStart) AS "StayDate"
    FROM Stays AS t 
    INNER JOIN DaysTally AS c ON 
        DATEDIFF(day, t.DateStart, t.DateEnd) - 1 >= c.Tally

OPTION (MAXRECURSION 0)

但是没有 MAXRECURSION 我无法让它工作,而且我认为你不能用 MAXRECURSION

保存视图

现在我在胡说八道。所以我正在寻找的帮助是:实现我的目标最有效的方法是什么?如果您有代码示例,那也会很有帮助!谢谢。

这是一个有趣且问得很好的问题。我首先用递归 cte 枚举每个客户从第一次入住开始到最后一次入住结束后 90 天的天数。然后,您可以将 table 与左连接一起使用,并使用 window 函数来标记 "VIP" 天(请注意,这假设给定客户没有重叠停留,这与你的示例数据)。

接下来就是gaps-and-islands:你可以用一个window和把"adjacent"个VIP天数分组,然后合计。

with cte as (
    select clientID, min(dateStart) dt, dateadd(day, 90, max(dateEnd)) dateMax
    from stays 
    group by clientID
    union all
    select clientID, dateadd(day, 1, dt), dateMax 
    from cte 
    where dt < dateMax
)
select clientID, min(dt) VIPStart, max(dt) VIPEnd
from (
    select t.*, sum(isNotVip) over(partition by clientID order by dt) grp
    from (
        select 
            c.clientID, 
            c.dt,
            case when count(s.clientID) over(
                partition by c.clientID 
                order by c.dt 
                rows between 90 preceding and current row
            ) >= 30
                then 0
                else 1
            end isNotVip
        from cte c
        left join stays s 
            on c.clientID = s.clientID and c.dt between s.dateStart and s.dateEnd
    ) t
) t
where isNotVip = 0
group by clientID, grp
order by clientID, VIPStart
option (maxrecursion 0)

这个 demo on DB Fiddle 与您的样本数据产生:

clientID | VIPStart   | VIPEnd    
-------: | :--------- | :---------
       1 | 2020-01-30 | 2020-04-01
       1 | 2020-05-03 | 2020-07-04
       2 | 2020-02-01 | 2020-04-28
       3 | 2020-02-07 | 2020-04-20

可以将其放在视图中,如下所示:

  • 创建视图时必须省略order byoption(maxrecursion)子句

  • 在其 from 子句中包含视图的每个查询都必须以 option(max recursion 0)

  • 结尾

Demo

您可以通过在视图中创建计数 table 来消除递归。方法如下:

  • 对于每个期间,生成从期间之前 90 天到之后 90 天的日期。这些是周期可能影响的所有 "candidate days"。
  • 对于每一行,添加一个标志,说明它是否在期间(相对于前后 90 天)。
  • 按客户 ID 和日期汇总。
  • 使用 运行 总和来计算过去 90 天内超过 30 天的天数。
  • 然后筛选出 30 天以上的问题,并将其视为间隙和孤岛问题。

假设 1000 天足够时间段(包括前后 90 天),则查询如下所示:

with n as (
      select v.n
      from (values (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)) v(n)
     ),
     nums as (
      select (n1.n * 100 + n2.n * 10 + n3.n) as n
      from n n1 cross join n n2 cross join n n3
     ),
     running90 as (
      select clientid, dte, sum(in_period) over (partition by clientid order by dte rows between 89 preceding and current row) as running_90
      from (select t.clientid, dateadd(day, n.n - 90, datestart) as dte,
                   max(case when dateadd(day, n.n - 90, datestart) >= datestart and dateadd(day, n.n - 90, datestart) <= t.dateend then 1 else 0 end) as in_period
            from t join
                 nums n
                 on dateadd(day, n.n - 90, datestart) <= dateadd(day, 90, dateend)
            group by t.clientid, dateadd(day, n.n - 90, datestart)
           ) t
     )
select clientid, min(dte), max(dte)
from (select r.*,
             row_number() over (partition by clientid order by dte) as seqnum
      from running90 r
      where running_90 >= 30
     ) r
group by clientid, dateadd(day, - seqnum, dte);

没有递归 CTE(尽管可以用于 n),这不受 maxrecursion 问题的影响。

Here 是一个 db<>fiddle.

结果与您的结果略有不同。这可能是由于定义上的一些细微差别。以上包括作为 "occupied" 天的结束日。上述查询中的 90 天是之前的 89 天加上当天。倒数第二个查询显示 90 天 运行 天,这对我来说似乎是正确的。