检索按顶级表分组的子查询 COUNT 的最有效方法?

Jam*_*mes 7 performance sql-server azure-sql-database query-performance

鉴于以下架构

CREATE TABLE categories
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    name NVARCHAR(50)
);

CREATE TABLE [group]
(
    id UNIQUEIDENTIFIER PRIMARY KEY
);

CREATE TABLE logger
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    group_id UNIQUEIDENTIFIER,
    uuid CHAR(17)
);

CREATE TABLE data
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    logger_uuid CHAR(17),
    category_name NVARCHAR(50),
    recorded_on DATETIME
);
Run Code Online (Sandbox Code Playgroud)

以及以下规则

  1. 每条data记录引用一个logger和一个category
  2. 每个logger人总会有一个group
  3. 每个group可以有多个loggers
  4. 我只想统计最近记录的数据

category_name每行不是唯一的,它只是将给定数据记录关联到类别下的一种方式,id实际上只是一个代理键。

实现结果集的最佳方法是什么

category_id | logger_group_count
--------------------------------
12345          4
67890          2
.....          ...
Run Code Online (Sandbox Code Playgroud)

即数数。记录器记录数据的每个类别的组数?

作为最初的尝试,我想出了:

SELECT g.id, COUNT(DISTINCT(a.id)) AS logger_group_count 
FROM categories g
  LEFT OUTER JOIN data d ON d.category_name = g.name
  INNER JOIN logger s ON s.uuid = d.logger_uuid
  INNER JOIN group a ON a.id = s.group_id
GROUP BY g.id
Run Code Online (Sandbox Code Playgroud)

但是非常慢(~45 秒),data有 40 万多条记录 - 这是查询计划,这是一个可以玩的小提琴

我想确保在开始查看其他内容(例如硬件利用率等)之前,我已经充分利用了查询。Azure SQL 成本可能会大幅上升(即使您可能只需要从当前层中提取更多的汁液) .

Joe*_*ish 8

您使用的是较新版本的 SQL Server,因此实际计划为您提供了大量信息。看到SELECT操作员上的警告标志了吗?这意味着 SQL Server 生成了可能影响查询性能的警告。你应该总是看看那些:

<Warnings>
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="[s].[logger_uuid]=CONVERT_IMPLICIT(nchar(17),[d].[uuid],0)" />
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="CONVERT_IMPLICIT(nvarchar(100),[d].[name],0)=[g].[name]" />
</Warnings>
Run Code Online (Sandbox Code Playgroud)

您的架构会导致两种数据类型转换。根据警告,我怀疑 name 实际上是 anNVARCHAR(100)并且logger_uuidNCHAR(17). 问题中发布的表架构可能不正确。您应该了解发生这些转换的根本原因并修复它。某些类型的数据类型转换会阻止索引查找,导致基数估计问题,并导致其他问题。

另一个要检查的重要事情是等待统计数据。您也可以在SELECT操作员的详细信息中看到这些内容。这是您的等待统计信息和查询所花费的时间的 XML:

<WaitStats>
<Wait WaitType="RESOURCE_GOVERNOR_IDLE" WaitTimeMs="49515" WaitCount="3773" />
<Wait WaitType="SOS_SCHEDULER_YIELD" WaitTimeMs="57164" WaitCount="2466" />
</WaitStats>
<QueryTimeStats ElapsedTime="67135" CpuTime="10007" />
Run Code Online (Sandbox Code Playgroud)

我不是云专家,但看起来您的查询无法完全占用 CPU。这可能与您当前的 Azure 层有关。该查询在执行时只需要大约 10 秒的 CPU,但需要 67 秒。我相信那 50 秒的时间花在了节流上,而其中的 7 秒给了您,但用于同时运行的其他查询。坏消息是查询速度比您的层可能慢。好消息是 CPU 的任何减少都可能导致运行时间减少 5 倍。换句话说,如果您可以让查询使用 1 秒的 CPU,那么您可能会看到大约 5 秒的运行时间。

接下来,您可以查看操作员详细信息中的“实际时间统计”属性,以查看 CPU 时间花费在哪里。您的计划使用行模式,因此运算符的 CPU 时间是该运算符及其子级花费的时间总和。这是一个相对简单的计划,因此很快就会发现聚集索引扫描logger_data使用了 6527 毫秒的 CPU 时间。调用它的循环连接使用 10006 毫秒的 CPU 时间,因此所有查询的 CPU 都用在该步骤。通过查看相关箭头的粗细,可以找到该步骤出现问题的另一个线索:

粗箭头

该运算符返回了很多行,因此值得查看详细信息。查看聚集索引扫描的实际行数,您可以看到返回了 14088885 行并读取了 14100798 行。但是,表基数只有 484803 行。直觉上,这似乎效率很低,对吧?聚集索引扫描返回的数据远远超过表中的行数。对表具有不同联接类型或访问方法的其他一些计划可能更有效。

为什么 SQL Server 读取并返回这么多行?聚集索引位于嵌套循环的内侧。循环的外侧(logger表上的扫描)返回了 38 行,因此扫描logger_data执行了 38 次。484803*38 = 18422514 非常接近读取的行数。那么为什么 SQL Server 会选择这样一个感觉如此低效的计划呢?它甚至估计它会对表格进行 57 次扫描,因此可以说你得到的计划比它怀疑的更有效率。

您可能想知道为什么TOP您的计划中有运营商。SQL Server在为查询创建查询计划时引入了行目标。这可能比您想要的更详细,但简而言之,SQL Server 并不总是需要从聚集索引扫描中返回所有行。有时,如果它只需要固定数量的行并且它在到达扫描结束之前找到这些行,则它可以提前停止。如果扫描可以提前停止,那么扫描就不会那么昂贵,因此当存在行目标时,操作员的成本会被公式打折。换句话说,SQL Server 期望扫描聚集索引 57 次,但它认为它会很快找到它需要的单行。由于存在TOP 操作员。

您可以通过鼓励查询优化器选择不扫描logger_data表 38 次的计划来加快查询速度。这可能就像消除数据类型转换一样简单。这可能允许 SQL Server 执行索引查找而不是扫描。如果没有,请修复转换并为以下内容创建覆盖索引logger_data

CREATE INDEX IX ON logger_data (category_name, logger_uuid);
Run Code Online (Sandbox Code Playgroud)

查询优化器根据成本选择计划。添加此索引使得不太可能获得对 logger_data 进行多次扫描的慢速计划,因为通过索引查找而不是聚集索引扫描访问表会更便宜。

如果您不能添加索引,你可以考虑增加一个查询提示禁用引进排目标:USE HINT('DISABLE_OPTIMIZER_ROWGOAL'))。只有当您对行目标的概念感到满意并理解它们时,才应该这样做。添加该提示应该会产生不同的计划,但我不能说它会有多高效。


Jam*_*mes 0

感谢@JoeObbish 的精彩回答,我能够更好地理解查询计划并找出它的困境以及我可以使用哪些索引来改进它。在这之间,目标帖子确实发生了一些变化,因为我忘记提及,我需要它仅适用于每个记录器的最新读数,例如,如果logger_a记录的数据在下面category_x @ 11:50category_y @ 11:51我只想将其计为category_y

这是生成的 SQL

;WITH logger_data AS (
  SELECT 
    category_name,
    logger_uuid,
    recorded_on,
    RN = ROW_NUMBER() OVER (PARTITION BY logger_uuid ORDER BY recorded_on DESC)
  FROM data
)
SELECT c.id, count(DISTINCT l.group_id) FROM categories c
INNER JOIN logger_data d on d.category_name = c.name
INNER JOIN logger l ON l.uuid = d.logger_uuid
WHERE RN = 1
GROUP BY c.id
Run Code Online (Sandbox Code Playgroud)

然而,应用以下索引后,这仍然是一个昂贵的查询

CREATE CLUSTERED INDEX ix_latest ON "dbo"."data"
(
    logger_uuid,
    recorded_on DESC
)
GO
CREATE CLUSTERED INDEX ix_groups ON "dbo"."logger"
(
    group_id
)
Run Code Online (Sandbox Code Playgroud)

对于具有 ~500k 行的表,从 ~25 秒缩短到 ~3 秒。对此非常满意,我认为可能还有更多改进的空间,但就目前情况而言,这已经足够好了。

这是最终计划,欢迎任何其他建议/改进。