【问题标题】:Aggregate for each day over time series, without using non-equijoin logic汇总时间序列中的每一天,而不使用非等值连接逻辑
【发布时间】:2018-09-05 14:46:03
【问题描述】:

最初的问题

鉴于以下数据集与日期表配对:

MembershipId | ValidFromDate | ValidToDate
==========================================
0001         | 1997-01-01    | 2006-05-09
0002         | 1997-01-01    | 2017-05-12
0003         | 2005-06-02    | 2009-02-07

在任何给定的日期或时间序列中,有多少 Memberships 处于打开状态?

初步回答

here 提出这个问题后,这个答案提供了必要的功能:

select d.[Date]
      ,count(m.MembershipID) as MembershipCount
from DIM.[Date] as d
    left join Memberships as m
        on(d.[Date] between m.ValidFromDateKey and m.ValidToDateKey)
where d.CalendarYear = 2016
group by d.[Date]
order by d.[Date];

尽管评论者评论说 当非等值连接耗时太长时,还有其他方法。

跟进

因此,复制上述查询的输出时,仅 equijoin 的逻辑是什么样的?


目前的进展

根据目前提供的答案,我得出以下结论,在我使用的 320 万条Membership 记录中,它的性能优于硬件:

declare @s date = '20160101';
declare @e date = getdate();

with s as
(
    select d.[Date] as d
        ,count(s.MembershipID) as s
    from dbo.Dates as d
        join dbo.Memberships as s
            on d.[Date] = s.ValidFromDateKey
    group by d.[Date]
)
,e as
(
    select d.[Date] as d
        ,count(e.MembershipID) as e
    from dbo.Dates as d
        join dbo.Memberships as e
            on d.[Date] = e.ValidToDateKey
    group by d.[Date]
),c as
(
    select isnull(s.d,e.d) as d
            ,sum(isnull(s.s,0) - isnull(e.e,0)) over (order by isnull(s.d,e.d)) as c
    from s
        full join e
            on s.d = e.d
)
select d.[Date]
    ,c.c
from dbo.Dates as d
    left join c
        on d.[Date] = c.d
where d.[Date] between @s and @e
order by d.[Date]
;

从那以后,为了每天将这个聚合分成组成组,我有以下,它也表现良好:

declare @s date = '20160101';
declare @e date = getdate();

with s as
(
    select d.[Date] as d
        ,s.MembershipGrouping as g
        ,count(s.MembershipID) as s
    from dbo.Dates as d
        join dbo.Memberships as s
            on d.[Date] = s.ValidFromDateKey
    group by d.[Date]
            ,s.MembershipGrouping
)
,e as
(
    select d.[Date] as d
        ,e..MembershipGrouping as g
        ,count(e.MembershipID) as e
    from dbo.Dates as d
        join dbo.Memberships as e
            on d.[Date] = e.ValidToDateKey
    group by d.[Date]
            ,e.MembershipGrouping
),c as
(
    select isnull(s.d,e.d) as d
            ,isnull(s.g,e.g) as g
            ,sum(isnull(s.s,0) - isnull(e.e,0)) over (partition by isnull(s.g,e.g) order by isnull(s.d,e.d)) as c
    from s
        full join e
            on s.d = e.d
                and s.g = e.g
)
select d.[Date]
    ,c.g
    ,c.c
from dbo.Dates as d
    left join c
        on d.[Date] = c.d
where d.[Date] between @s and @e
order by d.[Date]
        ,c.g
;

任何人都可以改进上述内容吗?

【问题讨论】:

  • 你的方式并不是唯一的方式。我正在尝试其他几种方法来做到这一点。
  • @AlanBurstein 发现了什么值得注意的东西?
  • 您的查询返回的计数不正确。运行查询,然后运行一个更简单的表单,该表单仅从成员资格表中计算单个日期,例如“2016-01-01”
  • @PittsburghDBA 您能否详细说明您指的是哪个查询而不是更正方式?尽管我很高兴看到我犯的任何错误,但我在测试中没有发现任何差异。
  • @PittsburghDBA 你是对的,会员资格的最后一天不计入当前查询。需要dateadd(d,1,ValidToDateKey) 来避免此问题。检查我的答案。

标签: sql sql-server tsql date join


【解决方案1】:

如果您的大多数会员资格有效期都超过几天,请查看 Martin Smith 的回答。这种方法可能会更快。


当您使用日历表 (DIM.[Date]) 并将其与 Memberships 左连接时,您最终可能会针对范围内的每个日期扫描 Memberships 表。即使(ValidFromDate, ValidToDate) 上有索引,它也可能不是超级有用。

很容易扭转局面。 只需扫描Memberships 表一次,然后为每个成员查找使用CROSS APPLY 的有效日期。

样本数据

DECLARE @T TABLE (MembershipId int, ValidFromDate date, ValidToDate date);

INSERT INTO @T VALUES
(1, '1997-01-01', '2006-05-09'),
(2, '1997-01-01', '2017-05-12'),
(3, '2005-06-02', '2009-02-07');

DECLARE @RangeFrom date = '2006-01-01';
DECLARE @RangeTo   date = '2006-12-31';

查询 1

SELECT
    CA.dt
    ,COUNT(*) AS MembershipCount
FROM
    @T AS Memberships
    CROSS APPLY
    (
        SELECT dbo.Calendar.dt
        FROM dbo.Calendar
        WHERE
            dbo.Calendar.dt >= Memberships.ValidFromDate
            AND dbo.Calendar.dt <= Memberships.ValidToDate
            AND dbo.Calendar.dt >= @RangeFrom
            AND dbo.Calendar.dt <= @RangeTo
    ) AS CA
GROUP BY
    CA.dt
ORDER BY
    CA.dt
OPTION(RECOMPILE);

OPTION(RECOMPILE) 并不是真正需要的,我在比较执行计划时将其包含在所有查询中,以确保在处理查询时获得最新计划。

当我查看此查询的计划时,我发现Calendar.dt 表中的查找只使用了ValidFromDateValidToDate@RangeFrom@RangeTo 被推送到残基谓词。这并不理想。优化器不够聪明,无法计算最多两个日期(ValidFromDate@RangeFrom)并将该日期用作搜索的起点。

帮助优化器很容易:

查询 2

SELECT
    CA.dt
    ,COUNT(*) AS MembershipCount
FROM
    @T AS Memberships
    CROSS APPLY
    (
        SELECT dbo.Calendar.dt
        FROM dbo.Calendar
        WHERE
            dbo.Calendar.dt >= 
                CASE WHEN Memberships.ValidFromDate > @RangeFrom 
                THEN Memberships.ValidFromDate 
                ELSE @RangeFrom END
            AND dbo.Calendar.dt <= 
                CASE WHEN Memberships.ValidToDate < @RangeTo 
                THEN Memberships.ValidToDate 
                ELSE @RangeTo END
    ) AS CA
GROUP BY
    CA.dt
ORDER BY
    CA.dt
OPTION(RECOMPILE)
;

在此查询中,搜索是最佳的,不会读取以后可能会丢弃的日期。

最后,您可能不需要扫描整个Memberships 表。 我们只需要给定日期范围与成员的有效范围相交的那些行。

查询 3

SELECT
    CA.dt
    ,COUNT(*) AS MembershipCount
FROM
    @T AS Memberships
    CROSS APPLY
    (
        SELECT dbo.Calendar.dt
        FROM dbo.Calendar
        WHERE
            dbo.Calendar.dt >= 
                CASE WHEN Memberships.ValidFromDate > @RangeFrom 
                THEN Memberships.ValidFromDate 
                ELSE @RangeFrom END
            AND dbo.Calendar.dt <= 
                CASE WHEN Memberships.ValidToDate < @RangeTo 
                THEN Memberships.ValidToDate 
                ELSE @RangeTo END
    ) AS CA
WHERE
    Memberships.ValidToDate >= @RangeFrom
    AND Memberships.ValidFromDate <= @RangeTo
GROUP BY
    CA.dt
ORDER BY
    CA.dt
OPTION(RECOMPILE)
;

[a1;a2][b1;b2] 两个区间在何时相交

a2 >= b1 and a1 <= b2

这些查询假定Calendar 表在dt 上有一个索引。

您应该尝试看看哪些索引更适合Memberships 表。 对于最后一个查询,如果表相当大,很可能ValidFromDateValidToDate 上的两个独立索引比(ValidFromDate, ValidToDate) 上的一个索引要好。

您应该尝试不同的查询并使用真实数据在真实硬件上衡量它们的性能。性能可能取决于数据分布、有多少成员资格、他们的有效日期是什么、给定范围有多宽或多窄等。

我建议使用名为SQL Sentry Plan Explorer 的出色工具来分析和比较执行计划。这是免费的。它显示了许多有用的统计信息,例如每个查询的执行时间和读取次数。上面的截图来自这个工具。

【讨论】:

  • 虽然可能比原始答案更有效,但这并没有使用等值连接逻辑,并且无论我对索引做什么,实际上在我的服务器上的性能仍然相当差。我已经用我得出的解决方案更新了这个问题,该解决方案表现更好,但我很想看看您是否有任何进一步的见解或建议?
  • @iamdave,1. 我还没有详细查看您的长查询(我会在周末尝试),但请务必明确写 SUM(...) OVER (... ORDER BY ... BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)。如果未指定ROW,则假定为RANGE,效率不如ROW。 (当然,如果ROW 的查询逻辑仍然正确,所有这些都适用)。
  • ROWS/RANGE 性能差异取决于版本。它已在最近的版本中得到修复(本次会议中提到sqlbits.com/Sessions/Event17/Window_Functions
  • 这将在会议结束时(最后几分钟)进行讨论。该修复需要批处理模式窗口聚合,因此对于行模式仍然如此。
  • @VladimirBaranov 时间段到处都是;有些长,有些短,有时在一个日期更多,而另一些则更少,没有真正的开始/结束模式或偏见。日期在Date 列中,列名的差异是代码混淆的产物。马上更新。
【解决方案2】:

假设您的日期维度包含所有会员期间包含的所有日期,您可以使用类似以下的内容。

连接是一个等连接,因此可以使用散列连接或合并连接,而不仅仅是嵌套循环(它将为每个外行执行一次内部子树)。

假设(ValidToDate) include(ValidFromDate) 上的索引或反向索引可以使用单次 查找Memberships单次 扫描日期维度。下面我用不到一秒的时间返回一个有 320 万会员和 140 万一般活跃会员的表格(script

DECLARE @StartDate DATE = '2016-01-01',
        @EndDate   DATE = '2016-12-31';

WITH MD
     AS (SELECT Date,
                SUM(Adj) AS MemberDelta
         FROM   Memberships
                CROSS APPLY (VALUES ( ValidFromDate, +1),
                                    --Membership count decremented day after the ValidToDate
                                    (DATEADD(DAY, 1, ValidToDate), -1) ) V(Date, Adj)
         WHERE
          --Members already expired before the time range of interest can be ignored
          ValidToDate >= @StartDate
          AND
          --Members whose membership starts after the time range of interest can be ignored
          ValidFromDate <= @EndDate
         GROUP  BY Date),
     MC
     AS (SELECT DD.DateKey,
                SUM(MemberDelta) OVER (ORDER BY DD.DateKey ROWS UNBOUNDED PRECEDING) AS CountOfNonIgnoredMembers
         FROM   DIM_DATE DD
                LEFT JOIN MD
                  ON MD.Date = DD.DateKey)
SELECT DateKey,
       CountOfNonIgnoredMembers AS MembershipCount
FROM   MC
WHERE  DateKey BETWEEN @StartDate AND @EndDate 
ORDER BY DateKey

Demo(使用延长期,因为示例数据对 2016 日历年不是很感兴趣)

【讨论】:

  • 我认为可以在这里应用类似于Packing Intervals by Itzik Ben-Gan 的东西。区间开始时为 +1,区间结束时为 -1。然后统计它们。 Itzik 的文章有一个很好的说明(图 2)。我没有机会研究它。你打败了我。
  • @VladimirBaranov - 是的。很确定我首先在 Itzik 的一本书中看到了这个想法。我认为在呼叫中心的公开电话的背景下
  • 这看起来很有希望,谢谢!我将在星期一首先进行一些测试并报告。
  • 我喜欢这个只是为了避免嵌套循环并启用 HASH JOIN。干得好。
  • 绝对是迄今为止最好的解决方案!我怀疑我们会变得更好,但本着公平的精神,我会将赏金开放到今天晚些时候。
【解决方案3】:

一种方法是首先使用 INNER JOIN 来查找匹配集,并使用 COUNT() 来投影 MemberCount GROUPed BY DateKey,然后使用相同的日期集 UNION ALL,该投影上的成员计数为 0每个日期。最后一步是对这个联合的 MemberCount 和 GROUP BY DateKey 进行 SUM()。根据要求,这避免了 LEFT JOIN 和 NOT EXISTS。正如另一位成员所指出的,这不是等值连接,因为我们需要使用范围,但我认为它符合您的意图。

这将为 1 年的数据提供大约 10 万次逻辑读取。在一台带有旋转磁盘的普通笔记本电脑上,从冷缓存中,它可以在一秒钟内运行 1 个月(计数正确)。

这是一个创建 330 万行随机持续时间的示例。底部的查询返回一个月的数据。

--Stay quiet for a moment
SET NOCOUNT ON
SET STATISTICS IO OFF
SET STATISTICS TIME OFF

--Clean up if re-running
DROP TABLE IF EXISTS DIM_DATE
DROP TABLE IF EXISTS FACT_MEMBER

--Date dimension
CREATE TABLE DIM_DATE
  (
  DateKey DATE NOT NULL 
  )

--Membership fact
CREATE TABLE FACT_MEMBER
  (
  MembershipId INT NOT NULL
  , ValidFromDateKey DATE NOT NULL
  , ValidToDateKey DATE NOT NULL
  )

--Populate Date dimension from 2001 through end of 2018
DECLARE @startDate DATE = '2001-01-01'
DECLARE @endDate DATE = '2018-12-31'
;WITH CTE_DATE AS
(
SELECT @startDate AS DateKey
UNION ALL
SELECT
       DATEADD(DAY, 1, DateKey)
FROM
       CTE_DATE AS D
WHERE
       D.DateKey < @endDate
)
INSERT INTO
  DIM_DATE
  (
  DateKey
  )
SELECT
  D.DateKey
FROM
  CTE_DATE AS D
OPTION (MAXRECURSION 32767)

--Populate Membership fact with members having a random membership length from 1 to 36 months 
;WITH CTE_DATE AS
(
SELECT @startDate AS DateKey
UNION ALL
SELECT
       DATEADD(DAY, 1, DateKey)
FROM
       CTE_DATE AS D
WHERE
       D.DateKey < @endDate
)
,CTE_MEMBER AS
(
SELECT 1 AS MembershipId
UNION ALL
SELECT MembershipId + 1 FROM CTE_MEMBER WHERE MembershipId < 500
)
,
CTE_MEMBERSHIP
AS
(
SELECT
  ROW_NUMBER() OVER (ORDER BY NEWID()) AS MembershipId
  , D.DateKey AS ValidFromDateKey
FROM
  CTE_DATE AS D
  CROSS JOIN CTE_MEMBER AS M
)
INSERT INTO
    FACT_MEMBER
    (
    MembershipId
    , ValidFromDateKey
    , ValidToDateKey
    )
SELECT
    M.MembershipId
    , M.ValidFromDateKey
      , DATEADD(MONTH, FLOOR(RAND(CHECKSUM(NEWID())) * (36-1)+1), M.ValidFromDateKey) AS ValidToDateKey
FROM
    CTE_MEMBERSHIP AS M
OPTION (MAXRECURSION 32767)

--Add clustered Primary Key to Date dimension
ALTER TABLE DIM_DATE ADD CONSTRAINT PK_DATE PRIMARY KEY CLUSTERED
    (
    DateKey ASC
    )

--Index
--(Optimize in your spare time)
DROP INDEX IF EXISTS SK_FACT_MEMBER ON FACT_MEMBER
CREATE CLUSTERED INDEX SK_FACT_MEMBER ON FACT_MEMBER
    (
    ValidFromDateKey ASC
    , ValidToDateKey ASC
    , MembershipId ASC
    )


RETURN

--Start test
--Emit stats
SET STATISTICS IO ON
SET STATISTICS TIME ON

--Establish range of dates
DECLARE
  @rangeStartDate DATE = '2010-01-01'
  , @rangeEndDate DATE = '2010-01-31'

--UNION the count of members for a specific date range with the "zero" set for the same range, and SUM() the counts
;WITH CTE_MEMBER
AS
(
SELECT
    D.DateKey
    , COUNT(*) AS MembershipCount
FROM
    DIM_DATE AS D
    INNER JOIN FACT_MEMBER AS M ON
        M.ValidFromDateKey <= @rangeEndDate
        AND M.ValidToDateKey >= @rangeStartDate
        AND D.DateKey BETWEEN M.ValidFromDateKey AND M.ValidToDateKey
WHERE
    D.DateKey BETWEEN @rangeStartDate AND @rangeEndDate
GROUP BY
    D.DateKey

UNION ALL

SELECT
    D.DateKey
    , 0 AS MembershipCount
FROM
    DIM_DATE AS D
WHERE
    D.DateKey BETWEEN @rangeStartDate AND @rangeEndDate
)
SELECT
    M.DateKey
    , SUM(M.MembershipCount) AS MembershipCount
FROM
    CTE_MEMBER AS M
GROUP BY
    M.DateKey
ORDER BY
    M.DateKey ASC
OPTION (RECOMPILE, MAXDOP 1)

【讨论】:

  • 想知道您是否对超过 3 个会员资格进行了测试?在我的 3.2m 行表上,这执行了超过 10m 的逻辑读取,并且运行一个月需要将近一分钟,而我的问题的 Progress So Far 部分中的答案执行不到 35k 逻辑全年阅读并在 3 秒内完成。
  • 假设您的服务器比我这里的要好得多,因为这个更新版本的运行时间比我愿意等待的时间长得多......
  • 这是我的最新版本。告诉我。
【解决方案4】:

以下是我如何使用 equijoin 解决此问题:

--data generation
declare @Membership table (MembershipId varchar(10), ValidFromDate date, ValidToDate date)
insert into @Membership values
('0001', '1997-01-01', '2006-05-09'),
('0002', '1997-01-01', '2017-05-12'),
('0003', '2005-06-02', '2009-02-07')

declare @startDate date, @endDate date
select @startDate =  MIN(ValidFromDate), @endDate = max(ValidToDate) from @Membership
--in order to use equijoin I need all days between min date and max date from Membership table (both columns)
;with cte as (
    select @startDate [date]
    union all
    select DATEADD(day, 1, [date]) from cte
    where [date] < @endDate
)
--in this query, we will assign value to each day:
--one, if project started on that day
--minus one, if project ended on that day
--then, it's enough to (cumulative) sum all this values to get how many projects were ongoing on particular day
select [date],
       sum(case when [DATE] = ValidFromDate then 1 else 0 end +
            case when [DATE] = ValidToDate then -1 else 0 end)
            over (order by [date] rows between unbounded preceding and current row)
from cte [c]
left join @Membership [m]
on [c].[date] = [m].ValidFromDate  or [c].[date] = [m].ValidToDate
option (maxrecursion 0)

这是另一个解决方案:

--data generation
declare @Membership table (MembershipId varchar(10), ValidFromDate date, ValidToDate date)
insert into @Membership values
('0001', '1997-01-01', '2006-05-09'),
('0002', '1997-01-01', '2017-05-12'),
('0003', '2005-06-02', '2009-02-07')

;with cte as (
    select CAST('2016-01-01' as date) [date]
    union all
    select DATEADD(day, 1, [date]) from cte
    where [date] < '2016-12-31'
)

select [date],
       (select COUNT(*) from @Membership where ValidFromDate < [date]) - 
       (select COUNT(*) from @Membership where ValidToDate < [date]) [ongoing]
from cte
option (maxrecursion 0)

【讨论】:

    【解决方案5】:

    注意,当@PittsburghDBA 说当前查询返回错误结果时,我认为它是正确的。
    会员资格的最后一天不计算在内,因此最终金额低于应有的金额。
    我已经在这个版本中更正了它。

    这应该会提高你的实际进度:

    declare @s date = '20160101';
    declare @e date = getdate();
    
    with 
    x as (
        select d, sum(c) c
        from (
            select ValidFromDateKey d, count(MembershipID) c
            from Memberships
            group by ValidFromDateKey 
    
            union all
    
            -- dateadd needed to count last day of membership too!!
            select dateadd(dd, 1, ValidToDateKey) d, -count(MembershipID) c
            from Memberships
            group by ValidToDateKey 
        )x
        group by d
    ),
    c as
    (
        select d, sum(x.c) over (order by d) as c
        from x
    )
    select d.day, c cnt
    from calendar d
    left join c on d.day = c.d
    where d.day between @s and @e
    order by d.day;
    

    【讨论】:

      【解决方案6】:

      首先,即使在给定日期不存在活动成员,您的查询也会产生“1”为MembershipCount

      你应该返回SUM(CASE WHEN m.MembershipID IS NOT NULL THEN 1 ELSE 0 END) AS MembershipCount

      为了获得最佳性能,在 Memberships(ValidFromDateKey, ValidToDateKey, MembershipId) 上创建一个索引,在 DIM.[Date](CalendarYear, DateKey) 上创建另一个索引。

      完成后,最佳查询应为:

      DECLARE @CalendarYear INT = 2000
      
      SELECT dim.DateKey, SUM(CASE WHEN con.MembershipID IS NOT NULL THEN 1 ELSE 0 END) AS MembershipCount
      FROM
          DIM.[Date] dim
              LEFT OUTER JOIN (
                  SELECT ValidFromDateKey, ValidToDateKey, MembershipID
                  FROM Memberships
                  WHERE
                          ValidFromDateKey <= CONVERT(DATETIME, CONVERT(VARCHAR, @CalendarYear) + '1231')
                      AND ValidToDateKey   >= CONVERT(DATETIME, CONVERT(VARCHAR, @CalendarYear) + '0101')
              ) con
              ON dim.DateKey BETWEEN con.ValidFromDateKey AND con.ValidToDateKey
      WHERE dim.CalendarYear = @CalendarYear
      GROUP BY dim.DateKey
      ORDER BY dim.DateKey
      

      现在,对于您的最后一个问题,什么是等价连接查询

      没有办法您可以将其重写为非等值连接!

      Equijoin 并不意味着使用join sintax。 Equijoin 意味着使用equals 谓词,不管是什么sintax。

      您的查询会产生范围比较,因此 equals 不适用:需要 between 或类似名称。

      【讨论】:

      • 即使在给定日期不存在有效成员资格,您的查询也会产生“1”作为 MembershipCount。 这是不正确的。 null 值在聚合操作中被忽略,因此 1 个 null 值中的 count 实际上是 0
      • 您无法将其重写为非等值连接! 虽然这在技术上是正确的,因为必须有between 逻辑在某处,问题是指仅使用等连接逻辑将日期列表组合到正在聚合的表中,正如您从其他答案中已经看到的那样可能的。
      • 基本上,听起来 OP 想要在不使用 LEFT JOIN 或 NOT EXISTS 的情况下执行此操作。看我的回答。
      猜你喜欢
      • 2016-09-12
      • 2018-05-15
      • 2022-07-07
      • 2019-06-23
      • 2016-07-31
      • 1970-01-01
      • 1970-01-01
      • 2018-06-22
      • 1970-01-01
      相关资源
      最近更新 更多