Почему план выполнения SQL Server зависит от порядка сравнения

Я оптимизировал запрос на SQL Server и столкнулся с тем, чего не ожидал. В базе есть таблица tblEvent, в ней среди прочих столбцов есть IntegrationEventStateId и ModifiedDateUtc. По этим столбцам есть индекс:

create index IX_tblEvent_IntegrationEventStateId_ModifiedDateUtc
on dbo.tblEvent (
    IntegrationEventStateId,
    ModifiedDateUtc
)

Когда я выполняю следующий оператор:

select *
from dbo.tblEvent e
where
    e.IntegrationEventStateId = 1
    or e.IntegrationEventStateId = 2
    or e.IntegrationEventStateId = 5
    or (e.IntegrationEventStateId = 4 and e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))

Я получаю этот план выполнения (обратите внимание, что индекс НЕ используется):

введите здесь описание изображения

Но когда я выполняю этот оператор:

select *
from dbo.tblEvent e
where
    1 = e.IntegrationEventStateId
    or 2 = e.IntegrationEventStateId
    or 5 = e.IntegrationEventStateId
    or (4 = e.IntegrationEventStateId and e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))

Я получаю этот план выполнения (обратите внимание, что индекс используется):

введите здесь описание изображения

Единственная разница между этими двумя операторами заключается в порядке сравнений в предложении where. Кто-нибудь может объяснить, почему я получаю разные планы выполнения?

Обновление 1 — полный сценарий воспроизведения приведен ниже

CREATE TABLE dbo.tblEvent
(
   EventId                 INT IDENTITY PRIMARY KEY,
   IntegrationEventStateId INT,
   ModifiedDateUtc         DATETIME,
   OtherCol                CHAR(1),
   index IX_tblEvent_IntegrationEventStateId_ModifiedDateUtc(IntegrationEventStateId, ModifiedDateUtc)
);

INSERT INTO dbo.tblEvent
SELECT TOP 356525 3,
                  DATEADD(SECOND, ROW_NUMBER() OVER (ORDER BY @@SPID)%63424, GETUTCDATE()),
                  'A'
FROM   sys.all_objects o1,
       sys.all_objects o2;

UPDATE STATISTICS dbo.tblEvent WITH FULLSCAN


select *
from dbo.tblEvent e 
where
    e.IntegrationEventStateId = 1
    or e.IntegrationEventStateId = 2
    or e.IntegrationEventStateId = 5
    or (e.IntegrationEventStateId = 4 and e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))


select *
from dbo.tblEvent e
where
    1 = e.IntegrationEventStateId
    or 2 = e.IntegrationEventStateId
    or 5 = e.IntegrationEventStateId
    or (4 = e.IntegrationEventStateId and e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))

Обновление 2 – DDL исходной таблицы

CREATE TABLE [dbo].[tblEvent]
(
[EventId] [int] NOT NULL IDENTITY(1, 1),
[EventTypeId] [int] NOT NULL,
[ScorecardId] [int] NULL,
[ScorecardAreaId] [int] NULL,
[AreaId] [int] NULL,
[ScorecardTopicId] [int] NULL,
[TopicId] [int] NULL,
[ScorecardRequirementId] [int] NULL,
[RequirementId] [int] NULL,
[DocumentId] [int] NULL,
[FileId] [int] NULL,
[TopicTitle] [nvarchar] (100) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[ScorecardTopicStatus] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[RequirementText] [nvarchar] (500) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[ScorecardRequirementStatus] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[DocumentName] [nvarchar] (260) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[CreatedByUserId] [int] NOT NULL,
[CreatedByUserSessionId] [int] NOT NULL,
[CreatedDateUtc] [datetime2] (4) NOT NULL CONSTRAINT [DF__tblEvent__Create__0737E4A2] DEFAULT (sysutcdatetime()),
[CreatedDateLocal] [datetime2] (4) NOT NULL CONSTRAINT [DF__tblEvent__Create__082C08DB] DEFAULT (sysdatetime()),
[ModifiedByUserId] [int] NOT NULL,
[ModifiedByUserSessionId] [int] NOT NULL,
[ModifiedDateUtc] [datetime2] (4) NOT NULL CONSTRAINT [DF__tblEvent__Modifi__09202D14] DEFAULT (sysutcdatetime()),
[ModifiedDateLocal] [datetime2] (4) NOT NULL CONSTRAINT [DF__tblEvent__Modifi__0A14514D] DEFAULT (sysdatetime()),
[IsDeleted] [bit] NOT NULL,
[RowVersion] [timestamp] NOT NULL,
[ScorecardRequirementPriority] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[AffectedUserId] [int] NULL,
[UserId] [int] NULL,
[CorrelationId] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[IntegrationEventStateId] [int] NULL,
[IntegrationEventId] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[IntegrationEventContent] [nvarchar] (max) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[IntegrationEventType] [nvarchar] (255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[IntegrationEventTryCount] [int] NULL
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
ALTER TABLE [dbo].[tblEvent] ADD CONSTRAINT [PK_dbo.tblEvent] PRIMARY KEY CLUSTERED ([EventId]) ON [PRIMARY]
GO
CREATE NONCLUSTERED INDEX [IX_tblEvent_IntegrationEventStateId_ModifiedDateUtc] ON [dbo].[tblEvent] ([IntegrationEventStateId], [ModifiedDateUtc]) ON [PRIMARY]
GO
ALTER TABLE [dbo].[tblEvent] ADD CONSTRAINT [FK_dbo.tblEvent_dbo.tblEventType_EventTypeId] FOREIGN KEY ([EventTypeId]) REFERENCES [dbo].[tblEventType] ([EventTypeId])
GO
ALTER TABLE [dbo].[tblEvent] ADD CONSTRAINT [FK_dbo.tblEvent_dbo.tblIntegrationEventState_IntegrationEventStateId] FOREIGN KEY ([IntegrationEventStateId]) REFERENCES [dbo].[tblIntegrationEventState] ([IntegrationEventStateId])
GO

person Andrew    schedule 28.03.2020    source источник
comment
Какая версия SQL Server и какой уровень совместимости базы данных? Разные планировщики выполнения используются для разных версий (и уровней совместимости), поэтому такая разница в поведении была бы очень интересной. Является ли он повторяемым, или планировщик просто выбрал другую стратегию, потому что статистика по индексу изменилась, что сделало его более кратким для оценки затрат?   -  person AlwaysLearning    schedule 28.03.2020
comment
Привет @AlwaysLearning. Версия SQL Server: Microsoft SQL Server 2016 (SP1-CU5) (KB4040714) - 13.0.4451.0 (X64) Sep 5 2017 16:12:34 Copyright (c) Microsoft Corporation Standard Edition (64-bit) on Windows Server 2012 R2 Standard 6.3 <X64> (Build 9600: ) (Hypervisor). Уровень совместимости БД: SQL Server 2016 (130). Поведение повторяется, я запускал запросы несколько раз и постоянно получал вышеуказанные планы выполнения.   -  person Andrew    schedule 28.03.2020
comment
Можете ли вы предоставить XML для обоих планов выполнения? например на brentozar.com/pastetheplan   -  person Martin Smith    schedule 28.03.2020
comment
@MartinSmith Готово, вот ссылки: brentozar.com/pastetheplan/?id=ry40ir3I8, brentozar.com/pastetheplan/?id=SJcShB2LI   -  person Andrew    schedule 28.03.2020
comment
Возможно, он просто кэшировал плохой план, и когда вы изменили синтаксис, он выбрал новый (лучший) план. Что произойдет, если вы добавите where 1=1 к первому плану?   -  person Nick.McDermaid    schedule 28.03.2020
comment
Просто странно... во второй план входит StatementOptmEarlyAbortReason="GoodEnoughPlanFound", а в первый нет. Все еще смотрит.   -  person AlwaysLearning    schedule 28.03.2020
comment
На самом деле, Nick.McDermaid может быть прав... Получаете ли вы все те же планы запросов, если вы выполняете DBCC FREEPROCCACHE заранее? (Кстати, я предполагаю, что это не рабочий сервер.)   -  person AlwaysLearning    schedule 28.03.2020
comment
@Nick.McDermaid, @AlwaysLearning Я выполнял исходные запросы, а также запросы с добавлением and 1=1 и запросы с добавлением or 1=0, выполняя DBCC FREEPROCCACHE каждый раз перед выполнением запроса. Никаких изменений в исполнительных планах.   -  person Andrew    schedule 28.03.2020
comment
Интригующий. Это может только добавить к тайне, но являются ли они оценочными или реальными? как выглядят фактические планы выполнения для них?   -  person Nick.McDermaid    schedule 28.03.2020
comment
@ Nick.McDermaid Это настоящие планы.   -  person Andrew    schedule 28.03.2020
comment
По какой-то причине в первом плане он оценивает, что 356,525 строки будут соответствовать предикату, то есть 100% строк в таблице. Вероятно, поэтому он не выполняет план с поиском + поиск. Непонятно, почему это оценивается, когда оценки индивидуальных запросов составляют 4 во втором плане.   -  person Martin Smith    schedule 28.03.2020
comment
Как выглядит статистика по IX_tblEvent_IntegrationEventStateId_ModifiedDateUtc? Я могу воспроизвести плохие оценки, если вставлю 356 525 строк, каждая из которых имеет 3 вместо IntegrationEventStateId, но это относится к обоим запросам.   -  person Martin Smith    schedule 28.03.2020
comment
каков тип данных ModifiedDateUtc?   -  person lptr    schedule 29.03.2020
comment
Определение @lptr ModifiedDateUtc: [ModifiedDateUtc] [datetime2] (4) NOT NULL CONSTRAINT [DF__tblEvent__Modifi__09202D14] DEFAULT (sysutcdatetime())   -  person Andrew    schedule 29.03.2020
comment
@MartinSmith Я добавил к вопросу статистику индекса. Я также повторно выполнил оба запроса с помощью dbcc freeproccache, чтобы убедиться, что я по-прежнему получаю те же планы exec со статистикой, которую публикую.   -  person Andrew    schedule 29.03.2020
comment
@lptr К вашему сведению, я только что попытался изменить дату в запросах на переменную типа datetime2(4), это не имело никакого значения.   -  person Andrew    schedule 29.03.2020
comment
К вашему сведению, я опубликовал еще один вопрос, который объясняет, как возник этот вопрос: -where-clause" title="почему Entity Framework 6 меняет порядок сравнения в предложении where"> stackoverflow.com/questions/60912735/. Вы можете найти это интересным.   -  person Andrew    schedule 29.03.2020
comment
Можете ли вы опубликовать полную таблицу и индекс DDL?   -  person David Browne - Microsoft    schedule 29.03.2020
comment
@DavidBrowne-Microsoft - я добавил репродукцию   -  person Martin Smith    schedule 29.03.2020
comment
@DavidBrowne-Microsoft Я добавил DDL к вопросу.   -  person Andrew    schedule 30.03.2020


Ответы (2)


Здесь есть ряд вопросов, но наиболее важным является оценка кардинальности (CE).

Более новая («по умолчанию») модель CE испытывает трудности с предикатами, когда пытается вычислить селективность по гистограмме без совпадающих шагов.

Например, начальная оценка кардинальности возвращает селективность, равную 1, для:

select *
from dbo.tblEvent e
where
    1 = e.IntegrationEventStateId
    or 2 = e.IntegrationEventStateId
    or 5 = e.IntegrationEventStateId
    or (4 = e.IntegrationEventStateId and e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))

как показано с использованием флагов трассировки 3604 и 2363:

Begin selectivity computation

Input tree:

  LogOp_Select
      CStCollBaseTable(ID=1, CARD=356525 TBL: dbo.tblEvent AS TBL: e)
      ScaOp_Logical x_lopOr
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=1)
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=2)
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=5)
          ScaOp_Logical x_lopAnd
              ScaOp_Comp x_cmpGe
                  ScaOp_Identifier QCOL: [e].ModifiedDateUtc
                  ScaOp_Identifier COL: ConstExpr1001 
              ScaOp_Comp x_cmpEq
                  ScaOp_Identifier QCOL: [e].IntegrationEventStateId
                  ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=4)

Plan for computation:

  CSelCalcCombineFilters_ExponentialBackoff (OR)
      CSelCalcCombineFilters_ExponentialBackoff (AND)
          CSelCalcColumnInInterval
              Column: QCOL: [e].ModifiedDateUtc
          CSelCalcColumnInInterval
              Column: QCOL: [e].IntegrationEventStateId
      CSelCalcColumnInInterval
          Column: QCOL: [e].IntegrationEventStateId

Loaded histogram for column QCOL: [e].ModifiedDateUtc from stats with id 3
Loaded histogram for column QCOL: [e].IntegrationEventStateId from stats with id 2

Selectivity: 1

Stats collection generated: 

  CStCollFilter(ID=2, CARD=356525)
      CStCollBaseTable(ID=1, CARD=356525 TBL: dbo.tblEvent AS TBL: e)

End selectivity computation

Когда начинается оптимизация на основе затрат и входное дерево имеет немного другую форму, CE просят вычислить селективность более простых предикатов:

Begin selectivity computation

Input tree:

  LogOp_Select
      CStCollBaseTable(ID=1, CARD=356525 TBL: dbo.tblEvent AS TBL: e)
      ScaOp_Logical x_lopOr
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=1)
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=2)
          ScaOp_Comp x_cmpEq
              ScaOp_Identifier QCOL: [e].IntegrationEventStateId
              ScaOp_Const TI(int,ML=4) XVAR(int,Not Owned,Value=5)

Plan for computation:

  CSelCalcColumnInInterval
      Column: QCOL: [e].IntegrationEventStateId

Selectivity: 1

Stats collection generated: 

  CStCollFilter(ID=3, CARD=356525)
      CStCollBaseTable(ID=1, CARD=356525 TBL: dbo.tblEvent AS TBL: e)

End selectivity computation

Это эквивалент:

SELECT *
FROM dbo.tblEvent AS TE 
WHERE TE.IntegrationEventStateId IN (1, 2, 5);

В обоих случаях CE оценивает совпадение 100% строк, несмотря на отсутствие шагов гистограммы для значений 1, 2 или 5 (выборочные данные имеют только значения 3). Заманчиво обвинить в этом калькулятор CSelCalcColumnInInterval, поскольку он, кажется, обрабатывает {1, 2, 5} как один интервал {1:5}.

Как это часто бывает, «устаревший» CE выполняет здесь лучшую (более детальную) работу, поэтому вы должны обнаружить, что следующая подсказка создаст гораздо лучшие планы:

OPTION (USE HINT ('FORCE_LEGACY_CARDINALITY_ESTIMATION'));

С репродукционными данными это приводит к единому поиску и поиску ключа, как можно было бы надеяться.

устаревший план CE

Обратите внимание, что seek выполняет четыре операции поиска, по одной для каждого непересекающегося предиката.

[1] Seek Keys[1]: Prefix: IntegrationEventStateId = 1
[2] Seek Keys[1]: Prefix: IntegrationEventStateId = 2
[3] Seek Keys[1]: Prefix: IntegrationEventStateId = 4, Start: ModifiedDateUtc >= dateadd(minute,(-5),getutcdate())
[4] Seek Keys[1]: Prefix: IntegrationEventStateId = 5

Новый CE спроектирован так, чтобы быть более предсказуемым и простым в обслуживании/расширении, чем исходный CE. К «устаревшему» были прикручены детали и усовершенствования, которые вносились в течение длительного периода времени. Эта сложность имеет свои преимущества и недостатки. Регрессии и оценки более низкого качества в некоторой степени ожидаются с более новым CE. Это должно улучшиться со временем, но мы еще не достигли этого. Я бы рассматривал поведение, показанное здесь, как ограничение калькулятора. Возможно, они это исправят.

См. раздел Оптимизация планов запросов с помощью мощностей SQL Server 2014. Оценщик.


Вопрос о том, почему форма плана зависит от текстового представления, является скорее побочным вопросом. Процесс компиляции содержит логику (например, правило SelPredNorm) для перезаписи предикатов в нормализованную форму, и оба репро-запроса успешно перезаписываются в одно и то же дерево. Это делается для различных внутренних целей, включая сопоставление индексов и вычисляемых столбцов, а также для облегчения обработки логического упрощения.

К сожалению, переписанная форма используется только до оптимизации на основе затрат. Входные данные для оптимизатора на основе затрат сохраняют различия в порядке текста, присутствующие в исходном запросе. Я считаю, что это сделано намеренно и сделано для предотвращения неожиданных изменений плана. Иногда люди пишут запросы немного отличающимися и необычными способами для достижения определенной формы плана. Если бы оптимизатор вдруг начал отсеивать эти попытки как логически избыточные, люди бы расстроились. Возможно, это не так важно для таких вещей, как хранилище запросов и более эффективное принудительное выполнение планов, но это относительно недавние новшества.

Другими словами, планы различны, потому что в прошлом люди полагались на другой текст, производя разные планы, и изменение этого сейчас было бы слишком разрушительным.

person Community    schedule 30.03.2020

Это интересно и необычно, и я не знаю, почему QO признает, что поиск индекса 4 раза будет дешевле, чем сканирование только в одном из этих запросов.

Иногда, когда вы получаете странные планы, лучшее решение — изменить индексы и запросы, чтобы легче было получить хорошие планы.

возможно что-то вроде

CREATE TABLE dbo.tblEvent
(
   EventId                 INT IDENTITY PRIMARY KEY,
   IntegrationEventStateId INT,
   ModifiedDateUtc         DATETIME,
   OtherCol                CHAR(1),
   index IX_tblEvent_IntegrationEventStateId_ModifiedDateUtc(IntegrationEventStateId, ModifiedDateUtc) 
     include (OtherCol) 
     where IntegrationEventStateId in (1,2,4,5)
);

а потом

select *
from dbo.tblEvent e 
where
    e.IntegrationEventStateId in (1,2,4,5)
    and (e.IntegrationEventStateId <> 4 or e.ModifiedDateUtc >= dateadd(minute, -5, getutcdate()))
person David Browne - Microsoft    schedule 29.03.2020
comment
Спасибо @DavidBrowne-Microsoft, чтобы устранить ненадежность, я переписал запрос с нескольких or на несколько union all. - person Andrew; 30.03.2020