我有下表:
CREATE TABLE [dbo].[MP_Notification_Audit](
[id] [bigint] IDENTITY(1,1) NOT NULL,
[type] [int] NOT NULL,
[source_user_id] [bigint] NOT NULL,
[target_user_id] [bigint] NOT NULL,
[discussion_id] [bigint] NULL,
[discussion_comment_id] [bigint] NULL,
[discussion_media_id] [bigint] NULL,
[patient_id] [bigint] NULL,
[task_id] [bigint] NULL,
[date_created] [datetimeoffset](7) NOT NULL,
[clicked] [bit] NULL,
[date_clicked] [datetimeoffset](7) NULL,
[title] [nvarchar](max) NULL,
[body] [nvarchar](max) NULL,
CONSTRAINT [PK_MP_Notification_Audit] PRIMARY KEY CLUSTERED
(
[id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
ALTER TABLE [dbo].[MP_Notification_Audit] ADD CONSTRAINT [DF_MP_Notification_Audit_date_created] DEFAULT (sysdatetimeoffset()) FOR [date_created]
GO
CREATE NONCLUSTERED INDEX [IX_MP_Notification_Audit_TargetUserDateCreated] ON [dbo].[MP_Notification_Audit]
(
[target_user_id] ASC,
[date_created] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO
表中有超过 10000 行,其中 a[target_user_id]
为100017
.
当我执行以下查询时:
SELECT
[target_user_id], [patient_id]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 9200 ROWS
FETCH NEXT 10 ROWS ONLY
...我得到以下实际执行计划:
为什么 SQL Server 需要进行 9210 次而不是 10 次聚集键查找?索引[IX_MP_Notification_Audit_TargetUserDateCreated]
应该允许它找出它需要检索到的 10 个 RID [patient_id]
,并且只进行 10 个聚集键查找,对吗?
我还发现了一些更奇怪的行为——看起来 SQL Server 会因为你没有选择不可索引的列而“惩罚”你。如果我改为OFFSET
10000 行,我会得到以下执行计划:
SELECT
[target_user_id], [patient_id]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 10000 ROWS
FETCH NEXT 10 ROWS ONLY
...建议创建一个包含 的索引[patient_id]
,并对整个表进行低效的聚集索引扫描。花费的时间是 0.126 秒,但这显然会好很多,因为当我将不可索引的列添加[title]
到查询中时,我得到了这个:
SELECT
[target_user_id], [patient_id], [title]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 10000 ROWS
FETCH NEXT 10 ROWS ONLY
...并且仍然使用非聚集索引,所用时间仅为0.032s。SQL Server 是否基本上说“您本可以创建一个索引来更有效地执行此操作,所以我们甚至不会使用您拥有的索引,我们会低效地进行查找来惩罚您”,或者我我错过了什么?
这是因为执行查询的子句的操作顺序。
WHERE
子句出现在OFFSET
andFETCH
子句之前。这就是为什么不仅有大约 10,000 个(在您的第一个示例中为 9,000 个)key lookups,而且还有同样多的index seeks。这是由于target_user_id
您的WHERE
子句中的谓词而发生的过滤。执行计划从右到左读取事件的顺序,因此,如果您按照从索引开始的计划查找并按您的方式向左工作,您将看到Top运算符,它代表您的OFFSET
/FETCH
子句,因此位于您的WHERE
子句之后。WHERE
简单地说,SQL Server 首先需要根据您通过谓词(在、HAVING
和JOIN
子句中)应用的过滤器来定位行。然后,一旦找到正确的行,它就可以应用您的OFFSET
/FETCH
子句。如果它没有首先根据您的过滤器获取所有行,它就不会知道OFFSET
/需要哪些行FETCH
。在这种情况下,SQL Server 引擎是否可以在逻辑上更好地编程?可能。他们可以以这样一种方式设计引擎,即实现Top操作最终将在该索引搜索的数据上发生,并在键查找发生之前应用它以至少最小化键查找的数量,但我想它只会在某些情况下有助于提高性能,并以编程方式使事情变得更加复杂,以至于它可能不值得。例如,键查找与索引查找操作并行发生(因此它们在计划步骤中同样位于右侧)。如果顶部操作要在键查找之前发生以减少数据,那么剩余的行键查找必须在该Top操作之后连续发生,结果也是在索引查找操作之后连续发生,这在某些情况下性能会更差。
关于第二个问题,关于执行计划从索引搜索更改为聚集索引扫描再回到索引搜索的经历,这被称为引爆点。基本上,查询优化器会分析您的查询,并在可用于获取数据的一系列不同执行计划中快速计算每个操作的成本,部分基于 SQL Server 维护的数据缓存统计信息。这些成本的总和允许查询优化器选择成本最低的执行计划(理想情况下是最快的执行计划))。
这些缓存的统计信息基于表中总行数中每列中每个值存在的行数,并用于对需要发生的每个操作进行基数估计。简而言之,基数估计是 SQL Server 认为特定操作将返回的行数。例如,
WHERE
第一个示例中的子句返回大约 9,000 行,SQL Server 引擎可能估计基数接近该数字,这导致索引查找操作的成本足以选择索引查找。当您明确告诉 SQL Server 改为返回 10,000 行时,它可能会触发Tipping Point,这使得它认为它需要返回的行数将通过索引扫描操作更有效,因为行的基数足够高SQL 引擎认为扫描可能会有效地遇到更连续的行,而不是进行 10,000 次索引查找。(长话短说,优化器并不完美,尤其是在接近临界点时,索引搜索在这里可能仍然更有效。)
至于为什么当您在
SELECT
列表中添加另一个未索引的列时会导致查询优化器选择一个不超过临界点的计划,我无法确切地告诉您(尤其是在我面前没有实际执行计划的情况下),因为它很漂亮使幕后发生的事情变得复杂,但一般来说,它所做的估计是基于您当时数据的缓存统计信息,导致它计算的成本不再超过临界点,并导致它选择一个利用索引搜索的执行计划。我同意优化器可以更聪明地使用
OFFSET
和来确定计划中的密钥查找位置FETCH
。作为一种解决方法,您可以使用如下所示的 CTE。
然后在计划
TOP
中的运算符之后完成嵌套循环键查找。