19

我有一个记录随时间变化的值的表,类似于以下内容:

RecordId  Time   Name
========================
1         10     Running
2         18     Running
3         21     Running
4         29     Walking
5         33     Walking
6         57     Running
7         66     Running

查询此表后,我需要类似于以下的结果:

FromTime  ToTime  Name
=========================
10        29      Running
29        57      Walking
57        NULL    Running

我玩弄了一些聚合函数(例如 MIN、MAX 等)、PARTITION 和 CTE,但我似乎无法找到正确的解决方案。我希望 SQL 大师可以帮助我,或者至少为我指明正确的方向。有没有一种相当直接的方法来查询这个(最好没有游标?)

4

5 回答 5

22

通过聚合而不是连接查找“ToTime”

我想分享一个非常疯狂的查询,它只需要对表进行 1 次扫描和 1 次逻辑读取。相比之下,页面上最好的其他答案 Simon Kingston 的查询需要 2 次扫描。

在一个非常大的数据集(17,408 个输入行,产生 8,193 个结果行)上,它需要 574 个 CPU 和 2645 个时间,而 Simon Kingston 的查询需要 63,820 个 CPU 和 37,108 个时间。

使用索引可能会使页面上的其他查询性能好很多倍,但我很感兴趣的是,仅通过重写查询就可以实现 111 倍的 CPU 提升和 14 倍的速度提升。

(请注意:我完全没有对 Simon Kingston 或其他任何人的不尊重;我只是对我对这个查询的想法感到兴奋。他的查询比我的要好,因为它的性能很好,而且实际上是可以理解和维护的,不像我的。)

这是不可能的查询。很难理解。很难写。但这太棒了。:)

WITH Ranks AS (
   SELECT
      T = Dense_Rank() OVER (ORDER BY Time, Num),
      N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time, Num),
      *
   FROM
      #Data D
      CROSS JOIN (
         VALUES (1), (2)
      ) X (Num)
), Items AS (
   SELECT
      FromTime = Min(Time),
      ToTime = Max(Time),
      Name = IsNull(Min(CASE WHEN Num = 2 THEN Name END), Min(Name)),
      I = IsNull(Min(CASE WHEN Num = 2 THEN T - N END), Min(T - N)),
      MinNum = Min(Num)
   FROM
      Ranks
   GROUP BY
      T / 2
)
SELECT
   FromTime = Min(FromTime),
   ToTime = CASE WHEN MinNum = 2 THEN NULL ELSE Max(ToTime) END,
   Name
FROM Items
GROUP BY
   I, Name, MinNum
ORDER BY
   FromTime

注意:这需要 SQL 2008 或更高版本。要使其在 SQL 2005 中工作,请将 VALUES 子句更改为SELECT 1 UNION ALL SELECT 2.

更新查询

想了想,我意识到我同时完成了两个独立的逻辑任务,这使查询变得不必要的复杂:1)剪掉与最终解决方案无关的中间行(不开始的行)一个新任务)和 2)从下一行中提取“ToTime”值。通过在 # 2之前执行 #1 ,查询更简单,并且使用大约一半的 CPU 执行!

所以这里是一个简化的查询,首先修剪掉我们不关心的行,然后使用聚合而不是 JOIN 获取 ToTime 值。是的,它确实有 3 个窗口函数而不是 2 个,但最终由于行数更少(在修剪我们不关心的行之后),它要做的工作更少:

WITH Ranks AS (
   SELECT
      Grp =
         Row_Number() OVER (ORDER BY Time)
         - Row_Number() OVER (PARTITION BY Name ORDER BY Time),
      [Time], Name
   FROM #Data D
), Ranges AS (
   SELECT
      Result = Row_Number() OVER (ORDER BY Min(R.[Time]), X.Num) / 2,
      [Time] = Min(R.[Time]),
      R.Name, X.Num
   FROM
      Ranks R
      CROSS JOIN (VALUES (1), (2)) X (Num)
   GROUP BY
      R.Name, R.Grp, X.Num
)
SELECT
   FromTime = Min([Time]),
   ToTime = CASE WHEN Count(*) = 1 THEN NULL ELSE Max([Time]) END,
   Name = IsNull(Min(CASE WHEN Num = 2 THEN Name ELSE NULL END), Min(Name))
FROM Ranges R
WHERE Result > 0
GROUP BY Result
ORDER BY FromTime;

这个更新后的查询与我在解释中提出的所有问题相同,但是,它们更容易解决,因为我不处理多余的不需要的行。我还看到Row_Number() / 2我必须排除 0 的值,我不知道为什么我没有从先前的查询中排除它,但无论如何这完美地工作并且速度非常快!

外部应用收拾东西

最后,这是一个与 Simon Kingston 的查询基本相同的版本,我认为它的语法更易于理解。

SELECT
   FromTime = Min(D.Time),
   X.ToTime,
   D.Name
FROM
   #Data D
   OUTER APPLY (
      SELECT TOP 1 ToTime = D2.[Time]
      FROM #Data D2
      WHERE
         D.[Time] < D2.[Time]
         AND D.[Name] <> D2.[Name]
      ORDER BY D2.[Time]
   ) X
GROUP BY
   X.ToTime,
   D.Name
ORDER BY
   FromTime;

如果您想对更大的数据集进行性能比较,请使用以下设置脚本:

CREATE TABLE #Data (
    RecordId int,
    [Time]  int,
    Name varchar(10)
);
INSERT #Data VALUES
    (1, 10, 'Running'),
    (2, 18, 'Running'),
    (3, 21, 'Running'),
    (4, 29, 'Walking'),
    (5, 33, 'Walking'),
    (6, 57, 'Running'),
    (7, 66, 'Running'),
    (8, 77, 'Running'),
    (9, 81, 'Walking'),
    (10, 89, 'Running'),
    (11, 93, 'Walking'),
    (12, 99, 'Running'),
    (13, 107, 'Running'),
    (14, 113, 'Walking'),
    (15, 124, 'Walking'),
    (16, 155, 'Walking'),
    (17, 178, 'Running');
GO
insert #data select recordid + (select max(recordid) from #data), time + (select max(time) +25 from #data), name from #data
GO 10

解释

这是我的查询背后的基本思想。

  1. 代表切换的时间必须出现在两个相邻的行中,一个用于结束前一个活动,一个用于开始下一个活动。对此的自然解决方案是连接,以便输出行可以从其自己的行(开始时间)和下一个更改的行(结束时间)中拉出。

  2. 但是,我的查询通过重复该行两次来完成使结束时间出现在两个不同行中的需要,使用CROSS JOIN (VALUES (1), (2)). 我们现在已经复制了所有行。这个想法是,我们将使用某种形式的聚合将每对所需的行合并为一个,而不是使用 JOIN 跨列进行计算。

  3. 下一个任务是正确拆分每个重复的行,以便一个实例与前一对一起使用,一个与下一对一起使用。这是通过 T 列完成的,按ROW_NUMBER()排序Time,然后除以 2(尽管我将其更改为 DENSE_RANK() 以实现对称性,因为在这种情况下它返回与 ROW_NUMBER 相同的值)。为了提高效率,我在下一步中执行了除法,以便可以在另一个计算中重复使用行号(继续阅读)。由于行号从 1 开始,除以 2 隐式转换为 int,这具有产生0 1 1 2 2 3 3 4 4 ...具有所需结果的序列的效果:通过按此计算值分组,因为我们还按Num在行号中,我们现在已经完成了第一个之后的所有集合都由“前”行的 Num = 2 和“下”行的 Num = 1 组成。

  4. 下一个艰巨的任务是找出一种方法来消除我们不关心的行,并以某种方式将块的开始时间折叠到与块的结束时间相同的行中。我们想要的是一种方法,让每个离散的 Running 或 Walking 集合都有自己的编号,以便我们可以按它进行分组。DENSE_RANK()是一个自然的解决方案,但问题是它关注ORDER BY子句中的每个值——我们没有语法可以这样做DENSE_RANK() OVER (PREORDER BY Time ORDER BY Name),这样Time不会导致RANK计算发生变化,除非Name. 经过一番思考后,我意识到我可以从Itzik Ben-Gan 的分组岛屿解决方案背后的逻辑中汲取一点灵感,我发现TimeName并按 排序Time,将产生一个值,该值对于同一组中的每一行都相同,但与其他组不同。通用的分组岛技术是创建两个计算值,它们都与行同步上升,例如4 5 61 2 3,减去它们时将产生相同的值(在本示例3 3 3中为 、 和 的4 - 1结果5 - 26 - 3。注意:我最初是从ROW_NUMBER()我的N计算开始的,但它不起作用。正确的答案是DENSE_RANK()虽然我很抱歉地说我不记得我当时为什么得出这个结论,但我必须再次深入研究才能弄清楚。但无论如何,这就是T-N计算:可以分组以隔离一个状态(跑步或步行)的每个“岛”的数字。

  5. 但这不是结束,因为有一些皱纹。首先,每组中的“下一个”行包含 、 和 的Name错误NT。我们通过从每个组中选择存在的Num = 2行中的值来解决这个问题(但如果不存在,则使用剩余的值)。这会产生如下表达式CASE WHEN NUM = 2 THEN x END:这将正确清除不正确的“下一个”行值。

  6. 经过一些实验,我意识到单独分组是不够的T - N,因为步行组和跑步组可以有相同的计算值(在我提供的样本数据最多为 17 的情况下,有两个T - N值6)。但是简单地分组Name也可以解决这个问题。“跑步”或“步行”组中的任何组都不会具有来自相反类型的相同数量的干预值。也就是说,由于第一组以“Running”开始,并且在下一个“Running”组之前有两个“Walking”行,因此 N 的值将比T下一个“Running”组中的值小 2 . 我刚刚意识到,思考这个问题的一种方法是T - N计算计算当前行之前不属于相同值“Running”或“Walking”的行数。一些想法会表明这是真的:如果我们继续前进到第三组“跑步”组,它只是第三组,因为有一个“步行”组将它们分开,所以它有不同数量的中间行进入在它之前,并且由于它从更高的位置开始,它足够高,以至于不能复制值。

  7. 最后,由于我们的最后一组仅包含一行(没有结束时间,我们需要显示 aNULL代替),我不得不进行计算,该计算可用于确定我们是否有结束时间。这是通过Min(Num)表达式完成的,然后最终检测到当 Min(Num) 为 2 时(意味着我们没有“下一个”行),然后显示 aNULL而不是Max(ToTime)值。

我希望这个解释对人们有所帮助。我不知道我的“行乘法”技术是否普遍有用并适用于生产环境中的大多数 SQL 查询编写器,因为理解它的困难和维护的困难它肯定会呈现给下一个访问代码(反应可能是“它到底在做什么!?!”然后是快速的“重写时间!”)。

如果您已经做到了这一步,那么我感谢您抽出宝贵的时间,让我沉迷于我对令人难以置信的有趣 sql-puzzle-land 的小旅行。

亲眼看看

又名模拟“PREORDER BY”:

最后一点。要了解T - N这项工作的效果——并注意使用我的方法的这一部分可能不适用于 SQL 社区——对示例数据的前 17 行运行以下查询:

WITH Ranks AS (
   SELECT
      T = Dense_Rank() OVER (ORDER BY Time),
      N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time),
      *
   FROM
      #Data D
)
SELECT
   *,
   T - N
FROM Ranks
ORDER BY
   [Time];

这产生:

RecordId    Time Name       T    N    T - N
----------- ---- ---------- ---- ---- -----
1           10   Running    1    1    0
2           18   Running    2    2    0
3           21   Running    3    3    0
4           29   Walking    4    1    3
5           33   Walking    5    2    3
6           57   Running    6    4    2
7           66   Running    7    5    2
8           77   Running    8    6    2
9           81   Walking    9    3    6
10          89   Running    10   7    3
11          93   Walking    11   4    7
12          99   Running    12   8    4
13          107  Running    13   9    4
14          113  Walking    14   5    9
15          124  Walking    15   6    9
16          155  Walking    16   7    9
17          178  Running    17   10   7

重要的部分是每组“Walking”或“Running”具有相同的值,T - N这与任何其他具有相同名称的组不同。

表现

我不想详细说明我的查询比其他人的查询快。但是,考虑到差异有多么显着(当没有索引时),我想以表格格式显示数字。当需要这种行间关联的高性能时,这是一种很好的技术。

在每个查询运行之前,我使用了DBCC FREEPROCCACHE; DBCC DROPCLEANBUFFERS;. 我将每个查询的 MAXDOP 设置为 1,以消除并行性的时间折叠效应。我将每个结果集选择为变量而不是将它们返回给客户端,以便仅衡量性能而不衡量客户端数据传输。所有查询都被赋予相同的 ORDER BY 子句。所有测试使用 17,408 个输入行,产生 8,193 个结果行。

以下人员/原因未显示任何结果:

RichardTheKiwi *Could not test--query needs updating*
ypercube       *No SQL 2012 environment yet :)*
Tim S          *Did not complete tests within 5 minutes*

没有索引:

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          344         344         99          0
Simon Kingston 68672       69582       549203      49

带索引CREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time);

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          328         336         99          0
Simon Kingston 70391       71291       549203      49          * basically not worse

带索引CREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time, Name);

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          375         414         359         0           * IO WINNER
Simon Kingston 172         189         38273       0           * CPU WINNER

所以这个故事的寓意是:

适当的索引比查询魔法更重要

使用适当的索引,Simon Kingston 的版本总体上获胜,尤其是在包括查询复杂性/可维护性时。

好好听听这个教训!38k 的阅读量并没有那么多,Simon Kingston 的版本运行时间是我的一半。我的查询速度的提高完全是由于表上没有索引,并且随之而来的灾难性成本给任何需要连接的查询(我的没有):全表扫描哈希匹配杀死了它的性能。有了索引,他的查询就能够通过聚集索引查找(又名书签查找)进行嵌套循环,这让事情变得非常快。

有趣的是,仅 Time 上的聚集索引是不够的。尽管 Times 是唯一的,这意味着每次只出现一个 Name,但它仍然需要 Name 作为索引的一部分才能正确使用它。

在充满数据的情况下将聚集索引添加到表中需要不到 1 秒的时间!不要忽视你的索引。

于 2012-11-29T02:45:17.950 回答
9

这在 SQL Server 2008 中不起作用,仅在具有LAG()LEAD()分析功能的 SQL Server 2012 版本中起作用,但我将把它留给任何使用较新版本的人:

SELECT Time AS FromTime
     , LEAD(Time) OVER (ORDER BY Time) AS ToTime
     , Name
FROM
  ( SELECT Time 
         , LAG(Name) OVER (ORDER BY Time) AS PreviousName
         , Name
    FROM Data  
  ) AS tmp
WHERE PreviousName <> Name 
   OR PreviousName IS NULL ;

SQL-Fiddle中测试

有了索引,(Time, Name)就需要进行索引扫描。

编辑:

如果NULL是一个有效值Name,则需要将其视为有效条目,请使用以下WHERE子句:

WHERE PreviousName <> Name 
   OR (PreviousName IS NULL AND Name IS NOT NULL)
   OR (PreviousName IS NOT NULL AND Name IS NULL) ;
于 2012-11-28T22:33:55.633 回答
4

我假设 RecordID 并不总是连续的,因此 CTE 创建一个不间断的序列号。

SQLFiddle

;with SequentiallyNumbered as (
    select *, N = row_number() over (order by RecordId)
      from Data)
, Tmp as (
    select A.*, RN=row_number() over (order by A.Time)
      from SequentiallyNumbered A
 left join SequentiallyNumbered B on B.N = A.N-1 and A.name = B.name
     where B.name is null)
   select A.Time FromTime, B.Time ToTime, A.Name
     from Tmp A
left join Tmp B on B.RN = A.RN + 1;

我用来测试的数据集

create table Data (
    RecordId int,
    Time  int,
    Name varchar(10));
insert Data values
    (1         ,10     ,'Running'),
    (2         ,18     ,'Running'),
    (3         ,21     ,'Running'),
    (4         ,29     ,'Walking'),
    (5         ,33     ,'Walking'),
    (6         ,57     ,'Running'),
    (7         ,66     ,'Running');
于 2012-11-28T21:56:36.893 回答
4

这是一个 CTE 解决方案,可以得到您正在寻找的结果:

;WITH TheRecords (FirstTime,SecondTime,[Name])
AS
(
    SELECT [Time],
    (
        SELECT MIN([Time]) 
        FROM ActivityTable at2
        WHERE at2.[Time]>at.[Time]
        AND at2.[Name]<>at.[Name]
    ),
    [Name]
    FROM ActivityTable at
)
SELECT MIN(FirstTime) AS FromTime,SecondTime AS ToTime,MIN([Name]) AS [Name]
FROM TheRecords
GROUP BY SecondTime
ORDER BY FromTime,ToTime
于 2012-11-28T21:56:53.173 回答
4

我认为您本质上对“名称”从一条记录更改为下一条记录的位置(按“时间”顺序)感兴趣。如果您可以确定发生这种情况的位置,则可以生成所需的输出。

由于您提到了 CTE,我假设您使用的是 SQL Server 2005+,因此可以使用该ROW_NUMBER()功能。您可以使用ROW_NUMBER()一种方便的方法来识别连续的记录对,然后找到“名称”发生变化的那些。

这个怎么样:

WITH OrderedTable AS
(
    SELECT
        *,
        ROW_NUMBER() OVER (ORDER BY Time) AS Ordinal
    FROM
        [YourTable]
),
NameChange AS
(
    SELECT
        after.Time AS Time,
        after.Name AS Name,
        ROW_NUMBER() OVER (ORDER BY after.Time) AS Ordinal
    FROM
        OrderedTable before
        RIGHT JOIN OrderedTable after ON after.Ordinal = before.Ordinal + 1
    WHERE
        ISNULL(before.Name, '') <> after.Name
)

SELECT
    before.Time AS FromTime,
    after.Time AS ToTime,
    before.Name
FROM
    NameChange before
    LEFT JOIN NameChange after ON after.Ordinal = before.Ordinal + 1
于 2012-11-28T21:59:38.497 回答