以不妨碍并行的方式模拟用户定义的标量函数

Rom*_*kar 12 performance sql-server functions sql-server-2016 query-performance

我想看看是否有办法欺骗 SQL Server 为查询使用某个计划。

1. 环境

想象一下,您有一些在不同进程之间共享的数据。因此,假设我们有一些占用大量空间的实验结果。然后,对于每个过程,我们知道我们想要使用哪一年/哪月的实验结果。

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go
Run Code Online (Sandbox Code Playgroud)

现在,对于每个过程,我们都将参数保存在表中

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go
Run Code Online (Sandbox Code Playgroud)

2. 测试数据

让我们添加一些测试数据:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go
Run Code Online (Sandbox Code Playgroud)

3. 获取结果

现在,通过以下方式很容易获得实验结果@experiment_year/@experiment_month

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go
Run Code Online (Sandbox Code Playgroud)

该计划很好且平行:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number
Run Code Online (Sandbox Code Playgroud)

查询 0 计划

在此处输入图片说明

4. 问题

但是,为了使数据的使用更通用,我想要另一个功能 - dbo.f_GetSharedDataBySession(@session_id int). 因此,直接的方法是创建标量函数,翻译@session_id-> @experiment_year/@experiment_month

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go
Run Code Online (Sandbox Code Playgroud)

现在我们可以创建我们的函数:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go
Run Code Online (Sandbox Code Playgroud)

查询 1 计划

在此处输入图片说明

该计划是相同的,当然,它不是并行的,因为执行数据访问的标量函数使整个计划成为串行

所以我尝试了几种不同的方法,比如使用子查询而不是标量函数:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go
Run Code Online (Sandbox Code Playgroud)

查询 2 计划

在此处输入图片说明

或使用 cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go
Run Code Online (Sandbox Code Playgroud)

查询 3 计划

在此处输入图片说明

但是我找不到一种方法来编写此查询,使其与使用标量函数的查询一样好。

几个想法:

  1. 基本上我想要的是能够以某种方式告诉 SQL Server 预先计算某些值,然后将它们作为常量进一步传递。
  2. 如果我们有一些中间物化提示,可能会有帮助。我已经检查了几个变体(多语句 TVF 或带有 top 的 cte),但到目前为止,没有任何计划比具有标量函数的计划好
  3. 我知道 SQL Server 2017 即将改进 - Froid:关系数据库中命令式程序的优化。不过,我不确定它会有所帮助。不过,如果能在这里被证明是错误的,那就太好了。

附加信息

我正在使用一个函数(而不是直接从表中选择数据),因为在许多不同的查询中使用它要容易得多,这些查询通常@session_id作为参数使用。

我被要求比较实际执行时间。在这种特殊情况下

  • 查询 0 运行约 500 毫秒
  • 查询 1 运行约 1500 毫秒
  • 查询 2 运行约 1500 毫秒
  • 查询 3 运行约 2000 毫秒。

计划 #2 有一个索引扫描而不是一个搜索,然后由嵌套循环上的谓词过滤。计划 #3 并没有那么糟糕,但仍然比计划 #0 做更多的工作并且工作得更慢。

假设它dbo.Params很少改变,通常有大约 1-200 行,不超过,假设 2000 是预期的。现在大约有 10 列,我不希望经常添加列。

Params 中的行数不是固定的,所以每一个都会@session_id有一行。列数不固定,这是我不想dbo.f_GetSharedData(@experiment_year int, @experiment_month int)从任何地方调用的原因之一,因此我可以在内部向此查询添加新列。我很高兴听到对此的任何意见/建议,即使它有一些限制。

Pau*_*ite 13

在问题中列出的限制(如我所见)内,您无法真正安全地在今天的 SQL Server 中准确实现您想要的内容,即在单个语句和并行执行中。

所以我的简单回答是否定的。这个答案的其余部分主要是讨论为什么会这样,以防万一。

如问题中所述,可以获得并行计划,但有两种主要类型,均不适合您的需求:

  1. 相关的嵌套循环连接,在顶层使用循环分配流。鉴于保证来自Params特定session_id值的单行,内侧将在单个线程上运行,即使它标有并行图标。这就是表面上并行的计划 3表现不佳的原因;它实际上是连续的。

  2. 另一种选择是用于嵌套循环连接内侧的独立并行性。这里的独立意味着线程在内侧启动,而不仅仅是与执行嵌套循环连接外侧的线程相同。SQL Server 仅在保证有一个外部行并且没有相关连接参数(计划 2)时才支持独立的内部嵌套循环并行性。

因此,我们可以选择一个具有所需相关值的串行(由于一个线程)的并行计划;或必须扫描的内侧并行计划,因为它没有要查找的参数。(旁白:确实应该允许仅使用组相关参数来驱动内部并行性,但它从未实现过,可能有充分的理由)。

那么一个自然的问题是:为什么我们需要相关参数?为什么 SQL Server 不能简单地直接寻找由例如子查询提供的标量值?

好吧,SQL Server 只能使用简单的标量引用来“索引查找”,例如常量、变量、列或表达式引用(因此标量函数结果也可以限定)。子查询(或其他类似的构造)太复杂(并且可能不安全)而无法推入整个存储引擎。因此,需要单独的查询计划运算符。这反过来需要相关性,这意味着没有您想要的那种并行性。

总而言之,目前确实没有比将查找值分配给变量然后在单独的语句中在函数参数中使用这些值的方法更好的解决方案。

现在您可能有特定的本地考虑,这意味着缓存年和月的当前值SESSION_CONTEXT是值得的,即:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;
Run Code Online (Sandbox Code Playgroud)

但这属于解决方法的范畴。

另一方面,如果聚合性能是最重要的,您可以考虑坚持使用内联函数并在表上创建列存储索引(主要或次要)。您可能会发现列存储存储、批处理模式处理和聚合下推的好处无论如何都比行模式并行查找提供了更大的好处。

但要注意标量 T-SQL 函数,尤其是列存储存储,因为很容易最终在单独的行模式过滤器中按行计算函数。通常很难保证 SQL Server 选择评估标量的次数,最好不要尝试。


Joe*_*ish 8

据我所知,您想要的计划形状仅使用 T-SQL 是不可能的。似乎您希望原始计划形状(查询 0 计划)以及来自您的函数的子查询被直接应用于聚集索引扫描的过滤器。如果您不使用局部变量来保存标量函数的返回值,您将永远不会得到这样的查询计划。过滤将改为作为嵌套循环连接实现。可以通过三种不同的方式(从并行性的角度)实现循环连接:

  1. 整个计划是连续的。这是你不能接受的。这是您为查询 1 获得的计划。
  2. 循环连接串行运行。我相信在这种情况下,内侧可以并行运行,但不可能将任何谓词传递给它。所以大部分工作将并行完成,但您正在扫描整个表,部分聚合比以前昂贵得多。这是您为查询 2 获得的计划。
  3. 循环连接并行运行。使用并行嵌套循环连接,循环的内侧以串行方式运行,但您可以同时在内侧运行多达 DOP 线程。您的外部结果集只有一行,因此您的并行计划实际上是串行的。这是您为查询 3 获得的计划。

这些是我所知道的唯一可能的计划形状。如果您使用临时表,您可以获得一些其他的,但如果您希望查询性能与查询 0 一样好,它们都不能解决您的基本问题。

通过使用标量 UDF 将返回值分配给局部变量并在查询中使用这些局部变量,可以实现等效的查询性能。您可以将该代码包装在存储过程或多语句 UDF 中以避免可维护性问题。例如:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;
Run Code Online (Sandbox Code Playgroud)

标量 UDF 已移到您希望符合并行性的查询之外。我得到的查询计划似乎是你想要的:

并行查询计划

如果您需要在其他查询中使用此结果集,这两种方法都有缺点。您不能直接加入存储过程。您必须将结果保存到一个有其自身问题的临时表中。您可以加入 MS-TVF,但在 SQL Server 2016 中,您可能会看到基数估计问题。SQL Server 2017为 MS-TVF提供了交错执行,可以完全解决问题。

澄清一些事情:T-SQL 标量 UDF 总是禁止并行性,微软并没有说 FROID 将在 SQL Server 2017 中可用。