如何更快地获得最近行的运行总数?

use*_*379 8 performance database-design sql-server t-sql execution-plan query-performance

我目前正在设计一个事务表。我意识到需要计算每一行的运行总数,这可能会降低性能。所以我创建了一个包含 100 万行的表用于测试。

CREATE TABLE [dbo].[Table_1](
    [seq] [int] IDENTITY(1,1) NOT NULL,
    [value] [bigint] NOT NULL,
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
    [seq] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
Run Code Online (Sandbox Code Playgroud)

我试图获取最近的 10 行及其运行总数,但花了大约 10 秒。

--1st attempt
SELECT TOP 10 seq
    ,value
    ,sum(value) OVER (ORDER BY seq) total
FROM Table_1
ORDER BY seq DESC

--(10 rows affected)
--Table 'Worktable'. Scan count 1000001, logical reads 8461526, physical reads 2, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--Table 'Table_1'. Scan count 1, logical reads 2608, physical reads 516, read-ahead reads 2617, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--
--(1 row affected)
--
-- SQL Server Execution Times:
--   CPU time = 8483 ms,  elapsed time = 9786 ms.
Run Code Online (Sandbox Code Playgroud)

第一次尝试执行计划

我怀疑TOP是计划的性能慢的原因,所以我改变了这样的查询,大约花了1~2秒。但我认为这对于生产来说仍然很慢,我想知道这是否可以进一步改进。

--2nd attempt
SELECT *
    ,(
        SELECT SUM(value)
        FROM Table_1
        WHERE seq <= t.seq
        ) total
FROM (
    SELECT TOP 10 seq
        ,value
    FROM Table_1
    ORDER BY seq DESC
    ) t
ORDER BY seq DESC

--(10 rows affected)
--Table 'Table_1'. Scan count 11, logical reads 26083, physical reads 1, read-ahead reads 443, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--
--(1 row affected)
--
-- SQL Server Execution Times:
--   CPU time = 1422 ms,  elapsed time = 1621 ms.
Run Code Online (Sandbox Code Playgroud)

第二次尝试执行计划

我的问题是:

  • 为什么第一次尝试的查询比第二次慢?
  • 我怎样才能进一步提高性能?我也可以更改模式。

为了清楚起见,两个查询都返回与下面相同的结果。

结果

Jos*_*ell 5

前两种方法的区别

一个计划在 Window Spool 操作符上花费了大约 10 秒中的 7 秒,因此这是它如此缓慢的主要原因。它在 tempdb 中执行大量 I/O 来创建它。我的统计 I/O 和时间如下所示:

Table 'Worktable'. Scan count 1000001, logical reads 8461526
Table 'Table_1'. Scan count 1, logical reads 2609
Table 'Worktable'. Scan count 0, logical reads 0

 SQL Server Execution Times:
   CPU time = 8641 ms,  elapsed time = 8537 ms.
Run Code Online (Sandbox Code Playgroud)

第二种方案能够避免线轴,从而完全避免工作台。它只是从聚集索引中获取前 10 行,然后对来自单独聚集索引扫描的聚合(总和)进行嵌套循环连接。内侧最终仍会读取整个表,但表非常密集,因此对于一百万行来说这是相当有效的。

Table 'Table_1'. Scan count 11, logical reads 26093
 SQL Server Execution Times:
   CPU time = 1563 ms,  elapsed time = 1671 ms.
Run Code Online (Sandbox Code Playgroud)

提高绩效

列存储

如果您确实想要“在线报告”方法,列存储可能是您的最佳选择。

ALTER TABLE [dbo].[Table_1] DROP CONSTRAINT [PK_Table_1];

CREATE CLUSTERED COLUMNSTORE INDEX [PK_Table_1] ON dbo.Table_1;
Run Code Online (Sandbox Code Playgroud)

那么这个查询速度快得离谱:

SELECT TOP 10
    seq, 
    value, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING)
FROM dbo.Table_1
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

以下是我的机器的统计数据:

Table 'Table_1'. Scan count 4, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 3319
Table 'Table_1'. Segment reads 1, segment skipped 0.
Table 'Worktable'. Scan count 0, logical reads 0

 SQL Server Execution Times:
   CPU time = 375 ms,  elapsed time = 205 ms.
Run Code Online (Sandbox Code Playgroud)

你可能无法打败它(除非你真的很聪明——很好,乔)。列存储非常擅长扫描和聚合大量数据。

使用ROW而不是RANGE窗口函数选项

使用这种方法,您可以获得与第二个查询非常相似的性能,这在另一个答案中提到过,我在上面的列存储示例中使用了该方法(执行计划):

SELECT TOP 10
    seq, 
    value, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING)
FROM dbo.Table_1
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

与第二种方法相比,它的读取次数更少,并且与第一种方法相比没有 tempdb 活动,因为窗口假脱机发生在内存中

...RANGE 使用磁盘上的假脱机,而 ROWS 使用内存中的假脱机

不幸的是,运行时间与第二种方法大致相同。

Table 'Worktable'. Scan count 0, logical reads 0
Table 'Table_1'. Scan count 1, logical reads 2609
Table 'Worktable'. Scan count 0, logical reads 0

 SQL Server Execution Times:
   CPU time = 1984 ms,  elapsed time = 1474 ms.
Run Code Online (Sandbox Code Playgroud)

基于模式的解决方案:异步运行总计

由于您愿意接受其他想法,因此您可以考虑异步更新“运行总计”。您可以定期获取这些查询之一的结果,并将其加载到“总计”表中。所以你会做这样的事情:

CREATE TABLE [dbo].[Table_1_Totals]
(
    [seq] [int] NOT NULL,
    [running_total] [bigint] NOT NULL,
    CONSTRAINT [PK_Table_1_Totals] PRIMARY KEY CLUSTERED ([seq])
);
Run Code Online (Sandbox Code Playgroud)

每天/每小时/无论什么时候加载它(在我的机器上,行数为 1 毫米,大约需要 2 秒,并且可以优化):

INSERT INTO dbo.Table_1_Totals
SELECT
    seq, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING) as total
FROM dbo.Table_1 t
WHERE NOT EXISTS (
            SELECT NULL 
            FROM dbo.Table_1_Totals t2
            WHERE t.seq = t2.seq)
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

那么您的报告查询将非常高效:

SELECT TOP 10
    t.seq, 
    t.value, 
    t2.running_total
FROM dbo.Table_1 t
    INNER JOIN dbo.Table_1_Totals t2
        ON t.seq = t2.seq
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

以下是读取的统计数据:

Table 'Table_1'. Scan count 0, logical reads 35
Table 'Table_1_Totals'. Scan count 1, logical reads 3
Run Code Online (Sandbox Code Playgroud)

基于模式的解决方案:具有约束的行内总计

这个问题的答案详细介绍了一个非常有趣的解决方案:编写一个简单的银行模式:我应该如何使我的余额与其交易历史记录同步?

基本方法是跟踪行内当前的运行总计以及之前的运行总计和序列号。然后,您可以使用约束来验证运行总计始终正确且最新。

感谢Paul White在本问答中提供了该架构的示例实现:

CREATE TABLE dbo.Table_1
(
    seq integer IDENTITY(1,1) NOT NULL,
    val bigint NOT NULL,
    total bigint NOT NULL,

    prev_seq integer NULL,
    prev_total bigint NULL,

    CONSTRAINT [PK_Table_1] 
        PRIMARY KEY CLUSTERED (seq ASC),

    CONSTRAINT [UQ dbo.Table_1 seq, total]
        UNIQUE (seq, total),

    CONSTRAINT [UQ dbo.Table_1 prev_seq]
        UNIQUE (prev_seq),

    CONSTRAINT [FK dbo.Table_1 previous seq and total]
        FOREIGN KEY (prev_seq, prev_total) 
        REFERENCES dbo.Table_1 (seq, total),

    CONSTRAINT [CK dbo.Table_1 total = prev_total + val]
        CHECK (total = ISNULL(prev_total, 0) + val),

    CONSTRAINT [CK dbo.Table_1 denormalized columns all null or all not null]
        CHECK 
        (
            (prev_seq IS NOT NULL AND prev_total IS NOT NULL)
            OR
            (prev_seq IS NULL AND prev_total IS NULL)
        )
);
Run Code Online (Sandbox Code Playgroud)


Joe*_*ish 5

我建议使用更多数据进行测试,以更好地了解正在发生的事情并了解不同方法的性能。我将 1600 万行加载到具有相同结构的表中。您可以在此答案的底部找到填充表格的代码。

以下方法在我的机器上需要 19 秒:

SELECT TOP (10) seq
    ,value
    ,sum(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING) total
FROM dbo.[Table_1_BIG]
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

实际计划在这里。大部分时间都花在计算总和和进行排序上。令人担忧的是,查询计划几乎完成了整个结果集的所有工作,并过滤到您最后请求的 10 行。此查询的运行时根据表的大小而不是结果集的大小进行缩放。

这个选项在我的机器上需要 23 秒:

SELECT *
    ,(
        SELECT SUM(value)
        FROM dbo.[Table_1_BIG]
        WHERE seq <= t.seq
        ) total
FROM (
    SELECT TOP (10) seq
        ,value
    FROM dbo.[Table_1_BIG]
    ORDER BY seq DESC
    ) t
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

实际计划在这里。这种方法与请求的行数和表的大小成比例。从表中读取了近 1.6 亿行:

你好

要获得正确的结果,您必须对整个表的行求和。理想情况下,您只需执行此求和一次。如果您改变处理问题的方式,就有可能做到这一点。您可以计算整个表的总和,然后从结果集中的行中减去运行总计。这使您可以找到第 N 行的总和。一种方法:

SELECT TOP (10) seq
,value
, [value]
    - SUM([value]) OVER (ORDER BY seq DESC ROWS UNBOUNDED PRECEDING)
    + (SELECT SUM([value]) FROM dbo.[Table_1_BIG]) AS total
FROM dbo.[Table_1_BIG]
ORDER BY seq DESC;
Run Code Online (Sandbox Code Playgroud)

实际计划在这里。新查询在我的机器上运行 644 毫秒。该表被扫描一次以获得完整的总数,然后为结果集中的每一行读取一个额外的行。没有排序,几乎所有的时间都花在计算计划并行部分的总和上:

非常好

如果您希望此查询更快,您只需要优化计算完整总和的部分。上面的查询进行了聚集索引扫描。聚集索引包括所有列,但您只需要该[value]列。一种选择是在该列上创建非聚集索引。另一种选择是在该列上创建非聚集列存储索引。两者都会提高性能。如果您使用 Enterprise,一个不错的选择是创建一个索引视图,如下所示:

CREATE OR ALTER VIEW dbo.Table_1_BIG__SUM
WITH SCHEMABINDING
AS
SELECT SUM([value]) SUM_VALUE
, COUNT_BIG(*) FOR_U
FROM dbo.[Table_1_BIG];

GO

CREATE UNIQUE CLUSTERED INDEX CI ON dbo.Table_1_BIG__SUM (SUM_VALUE);
Run Code Online (Sandbox Code Playgroud)

此视图返回单行,因此几乎不占用空间。执行 DML 时会有惩罚,但它应该与索引维护没有太大区别。使用索引视图,查询现在需要 0 毫秒:

在此处输入图片说明

实际计划在这里。这种方法最好的部分是运行时不会因表的大小而改变。唯一重要的是返回了多少行。例如,如果您获得前 10000 行,则查询现在需要 18 毫秒来执行。

填充表的代码:

DROP TABLE IF EXISTS dbo.[Table_1_BIG];

CREATE TABLE dbo.[Table_1_BIG] (
    [seq] [int] NOT NULL,
    [value] [bigint] NOT NULL
);

DROP TABLE IF EXISTS #t;
CREATE TABLE #t (ID BIGINT);

INSERT INTO #t WITH (TABLOCK)
SELECT TOP (4000) -1 + ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM master..spt_values t1
CROSS JOIN master..spt_values t2
OPTION (MAXDOP 1);

INSERT INTO dbo.[Table_1_BIG] WITH (TABLOCK)
SELECT t1.ID * 4000 + t2.ID, 8 * t2.ID + t1.ID
FROM (SELECT TOP (4000) ID FROM #t) t1
CROSS JOIN #t t2;

ALTER TABLE dbo.[Table_1_BIG]
ADD CONSTRAINT [PK_Table_1] PRIMARY KEY ([seq]);
Run Code Online (Sandbox Code Playgroud)