为什么 SELECT 语句中的简单函数调用会大大降低我的查询速度?
Why do simple function calls in a SELECT statement slow down my query SO much?
我正在尝试重构一些 SQL 代码以使其更具可读性和可维护性;但是,我不想破坏性能。我试图将 select 语句中的一些列逻辑移动到多个函数中,但我发现性能大幅下降。我希望大家能帮助我理解为什么;更好的是,如何修复它!
重构后我的代码看起来大致类似于下面的示例。 在重构之前,CASE语句直接位于select子句.[=13=的SUM函数中,而不是函数调用]
FUNCTION funcOne(@colA, @colB, @colC, @valX, @valY)
RETURNS INT AS
BEGIN
RETURN CASE
WHEN @colA = @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valX
ELSE 0
END
END
FUNCTION funcTwo(@colA, @colB, @colC, @valX, @valY)
RETURNS INT AS
BEGIN
RETURN CASE
WHEN @colA <> @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valY
ELSE 0
END
END
SELECT mt.[Ident]
,SUM(funcOne(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY])) AS funcOne
,SUM(funcTwo(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY])) AS funcTwo
FROM MyTable AS mt
INNER JOIN SomeOtherTable AS ot
ON mt.[Ident] = ot.[Ident]
WHERE mt.[colA] BETWEEN 1 AND 100
GROUP BY mt.[Ident]
在重构查询之前大约需要 60 秒 运行。重构后需要将近7分钟!扫描和读取计数相同,所以我很奇怪需要这么长的时间。
SQL 做了什么导致重构后效率如此低下?有没有办法解决这个问题并保持我的代码可读性好?
解决方案
感谢所有 "why?" 信息,@conor-cunningham-msft。
在解决性能问题方面,我最终使用了@Simonare 和其他人的建议。
我的代码如下所示:
FUNCTION funcOne(@colA, @colB, @colC, @valX, @valY)
RETURNS TABLE AS
RETURN (
SELECT CASE
WHEN @colA = @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valX
ELSE 0
END AS [MyValue]
)
FUNCTION funcTwo(@colA, @colB, @colC, @valX, @valY)
RETURNS TABLE AS
RETURN (
SELECT CASE
WHEN @colA <> @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valY
ELSE 0
END AS [MyValue]
)
SELECT mt.[Ident]
,SUM(funcOne.[MyValue]) AS funcOneValue
,SUM(funcTwo.[MyValue]) AS funcTwoValue
FROM MyTable AS mt
INNER JOIN SomeOtherTable AS ot
ON mt.[Ident] = ot.[Ident]
CROSS APPLY funcOne(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY]) AS funcOne
CROSS APPLY funcTwo(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY]) AS funcTwo
WHERE mt.[colA] BETWEEN 1 AND 100
GROUP BY mt.[Ident]
这确实比重构前慢了一点;但降级是最小的,我认为,为了可维护性和可读性,这是值得的。
SQL 只是不能很好地分解函数。调用用户定义的函数有很多开销。您可能会看到内联函数的一些改进,但我建议您使用计算列:
alter table mytable add funcone as ( your `case` expression here );
之所以有效,是因为一切都来自一个 table。
标量值函数在性能方面通常是不好的做法。假设你有功能
CREATE FUNCTION fn_GetName(@CustomerID int)
RETURNS varchar(100)
AS
RETURN (
DECLARE @myResult VARCHAR(100);
SELECT @myResult = Fullname
FROM Sales s
WHERE s.CustomerID = @CustomerID
RETURN @myResult
)
假设我们正在调用这个函数,就像
select
fn_GetName(id)
from Student;
SQL 正在逐行解释此函数,这会导致性能瓶颈。但是 Table 值函数不进行逐行操作,
CREATE FUNCTION fn_GetNameTable(@CustomerID int)
RETURNS TABLE
AS
RETURN (
SELECT Fullname
FROM Sales s
WHERE s.CustomerID = @CustomerID
)
然后,
SELECT I.[Customer Name]
,S.CustomerType
FROM Sales s
CROSS APPLY fn_GetNameTable(S.CustomerID) I
是 SQL 本地人。
您可以从 This addess
阅读更多内容
有几个原因导致 T-SQL 中的标量函数历来对查询性能有害(尽管这有望很快得到改善 - 我将在最后解释)。
- 首先,即使 T-SQL 标量函数只包含标量逻辑(没有查询或连接),在查询中间调用解释函数也会产生开销。对于包含许多行且 T-SQL 标量处理速度很慢的查询,这种开销是可以衡量的。我不确定它是否会使您的查询在所有情况下都减慢几次,但还有其他原因可能会变慢。
- 其次,在查询优化器中处理标量操作的方式可能会以您不希望的方式对您的性能产生负面影响。 SQL 中查询优化开始时使用的一种启发式方法是将标量操作向下推向查询的叶子。这是为了让优化器匹配计算列(可以持久化,从而加速昂贵的标量计算)。然而,这样做的负面后果是非持久化计算的执行频率可能比您预期的要高。因此,如果您有一个 filter/join 从查询中输出 1000 行,但处理了 1MM 行以生成该结果,则标量函数可能会执行 1MM 次,而不是您在查询中编写的 1000 次。从历史上看,优化器假定标量函数的成本为零,并且不会尝试推断它们的执行成本(如果您有兴趣了解更多相关信息,请在最后查看一些历史)。
- 第三,如果您碰巧将子查询或 "lookups" 放入您的 t-sql 标量函数中,您可以实现代码分解,但通过阻止优化器看到可能的连接顺序来完全蒙蔽优化器可以加快您的查询。因此,虽然在过程语言中尝试分解像这样的常见编码模式是完全有意义的,但当优化器需要重写所有内容以获得尽可能快的查询计划时,它就毫无意义了。
过去 10 多年以来,SQL Server 的大多数指南通常都建议不要使用标量 T-SQL 函数,原因我已经解释过了。您会发现的大多数外部内容都可能与此概念一致。请注意,历史上 SQL Server did 内联单语句 T-SQL table 值函数(将它们视为视图在 SQL), 但这是一个完整的历史神器,与 T-SQL 标量函数处理明显不一致。
Microsoft 的 QP 团队已经知道这些有一段时间了。然而,修复这些问题需要做大量工作才能使系统进入一种形式,其中标量 T-SQL 函数内联通常可以帮助所有客户并且不会导致某些查询变慢。不幸的是,大多数商业优化器的工作方式创建了一个模型,该模型根据对计算工作方式的一些假设来估计运行时间。该模型将是不完整的(例如:正如我所指出的,我们今天根本不花费 t-sql 标量函数)。拥有模型的一个不明显的副作用是一些查询将在模型之外(意味着优化器正在猜测或使用不完整的数据)并得到一个很好的计划。一些查询将在模型之外并得到一个糟糕的计划。模型内部的查询并不总能得到很好的计划,但平均而言它们会做得更好。更进一步,如果成本或考虑的替代方案集从 SQL 的一个主要版本更改为下一个,那么当您升级时,您可能会开始获得与以前不同的计划。对于那些 "outside the model" 个案例,效果是相当随机的 - 在某些情况下,您可以获得更快或更慢的计划。因此,如果没有一套机制来找到防止计划回归的机制,就很难改变优化器的成本模型——否则很多客户会有一些查询,这些查询是 "tuned" 一组不完整的假设,然后得到一个当这些从他们下面改变时,更糟糕的计划。 Net-net:优化器团队没有去改变成本模型来解决这个问题,因为它会造成更多的客户伤害,平均而言,而不是处理痛苦,直到有足够的机制在升级时提供良好的客户体验。
在过去的几个版本中,这正是 SQL 团队一直在做的事情。首先,对成本模型或计划集的任何更改(称为搜索 space)都更改为与数据库 compatibility_level 相关联。这允许客户升级,保持旧的兼容性级别,因此通常不会在同一硬件上看到计划更改。它还允许客户尝试更改为新的,并在工作负载出现问题时立即停机,从而大大降低了以前单向升级的风险。您可以阅读更多关于升级建议 here。其次,SQL 团队添加了一个 "flight data recorder" 用于随时间推移进行的计划选择,称为查询存储。它捕获先前的计划和这些计划的执行情况。这允许客户 "go back" 到之前的计划,如果它更快(即使您受到其中一个模型案例的影响)。这提供了另一层保险,防止在升级时破坏应用程序。
(抱歉,这是冗长的 - 上下文很重要)。
对于SQL Server 2019 + SQL Azure,QP团队引入了一种内联许多T-SQL标量函数的机制。您可以阅读公告here。仍然有一些启发式正在调整此功能,以确保与不内联相比有 few/no 性能回归(这意味着 QP 通常会弄清楚什么时候内联和不内联更好,并且只内联这些情况) .内联时,优化器能够重新排序连接并考虑各种计划选择备选方案,以便为您提供更快的查询计划。所以,最后,这在查询处理器中使用了普通的关系运算符,并以这种方式消耗它们。
我希望这能解释为什么现在您的速度可能变慢,并让您看到 SQL 团队确实在努力改善我们所说的这种情况。祝您在调整您的应用程序时好运。
我正在尝试重构一些 SQL 代码以使其更具可读性和可维护性;但是,我不想破坏性能。我试图将 select 语句中的一些列逻辑移动到多个函数中,但我发现性能大幅下降。我希望大家能帮助我理解为什么;更好的是,如何修复它!
重构后我的代码看起来大致类似于下面的示例。 在重构之前,CASE语句直接位于select子句.[=13=的SUM函数中,而不是函数调用]
FUNCTION funcOne(@colA, @colB, @colC, @valX, @valY)
RETURNS INT AS
BEGIN
RETURN CASE
WHEN @colA = @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valX
ELSE 0
END
END
FUNCTION funcTwo(@colA, @colB, @colC, @valX, @valY)
RETURNS INT AS
BEGIN
RETURN CASE
WHEN @colA <> @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valY
ELSE 0
END
END
SELECT mt.[Ident]
,SUM(funcOne(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY])) AS funcOne
,SUM(funcTwo(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY])) AS funcTwo
FROM MyTable AS mt
INNER JOIN SomeOtherTable AS ot
ON mt.[Ident] = ot.[Ident]
WHERE mt.[colA] BETWEEN 1 AND 100
GROUP BY mt.[Ident]
在重构查询之前大约需要 60 秒 运行。重构后需要将近7分钟!扫描和读取计数相同,所以我很奇怪需要这么长的时间。
SQL 做了什么导致重构后效率如此低下?有没有办法解决这个问题并保持我的代码可读性好?
解决方案
感谢所有 "why?" 信息,@conor-cunningham-msft。
在解决性能问题方面,我最终使用了@Simonare 和其他人的建议。
我的代码如下所示:
FUNCTION funcOne(@colA, @colB, @colC, @valX, @valY)
RETURNS TABLE AS
RETURN (
SELECT CASE
WHEN @colA = @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valX
ELSE 0
END AS [MyValue]
)
FUNCTION funcTwo(@colA, @colB, @colC, @valX, @valY)
RETURNS TABLE AS
RETURN (
SELECT CASE
WHEN @colA <> @colB
THEN @valX + @valY
WHEN @colC BETWEEN 1 AND 10
THEN @valY
ELSE 0
END AS [MyValue]
)
SELECT mt.[Ident]
,SUM(funcOne.[MyValue]) AS funcOneValue
,SUM(funcTwo.[MyValue]) AS funcTwoValue
FROM MyTable AS mt
INNER JOIN SomeOtherTable AS ot
ON mt.[Ident] = ot.[Ident]
CROSS APPLY funcOne(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY]) AS funcOne
CROSS APPLY funcTwo(mt.[colA], ot.[colB], ot.[colC], mt.[valX], ot.[valY]) AS funcTwo
WHERE mt.[colA] BETWEEN 1 AND 100
GROUP BY mt.[Ident]
这确实比重构前慢了一点;但降级是最小的,我认为,为了可维护性和可读性,这是值得的。
SQL 只是不能很好地分解函数。调用用户定义的函数有很多开销。您可能会看到内联函数的一些改进,但我建议您使用计算列:
alter table mytable add funcone as ( your `case` expression here );
之所以有效,是因为一切都来自一个 table。
标量值函数在性能方面通常是不好的做法。假设你有功能
CREATE FUNCTION fn_GetName(@CustomerID int)
RETURNS varchar(100)
AS
RETURN (
DECLARE @myResult VARCHAR(100);
SELECT @myResult = Fullname
FROM Sales s
WHERE s.CustomerID = @CustomerID
RETURN @myResult
)
假设我们正在调用这个函数,就像
select
fn_GetName(id)
from Student;
SQL 正在逐行解释此函数,这会导致性能瓶颈。但是 Table 值函数不进行逐行操作,
CREATE FUNCTION fn_GetNameTable(@CustomerID int)
RETURNS TABLE
AS
RETURN (
SELECT Fullname
FROM Sales s
WHERE s.CustomerID = @CustomerID
)
然后,
SELECT I.[Customer Name]
,S.CustomerType
FROM Sales s
CROSS APPLY fn_GetNameTable(S.CustomerID) I
是 SQL 本地人。
您可以从 This addess
阅读更多内容有几个原因导致 T-SQL 中的标量函数历来对查询性能有害(尽管这有望很快得到改善 - 我将在最后解释)。
- 首先,即使 T-SQL 标量函数只包含标量逻辑(没有查询或连接),在查询中间调用解释函数也会产生开销。对于包含许多行且 T-SQL 标量处理速度很慢的查询,这种开销是可以衡量的。我不确定它是否会使您的查询在所有情况下都减慢几次,但还有其他原因可能会变慢。
- 其次,在查询优化器中处理标量操作的方式可能会以您不希望的方式对您的性能产生负面影响。 SQL 中查询优化开始时使用的一种启发式方法是将标量操作向下推向查询的叶子。这是为了让优化器匹配计算列(可以持久化,从而加速昂贵的标量计算)。然而,这样做的负面后果是非持久化计算的执行频率可能比您预期的要高。因此,如果您有一个 filter/join 从查询中输出 1000 行,但处理了 1MM 行以生成该结果,则标量函数可能会执行 1MM 次,而不是您在查询中编写的 1000 次。从历史上看,优化器假定标量函数的成本为零,并且不会尝试推断它们的执行成本(如果您有兴趣了解更多相关信息,请在最后查看一些历史)。
- 第三,如果您碰巧将子查询或 "lookups" 放入您的 t-sql 标量函数中,您可以实现代码分解,但通过阻止优化器看到可能的连接顺序来完全蒙蔽优化器可以加快您的查询。因此,虽然在过程语言中尝试分解像这样的常见编码模式是完全有意义的,但当优化器需要重写所有内容以获得尽可能快的查询计划时,它就毫无意义了。
过去 10 多年以来,SQL Server 的大多数指南通常都建议不要使用标量 T-SQL 函数,原因我已经解释过了。您会发现的大多数外部内容都可能与此概念一致。请注意,历史上 SQL Server did 内联单语句 T-SQL table 值函数(将它们视为视图在 SQL), 但这是一个完整的历史神器,与 T-SQL 标量函数处理明显不一致。
Microsoft 的 QP 团队已经知道这些有一段时间了。然而,修复这些问题需要做大量工作才能使系统进入一种形式,其中标量 T-SQL 函数内联通常可以帮助所有客户并且不会导致某些查询变慢。不幸的是,大多数商业优化器的工作方式创建了一个模型,该模型根据对计算工作方式的一些假设来估计运行时间。该模型将是不完整的(例如:正如我所指出的,我们今天根本不花费 t-sql 标量函数)。拥有模型的一个不明显的副作用是一些查询将在模型之外(意味着优化器正在猜测或使用不完整的数据)并得到一个很好的计划。一些查询将在模型之外并得到一个糟糕的计划。模型内部的查询并不总能得到很好的计划,但平均而言它们会做得更好。更进一步,如果成本或考虑的替代方案集从 SQL 的一个主要版本更改为下一个,那么当您升级时,您可能会开始获得与以前不同的计划。对于那些 "outside the model" 个案例,效果是相当随机的 - 在某些情况下,您可以获得更快或更慢的计划。因此,如果没有一套机制来找到防止计划回归的机制,就很难改变优化器的成本模型——否则很多客户会有一些查询,这些查询是 "tuned" 一组不完整的假设,然后得到一个当这些从他们下面改变时,更糟糕的计划。 Net-net:优化器团队没有去改变成本模型来解决这个问题,因为它会造成更多的客户伤害,平均而言,而不是处理痛苦,直到有足够的机制在升级时提供良好的客户体验。
在过去的几个版本中,这正是 SQL 团队一直在做的事情。首先,对成本模型或计划集的任何更改(称为搜索 space)都更改为与数据库 compatibility_level 相关联。这允许客户升级,保持旧的兼容性级别,因此通常不会在同一硬件上看到计划更改。它还允许客户尝试更改为新的,并在工作负载出现问题时立即停机,从而大大降低了以前单向升级的风险。您可以阅读更多关于升级建议 here。其次,SQL 团队添加了一个 "flight data recorder" 用于随时间推移进行的计划选择,称为查询存储。它捕获先前的计划和这些计划的执行情况。这允许客户 "go back" 到之前的计划,如果它更快(即使您受到其中一个模型案例的影响)。这提供了另一层保险,防止在升级时破坏应用程序。
(抱歉,这是冗长的 - 上下文很重要)。
对于SQL Server 2019 + SQL Azure,QP团队引入了一种内联许多T-SQL标量函数的机制。您可以阅读公告here。仍然有一些启发式正在调整此功能,以确保与不内联相比有 few/no 性能回归(这意味着 QP 通常会弄清楚什么时候内联和不内联更好,并且只内联这些情况) .内联时,优化器能够重新排序连接并考虑各种计划选择备选方案,以便为您提供更快的查询计划。所以,最后,这在查询处理器中使用了普通的关系运算符,并以这种方式消耗它们。
我希望这能解释为什么现在您的速度可能变慢,并让您看到 SQL 团队确实在努力改善我们所说的这种情况。祝您在调整您的应用程序时好运。