SQL Server:聚集索引比等效的非聚集索引慢得多

Mik*_*org 7 sql sql-server indexing performance query-optimization

设置

我将要描述的是在以下硬件上运行:

  • 磁盘:RAID5 中的 6 个 2TB 硬盘(带 1 个冗余驱动器)
  • CPU:英特尔至强 E5-2640 @ 2.4 GHz,6 核
  • 内存:64GB
  • SQL Server 版本:SQL Server 2016 开发人员

SQL Server Management Studio (SSMS) 和 sql server 实例都在此服务器上运行。所以所有的查询都是在本地执行的。此外,在执行任何查询之前,我总是运行以下命令以确保没有数据访问缓存在内存中:

DBCC DROPCLEANBUFFERS
Run Code Online (Sandbox Code Playgroud)

问题

我们有一个包含大约 11'600'000 行的 SQL Server 表。在大计划中,不是一张特别大的桌子,但它会随着时间的推移而大大增加。

该表具有以下结构:

CREATE TABLE [Trajectory](
  [Id] [int] IDENTITY(1,1) NOT NULL,
  [FlightDate] [date] NOT NULL,
  [EntryTime] [datetime2] NOT NULL,
  [ExitTime] [datetime2] NOT NULL,
  [Geography] [geography] NOT NULL,
  [GreatArcDistance] [real] NULL,
  CONSTRAINT [PK_Trajectory] PRIMARY KEY CLUSTERED ([Id])
)
Run Code Online (Sandbox Code Playgroud)

(为简单起见,排除了一些列,但它们的数量和大小非常小)

虽然没有那么多行,但由于[Geography]列的原因,该表占用了相当多的磁盘空间。此列的内容是 LINESTRINGS,大约有 3000 个点(包括 Z 和 M 值)。

现在,假设我们只是在表的 Id 列上有一个聚集索引,它也表示主键约束,如上面的 DDL 中所述。

我们遇到的问题是,当我们查询日期范围和特定地理交叉点的表时,完成该查询需要相当长的时间。

我们正在查看的查询如下所示:

DEFINE @p1 = [...]

SELECT [Id], [Geography]--, (+ some other columns)
WHERE [FlightDate] BETWEEN '2018-09-04' AND '2018-09-12' AND [Geography].STIntersects(@p1) = 1
Run Code Online (Sandbox Code Playgroud)

这是一个相当简单的查询,使用我上面提到的两个过滤器。为了快速查询,我们尝试了几种不同类型的索引:

1.创建非聚集索引[IX_Trajectory_FlightDate] ON [Trajectory] ​​([FlightDate] ASC)

当我们查询表时,在添加了这样的索引之后,期望查询计划看起来像这样:

  1. 对索引执行 INDEX SEEK(此操作将 11'600'000 行过滤到大约 50'000)
  2. 查找主表以获取 [Geography] 列以及任何额外选择的列
  3. [Geography].STIntersects(@p1) = 1对返回的每一行执行地理过滤器

这也正是它的作用。这是在 (SSMS) 中看到的实际查询执行计划的快照:

FlightDate 索引的实际查询执行计划

此查询需要很长时间才能完成(可以以分钟为单位,如上面的屏幕截图所示)。

--- 更新 1 开始 ---

附加查询计划信息(对于主要步骤,注意:与上面显示的查询执行不同,因此时间有所不同。此查询耗时 2:39):

  • SELECT.QueryTimeStats
    • CpuTime=12241ms
    • ElapsedTime=157591ms
  • Key Lookup (97%)
    • Actual I/O Statistics
      • Actual Logical Reads=48165
      • Actual Physical Reads=81
    • Actual Time Statistics
      • Actual Elapsed CPU Time=144ms
      • Actual Elapsed Time=266ms
  • Index Seek (0%)
    • Actual I/O Statistics
      • Actual Logical Reads=85
      • Actual Physical Reads=0
      • Actual Read Aheads=73
      • Actual Scans=21
  • Filter (3%)
    • Actual Time Statistics
      • Actual Elapsed CPU Time=12156ms
      • Actual Elapsed Time=157583ms

对我来说,这个查询的所有时间或多或少都花在了 IO 上。为什么我无法解释。我还将添加以下感兴趣的内容:

  • 表示占用3%时间/资源的步骤耗时157583ms,而占用97%时间/资源的步骤耗时266ms。我觉得很奇怪。
  • 如果我将 STIntersect 过滤器替换为使用该EntryTime列的不同过滤器(未编入索引!),它大致返回相同的行数,那么查询时间将减少到大约 20 秒,尽管事实上我仍然选择相同的行数。我想对此的唯一解释是查询实际上不需要[Geography]在丢弃该行之前读取昂贵的列。

--- 更新 1 结束 ---

2. 创建非聚集索引 [IX_Trajectory_FlightDate_Includes_Geography] ON [Trajectory] ​​([FlightDate] ASC) INCLUDE ([Geography])

该索引与其他索引的不同之处仅在于它与索引一起存储了大的 [Geography] 列。但是对查询计划的期望或多或少是相同的:

当我们查询表时,在添加了这样的索引之后,期望查询计划看起来像这样:

  1. 对索引执行 INDEX SEEK(此操作将 11'600'000 行过滤到大约 50'000)
  2. [Geography].STIntersects(@p1) = 1对返回的每一行执行地理过滤器
  3. 查找主表以获取额外选择的列

此查询耗时不到 10 秒。这是在 SSMS 中看到的两个查询计划:

FlightDate 索引的实际查询执行计划,包括地理

请注意,在上面的第 2 步和第 3 步中,与使用其他索引的查询相比,它进行了切换(意味着它只有在完全完成过滤后才执行查找,因此它只对主表进行了大约 1'000 次查找,而不是 50,000 次)。现在,这向我表明执行此查询时实际花费的时间是查找主表,而不是其他任何内容,例如 INDEX SEEK 或 FILTER。

现在维护这样的索引并不是我们想要做的理想的事情,因为当我们考虑[Geography]表中的列有多大以及它会增长多少时,它会使用相当多的空间。重建这样的索引需要几个小时。

--- 更新 2 开始 ---

附加查询计划信息:

  • SELECT.QueryTimeStats
    • CpuTime=11648ms
    • ElapsedTime=7533ms
  • Key Lookup (88%)
    • Actual I/O Statistics
      • Actual Logical Reads=1191
      • Actual Physical Reads=0
    • Actual Time Statistics
      • Actual Elapsed CPU Time=0ms
      • Actual Elapsed Time=0ms
  • Index Seek (3%)
    • Actual I/O Statistics
      • Actual Logical Reads=7119
      • Actual Physical Reads=4
      • Actual Read Aheads=6678
      • Actual Scans=21
    • Actual Time Statistics
      • Actual Elapsed CPU Time=104ms
      • Actual Elapsed Time=168ms
  • Filter (9%)
    • Actual Time Statistics
      • Actual Elapsed CPU Time=11535ms
      • Actual Elapsed Time=6888ms

关于统计的附加说明:

  • 当深入研究这些数字中的大部分时,它们在可用线程之间非常好地分开。
  • 我的猜测是,从这些统计数据中得出的主要结论是,该查询在“键查找”期间花费的 IO 工作时间为零,而另一个查询必须执行大量操作。我不确定为什么这会更好,因为它仍然需要找到额外选择的列(我选择的不是[Geography]列。但是由于在执行查找之前已经应用了过滤器,它显然必须少做很多。但即便如此,零 IO 还是让我感到困惑。
  • 物理读取很少。所有需要的数据(包括 [Geography] 列都是从索引查找中读取的,只需 4 次物理读取。

--- 更新 2 结束 ---

3. 改变表格,使其聚集在 ([FlightDate] ASC, [Id] ASC)

现在,考虑到分区是我们考虑对表做的事情,我们还考虑更改聚集索引,使其包含 [FlightDate]。看看下面的 SQL DDL:

ALTER TABLE [Trajectory] DROP CONSTRAINT [PK_Trajectory]
ALTER TABLE [Trajectory] ADD CONSTRAINT [PK_Trajectory] PRIMARY KEY CLUSTERED ([FlightDate] ASC, [Id] ASC)
CREATE UNIQUE INDEX [AK_Trajectory] ON [Trajectory] ([Id] ASC)
Run Code Online (Sandbox Code Playgroud)

这会更改表,使其现在聚集在 [FlightDate] 上,然后是 [Id],以确保唯一性。另外我们在[Id]上添加了一个替代键约束,所以理论上它仍然可以用来引用表。

这 3 条 sql 语句需要几个小时才能完成,但这样做的一个额外好处是,将来可以非常轻松地在 [FlightDate] 上创建分区,从而允许对针对该表进行的所有查询进行分区消除。

当我们现在对表执行相同的查询时,期望查询计划如下所示:

  1. 对索引执行 CLUSTERED INDEX SEEK(此操作将 11'600'000 行过滤到大约 50'000)
  2. [Geography].STIntersects(@p1) = 1对返回的每一行执行地理过滤器

这是一个比前面示例中描述的更简单的查询计划,实际上它确实使用了这个计划,如下所示:

带有聚集索引的实际查询执行计划

唯一的问题?大约需要一分钟才能完成。但是,如果我们仔细查看查询计划本身,它也会反驳先前的结论,即查询中实际花费时间的是对主表的查找,因为这里说大部分时间都花在了对 [Geography ] 柱子。

我对此有一个可能感兴趣的附加评论:即使我不删除我在上一节 ( [IX_Trajectory_FlightDate_Includes_Geography]) 中创建的索引,在像这样更改表结构后查询也会很慢。但是,如果我暗示查询编译器它应该使用上一节中的索引和我在这一步中刚刚创建的替代键 [AK_Trajectory] ​​using,WITH (INDEX([AK_Trajectory], [IX_Trajectory_FlightDate_Includes_Geography])那么查询将具有与 (2) 中大致相同的性能。

所以SQL Server实际上主动决定使用较慢的查询计划,显然认为它更快。坦率地说,我不怪它。我也会这样做,因为该查询计划要简单得多。到底是怎么回事?

现在,您可能想知道我们是否考虑SPATIAL INDEX[Geography]列中添加 a 。这已经是一个考虑。这种索引的问题(以及为什么它不能真正使用)有两个方面:

  1. 与这样的索引相比,该[FlightDate]索引能够过滤掉大量的[Trajectory]行。问题的关键在于,这种空间索引“SEEK”的结果会随着表的增长而线性增长,而 INDEX SEEK 的结果[FlightDate]则不会。
  2. 维护这样的空间索引是昂贵的,并且随着索引变大,插入操作变得越来越慢。

--- 更新 3 开始 ---

附加查询计划信息(对于主要步骤,注意:与上面显示的查询执行不同,因此时间有所不同。此查询花费了 0:49):

  • SELECT.QueryTimeStats
    • CpuTime=11818ms
    • ElapsedTime=48253ms
  • Parallelism (7%)
    • Actual Time Statistics
      • Actual Elapsed CPU Time=7ms
      • Actual Elapsed Time=47638ms
  • Clustered Index Seek (25%)
    • Actual I/O Statistics
      • Actual Logical Reads=7403
      • Actual Physical Reads=4
      • Actual Read Aheads=6939
      • Actual Scans=21
    • Actual Time Statistics
      • Actual Elapsed CPU Time=107ms
      • Actual Elapsed Time=57ms
  • Filter (69%)
    • Actual Time Statistics
      • Actual Elapsed CPU Time=11727ms
      • Actual Elapsed Time=48250ms

值得注意的是:

  • 集群中的逻辑读取分布在所有线程中。
  • Read Aheads 不会在线程之间传播(但也不会与任何其他索引一起传播)。
  • 不知道这如何解释为什么这会比索引 #2 慢。

--- 更新 3 结束 ---

--- 更新 4 开始 ---

Lucky Brain 建议它可能会更慢,因为数据实际上存储在ROW_OVERFLOW_DATA页面中而不是IN_ROW_PAGES. 下面仔细看看数据是如何实际存储在表中的,使用以下查询进行查询:

SELECT  
  OBJECT_SCHEMA_NAME(p.object_id) table_schema, 
  OBJECT_NAME(p.object_id) table_name, 
  p.index_id, 
  p.partition_number, 
  au.allocation_unit_id, 
  au.type_desc, 
  au.total_pages, 
  au.used_pages, 
  au.data_pages 
FROM sys.system_internals_allocation_units au 
JOIN sys.partitions p 
ON au.container_id = p.partition_id 
WHERE OBJECT_NAME(p.object_id) = 'Trajectory' 
ORDER BY table_schema, table_name, p.index_id, p.partition_number, au.type; 
Run Code Online (Sandbox Code Playgroud)

这提供了有关如何为主表(聚集索引)和其他索引存储数据的信息。这样做的结果是:

  • Clustered Index
    • IN_ROW_DATA: total_pages=705137, used_pages=705137, data_pages=697811
    • LOB_DATA: total_pages=10302796, used_pages=10248361, data_pages=0
    • ROW_OVERFLOW_DATA: total_pages=9, used_pages=2, data_pages=0
  • Index #2
    • IN_ROW_DATA: total_pages=497639, used_pages=494629, data_pages=496531
    • LOB_DATA: total_pages=10219824, used_pages=10217546, data_pages=0
    • ROW_OVERFLOW_DATA: ------------------------------------------------------------

由此可以看出,虽然数据没有完全存储在 中ROW_OVERFLOW_DATA,但也没有存储在IN_ROW_PAGES中。话虽这么说,我不认为有任何理由认为,从获取的数据LOB_DATA被认为是比快ROW_OVERFLOW_DATA。稍微阅读一下这些类型,很明显,LOB_DATA鉴于单个列通常超过 8kB 的最大值,必须存储这些数据ROW_OVERFLOW_DATA

但是从上面也可以看出,主表(聚集索引)和索引 #2 都使用LOB_DATA页面,所以我不完全确定为什么索引 #2 会快得多,除非LOB_DATA意味着与索引不同的东西,与聚集索引相比。

但我觉得我所看到的一切都支持同样的结论:

  • 当查询必须对包含 中的数据的主表进行查找时LOB_DATA,该查找总是非常缓慢(即使它作为聚集索引上的 INDEX SEEK 的一部分执行)。基本上我所做的每个查询(快速或慢速)都表明了这一点。例如,考虑索引 #1:
    1. 使用原始查询,它必须进行大约 50'000 次 Key Lookup,需要将近 3 分钟才能完成。
    2. 如果我更改查询,使其过滤 [EntryTime](示例已在UPDATE 1中解释),结果集保持大致相等(约 1'000 行),那么查询会突然花费大约 20 秒。这种更改意味着它只需要LOB_DATA在主表中查找实际结果集的页面,而不是在索引 #1 中查找所有 50'000 个条目。(这里的重要注意事项是它仍然必须对所有主表进行 Key Lookup,它只是不需要LOB_DATA为每个条目转到。)
    3. 但是,虽然 20 秒比 3 分钟快得多,但在使用索引 #2(必须触及所有 50'000 [Geometry] 值!)执行时,它仍然低于原始查询。现在我觉得唯一合乎逻辑的解释是对主表的查找以LOB_DATA某种方式显着减慢了查询速度。
    4. 我想说这可以解释索引 #1 和索引 #2 之间相当显着的性能差异。更少,索引#2 和聚集索引#3 之间的差异。

--- 更新 4 结束 ---

--- 更新 5 开始 ---

之前的更新包括两个索引的物理页面统计信息,这是第一个索引的相同统计信息:

  • Index #1
    • IN_ROW_DATA: total_pages=18705, used_pages=18698, data_pages=18659
    • LOB_DATA: ------------------------------------------------------------
    • ROW_OVERFLOW_DATA: ------------------------------------------------------------

显然,这不包括LOB_DATAROW_OVERFLOW_DATA。但更令人惊讶的是,与IN_ROW_DATA索引 #2 相比,使用的页面显着减少(大约 20-30 的数量级)。这表明,正如 Lucky Brain 所建议的那样,当索引中包含空间列时,SQL Server 可能会直接在 中存储有关该几何/地理的一些信息,例如边界框,IN_ROW_DATA以便快速执行几何操作。

这当然假设表不会为“正常”空间列执行此操作,当它是聚集索引的一部分时。

--- 更新 5 结束 ---

问题

谁能回答这两个问题:

  • 难道简单的查找操作就可以解释(1)和(2)中描述的索引之间的性能差异吗?
  • 为什么 (3) 中描述的聚集索引比 (2) 中描述的索引慢得多?
  • 如果以上两个都无法回答,那么在比较问题 1 和问题 2 中描述的这两个指标时,我们是否应该看到如此大的性能缺陷,还是更有可能是我们的设置存在其他问题?

Luc*_*ain 2

查看您的查询,首先要考虑的是您在 SELECT 列表中包含一个 .NET/CLR 数据类型的空间列,并且这些列存储在IN_ROW_DATA需要键查找的页面外部,除非该空间列包含在索引中它还可能在索引数据页中包含空间边界框,以加快过滤速度,从而节省大部分磁盘 I/O。我想说您发现了一种有效的技巧来加速空间列过滤而不需要空间索引

为了证明我的观点,我建议您参阅原始 SQL 文档,我相信您已经了解了有关覆盖索引的内容,其中澄清了以下内容:“将非键列添加到leaf level非聚集索引以提高查询性能。这允许查询优化器从索引扫描中定位所有需要的信息;不访问表或聚集索引数据 ”。最后一部分在这里非常重要,因此我假设边界框是空间列的“必需信息”的一部分,以帮助查询优化器避免访问IN_ROW_DATA.

结论:

  1. 简单的查找操作真的可以解释(1)和(2)中描述的索引之间的性能差异吗? 我之所以这么说,是因为 (1) 中的空间 CLR 数据类型存储在IN_ROW_DATA需要更多磁盘 I/O 的页面之外。
  2. 为什么(3)中描述的聚集索引比(2)中描述的索引慢得多? 同样的原因,将地理数据包含在索引 (2) 中可以节省在页面外部查找的需要,从而IN_ROW_DATA节省大部分磁盘 I/O;请记住,索引(3)仍然需要查找LOB_DATA.