模拟用户定义的标量函数,其方式不会阻止并行性


12

我正在尝试查看是否存在一种诱使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

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

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

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

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

该计划很好并且并行:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

查询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

现在我们可以创建函数:

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

查询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

查询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

查询3计划

在此处输入图片说明

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

几个想法:

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

附加信息

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

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

  • 查询0运行〜500ms
  • 查询1运行〜1500ms
  • 查询2运行〜1500ms
  • 查询3的运行时间约为2000毫秒。

计划2使用索引扫描而不是搜索,然后由嵌套循环上的谓词过滤。计划3并没有那么糟糕,但仍然比计划0的工作量更大,工作更慢。

假设dbo.Params更改很少,通常有1-200行,最多不超过2000行。现在大约有10列,我不希望增加太多列。

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


使用Froid的查询计划将与上面的query2相似,因此是的,在这种情况下,它不会带您达到您想要的解决方案。
Karthik

Answers:


13

您不能真正安全地在问题中提出的限制内(如我所知)在SQL Server中准确地实现当今所需的功能,即在一个语句中并行执行。

所以我的简单答案是“ 否”。该答案的其余部分主要是讨论在有兴趣的情况下为什么这样做。

如问题中所述,有可能获得并行计划,但是有两个主要品种,都不适合您的需求:

  1. 相关的嵌套循环连接在一起,并在顶级循环分配流。假定可以保证有一行来自Params特定session_id值,即使内侧已标有并行图标,内侧也将在单个线程上运行。这就是为什么看上去平行的计划3表现不佳的原因;它实际上是串行的。

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

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

那么自然的问题是:为什么我们根本需要相关的参数?为什么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;

但这属于解决方法的类别。

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

但是要提防标量T-SQL函数(尤其是列存储),因为很容易最终在单独的行模式过滤器中按行评估函数。确保SQL Server选择评估标量的次数通常非常棘手,最好不要尝试。


谢谢,保罗,很好的回答!我曾考虑过使用,session_context但我认为这对我来说有点太疯狂了,我不确定它如何适应当前的架构。尽管可能有用的是一些提示,但我可以使用这些提示让优化器知道它应该像简单的标量引用一样对待子查询的结果。
罗曼·佩卡

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;

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

并行查询计划

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

只是为了澄清一些事情:T-SQL标量UDF始终禁止并行性,Microsoft并未表示FROID将在SQL Server 2017中可用。


有关SQL 2017中的Froid的信息-不确定为什么我认为它在那里。它确认在vNext - brentozar.com/archive/2018/01/...
罗马PEKAR

4

这很可能使用SQLCLR完成。SQLCLR标量UDF的好处之一是,如果它们进行任何数据访问(有时还需要标记为“确定性”),它们就不会阻止并行性。那么,当操作本身需要数据访问时,您如何利用不需要数据访问的内容呢?

好吧,因为该dbo.Params表有望:

  1. 通常它的行数永远不会超过2000,
  2. 很少改变结构
  3. 仅(当前)需要有两INT

将三列缓存session_id, experiment_year int, experiment_month到静态集合(例如,可能是字典)中是可行的,该集合在过程外填充并由获取experiment_year intexperiment_month值的标量UDF读取。我所说的“进程外”是:您可以拥有一个完全独立的SQLCLR标量UDF或存储过程,该存储过程可以进行数据访问并从dbo.Params表中读取数据以填充静态集合。该UDF或存储过程将在使用获得“年”和“月”值的UDF之前执行,这样,获得“年”和“月”值的UDF不会进行任何DB数据访问。

可以先检查读取数据的UDF或存储过程,以查看该集合是否具有0项,如果有,则进行填充,否则进行跳过。您甚至可以跟踪它的填充时间,如果它已经超过X分钟(或类似时间),那么即使该集合中有条目,也要清除并重新填充它。但是跳过填充将有所帮助,因为将需要经常执行该填充以确保始终填充两个主要UDF来获取其值。

主要关注的问题是SQL Server出于任何原因决定卸载App Domain的原因(或者它是由使用触发的DBCC FREESYSTEMCACHE('ALL');)。您不希望冒着在执行“填充” UDF或存储过程与UDF之间清理收集以获取“年”和“月”值的风险。在这种情况下,如果集合为空,则可以在这两个UDF的开头进行检查以引发异常,因为出错比成功提供错误的结果要好。

当然,上面提到的问题是假定将大会标记为SAFE。如果Assembly可以标记为EXTERNAL_ACCESS,则可以让静态构造函数执行读取数据并填充集合的方法,这样,您只需手动执行该操作即可刷新行,但始终会填充这些行。 (因为静态类构造函数始终在加载类时运行,这会在重新启动后或卸载App Domain后执行此类中的方法时发生)。这需要使用常规连接,而不是进程内上下文连接(静态构造函数不可用,因此需要EXTERNAL_ACCESS)。

请注意:为了不需要将Assembly标记为UNSAFE,您需要将所有静态类变量标记为readonly。这至少意味着收集。这不是问题,因为只读集合可以添加或删除项目,而不能在构造函数或初始加载之外对其进行初始化。跟踪集合的加载时间以使其在X分钟后过期是很棘手的,因为static readonly DateTime无法在构造函数或初始加载范围之外更改类变量。要解决此限制,您需要使用一个静态的只读集合,该集合包含一个值为该DateTime值的项目,以便可以在刷新后将其删除并重新添加。


不知道为什么有人对此表示反对。虽然不是很通用,但我认为它可能适用于我目前的情况。我希望有一个纯SQL解决方案,但是我一定会仔细研究一下,看看是否
可行

@RomanPekar不确定,但是有很多反对SQLCLR的人。也许有一些反我;-)。无论哪种方式,我都无法想到为什么此解决方案不起作用。我了解纯T-SQL的偏爱,但是我不知道如何做到这一点,如果没有竞争性的答案,那么也许没人会这么做。我不知道内存优化表和本地编译的UDF在这里是否会更好。另外,我只添加了一段带有一些实现注意事项的段落。
所罗门·鲁兹基'18

1
我从来没有完全相信readonly staticsSQLCLR中的使用是安全或明智的。我不太相信接下来要通过创建readonly引用类型来欺骗系统,然后再进行更改。给我绝对的意志tbh。
保罗·怀特9

@PaulWhite理解了,我还记得几年前在私人对话中提到的问题。鉴于staticSQL Server 中应用域(以及对象)的共享性质,是的,存在竞争条件的风险。这就是为什么我首先从OP确定此数据最少且稳定的原因,为什么我将此方法限定为要求“很少更改”,并在需要时提供了一种刷新方法。在这种用例中,我看不出有什么风险。几年前,我发现有一篇关于按设计更新只读集合的​​功能(在C#中,无讨论re:SQLCLR)。会尝试找到它。
所罗门·鲁茨基

2
没必要,除了官方的SQL Server文档说没问题之外,您没有办法让我感到满意,我敢肯定这是不存在的。
保罗·怀特9
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.