为什么 SELECT 语句中的简单函数调用会大大减慢我的查询速度?

hoy*_*tdj 2 sql sql-server query-performance

我正在尝试重构一些 SQL 代码,使其更具可读性和可维护性;但是,我不想破坏性能。我试图将 select 语句中的一些列逻辑移至多个函数中,但发现性能大幅下降。我希望大家能帮助我理解为什么;更好的是,如何解决它!

重构后,我的代码大致类似于下面的示例。在重构之前, CASE 语句不是函数调用,而是直接位于 select 子句中的 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]
Run Code Online (Sandbox Code Playgroud)

重构之前,查询运行大约需要 60 秒。重构后需要将近7分钟!扫描和读取计数是相同的,所以我很奇怪它花了这么长时间。

SQL 做了什么导致重构后效率如此低下?有没有办法解决这个问题并维护我的可读代码?

解决方案

感谢所有的“为什么?” 信息,@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]
Run Code Online (Sandbox Code Playgroud)

这确实比重构之前执行得慢一些;但降级是最小的,我认为,为了可维护性和可读性,这是值得的。

Con*_*SFT 7

T-SQL 中的标量函数历来对查询性能不利有几个原因(尽管这有望很快得到改善 - 我将在最后解释)。

  • 首先,即使 T-SQL 标量函数仅包含标量逻辑(没有查询或连接),在查询中间调用解释函数也会产生开销。对于包含许多行且 T-SQL 标量处理速度较慢的查询,此开销是可测量的。我不确定在所有情况下它都会使您的查询速度减慢数倍,但还有其他原因可能会减慢速度。
  • 其次,查询优化器中处理标量操作的方式可能会以您意想不到的方式对您的性能产​​生负面影响。SQL 查询优化开始时使用的一种启发式方法是将标量操作向下推向查询的叶子。这是为了允许优化器匹配计算列(可以持久化,从而可以加速昂贵的标量计算)。然而,这样做的负面后果是,非持久计算的执行频率可能比您预期的要高。因此,如果您的过滤器/联接具有查询输出的 1000 行,但处理了 1MM 行以生成该结果,则标量函数可能会执行 1MM 次,而不是您在查询中编写的 1000 次。从历史上看,优化器假设标量函数的成本为零,并且没有尝试推理它们的执行成本有多大(如果您有兴趣了解更多相关信息,请参阅最后的一些历史记录)。
  • 第三,如果您碰巧将子查询或“查找”放入 t-sql 标量函数中,您可能会实现代码分解,但会阻止优化器看到可能加快查询速度的连接顺序,从而完全蒙蔽优化器。因此,虽然在过程语言中尝试考虑像这样的常见编码模式是完全有意义的,但当优化器需要重写所有内容以获得尽可能快的查询计划时,它就没有意义了。

一般来说,过去 10 多年的大多数 SQL Server 指南都建议不要使用标量 T-SQL 函数,原因我已经解释过。您会发现的大多数外部内容可能都符合这个概念。请注意,历史上 SQL Server确实内联单语句 T-SQL 表值函数(将它们视为 SQL 中的视图),但这是一个完整的历史工件,它显然与 T-SQL 标量函数处理不一致。

Microsoft 的 QP 团队了解这些已经有一段时间了。然而,修复这些问题需要大量工作才能使系统达到标量 T-SQL 函数内联通常可以帮助所有客户并且不会导致某些查询变慢的形式。不幸的是,大多数商业优化器的工作方式创建了一个模型,该模型根据计算工作方式的某些假设来估计运行时间。该模型将是不完整的(例如:正如我所指出的,我们今天根本不花费 t-sql 标量函数)。拥有模型的一个不明显的副作用是,某些查询将在模型之外(这意味着优化器正在猜测或使用不完整的数据)并得到一个很好的计划。有些查询将在模型之外并得到一个糟糕的计划。模型内的查询并不总是能得到很好的计划,但平均而言它们会做得更好。更进一步,如果成本或一组考虑的替代方案从 SQL 的一个主要版本更改为下一个版本,那么当您升级时,您可能会开始获得与以前不同的计划。对于那些“模型之外”的情况,效果是相当随机的 - 在某些情况下您可以获得更快或更慢的计划。因此,如果没有一套机制来找到防止计划回归的机制,则更改优化器的成本模型变得非常困难 - 否则许多客户将有一些针对一组不完整假设进行“调整”的查询,然后变得更糟计划何时发生这些变化。Net-net:优化器团队没有改变成本模型来解决这个问题,因为平均而言,这会比处理痛苦造成更多的客户伤害,直到有足够的机制在升级时提供良好的客户体验。

在过去的几个版本中,这正是 SQL 团队一直在做的事情。首先,对成本模型或所考虑的计划集(称为搜索空间)的任何更改都更改为与数据库的兼容性级别相关联。这允许客户升级并保持旧的兼容性级别,因此通常不会在同一硬件上看到计划更改。它还允许客户尝试更换到新的,如果工作负载出现问题,则立即停止,从而大大降低了以前单向升级的风险。您可以在此处阅读有关升级建议的更多信息。其次,SQL 团队添加了一个“飞行数据记录器”,用于随着时间的推移选择计划,称为查询存储。它捕获先前的计划和这些计划的执行情况。这允许客户“返回”之前的计划(如果速度更快)(即使您受到其中一种不适用模型案例的影响)。这提供了另一个级别的保险,防止升级时破坏应用程序。

(抱歉,这很冗长 - 上下文很重要)。

对于 SQL Server 2019 + SQL Azure,QP 团队引入了一种内联许多 T-SQL 标量函数的机制。您可以在此处阅读该公告。仍然有一些启发式方法正在对此功能进行调整,以确保与不内联相比,几乎没有/没有性能回归(这意味着 QP 通常会弄清楚什么时候内联与不内联更好,并且只内联这些情况)。内联时,优化器能够对连接重新排序并考虑各种计划选择替代方案,以获得更快的查询计划。因此,最终,这会在查询处理器内使用正常的关系运算符,并以这种方式消耗它们。

我希望这能解释为什么现在对您来说速度可能会变慢,并给您带来一些希望,即正如我们所说,SQL 团队确实正在努力改进这种情况。祝您调整应用程序好运。