条件聚合性能

Conditional aggregation performance

让我们有以下数据

 IF OBJECT_ID('dbo.LogTable', 'U') IS NOT NULL  DROP TABLE dbo.LogTable

 SELECT TOP 100000 DATEADD(day, ( ABS(CHECKSUM(NEWID())) % 65530 ), 0) datesent 
 INTO [LogTable]
 FROM    sys.sysobjects
 CROSS JOIN sys.all_columns

我想统计行数,去年的行数和最近十年的行数。这可以使用条件聚合查询或使用子查询来实现,如下所示

-- conditional aggregation query
SELECT
    COUNT(*) AS all_cnt,
    SUM(CASE WHEN datesent > DATEADD(year,-1,GETDATE())
             THEN 1 ELSE 0 END) AS last_year_cnt,
    SUM(CASE WHEN datesent > DATEADD(year,-10,GETDATE())
             THEN 1 ELSE 0 END) AS last_ten_year_cnt
FROM LogTable


-- subqueries
SELECT
(
    SELECT count(*) FROM LogTable 
) all_cnt, 
(
    SELECT count(*) FROM LogTable WHERE datesent > DATEADD(year,-1,GETDATE())
) last_year_cnt,
(
    SELECT count(*) FROM LogTable WHERE datesent > DATEADD(year,-10,GETDATE())
) last_ten_year_cnt

如果您执行查询并查看查询计划,您会看到类似

的内容

显然,第一个解决方案有更好的查询计划、成本估算,甚至 SQL 命令看起来也更简洁和花哨。但是,如果您使用 SET STATISTICS TIME ON 测量查询的 CPU 时间,我会得到以下结果(我已经测量了几次,结果大致相同)

(1 row(s) affected)

 SQL Server Execution Times:
   CPU time = 47 ms,  elapsed time = 41 ms.

(1 row(s) affected)

(1 row(s) affected)

 SQL Server Execution Times:
   CPU time = 31 ms,  elapsed time = 26 ms.
SQL Server parse and compile time: 
   CPU time = 0 ms, elapsed time = 0 ms.

 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 0 ms.

因此,与使用条件聚合的解决方案相比,第二种解决方案的性能略好(或相同)。如果我们在 datesent 属性上创建索引,差异会变得更加明显。

CREATE INDEX ix_logtable_datesent ON dbo.LogTable(DateSent)

然后第二个解决方案开始使用 Index Seek 而不是 Table Scan 并且它的查询 CPU 时间性能在我的计算机上下降到 16ms。

我的问题有两个:(1)为什么条件聚合解决方案至少在没有索引的情况下不优于子查询解决方案,(2)是否可以为条件聚合解决方案创建 'index' (或重写条件聚合查询)以避免扫描,或者如果我们关注性能,条件聚合通常不适合吗?

旁注: 我可以说,这种情况对于条件聚合来说是相当乐观的,因为我们 select 所有行的数量总是导致使用扫描的解决方案.如果不需要所有行数,则带子查询的索引解决方案不扫描,而带条件聚合的解决方案无论如何都必须执行扫描。

编辑

Vladimir Baranov 基本回答了第一个问题(非常感谢)。然而,第二个问题仍然存在。我可以在 Whosebug 上看到经常使用条件聚合解决方案的答案,它们吸引了很多注意力,被认为是最优雅、最清晰的解决方案(有时被提议为最有效的解决方案)。因此,我将问题稍微概括一下:

你能举个例子吗,条件聚合明显优于子查询解决方案?

为简单起见,我们假设不存在物理访问(数据在缓冲区缓存中),因为今天的数据库服务器仍然将大部分数据保留在内存中。

简短摘要

  • 子查询方法的性能取决于数据分布。
  • 条件聚合的性能不依赖于数据分布。

子查询方法可能比条件聚合更快或更慢,这取决于数据分布。

自然地,如果 table 有一个 suitable 索引,那么子查询可能会从中受益,因为索引只允许扫描 table 的相关部分而不是全面扫描。拥有 suitable 索引不太可能对条件聚合方法有显着好处,因为它无论如何都会扫描完整索引。唯一的好处是如果索引比 table 窄并且引擎必须将更少的页面读入内存。

了解这一点,您可以决定选择哪种方法。


第一次测试

我做了一个更大的测试 table,有 500 万行。 table 上没有索引。 我使用 SQL Sentry Plan Explorer 测量了 IO 和 CPU 统计数据。我使用 SQL Server 2014 SP1-CU7 (12.0.4459.0) Express 64 位进行这些测试。

确实,您的原始查询的行为与您描述的一样,即子查询速度更快,即使读取速度提高了 3 倍。

在没有索引的 table 上尝试几次后,我重写了您的条件聚合并添加了变量来保存 DATEADD 表达式的值。

整体时间明显变快了。

然后我把SUM换成了COUNT,又变快了一点。

毕竟,条件聚合变得和子查询一样快。

预热缓存 (CPU=375)

SELECT -- warm cache
    COUNT(*) AS all_cnt
FROM LogTable
OPTION (RECOMPILE);

子查询 (CPU=1031)

SELECT -- subqueries
(
    SELECT count(*) FROM LogTable 
) all_cnt, 
(
    SELECT count(*) FROM LogTable WHERE datesent > DATEADD(year,-1,GETDATE())
) last_year_cnt,
(
    SELECT count(*) FROM LogTable WHERE datesent > DATEADD(year,-10,GETDATE())
) last_ten_year_cnt
OPTION (RECOMPILE);

原始条件聚合 (CPU=1641)

SELECT -- conditional original
    COUNT(*) AS all_cnt,
    SUM(CASE WHEN datesent > DATEADD(year,-1,GETDATE())
             THEN 1 ELSE 0 END) AS last_year_cnt,
    SUM(CASE WHEN datesent > DATEADD(year,-10,GETDATE())
             THEN 1 ELSE 0 END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

带变量的条件聚合 (CPU=1078)

DECLARE @VarYear1 datetime = DATEADD(year,-1,GETDATE());
DECLARE @VarYear10 datetime = DATEADD(year,-10,GETDATE());

SELECT -- conditional variables
    COUNT(*) AS all_cnt,
    SUM(CASE WHEN datesent > @VarYear1
             THEN 1 ELSE 0 END) AS last_year_cnt,
    SUM(CASE WHEN datesent > @VarYear10
             THEN 1 ELSE 0 END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

使用变量和 COUNT 而不是 SUM 的条件聚合 (CPU=1062)

SELECT -- conditional variable, count, not sum
    COUNT(*) AS all_cnt,
    COUNT(CASE WHEN datesent > @VarYear1
             THEN 1 ELSE NULL END) AS last_year_cnt,
    COUNT(CASE WHEN datesent > @VarYear10
             THEN 1 ELSE NULL END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

根据这些结果,我猜测 CASE 为每一行调用了 DATEADD,而 WHERE 足够聪明,可以计算一次。另外 COUNTSUM.

效率高一点点

最后,条件聚合只比子查询慢一点(1062 vs 1031),可能是因为WHERE本身比CASE效率高一点,此外,WHERE 过滤掉相当多的行,因此 COUNT 必须处理较少的行。


实际上我会使用条件聚合,因为我认为读取次数更重要。如果您的 table 很小以适合并保留在缓冲池中,那么最终用户的任何查询都会很快。但是,如果 table 大于可用内存,那么我预计从磁盘读取会显着降低子查询速度。


第二次测试

另一方面,尽早过滤掉行也很重要。

这里是测试的一个细微变化,它证明了这一点。这里我将阈值设置为 GETDATE() + 100 年,以确保没有行满足过滤条件。

预热缓存 (CPU=344)

SELECT -- warm cache
    COUNT(*) AS all_cnt
FROM LogTable
OPTION (RECOMPILE);

子查询 (CPU=500)

SELECT -- subqueries
(
    SELECT count(*) FROM LogTable 
) all_cnt, 
(
    SELECT count(*) FROM LogTable WHERE datesent > DATEADD(year,100,GETDATE())
) last_year_cnt
OPTION (RECOMPILE);

原始条件聚合 (CPU=937)

SELECT -- conditional original
    COUNT(*) AS all_cnt,
    SUM(CASE WHEN datesent > DATEADD(year,100,GETDATE())
             THEN 1 ELSE 0 END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

带变量的条件聚合 (CPU=750)

DECLARE @VarYear100 datetime = DATEADD(year,100,GETDATE());

SELECT -- conditional variables
    COUNT(*) AS all_cnt,
    SUM(CASE WHEN datesent > @VarYear100
             THEN 1 ELSE 0 END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

使用变量和 COUNT 而不是 SUM 的条件聚合 (CPU=750)

SELECT -- conditional variable, count, not sum
    COUNT(*) AS all_cnt,
    COUNT(CASE WHEN datesent > @VarYear100
             THEN 1 ELSE NULL END) AS last_ten_year_cnt
FROM LogTable
OPTION (RECOMPILE);

下面是一个带有子查询的计划。您可以看到在第二个子查询中有 0 行进入流聚合,所有这些都在 Table 扫描步骤中被过滤掉。

因此,子查询再次变得更快。

第三次测试

这里我更改了之前测试的过滤条件:将所有>替换为<。结果,条件 COUNT 计算了所有行而不是 none。惊喜,惊喜!条件聚合查询花费相同的 750 毫秒,而子查询变为 813 而不是 500。

这是子查询的计划:

Could you give me an example, where conditional aggregation notably outperforms the subquery solution?

在这里。子查询方法的性能取决于数据分布。条件聚合的性能不依赖于数据分布。

子查询方法可以比条件聚合更快或更慢,这取决于数据分布。

了解这一点,您可以决定选择哪种方法。


奖金详情

如果将鼠标悬停在 Table Scan 运算符上,您可以看到 Actual Data Size 的不同变体。

  1. 简单COUNT(*):

  1. 条件聚合

  1. 测试 2 中的子查询:

  1. 测试 3 中的子查询:

现在很明显,性能差异可能是由流经计划的数据量差异引起的。

在简单 COUNT(*) 的情况下,没有 Output list(不需要列值)并且数据大小最小 (43MB)。

在条件聚合的情况下,此数量在测试 2 和 3 之间没有变化,始终为 72MB。 Output list 有一列 datesent.

在子查询的情况下,这个数量根据数据分布而变化。

这是我的示例,其中大型 table 上的子查询非常慢(大约 40-50 秒),我被建议使用 FILTER(条件聚合)重写查询,这加快了它的速度最多 1 秒。我很惊讶。

现在我总是使用 FILTER 条件聚合,因为你只加入大型 tables 一次,所有检索都是用 FILTER。在大 table 上使用 sub-select 是个坏主意。

主题:

我需要一份表格报告,如下,

示例(首先是简单的平面内容,然后是复杂的表格内容):

RecallID | RecallDate | Event |..| WalkAlone | WalkWithPartner |..| ExerciseAtGym
256      | 10-01-19   | Exrcs |..| NULL      | NULL            |..| yes
256      | 10-01-19   | Walk  |..| yes       | NULL            |..| NULL
256      | 10-01-19   | Eat   |..| NULL      | NULL            |..| NULL
257      | 10-01-19   | Exrcs |..| NULL      | NULL            |..| yes

我的 SQL 对基于答案的表格列进行了内部选择,看起来像这样:

select 
-- Easy flat stuff first
r.id as recallid, r.recall_date as recalldate, ... ,

-- Example of Tabulated Columns:
(select l.description from answers_t ans, activity_questions_t aq, lookup_t l 
where l.id=aq.answer_choice_id and aq.question_id=13 
and aq.id=ans.activity_question_id and aq.activity_id=27 and ans.event_id=e.id) 
     as transportationotherintensity,
(select l.description from answers_t ans, activity_questions_t aq, lookup_t l
where l.id=66 and l.id=aq.answer_choice_id and aq.question_id=14
and aq.id=ans.activity_question_id and ans.event_id=e.id) 
     as commutework,
(select l.description from answers_t ans, activity_questions_t aq, lookup_t l
where l.id=67 and l.id=aq.answer_choice_id and aq.question_id=14 and aq.id=ans.activity_question_id and ans.event_id=e.id) 
     as commuteschool,
(select l.description from answers_t ans, activity_questions_t aq, lookup_t l
where l.id=95 and l.id=aq.answer_choice_id and aq.question_id=14 and aq.id=ans.activity_question_id and ans.event_id=e.id) 
     as dropoffpickup,

表演很糟糕。 Gordon Linoff 建议在大型 table ANSWERS_T 上 一次性加入,并在所有列表选择中酌情使用 FILTER。这加快了 1 秒。

select ans.event_id,
       max(l.description) filter (where aq.question_id = 13 and aq.activity_id = 27) as transportationotherintensity
       max(l.description) filter (where l.id = 66 and aq.question_id = 14 and aq.activity_id = 67) as commutework,
       . . .
from activity_questions_t aq join
     lookup_t l 
     on l.id = aq.answer_choice_id join
     answers_t ans
     on aq.id = ans.activity_question_id
group by ans.event_id