通过聚合而不是连接查找“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
解释
这是我的查询背后的基本思想。
代表切换的时间必须出现在两个相邻的行中,一个用于结束前一个活动,一个用于开始下一个活动。对此的自然解决方案是连接,以便输出行可以从其自己的行(开始时间)和下一个更改的行(结束时间)中拉出。
但是,我的查询通过重复该行两次来完成使结束时间出现在两个不同行中的需要,使用CROSS JOIN (VALUES (1), (2))
. 我们现在已经复制了所有行。这个想法是,我们将使用某种形式的聚合将每对所需的行合并为一个,而不是使用 JOIN 跨列进行计算。
下一个任务是正确拆分每个重复的行,以便一个实例与前一对一起使用,一个与下一对一起使用。这是通过 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 组成。
下一个艰巨的任务是找出一种方法来消除我们不关心的行,并以某种方式将块的开始时间折叠到与块的结束时间相同的行中。我们想要的是一种方法,让每个离散的 Running 或 Walking 集合都有自己的编号,以便我们可以按它进行分组。DENSE_RANK()
是一个自然的解决方案,但问题是它关注ORDER BY
子句中的每个值——我们没有语法可以这样做DENSE_RANK() OVER (PREORDER BY Time ORDER BY Name)
,这样Time
不会导致RANK
计算发生变化,除非Name
. 经过一番思考后,我意识到我可以从Itzik Ben-Gan 的分组岛屿解决方案背后的逻辑中汲取一点灵感,我发现Time
按Name
并按 排序Time
,将产生一个值,该值对于同一组中的每一行都相同,但与其他组不同。通用的分组岛技术是创建两个计算值,它们都与行同步上升,例如4 5 6
和1 2 3
,减去它们时将产生相同的值(在本示例3 3 3
中为 、 和 的4 - 1
结果5 - 2
)6 - 3
。注意:我最初是从ROW_NUMBER()
我的N
计算开始的,但它不起作用。正确的答案是DENSE_RANK()
虽然我很抱歉地说我不记得我当时为什么得出这个结论,但我必须再次深入研究才能弄清楚。但无论如何,这就是T-N
计算:可以分组以隔离一个状态(跑步或步行)的每个“岛”的数字。
但这不是结束,因为有一些皱纹。首先,每组中的“下一个”行包含 、 和 的Name
错误N
值T
。我们通过从每个组中选择存在的Num = 2
行中的值来解决这个问题(但如果不存在,则使用剩余的值)。这会产生如下表达式CASE WHEN NUM = 2 THEN x END
:这将正确清除不正确的“下一个”行值。
经过一些实验,我意识到单独分组是不够的T - N
,因为步行组和跑步组可以有相同的计算值(在我提供的样本数据最多为 17 的情况下,有两个T - N
值6)。但是简单地分组Name
也可以解决这个问题。“跑步”或“步行”组中的任何组都不会具有来自相反类型的相同数量的干预值。也就是说,由于第一组以“Running”开始,并且在下一个“Running”组之前有两个“Walking”行,因此 N 的值将比T
下一个“Running”组中的值小 2 . 我刚刚意识到,思考这个问题的一种方法是T - N
计算计算当前行之前不属于相同值“Running”或“Walking”的行数。一些想法会表明这是真的:如果我们继续前进到第三组“跑步”组,它只是第三组,因为有一个“步行”组将它们分开,所以它有不同数量的中间行进入在它之前,并且由于它从更高的位置开始,它足够高,以至于不能复制值。
最后,由于我们的最后一组仅包含一行(没有结束时间,我们需要显示 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 秒的时间!不要忽视你的索引。