性能重构:尽量避免表扫描

Cri*_*ilă 6 performance sql-server-2008 sql-server index-tuning query-performance

我有一个包含连接几个表的查询的过程,但我遇到了一些性能问题。

主表(这是一个巨大的表)有一个 PK 和一些 NC 索引。

CREATE TABLE [dbo].[TableA]
(
    [TableAID] [bigint] NOT NULL,
    [UserID] [int] NOT NULL,
    [IP1] [tinyint] NOT NULL,
    [IP2] [tinyint] NOT NULL,
    [IP3] [tinyint] NOT NULL,
    [IP4] [tinyint] NOT NULL
    CONSTRAINT [PK_TableA] 
        PRIMARY KEY CLUSTERED  ([TableAID] ASC)
) ON [PRIMARY]


CREATE NONCLUSTERED INDEX [idx_1] ON [dbo].[TableA] 
(
    [UserID] ASC
)


CREATE NONCLUSTERED INDEX [idx_2] ON [dbo].[TableA] 
(
    [IP1] ASC,
    [IP2] ASC,
    [IP3] ASC,
    [IP4] ASC
)
Run Code Online (Sandbox Code Playgroud)

这是性能不佳的查询:

SELECT DISTINCT a.UserID, a.IP1, a.IP2, a.IP3, a.IP4
    FROM [dbo].[TableA] a WITH (NOLOCK) 
    JOIN [dbo].[TableB] b WITH (NOLOCK) ON b.UserID = a.UserID 
    JOIN [dbo].[Tablec] c WITH (NOLOCK) ON b.CountryID = c.CountryID
    JOIN ( 
        SELECT 
            IP1, IP2, IP3, IP4          
            from 
            @IPs 
            ) as ip ON 
                ((ip.IP1 is NULL) OR (ip.IP1=a.IP1)) AND 
                ((ip.IP2 is NULL) OR (ip.IP2=a.IP2)) AND
                ((ip.IP3 is NULL) OR (ip.IP3=a.IP3)) AND
                ((ip.IP4 is NULL) OR (ip.IP4=a.IP4)) 
Run Code Online (Sandbox Code Playgroud)

@IPs表的定义:

DECLARE @IPs TABLE (
    IP1 int,
    IP2 int,
    IP3 int,
    IP4 int
    )


INSERT INTO @IPs(IP1,IP2,IP3,IP4)
SELECT      T.v.value('(IP1/node())[1]', 'int'),
            T.v.value('(IP2/node())[1]', 'int'),
            T.v.value('(IP3/node())[1]', 'int'),
            T.v.value('(IP4/node())[1]', 'int')
FROM @IPAddresses.nodes('//IPAddresses/IPAddress') T(v)
Run Code Online (Sandbox Code Playgroud)

@IPAddresses是 xml。刚刚发现xml可以发送更多的IP,所以这意味着IP表中不止一行。

问题是对 TableA 的读取次数。即使我有 IP 列的 NC 索引,该连接条件也会强制进行表扫描...

如何提高性能?如何重构此表/查询?

我还在考虑是否有更简单更好的方法来重写这段代码:

SELECT 
        IP1, IP2, IP3, IP4          
        from 
        @IPs 
        ) as ip ON 
            ((ip.IP1 is NULL) OR (ip.IP1=a.IP1)) AND 
            ((ip.IP2 is NULL) OR (ip.IP2=a.IP2)) AND
            ((ip.IP3 is NULL) OR (ip.IP3=a.IP3)) AND
            ((ip.IP4 is NULL) OR (ip.IP4=a.IP4))
Run Code Online (Sandbox Code Playgroud)

...如果我们有更多的 IP。

Joe*_*ish 9

有几件事使这具有挑战性。如果您不小心,NULL检查会阻止索引查找。此外,当列是NULL你显然不能搜索它们。所以如果IP1是,NULL那么四列索引idx_2将不会很有用。似乎不可能定义一个对任何NULL变量组合都有选择性的索引。此外,SQL Server在查找不等式谓词后不能继续索引查找

同样,如果我们在两列上有一个索引,那么如果我们在第一列上有一个等式谓词,我们只能使用索引来满足第二列上的谓词。

这意味着使用TINYINT数据类型边界的技巧不太可能有效,例如:

a.IP1 >= NULLIF(ip.IP1, 0) AND a.IP1 <= NULLIF(ip.IP1, 255)
Run Code Online (Sandbox Code Playgroud)

除此之外,我使用的策略似乎与 SQL Server 2014 中引入的新基数估计器配合得更好,并且您的问题被标记为 SQL Server 2008。

我强烈建议将表变量拆分成行并一次处理一行。这主要是为了处理NULL值,因为您的评论暗示大部分时间您只会得到一行。只要一行的性能足够好并且您没有太多行,它应该没问题。如果这是不可接受的,也许您可​​以检查临时表中的任何行(不使用表变量)是否存在NULL并在这种情况下分支您的代码。

综上所述,只要最多两个 IP 地址段不是NULL. 当它们中的三个是时,NULL您将取回大部分表,此时进行聚簇索引扫描可能是有意义的。

以下是我为测试各种解决方案而模拟的数据。我通过为每个部分随机选择一个 0 到 255 之间的整数生成了 1 亿个 IP 地址。我知道现实生活中的 IP 地址分布并不是那么随机,但没有办法生成更好的数据。

CREATE TABLE [dbo].[TableA](
    [TableAID] [bigint] NOT NULL,
    [UserID] [int] NOT NULL,
    [IP1] [tinyint] NOT NULL,
    [IP2] [tinyint] NOT NULL,
    [IP3] [tinyint] NOT NULL,
    [IP4] [tinyint] NOT NULL
CONSTRAINT [PK_TableA] PRIMARY KEY CLUSTERED 
( [TableAID] ASC )
);

-- insert 100 million random IP addresses
INSERT INTO [dbo].[TableA] WITH (TABLOCK)
SELECT TOP (100000000)
      ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
    , ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 10000
    , ABS(BINARY_CHECKSUM(NEWID()) % 256)
    , ABS(BINARY_CHECKSUM(NEWID()) % 256)
    , ABS(BINARY_CHECKSUM(NEWID()) % 256)
    , ABS(BINARY_CHECKSUM(NEWID()) % 256)
FROM master..spt_values t1
CROSS JOIN master..spt_values t2
CROSS JOIN master..spt_values t3;

CREATE TABLE [dbo].[TableB] (
    [UserID] [int] NOT NULL,
    FILLER VARCHAR(100),
    PRIMARY KEY (UserId)
);

-- insert 10k users
INSERT INTO [dbo].[TableB] WITH (TABLOCK)
SELECT TOP (10000) -1 + ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
, REPLICATE('Z', 100)
FROM master..spt_values t1
CROSS JOIN master..spt_values t2;
Run Code Online (Sandbox Code Playgroud)

请注意我没有创建您已经拥有的任何一个非聚集索引。相反,我将为每个 IP 片段创建一个索引:

CREATE NONCLUSTERED INDEX [idx_IP1] ON [dbo].[TableA] ([IP1] ASC);
CREATE NONCLUSTERED INDEX [idx_IP2] ON [dbo].[TableA] ([IP2] ASC);
CREATE NONCLUSTERED INDEX [idx_IP3] ON [dbo].[TableA] ([IP3] ASC);
CREATE NONCLUSTERED INDEX [idx_IP4] ON [dbo].[TableA] ([IP4] ASC);
Run Code Online (Sandbox Code Playgroud)

索引所需的空间并非微不足道。这是比较:

???????????????????????????
? IndexName ? IndexSizeKB ?
???????????????????????????
? idx_1     ?     1786488 ?
? idx_2     ?     1786480 ?
? idx_IP1   ?     1487616 ?
? idx_IP2   ?     1487616 ?
? idx_IP3   ?     1487632 ?
? idx_IP4   ?     1487608 ?
? PK_TableA ?     2482056 ?
???????????????????????????
Run Code Online (Sandbox Code Playgroud)

如有必要,您可以权衡使用行或页压缩来减小索引大小的利弊。但是,如果您不知道将是哪些 IP 片段NULL并且您需要避免聚集索引扫描,那么我看不到四个索引的更好替代方案。我将采用的策略称为索引连接。非聚集索引包括聚集键TableAID,这使得将索引连接在一起成为可能。每个索引应该具有大约 0.4% 的选择性,并且使用非聚集索引查找找到那些行应该相对便宜。将所有索引连接在一起应该会大大减少结果集,此时您可以对表进行聚集索引查找以获得您需要的其他列值,例如UserID.

这是查询:

DECLARE @ip1 TINYINT = ?;
DECLARE @ip2 TINYINT = ?;
DECLARE @ip3 TINYINT = ?;
DECLARE @ip4 TINYINT = ?;

SELECT DISTINCT a.UserID, a.IP1, a.IP2, a.IP3, a.IP4
FROM [dbo].[TableA] a 
JOIN [dbo].[TableB] b ON b.UserID = a.UserID 
WHERE
((@ip1 is NULL) OR (@ip1=a.IP1)) AND 
((@ip2 is NULL) OR (@ip2=a.IP2)) AND
((@ip3 is NULL) OR (@ip3=a.IP3)) AND
((@ip4 is NULL) OR (@ip4=a.IP4)) 
OPTION (RECOMPILE, QUERYTRACEON 9481);
Run Code Online (Sandbox Code Playgroud)

根据RECOMPILE提示,我正在利用参数嵌入优化。此优化仅适用于特定服务包 (SP4?),因此请确保您已打好补丁。TableA如果它认为合适,查询优化器能够将单个表访问拆分为一个索引连接。请注意,这里的估计计划很可能会产生误导。你要注意实际的计划。

QUERYTRACEON 9481提示不应包含在您的查询版本中。我正在使用它来强制 SQL Server 使用旧版 CE,这仅是必要的,因为我正在针对 SQL Server 2016 进行测试。

让我们运行一些测试。具有以下参数:

DECLARE @ip1 TINYINT = 1;
DECLARE @ip2 TINYINT = 102;
DECLARE @ip3 TINYINT = 234;
DECLARE @ip4 TINYINT = 172;
Run Code Online (Sandbox Code Playgroud)

我得到合并连接和循环连接。

没有空查询

具有以下参数:

DECLARE @ip1 TINYINT = NULL;
DECLARE @ip2 TINYINT = 102;
DECLARE @ip3 TINYINT = 234;
DECLARE @ip4 TINYINT = 172;
Run Code Online (Sandbox Code Playgroud)

我得到了一个非常相似的计划,只是IP1查询中没有使用索引。查询仍会在大约 125 毫秒内完成。

具有以下参数:

DECLARE @ip1 TINYINT = 88;
DECLARE @ip2 TINYINT = NULL;
DECLARE @ip3 TINYINT = NULL;
DECLARE @ip4 TINYINT = NULL;
Run Code Online (Sandbox Code Playgroud)

查询在大约 5 秒内完成。我得到一个散列连接和一个聚集索引扫描。这不一定是坏事,但可以通过一些努力来避免(请参阅以下段落):

3 NULL 计划

如果需要有效地强制索引联接(也许 SQL Server 2008 缺少我正在利用的优化),可以这样做,但要复杂得多


小智 3

关于您的性能问题 - 根据表变量的大小,使用 #tempTable 应该是更好的选择。查询优化器期望表变量始终返回 1 行(在 SQL Server 2016 中为 100 行),因此它可能会创建嵌套循环连接,这会增加额外的开销。使用临时表,您可以获得有效的统计数据和基数估计=更好的计划。

另外,您不会从表变量中返回任何行,我认为这仅用于验证目的,在这种情况下,我会将查询更改为:

CREATE table #IPs  (IP1 tinyint,IP2 tinyint, IP3 tinyint, IP4 tinyint)
insert into #IPs values (1,2,3,4),(null,2,3,null)
SELECT  a.UserID, a.IP1, a.IP2, a.IP3, a.IP4
    FROM [dbo].[TableA] a WITH (NOLOCK) 
   WHERE EXISTS (Select 1 from #IPs as b where a.IP1 = b.IP1 or a.IP2 = b.IP2 OR a.IP3 = b.IP3 OR a.IP4 = b.IP4) 
Run Code Online (Sandbox Code Playgroud)

EXISTS如果出于验证目的,我会使用连接而不是连接。它还将使您免于选择不同的值,这可能会增加 tempdb(统计数据中的工作表)的开销,对它们进行排序并选择每个值。

索引

您创建的包含 4 列的列将不起作用,因为您的查询使用这些列作为可选列(IP1、IP2 或 IP3),之后它必须进行查找以获取您请求的其他剩余列,因此查询优化器估计搜索整个表会更容易。

您可以做的是更改非聚集索引并将 UserID 列包含为:

CREATE NONCLUSTERED INDEX [idx_2] ON [dbo].[TableA] 
(
    [IP1] ASC,
    [IP2] ASC,
    [IP3] ASC,
    [IP4] ASC
) INCLUDE ([UserID])
Run Code Online (Sandbox Code Playgroud)