查询中 Latitude/Longitude 转换性能缓慢

Slow performance of Latitude/Longitude conversion in query

我在 SQL Server 2019 上的 SQL 查询 运行 遇到性能问题,return 来自地理列的 lat/long。
我的查询如下,return 553 行大约需要 5 秒:

SELECT ActivityLocations.ID, ActivityLocations.ActivityID, ActivityLocations.Number, ActivityLocations.Location.Lat AS 'Latitude', ActivityLocations.Location.Long AS 'Longitude'
FROM Plans
INNER JOIN Activities ON Plans.ID = Activities.PlanID
INNER JOIN ActivityLocations ON Activities.ID = ActivityLocations.ActivityID
WHERE CustomerID = 35041

它生成的查询计划是:

但是如果我将查询稍微更改为 return 少一点数据,则 return 207 行需要 0 秒:

SELECT ActivityLocations.ID, ActivityLocations.ActivityID, ActivityLocations.Number, ActivityLocations.Location.Lat AS 'Latitude', ActivityLocations.Location.Long AS 'Longitude'
FROM Plans
INNER JOIN Activities ON Plans.ID = Activities.PlanID
INNER JOIN ActivityLocations ON Activities.ID = ActivityLocations.ActivityID
WHERE PlanID > 22486

查询计划是:

我想我的问题是,为什么计算标量操作发生在慢速查询的连接之前和快速查询的连接之后?我不明白为什么它会在 activity 位置 table 的每一行上执行 Lat/Long 操作,而我们只需要行的一小部分?

如有任何帮助,我们将不胜感激。

编辑后包含 table 信息

CREATE TABLE [dbo].[Activities](
[ID] [int] NOT NULL,
[PlanID] [int] NOT NULL,
[Name] [nvarchar](255) NOT NULL,
CONSTRAINT [PK_Activity] PRIMARY KEY CLUSTERED 
(
    [ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]

CREATE TABLE [dbo].[ActivityLocations](
    [ID] [int] NOT NULL,
    [ActivityID] [int] NOT NULL,
    [Number] [int] NOT NULL,
    [Location] [geography] NOT NULL,
 CONSTRAINT [PK_ActivityLocations] PRIMARY KEY CLUSTERED 
(
    [ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

CREATE TABLE [dbo].[ActivityPlans](
    [ID] [int] NOT NULL,
    [CustomerID] [int] NOT NULL,
    [PurchaseOrder] [nvarchar](255) NULL,
    [Deleted] [bit] NOT NULL,
    [Name] [nvarchar](500) NULL,
 CONSTRAINT [PK_ActivityPlan] PRIMARY KEY CLUSTERED 
(
    [ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]

CREATE NONCLUSTERED INDEX [PlanID_IX] ON [dbo].[Activities]
(
    [PlanID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

CREATE NONCLUSTERED INDEX [ActivityID_IX] ON [dbo].[ActivityLocations]
(
    [ActivityID] ASC
)
INCLUDE([Number],[Location]) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

CREATE NONCLUSTERED INDEX [CustomerID_NCIX] ON [dbo].[ActivityPlans]
(
    [CustomerID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

问题出在索引本身:您的客户 ID 可以出现在计划 table 中的任何位置,而在第二个查询中,PlanID 上的 greater-than 意味着您将查询限制为只有 table 的末尾,因为聚簇索引(确定计划 table 中行的实际排序的索引)在 PlanID 上。

因此,查询规划器对如何安排查询执行顺序做出了不同的选择。

您最好的选择是添加三个额外的索引:

  • 第一个包含字段 CustomerID、PlanID 的计划
  • 其次是具有 PlanID、ActivityID 的活动
  • 在 ActivityLocations 上排名第三,具有 ActivityID、ID、Number、Lat、Long

这允许查询引擎:

  • 使用第一个索引立即找到客户计划中的行
  • 获取这些 PlanID 并使用第二个索引立即找到这些计划的活动(按 PlanID 查找活动)
  • 获取这些 ActivityID 并使用第三个索引立即 return 结果 ID、数字、纬度、经度

注意:OP 在最初发布的 queries/graphical 执行计划之间将 table Plans 的名称更改为 ActivityPlans以及他随后提供的执行计划(通过粘贴计划)和 DDL 脚本。我将根据 table Plans(正如最初发布的那样)来发言。

我将尝试完整地解释这一点,包括尝试注意这两个查询之间不是问题的事情,因为查看这两个查询时的明显差异不会导致问题(每个se).请继续阅读以了解我的解释。

相似之处

首先说一下这两个执行计划的相似之处。重要的是要注意相似之处,因为(假设第二个查询计划是 OP 的 acceptable)问题不在于两个查询计划之间的相似之处。

  • 两个执行计划都从访问 tables PlansActivities 开始。
    • table 访问第一个更改基于您的 WHERE 子句。哪个好。优化器做出了一个很好的决定,并且能够在这两种情况下使用索引查找。过滤器 WHERE CustomerID = 35041 被解析为对 Plans table 的索引 CustomerID_NCIX 的索引查找,过滤器 WHERE PlanID > 22486 被解析为对索引的索引查找PlanID_IXActivities table。然后连接到后续的 table(第一个查询中的 Activities 和第二个查询中的 Plans)。在这两种情况下,它都得到索引的支持,搜索操作的估计并不可怕,并且这两种连接都是使用嵌套循环完成的,输出一个相对接近最终结果集的数字。因此,尽管这两个查询中唯一的视觉差异是 WHERE 子句的差异,但似乎每个查询中的 WHERE 子句的处理方式非常相似,似乎不是问题。
  • 第一个和第二个查询中使用的所有 3 table 在两个执行计划中以相同顺序访问的实际顺序。
  • 两个查询都使用索引 ActivityID_IX.
  • 访问 table ActivityLocations
  • 这两个查询都有一个 Compute Scalar 运算符,可以从 SELECT 语句的 ActivityLocations.Location 中检索表达式 ActivityLocations.Location.LatActivityLocations.Location.Long 所需的值。

差异

现在让我们谈谈(重要的)差异,这就是问题所在。

  • 第一个查询使用 Index Seek 运算符访问 table ActivityLocations,而第二个查询使用 Index Scan 运算符。
  • 访问第一个查询的 table ActivityLocations 的索引扫描运算符的 Actual/Estimated 行计数为 329,475/331,523,而访问 table ActivityLocations 第二个查询的 Actual/Estimated 行计数为 207/9。
  • 第一个查询使用合并连接来连接前两个 table 的结果(PlansActivities),第二个查询使用嵌套循环连接。
  • 从第一个查询的 ActivityLocations.Location 中检索所需值的计算标量运算符的 Actual/Estimated 行计数为 329,475/331,523,在第二个查询中它具有 Actual/Estimated行数 207/9。
  • 第一个查询的最终连接输出中的 actual/estimated 行数从其输入排序运算符 (471/3341->553/3402) 开始增加,而 actual/estimated 行数第二个查询的最终连接输出中的行与其输入嵌套循环运算符 (207->207) 保持一致。

实际问题是什么?

简而言之,当我们查看执行计划时,第一个查询正在读取更多数据。在第一个查询中从 table ActivityLocations 读取大约 300k 的行比在第二个查询中读取的 207 行高得多。此外,第一个查询的计算标量运算符需要计算(相同的)大约 30 万行的值,而不是第二个查询的 207 行。这显然会导致更长的 运行ning 查询。

还值得注意的是,来自 table ActivityLocations 的较大行数是合并连接(在第一个查询计划中看到)代替嵌套循环连接的原因运算符(在第二个查询计划中看到)。根据 optmizer,给定您的环境,Merge Join 比 Nested Loop Join 更适合 table 将 300k 行连接到 3.3k 行。并且使用 Merge Join 需要连接的两侧按连接列排序,因此在第一个查询的查询计划中需要额外的 Sort 运算符。

为什么会这样?

估计。估计驱动优化器的决策制定。在第一个查询中,我们看到从 table ActivityLocations(来自索引扫描)中读取的估计行数是 331,523,在第二个查询中(来自索引查找)我们看到估计为9. 说起来可能有点奇怪,但这些估计比你想象的要接近。索引扫描(根据最新统计信息)通常会得到与 table 中的行相等的行估计值(过滤索引除外)。理想情况下,索引查找估计的行数少于 table 中包含的行数。理想情况下,该数字将与 Index Seek 需要接触的实际行数相匹配,但您的 Index Seek 估计值低于整个 table 的事实是朝着正确方向迈出的一步。

那么,如果问题不在于索引扫描或索引查找中的估计,那么问题出在哪里? 问题在于在第一个查询中选择使用索引扫描访问 table ActivityLocations 而不是选择使用索引查找。那么为什么第一个查询选择索引扫描呢?通过查看执行计划,很明显索引查找是更好的选择。我相信这种情况下的答案是基数估计,特别是在这种情况下,用于连接 table ActivityLocations.

的基数估计

我们看到第一个查询的最终连接输出中的估计行数从其输入排序运算符 (3341->3402) 增加,而最终连接输出中的估计行数第二个查询与其输入的嵌套循环运算符 (207->207) 保持一致。优化器不仅估计了这一点,而且是正确的。来自这些相同运算符的实际行数 return 反映了相同的模式。

为什么这很重要?这意味着根据优化器的估计,连接到 table ActivityLocations 将增加输入结果集的行数。这意味着此连接将是 1(输入行)到许多(输出行)。请记住,优化器需要 return 您请求的值 ActivityLocations.Location.LatActivityLocations.Location.Long 来自 table ActivityLocations。因此,当它考虑此连接时,它认为将增加它计划通过访问 table ActivityLocations 输出的行,同时记住它需要对从该连接输出的列执行计算标量table,在 运行 连接之前 运行 计算标量是有意义的,因为如果计算标量在连接之前是 运行 它可以保证计算标量每行 ActivityLocations 仅 运行ning 一次,但不能保证计算标量在连接后是否为 运行。在这种情况下,连接实际上最终限制了来自 AcitivityLocations 的行,并且来自 table 的 return 行数(出于此查询的目的)远低于table 的行数。在第二个查询中,估计表明输出行数将相同,因此 运行 在连接后计算标量对需要计算的行数没有影响,因此它使得执行索引查找代替索引扫描是有意义的。

总而言之,第一个和第二个查询(使用 WHERE 子句)的前两个 table 编辑的行 return 是不同的。并且第一个查询中的 return 行很可能导致连接估计,该估计估计了与第二个查询不同的基数。因此,查询计划的构建方式和随后的构建方式存在差异 运行.

基数估计(特别是连接)由几个因素组成。如果你真的想深入内部,我会推荐这两篇来自传奇 Paul White and SQL Shack 的文章。那里讨论的内容应该指导您如何查看系统内的估计。

如何解决?

第一个目标是改进估算。如果连接的估计基数不正确(这里实际上不是这种情况),那么更新统计信息可能会有所帮助。过时的统计信息可能导致基数估计错误,从而导致查询计划错误。

在某些情况下,您可以将查询重组为逻辑上的等效项,从而产生更好的执行计划。这可能是通过编写它来产生更好的估计,甚至可能 return 以不同的顺序排列不同的行。在这种情况下,我会说第一个查询对我来说很好,因此重写逻辑等效查询可能无济于事。

在这种情况下,基数估计是正确的(你提到你更新了统计信息但没有帮助),查询看起来写得很好,但选择的执行计划仍然是 sub-optmial.所以我会推荐一个查询提示。这个问题可以通过查询提示轻松解决,以查找 ActivityLocations 的索引 ActivityID_IX。您在第一个查询中的加入将如下所示:

INNER JOIN ActivityLocations (WITH FORCESEEK,INDEX(ActivityID_IX)) ON Activities.ID = ActivityLocations.ActivityID

关于为什么查询提示可能不是一个好主意,有很多信息,但根据我在这里的信息,我会说这是最好的选择。我总是对其他意见持开放态度。干杯!