TSQL 'lag' 分析函数 - 性能出乎意料地差

TSQL 'lag' analytic function - unexpectedly poor performance

在之前的工作中,我们必须将项目 x 与项目 x-1 进行比较以获得大量数据(约十亿行)。由于这是在 SQL Server 2008 R2 上完成的,我们不得不使用自连接。很慢。

我想我会尝试使用滞后函数;如果速度快,这将非常有价值。我发现它快了 2 到 3 倍,但它应该是一个简单的后台操作,而且由于它的查询 plan/table 扫描减少了 simpler/vastly,我感到非常失望。下面重现的代码。

创建数据库:

IF EXISTS (SELECT name 
           FROM sys.databases 
           WHERE name = N'TestDBForLag')
   DROP DATABASE TestDBForLag
GO

create database TestDBForLag
ALTER DATABASE TestDBForLag SET RECOVERY SIMPLE 
go

use TestDBForLag
go

set nocount on

create table big (g binary(16) not null)
go

begin transaction

declare @c int = 0

while @c < 100
begin
    insert into big(g) values(cast(newid() as binary(16)))
    set @c += 1
end
commit

go 10000 -- n repeats of last batch, "big" now has 1,000,000 rows

alter table big
    add constraint clustered_PK primary key clustered (g)

查询:

set statistics time on
set statistics io on

-- new style
select  
    g, lag(g, 1) over (order by g) as xx
from big
order by g

-- old style
select  obig.g, 
(
    select max(g)
    from big as ibig
    where ibig.g < obig.g
) as xx
from big as obig
order by g

您可以自己查看 actual/estimated 查询计划,但这里是统计结果(查询 运行 两次以缩短编译时间):

(1000000 row(s) affected)
Table 'Worktable'. {edit: everything zero here}.

**Table 'big'. Scan count 1, logical reads 3109**, {edit: everything else is zero here}.

SQL Server Execution Times: CPU time = 1045 ms,  elapsed time = 3516 ms.

---

(1000000 row(s) affected)

**Table 'big'. Scan count 1000001, logical reads 3190609**, {edit: everything else is zero here}.

SQL Server Execution Times:CPU time = 2683 ms,  elapsed time = 3439 ms.

因此,lag 需要 1 次扫描 + 3109 次读取并花费约 1 秒 cpu 时间,一个必须重复遍历 btree 的复杂自连接需要 100 万次扫描 + 320 万次读取大约需要 2.7 秒。

我看不出有任何理由导致这种糟糕的表现。有什么想法吗?

运行ning 在 ThinkServer 140 上,8G 内存(完全驻留在内存中),双核,无磁盘争用。我很满意将结果集传输到客户端的时间,在同一台机器上是 运行ning,可以忽略不计。

select @@version 

returns:

Microsoft SQL Server 2014 - 12.0.4213.0 (X64) Developer Edition (64-bit) 
on Windows NT 6.1 <X64> (Build 7601: Service Pack 1)

编辑:

根据@vnov 的评论,我在发帖前仔细考虑了客户开销。我看的是 CPU 时间,而不是总时间。测试:

select *
from big

Table 'big'. Scan count 1, logical reads 3109, {rest zero}
SQL Server Execution Times: CPU time = 125 ms,  elapsed time = 2840 ms.

select count(*)
from big

Table 'big'. Scan count 1, logical reads 3109, {rest zero}
SQL Server Execution Times: CPU time = 109 ms,  elapsed time = 129 ms.

lag 不应该添加任何重要的 AFAICS,更不用说一个数量级了。



编辑2:

@Frisbee 没看出来为什么我觉得延迟很差。基本上,该算法是记住先前的值并在 n 行之后传递它。如果 n = 1,那就更微不足道了,所以我使用游标编写了一些代码,有和没有自制滞后,并进行了测量。根据 vnov 的观点,我还简单地总结了结果,因此它不会返回大量结果集。游标和 selects 给出了相同的结果 sumg = 127539666,sumglag = 127539460。代码使用与上面创建的相同的 DB + table。

select版本:

select 
    sum(cast(g as tinyint)) as sumg
from (
    select g
    from big
) as xx


select 
    sum(cast(g as tinyint)) as sumg, 
    sum(cast(glag as tinyint)) as sumglag
from (
    select g, lag(g, 1) over (order by g) as glag
    from big
) as xx

我没有进行批量测量,但通过观察,这里的 select 与滞后相当一致,约为 360-400 毫秒与 1700-1900 毫秒,因此慢了 4 或 5 倍。

对于游标,顶部的第一个模拟 select,底部的一个模拟 select,但有滞后:

---------- nonlagging batch --------------
use TestDBForLag
set nocount on

DECLARE crsr CURSOR FAST_FORWARD READ_ONLY FOR 
select g from big order by g 

DECLARE @g binary(16), @sumg int = 0
OPEN crsr

FETCH NEXT FROM crsr INTO @g
WHILE (@@fetch_status <> -1)
BEGIN
    IF (@@fetch_status <> -2)
    BEGIN
        set @sumg += cast(@g as tinyint)
    END
    FETCH NEXT FROM crsr INTO @g
END

CLOSE crsr
DEALLOCATE crsr

select @sumg as sumg

go 300


---------- lagging batch --------------
use TestDBForLag
set nocount on

DECLARE crsr CURSOR FAST_FORWARD READ_ONLY FOR 
select g from big order by g

DECLARE @g binary(16), @sumg int = 0 
DECLARE @glag binary(16) = 0, @sumglag int = 0
OPEN crsr

FETCH NEXT FROM crsr INTO @g
WHILE (@@fetch_status <> -1)
BEGIN
    IF (@@fetch_status <> -2)
    BEGIN
        set @sumg += cast(@g as tinyint)
        set @sumglag += cast(@glag as tinyint)  -- the only ...
        set @glag = @g  -- ... differences
    END
    FETCH NEXT FROM crsr INTO @g
END

CLOSE crsr
DEALLOCATE crsr

select @sumg as sumg, @sumglag as sumglag

go 300

运行 上面的 SQL 分析器打开(删除 SQL:Batch 开始事件),对我来说需要大约 2.5 小时,将跟踪保存为 table 调用'trace',然后 运行 这得到平均持续时间

-- trace save duration as microseconds, 
-- divide by 1000 to get back to milli
select 
    cast(textdata as varchar(8000)) as textdata, 
    avg(duration/1000) as avg_duration_ms
from trace
group by cast(textdata as varchar(8000))

对我来说,非滞后游标平均需要 13.65 秒,游标模拟滞后需要 16.04 秒。后者的大部分额外时间将来自解释器处理额外语句的开销(我希望如果在 C 中实现它会少得多),但无论如何计算延迟的额外时间少于 20% .

那么,这个解释听起来合理吗,谁能提出为什么滞后在 select 语句中表现如此糟糕?

你落后的 g 是多少?扫描仍然必须在每个扫描行上找到 -1 g,如果 g 不是聚集键

,这可能 be/is 需要大量工作

lag 本身实际上可能不会花费太多时间,因为 g 是集群主键。如果你尝试:

select * from big

这也需要很多时间。

并且您的查询不能比这更快,因为它处理相同数量的数据。一定有很多 IO 正在进行。我不是这方面的专家,但 table 大小大约是。 24MB 和 sql 服务器按 8KB 块读取数据,因此这大约是 3000 次物理读取。 运行 查询并查看性能监视器/进程资源管理器,特别是磁盘 IO。

检查这两个变体的执行计划,您会看到发生了什么。 为此,我使用免费版 SQL Sentry Plan Explorer。

我正在比较这三个查询(加上一个 OUTER APPLY):

select count(*)
from big;

-- new style
select  
    g, lag(g) over (order by g) as xx
from big
order by g;

-- old style
select  obig.g, 
(
    select max(g)
    from big as ibig
    where ibig.g < obig.g
) as xx
from big as obig
order by g;

1) LAG 是使用 Window Spool 实现的,它从临时 Worktable 提供两倍的行数 (1,999,999)(在这种情况下它在内存中) ,但仍然)。 Window Spool 不会缓存 Worktable 中的所有 1,000,000 行,它仅缓存 window 大小。

The Window Spool operator expands each row into the set of rows that represents the window associated with it.

该计划中还有许多其他重量较轻的运算符。这里的要点是 LAG 没有像您在游标测试中那样实现。

2) 旧样式查询的计划非常好。优化器很聪明地扫描 table 一次,然后用 TOP 为每一行进行索引查找以计算 MAX。是的,是百万次寻道,但是都是在内存中,所以比较快。

3) 将鼠标悬停在计划运算符之间的粗箭头上,您将看到实际数据大小。 Window Spool 的两倍大。所以,当一切都在内存中并 CPU 绑定时,它就变得很重要。

4) 您的旧样式查询可以重写为:

select  obig.g, A.g
from big as obig
OUTER APPLY
(
    SELECT TOP(1) ibig.g
    FROM big as ibig
    WHERE ibig.g < obig.g
    ORDER BY ibig.g DESC
) AS A
order by obig.g;

,效率更高一些(见截图中的CPU栏)。


因此,LAG 在读取页数方面非常有效,但使用 CPU 相当多。