如何强制标量 UDF 在查询中只计算一次?

Joe*_*ish 12 performance sql-server functions query-performance

我有一个查询需要根据标量 UDF 的结果进行过滤。查询必须作为单个语句发送(因此我不能将 UDF 结果分配给局部变量)并且我不能使用 TVF。我知道标量 UDF 引起的性能问题,包括强制整个计划串行运行、过多的内存授予、基数估计问题和缺乏内联。对于这个问题,请假设我需要使用标量 UDF。

UDF 本身调用起来非常昂贵,但理论上查询可以由优化器在逻辑上以这样一种方式实现,即函数只需要计算一次。我为这个问题模拟了一个大大简化的例子。以下查询在我的机器上执行需要 6152 毫秒:

SELECT x1.ID
FROM dbo.X_100_INTEGERS x1
WHERE x1.ID >= dbo.EXPENSIVE_UDF();
Run Code Online (Sandbox Code Playgroud)

查询计划中的过滤器运算符表明该函数为每一行计算一次:

查询计划 1

DDL 和数据准备:

CREATE OR ALTER FUNCTION dbo.EXPENSIVE_UDF () RETURNS INT
AS
BEGIN
    DECLARE @tbl TABLE (VAL VARCHAR(5));

    -- make the function expensive to call
    INSERT INTO @tbl
    SELECT [VALUE]
    FROM STRING_SPLIT(REPLICATE(CAST('Z ' AS VARCHAR(MAX)), 20000), ' ');

    RETURN 1;
END;

GO

DROP TABLE IF EXISTS dbo.X_100_INTEGERS;

CREATE TABLE dbo.X_100_INTEGERS (ID INT NOT NULL);

-- insert 100 integers from 1 - 100
WITH
    L0   AS(SELECT 1 AS c UNION ALL SELECT 1),
    L1   AS(SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B),
    L2   AS(SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B),
    L3   AS(SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B),
    L4   AS(SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B),
    L5   AS(SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B),
    Nums AS(SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS n FROM L5)
INSERT INTO dbo.X_100_INTEGERS WITH (TABLOCK)
SELECT n FROM Nums WHERE n <= 100;
Run Code Online (Sandbox Code Playgroud)

这是上面示例的db fiddle 链接,尽管代码在那里执行大约需要 18 秒。

在某些情况下,我可能无法编辑函数的代码,因为它是由供应商提供的。在其他情况下,我可以进行更改。如何强制标量 UDF 在查询中只计算一次?

Joe*_*ish 17

最终,不可能强制 SQL Server 在查询中只计算一次标量 UDF。但是,可以采取一些步骤来鼓励它。通过测试,我相信您可以获得适用于当前版本的 SQL Server 的东西,但未来的更改可能需要您重新访问您的代码。

如果可以编辑代码,最好首先尝试使函数具有确定性。Paul White 在这里指出,必须使用SCHEMABINDING选项创建函数,并且函数代码本身必须是确定性的。

进行以下更改后:

CREATE OR ALTER FUNCTION dbo.EXPENSIVE_UDF () RETURNS INT
WITH SCHEMABINDING
AS
BEGIN
    DECLARE @tbl TABLE (VAL VARCHAR(5));

    -- make the function expensive to call
    INSERT INTO @tbl
    SELECT [VALUE]
    FROM STRING_SPLIT(REPLICATE(CAST('Z ' AS VARCHAR(MAX)), 20000), ' ');

    RETURN 1;
END;
Run Code Online (Sandbox Code Playgroud)

问题中的查询在 64 毫秒内执行:

SELECT x1.ID
FROM dbo.X_100_INTEGERS x1
WHERE x1.ID >= dbo.EXPENSIVE_UDF();
Run Code Online (Sandbox Code Playgroud)

查询计划不再有过滤操作符:

查询计划 1

为了确保它只执行一次,我们可以使用SQL Server 2016 中发布的新sys.dm_exec_function_stats DMV:

SELECT execution_count
FROM sys.dm_exec_function_stats
WHERE object_id = OBJECT_ID('EXPENSIVE_UDF', 'FN');
Run Code Online (Sandbox Code Playgroud)

ALTER对函数发出 一个将重置该execution_count对象的 。上面的查询返回 1,这意味着该函数只执行了一次。

请注意,仅仅因为该函数是确定性的,并不意味着它对于任何查询只会被评估一次。事实上,对于某些查询,添加SCHEMABINDING会降低性能。考虑以下查询:

WITH cte (UDF_VALUE) AS
(
    SELECT DISTINCT dbo.EXPENSIVE_UDF() UDF_VALUE
)
SELECT ID
FROM dbo.X_100_INTEGERS
INNER JOIN cte ON ID >= cte.UDF_VALUE;
Run Code Online (Sandbox Code Playgroud)

DISTINCT添加多余的内容是为了摆脱 Filter 运算符。该计划看起来很有希望:

查询计划 2

基于此,人们希望 UDF 被评估一次并用作嵌套循环连接中的外部表。但是,查询需要 6446 毫秒才能在我的机器上运行。根据sys.dm_exec_function_stats函数被执行了100次。那怎么可能?在《计算标量、表达式和执行计划性能》中,Paul White 指出计算标量运算符可以推迟:

通常情况下,计算标量只是定义一个表达式;实际计算被推迟到执行计划中稍后需要结果的地方。

对于这个查询,UDF 调用似乎被推迟到需要它时,此时它被评估了 100 次。

有趣的是,当 UDF 未使用 定义时,CTE 示例在我的机器上的执行时间为 71 毫秒SCHEMABINDING,如原始问题所示。该函数仅在查询运行时执行一次。这是查询计划:

查询计划 3

不清楚为什么不推迟计算标量。这可能是因为函数的不确定性限制了查询优化器可以重新排列的运算符。

另一种方法是向 CTE 添加一个小表并查询该表中唯一的行。任何小表都可以,但让我们使用以下内容:

CREATE TABLE dbo.X_ONE_ROW_TABLE (ID INT NOT NULL);

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);
Run Code Online (Sandbox Code Playgroud)

然后查询变为:

WITH cte (UDF_VALUE) AS
(       
    SELECT DISTINCT dbo.EXPENSIVE_UDF() UDF_VALUE
    FROM dbo.X_ONE_ROW_TABLE
)
SELECT ID
FROM dbo.X_100_INTEGERS
INNER JOIN cte ON ID >= cte.UDF_VALUE;
Run Code Online (Sandbox Code Playgroud)

添加的dbo.X_ONE_ROW_TABLE用于优化增加了不确定性。如果表有零行,则 CTE 将返回 0 行。在任何情况下,如果 UDF 不是确定性的,优化器不能保证 CTE 将返回一行,因此似乎有可能在连接之前评估 UDF。我希望优化器扫描dbo.X_ONE_ROW_TABLE,使用流聚合来获取返回的一行的最大值(这需要评估函数),并将其用作嵌套循环连接到dbo.X_100_INTEGERS主查询的外部表. 这似乎是发生了什么

查询计划 4

查询在我的机器上执行大约 110 毫秒,并且 UDF 仅根据sys.dm_exec_function_stats. 说查询优化器被迫只对 UDF 求值一次是不正确的。然而,即使存在 UDF 和计算标量成本计算方面的限制,也很难想象优化器重写会导致更低的查询成本。

总之,对于确定性函数(必须包含SCHEMABINDING选项),请尝试以尽可能简单的方式编写查询。如果在 SQL Server 2016 或更高版本上,请使用sys.dm_exec_function_stats. 在这方面,执行计划可能会产生误导。

对于 SQL Server 不认为是确定性的函数,包括任何缺少SCHEMABINDING选项的函数,一种方法是将 UDF 放在精心制作的 CTE 或派生表中。这需要一点小心,但相同的 CTE 可以同时用于确定性和非确定性函数。