在查询中的多列上调用相同表值函数的最有效方法

Maz*_*har 8 performance sql-server optimization functions sql-server-2016 query-performance

我正在尝试调整在 20 列上调用相同表值函数 (TVF) 的查询。

我做的第一件事是将标量函数转换为内联表值函数。

是否使用性能CROSS APPLY最佳的方式在查询中的多个列上执行相同的函数?

一个简单的例子:

SELECT   Col1 = A.val
        ,Col2 = B.val
        ,Col3 = C.val
        --do the same for other 17 columns
        ,Col21
        ,Col22
        ,Col23
FROM t
CROSS APPLY
    dbo.function1(Col1) A
CROSS APPLY
    dbo.function1(Col2) B
CROSS APPLY
    dbo.function1(Col3) C
--do the same for other 17 columns
Run Code Online (Sandbox Code Playgroud)

有更好的选择吗?

可以在针对 X 个列的多个查询中调用相同的函数。

这是函数:

CREATE FUNCTION dbo.ConvertAmountVerified_TVF
(
    @amt VARCHAR(60)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
(
    WITH cteLastChar
    AS(
        SELECT LastChar = RIGHT(RTRIM(@amt), 1)
    )
    SELECT
        AmountVerified  = CAST(RET.Y AS NUMERIC(18,2))
    FROM (SELECT 1 t) t
    OUTER APPLY (
        SELECT N =
                CAST(
                    CASE 
                        WHEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, '{ABCDEFGHI}', 0) >0
                            THEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, '{ABCDEFGHI}', 0)-1
                        WHEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, 'JKLMNOPQR', 0) >0
                            THEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, 'JKLMNOPQR', 0)-1
                        WHEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, 'pqrstuvwxy', 0) >0
                            THEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, 'pqrstuvwxy', 0)-1
                        ELSE 
                            NULL
                    END
                AS VARCHAR(1))
        FROM
            cteLastChar L
    ) NUM
    OUTER APPLY (
        SELECT N =
            CASE 
                WHEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, '{ABCDEFGHI}', 0) >0
                    THEN 0
                WHEN CHARINDEX(L.LastChar  COLLATE Latin1_General_CS_AS, 'JKLMNOPQRpqrstuvwxy', 0) >0
                    THEN 1
                ELSE 0
            END
        FROM cteLastChar L
    ) NEG
    OUTER APPLY(
        SELECT Amt= CASE
                        WHEN NUM.N IS NULL
                            THEN @amt 
                        ELSE
                            SUBSTRING(RTRIM(@amt),1, LEN(@amt) - 1) + Num.N
                    END
    ) TP
    OUTER APPLY(
        SELECT Y =  CASE
                        WHEN NEG.N = 0
                            THEN (CAST(TP.Amt AS NUMERIC) / 100)
                        WHEN NEG.N = 1
                            THEN (CAST (TP.Amt AS NUMERIC) /100) * -1
                    END
    ) RET
) ;

GO
Run Code Online (Sandbox Code Playgroud)

这是我继承的标量函数版本,如果有人感兴趣的话:

CREATE   FUNCTION dbo.ConvertAmountVerified 
(
    @amt VARCHAR(50)
)
RETURNS NUMERIC (18,3)  
AS
BEGIN   
    -- Declare the return variable here
    DECLARE @Amount NUMERIC(18, 3);
    DECLARE @TempAmount VARCHAR (50);
    DECLARE @Num VARCHAR(1);
    DECLARE @LastChar VARCHAR(1);
    DECLARE @Negative BIT ;
    -- Get Last Character
    SELECT @LastChar = RIGHT(RTRIM(@amt), 1) ;
    SELECT @Num = CASE @LastChar  collate latin1_general_cs_as
                        WHEN '{'  THEN '0'                                  
                        WHEN 'A' THEN '1'                       
                        WHEN 'B' THEN '2'                       
                        WHEN 'C' THEN '3'                       
                        WHEN 'D' THEN '4'                       
                        WHEN 'E' THEN '5'                       
                        WHEN 'F' THEN '6'                       
                        WHEN 'G' THEN '7'                       
                        WHEN 'H' THEN '8'                       
                        WHEN 'I' THEN '9'                       
                        WHEN '}' THEN '0'   
                        WHEN 'J' THEN '1'
                        WHEN 'K' THEN '2'                       
                        WHEN 'L' THEN '3'                       
                        WHEN 'M' THEN '4'                       
                        WHEN 'N' THEN '5'                       
                        WHEN 'O' THEN '6'                       
                        WHEN 'P' THEN '7'                       
                        WHEN 'Q' THEN '8'                       
                        WHEN 'R' THEN '9'

                        ---ASCII
                        WHEN 'p' Then '0'
                        WHEN 'q' Then '1'
                        WHEN 'r' Then '2'
                        WHEN 's' Then '3'
                        WHEN 't' Then '4'
                        WHEN 'u' Then '5'
                        WHEN 'v' Then '6'
                        WHEN 'w' Then '7'
                        WHEN 'x' Then '8'
                        WHEN 'y' Then '9'

                        ELSE ''

                END 
    SELECT @Negative = CASE @LastChar collate latin1_general_cs_as
                        WHEN '{' THEN 0         

                        WHEN 'A' THEN 0                 
                        WHEN 'B' THEN 0                     
                        WHEN 'C' THEN 0                     
                        WHEN 'D' THEN 0                     
                        WHEN 'E' THEN 0                     
                        WHEN 'F' THEN 0                     
                        WHEN 'G' THEN 0                     
                        WHEN 'H' THEN 0                     
                        WHEN 'I' THEN 0                     
                        WHEN '}' THEN 1 

                        WHEN 'J' THEN 1                     
                        WHEN 'K' THEN 1                     
                        WHEN 'L' THEN 1                     
                        WHEN 'M' THEN 1                 
                        WHEN 'N' THEN 1                     
                        WHEN 'O' THEN 1                     
                        WHEN 'P' THEN 1                     
                        WHEN 'Q' THEN 1                     
                        WHEN 'R' THEN 1

                        ---ASCII
                        WHEN 'p' Then '1'
                        WHEN 'q' Then '1'
                        WHEN 'r' Then '1'
                        WHEN 's' Then '1'
                        WHEN 't' Then '1'
                        WHEN 'u' Then '1'
                        WHEN 'v' Then '1'
                        WHEN 'w' Then '1'
                        WHEN 'x' Then '1'
                        WHEN 'y' Then '1'
                        ELSE 0
                END 
    -- Add the T-SQL statements to compute the return value here
    if (@Num ='')
    begin
    SELECT @TempAmount=@amt;
    end 
    else
    begin
    SELECT @TempAmount = SUBSTRING(RTRIM(@amt),1, LEN(@amt) - 1) + @Num;

    end
    SELECT @Amount = CASE @Negative
                     WHEN 0 THEN (CAST(@TempAmount AS NUMERIC) / 100)
                     WHEN 1 THEN (CAST (@TempAmount AS NUMERIC) /100) * -1
                     END ;
    -- Return the result of the function
    RETURN @Amount

END
Run Code Online (Sandbox Code Playgroud)

样本测试数据:

SELECT dbo.ConvertAmountVerified('00064170')    --  641.700
SELECT * FROM dbo.ConvertAmountVerified_TVF('00064170') --  641.700

SELECT dbo.ConvertAmountVerified('00057600A')   --  5760.010
SELECT * FROM dbo.ConvertAmountVerified_TVF('00057600A')    --  5760.010

SELECT dbo.ConvertAmountVerified('00059224y')   --  -5922.490
SELECT * FROM dbo.ConvertAmountVerified_TVF('00059224y')    --  -5922.490
Run Code Online (Sandbox Code Playgroud)

Sol*_*zky 8

首先:应该提到的是,获得所需结果的绝对最快的方法是执行以下操作:

  1. 将数据迁移到新列甚至新表中:
    1. 新列方法:
      1. {name}_new向具有DECIMAL(18, 3)数据类型的表添加新列
      2. 将数据从旧VARCHAR列一次性迁移到DECIMAL
      3. 将旧列重命名为 {name}_old
      4. 将新列重命名为 {name}
    2. 新表方法:
      1. {table_name}_new使用DECIMAL(18, 3)数据类型创建新表
      2. 将数据从当前表一次性迁移到新DECIMAL表。
      3. 将旧表重命名为 _old
      4. _new从新表中删除
  2. 更新应用程序等以从不插入以这种方式编码的数据
  3. 一个发布周期后,如果没有问题,删除旧的列或表
  4. 删除 TVF 和 UDF
  5. 以后不要再提这个了!

话虽如此:您可以删除很多代码,因为它在很大程度上是不必要的重复。此外,至少有两个错误会导致输出有时不正确,有时会引发错误。这些错误被复制到 Joe 的代码中,因为它产生与 OP 代码相同的结果(包括错误)。例如:

使用 将所有 3 个版本与 448,740 行进行比较SET STATISTICS TIME ON;,它们的运行时间都刚刚超过 5000 毫秒。但是对于 CPU 时间,结果是:

  • OP 的 TVF:7031 毫秒
  • 乔的 TVF: 3734 毫秒
  • 所罗门的 TVF:1407 毫秒

设置:数据

下面创建一个表并填充它。这应该在运行 SQL Server 2017 的所有系统中创建相同的数据集,因为它们在spt_values. 这有助于为在其系统上进行测试的其他人之间进行比较提供基础,因为随机生成的数据会影响系统间的时间差异,如果重新生成样本数据,甚至同一系统上的测试之间的时间差异也会被考虑在内。我开始使用与 Joe 相同的 3 列表,但使用问题中的示例值作为模板来提出各种数值,并附加每个可能的尾随字符选项(包括无尾随字符)。这也是我在列上强制使用排序规则的原因:我不希望我使用二进制排序规则实例来不公平地否定使用COLLATE 关键字以在 TVF 中强制使用不同的排序规则)。

唯一的区别在于表中行的顺序。

USE [tempdb];
SET NOCOUNT ON;

CREATE TABLE dbo.TestVals
(
  [TestValsID] INT IDENTITY(1, 1) NOT NULL PRIMARY KEY,
  [Col1] VARCHAR(50) COLLATE Latin1_General_100_CI_AS NOT NULL,
  [Col2] VARCHAR(50) COLLATE Latin1_General_100_CI_AS NOT NULL,
  [Col3] VARCHAR(50) COLLATE Latin1_General_100_CI_AS NOT NULL
);

;WITH cte AS
(
  SELECT (val.[number] + tmp.[blah]) AS [num]
  FROM [master].[dbo].[spt_values] val
  CROSS JOIN (VALUES (1), (7845), (0), (237), (61063), (999)) tmp(blah)
  WHERE val.[number] BETWEEN 0 AND 1000000
)
INSERT INTO dbo.TestVals ([Col1], [Col2], [Col3])
  SELECT FORMATMESSAGE('%08d%s', cte.[num], tab.[col]) AS [Col1],
       FORMATMESSAGE('%08d%s', ((cte.[num] + 2) * 2), tab.[col]) AS [Col2],
       FORMATMESSAGE('%08d%s', ((cte.[num] + 1) * 3), tab.[col]) AS [Col3]
  FROM    cte
  CROSS JOIN (VALUES (''), ('{'), ('A'), ('B'), ('C'), ('D'), ('E'), ('F'),
              ('G'), ('H'), ('I'), ('}'), ('J'), ('K'), ('L'), ('M'), ('N'),
              ('O'), ('P'), ('Q'), ('R'), ('p'), ('q'), ('r'), ('s'), ('t'),
              ('u'), ('v'), ('w'), ('x'), ('y')) tab(col)
  ORDER BY NEWID();
-- 463698 rows
Run Code Online (Sandbox Code Playgroud)

设置:TVF

GO
CREATE OR ALTER FUNCTION dbo.ConvertAmountVerified_Solomon
(
    @amt VARCHAR(50)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN

    WITH ctePosition AS
    (
        SELECT CHARINDEX(RIGHT(RTRIM(@amt), 1) COLLATE Latin1_General_100_BIN2,
                             '{ABCDEFGHI}JKLMNOPQRpqrstuvwxy') AS [Value]
    ),
    cteAppend AS
    (
        SELECT pos.[Value] AS [Position],
               IIF(pos.[Value] > 0,
                      CHAR(48 + ((pos.[Value] - 1) % 10)),
                      '') AS [Value]
        FROM   ctePosition pos
    )
    SELECT (CONVERT(DECIMAL(18, 3),
                    IIF(app.[Position] > 0,
                           SUBSTRING(RTRIM(@amt), 1, LEN(@amt) - 1) + app.[Value],
                           @amt))
                        / 100. )
                    * IIF(app.[Position] > 10, -1., 1.) AS [AmountVerified]
    FROM   cteAppend app;
GO
Run Code Online (Sandbox Code Playgroud)

请注意:

  1. 我使用了_BIN2比区分大小写的排序规则更快的二进制(即)排序规则,因为它不需要考虑任何语言规则。
  2. 唯一真正重要的是字母字符列表中最右侧字符的位置(即“索引”)加上两个大括号。操作上所做的一切都来自该职位,而不是角色本身的价值。
  3. 我使用的输入参数和返回值的数据类型为在由OP除非有充分的理由去改写原来的UDF表示VARCHAR(50)VARCHAR(60),从NUMERIC (18,3)NUMERIC (18,2)(良好的理由是“他们错了”),那么我会坚持带有原始签名/类型。
  4. 我添加了一个时期/十进制点到3数字文字/常量的末尾:100.-1.,和1.。这不是我这个 TVF 的原始版本(在这个答案的历史中),但我注意到CONVERT_IMPLICITXML 执行计划中有一些调用(因为100是 anINT但操作需要是NUMERIC/ DECIMAL)所以我只是提前处理了.
  5. 我使用该CHAR()函数创建了一个字符串字符,而不是将一个数字(例如'2')的字符串版本传递给一个CONVERT函数(这是我最初所做的,再次在历史记录中)。这似乎要稍微快一点。只有几毫秒,但仍然如此。

测试

请注意,我必须过滤掉以}as结尾的行,因为这会导致 OP 和 Joe 的 TVF 出错。虽然我的代码处理}正确,但我希望与在 3 个版本中测试的行保持一致。这就是为什么设置查询生成的行数略高于我在上面提到的测试结果中所测试的行数的原因。

SET STATISTICS TIME ON;

DECLARE @Dummy DECIMAL(18, 3);
SELECT --@Dummy =  -- commented out = results to client; uncomment to not return results
cnvrtS.[AmountVerified]
FROM  dbo.TestVals vals
CROSS APPLY dbo.ConvertAmountVerified_Solomon(vals.[Col1]) cnvrtS
WHERE RIGHT(vals.[Col1], 1) <> '}'; -- filter out rows that cause error in O.P.'s code

SET STATISTICS TIME OFF;
GO
Run Code Online (Sandbox Code Playgroud)

取消注释时--@Dummy =,CPU 时间仅略低,并且 3 个 TVF 之间的排名相同。但有趣的是,当取消对变量的注释时,排名会发生一些变化:

  • 乔的 TVF: 3295 毫秒
  • OP 的 TVF:2240 毫秒
  • 所罗门的 TVF:1203 毫秒

不知道为什么 OP 的代码在这种情况下会表现得更好(而我和 Joe 的代码仅略有改进),但在许多测试中似乎是一致的。不,我没有查看执行计划差异,因为我没有时间进行调查。

更快

我已经完成了对替代方法的测试,它确实对上面显示的内容提供了轻微但明确的改进。新方法使用 SQLCLR,它的伸缩性似乎更好。我发现在将第二列添加到查询中时,T-SQL 方法的时间加倍。但是,当使用 SQLCLR 标量 UDF 添加其他列时,时间会增加,但与单列计时的时间不同。也许在调用 SQLCLR 方法时有一些初始开销(与应用程序域和程序集初始加载到应用程序域的开销无关),因为时间是(经过的时间,而不是 CPU 时间):

  • 1 列:1018 毫秒
  • 2 列:1750 - 1800 毫秒
  • 3 列:2500 - 2600 毫秒

因此,时间(转储到变量,而不是返回结果集)可能有 200 毫秒 - 250 毫秒的开销,然后每个实例时间有 750 毫秒 - 800 毫秒。CPU 计时分别为:对于 1、2 和 3 个 UDF 实例,分别为 950 毫秒、1750 毫秒和 2400 毫秒。

代码

using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

public class Transformations
{
    private const string _CHARLIST_ = "{ABCDEFGHI}JKLMNOPQRpqrstuvwxy";

    [SqlFunction(IsDeterministic = true, IsPrecise = true,
        DataAccess = DataAccessKind.None, SystemDataAccess = SystemDataAccessKind.None)]
    public static SqlDouble ConvertAmountVerified_SQLCLR(
        [SqlFacet(MaxSize = 50)] SqlString Amt)
    {
        string _Amount = Amt.Value.TrimEnd();

        int _LastCharIndex = (_Amount.Length - 1);
        int _Position = _CHARLIST_.IndexOf(_Amount[_LastCharIndex]);

        if (_Position >= 0)
        {
            char[] _TempAmount = _Amount.ToCharArray();
            _TempAmount[_LastCharIndex] = char.ConvertFromUtf32(48 + (_Position % 10))[0];
            _Amount = new string(_TempAmount);
        }

        decimal _Return = decimal.Parse(_Amount) / 100M;

        if (_Position > 9)
        {
            _Return *= -1M;
        }

        return new SqlDouble((double)_Return);
    }
}
Run Code Online (Sandbox Code Playgroud)

我最初用作SqlDecimal返回类型,但与SqlDouble/相比,使用它会降低性能FLOAT。有时 FLOAT 有问题(因为它是一个不精确的类型),但我通过以下查询对 T-SQL TVF 进行了验证,没有检测到差异:

00062929x
00021577E
00000509H
Run Code Online (Sandbox Code Playgroud)

测试

SET STATISTICS TIME ON;

DECLARE @Dummy DECIMAL(18, 3), @Dummy2 DECIMAL(18, 3), @Dummy3 DECIMAL(18, 3);
SELECT @Dummy = 
       dbo.ConvertAmountVerified_SQLCLR(vals.[Col1])
              , @Dummy2 =
       dbo.ConvertAmountVerified_SQLCLR(vals.[Col2])
              , @Dummy3 =
       dbo.ConvertAmountVerified_SQLCLR(vals.[Col3])
FROM  dbo.TestVals vals
WHERE RIGHT(vals.[Col1], 1) <> '}';

SET STATISTICS TIME OFF;
Run Code Online (Sandbox Code Playgroud)