我想看看是否有办法欺骗 SQL Server 使用特定的查询计划。
一、环境
想象一下,您有一些在不同进程之间共享的数据。所以,假设我们有一些占用大量空间的实验结果。然后,对于每个过程,我们知道我们想要使用哪一年/哪月的实验结果。
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 计划
但是我找不到一种方法来编写这个查询,使其与使用标量函数的查询一样好。
几个想法:
- 基本上我想要的是能够以某种方式告诉 SQL Server 预先计算某些值,然后将它们作为常量进一步传递。
- 如果我们有一些中间具体化提示,可能会有帮助。我已经检查了几个变体(多语句 TVF 或 cte with top),但到目前为止,没有一个计划比标量函数更好
- 我知道 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)
从任何地方调用的原因之一,所以我可以在内部向这个查询添加新列。我很高兴听到对此的任何意见/建议,即使它有一些限制。
在今天的 SQL Server 中,您不能真正安全地实现您想要的,即在问题中规定的限制(我认为它们)内,在单个语句和并行执行中。
所以我的简单回答是否定的。这个答案的其余部分主要是讨论为什么会这样,以防感兴趣。
如问题中所述,可以获得并行计划,但主要有两种,都不适合您的需求:
相关的嵌套循环连接,在顶层使用循环分配流。鉴于保证单个行来自
Params
特定session_id
值,内侧将在单个线程上运行,即使它标有并行图标。这就是表面上平行的方案 3表现不佳的原因;它实际上是连续的。另一种选择是在嵌套循环连接的内侧实现独立并行。此处独立意味着线程在内侧启动,而不仅仅是执行嵌套循环连接外侧的相同线程。SQL Server 仅在保证有一个外侧行且没有关联连接参数的情况下才支持独立的内侧嵌套循环并行性(方案 2)。
因此,我们可以选择一个具有所需相关值的串行(由于一个线程)并行计划;或必须扫描的内侧平行计划,因为它没有可寻找的参数。(旁白:确实应该允许仅使用一组相关参数来驱动内部并行性,但它从未实现过,这可能是有充分理由的)。
那么一个自然的问题是:为什么我们根本需要相关参数?为什么 SQL Server 不能简单地直接查找由子查询等提供的标量值?
嗯,SQL Server 只能使用简单的标量引用“索引查找”,例如常量、变量、列或表达式引用(因此标量函数结果也可以符合条件)。子查询(或其他类似结构)太复杂(并且可能不安全),无法将其整个推入存储引擎。因此,需要单独的查询计划运算符。这又需要相关性,这意味着没有您想要的那种并行性。
总而言之,目前确实没有比将查找值分配给变量然后在单独的语句中使用函数参数中的方法更好的解决方案。
现在您可能有特定的本地注意事项,这意味着缓存年和月的当前值
SESSION_CONTEXT
是值得的,即:但这属于解决方法的范畴。
另一方面,如果聚合性能最重要,您可以考虑坚持使用内联函数并在表上创建列存储索引(主要或次要)。您可能会发现列存储存储、批处理模式处理和聚合下推的好处无论如何都比行模式并行查找提供更大的好处。
但要注意标量 T-SQL 函数,尤其是列存储存储,因为很容易以在单独的行模式过滤器中按行评估函数而告终。通常很难保证 SQL Server 选择计算标量的次数,最好不要尝试。
据我所知,您想要的计划形状仅使用 T-SQL 是不可能的。您似乎想要原始计划形状(查询 0 计划),其中您的函数的子查询被用作直接针对聚簇索引扫描的过滤器。如果您不使用局部变量来保存标量函数的返回值,您将永远不会得到这样的查询计划。过滤将改为作为嵌套循环连接实现。可以通过三种不同的方式(从并行的角度来看)实现循环连接:
这些是我所知道的唯一可能的计划形状。如果您使用临时表,您可以获得其他一些,但如果您希望查询性能与查询 0 一样好,它们都不能解决您的基本问题。
通过使用标量 UDF 将返回值分配给局部变量并在查询中使用这些局部变量,您可以获得等效的查询性能。您可以将该代码包装在存储过程或多语句 UDF 中以避免可维护性问题。例如:
标量 UDF 已移到您希望有资格进行并行处理的查询之外。我得到的查询计划似乎是您想要的:
如果您需要在其他查询中使用此结果集,则这两种方法都有缺点。您不能直接加入存储过程。您必须将结果保存到一个临时表中,该表有其自身的一系列问题。您可以加入 MS-TVF,但在 SQL Server 2016 中您可能会看到基数估计问题。SQL Server 2017为 MS-TVF 提供交错执行,可以完全解决问题。
只是为了澄清几件事:T-SQL 标量 UDF 始终禁止并行性,并且 Microsoft 并未表示 FROID 将在 SQL Server 2017 中可用。
这很可能使用 SQLCLR 来完成。SQLCLR 标量 UDF 的一个好处是,如果它们不进行任何数据访问(有时还需要标记为“确定性”) ,则它们不会阻止并行性。那么当操作本身需要数据访问时,您如何利用不需要数据访问的东西呢?
好吧,因为该
dbo.Params
表预计将:INT
列将这三列缓存到静态集合(例如字典,也许)中是可行的,该集合在进程外填充并由获取和值
session_id, experiment_year int, experiment_month
的标量 UDF 读取。我所说的“进程外”的意思是:您可以有一个完全独立的 SQLCLR 标量 UDF 或存储过程,它可以进行数据访问并从表中读取以填充静态集合。该 UDF 或存储过程将在使用获取“年”和“月”值的 UDF 之前执行,这样获取“年”和“月”值的 UDF 不会进行任何数据库数据访问。experiment_year int
experiment_month
dbo.Params
读取数据的 UDF 或存储过程可以首先检查集合是否有 0 个条目,如果是,则填充,否则跳过。您甚至可以跟踪它被填充的时间,如果它已经超过 X 分钟(或类似时间),那么即使集合中有条目,也可以清除并重新填充。但是跳过填充会有所帮助,因为它需要经常执行以确保它始终被填充以供两个主要 UDF 从中获取值。
主要问题是当 SQL Server 出于某种原因决定卸载应用程序域时(或者它是由使用 的东西触发的
DBCC FREESYSTEMCACHE('ALL');
)。您不希望冒着在执行“填充”UDF 或存储过程与 UDF 之间清除集合以获取“年”和“月”值的风险。在这种情况下,您可以在这两个 UDF 的最开头进行检查,以在集合为空时抛出异常,因为出错总比成功提供错误结果好。当然,上面提到的问题假设希望将 Assembly 标记为
SAFE
。如果 Assembly 可以标记为EXTERNAL_ACCESS
,则可以让静态构造函数执行读取数据并填充集合的方法,这样您只需要手动执行它来刷新行,但它们总是会被填充(因为静态类构造函数总是在加载类时运行,只要在重启后执行此类中的方法或卸载 App Domain 时就会发生这种情况)。这需要使用常规连接而不是进程内上下文连接(静态构造函数不可用,因此需要EXTERNAL_ACCESS
)。请注意:为了不需要将程序集标记为
UNSAFE
,您需要将任何静态类变量标记为readonly
。这至少意味着收藏。这不是问题,因为只读集合可以添加或删除项目,只是不能在构造函数或初始加载之外初始化。跟踪集合加载的时间以使其在 X 分钟后过期是比较棘手的,因为static readonly DateTime
类变量不能在构造函数或初始加载之外更改。要绕过此限制,您需要使用一个静态的只读集合,其中包含一个作为DateTime
值的项目,以便可以在刷新时将其删除并重新添加。