Несподівані сканування під час операції видалення за допомогою WHERE IN


40

У мене є такий запит:

DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
    SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)

tblFEStatsBrowsers має 553 рядки.
tblFEStatsPaperHits набрав 47.974.301 рядків.

tblFEStatsBrowsers:

CREATE TABLE [dbo].[tblFEStatsBrowsers](
    [BrowserID] [smallint] IDENTITY(1,1) NOT NULL,
    [Browser] [varchar](50) NOT NULL,
    [Name] [varchar](40) NOT NULL,
    [Version] [varchar](10) NOT NULL,
    CONSTRAINT [PK_tblFEStatsBrowsers] PRIMARY KEY CLUSTERED ([BrowserID] ASC)
)

tblFEStatsPaperHits:

CREATE TABLE [dbo].[tblFEStatsPaperHits](
    [PaperID] [int] NOT NULL,
    [Created] [smalldatetime] NOT NULL,
    [IP] [binary](4) NULL,
    [PlatformID] [tinyint] NULL,
    [BrowserID] [smallint] NULL,
    [ReferrerID] [int] NULL,
    [UserLanguage] [char](2) NULL
)

Існує кластерний індекс на tblFEStatsPaperHits, який не включає BrowserID. Таким чином, виконання внутрішнього запиту вимагатиме повного сканування таблиці tblFEStatsPaperHits - що цілком нормально.

В даний час для кожного рядка в tblFEStatsBrowsers виконується повне сканування, тобто я отримав 553 сканування повної таблиці tblFEStatsPaperHits.

Переписання лише на те, де існують, не змінює план:

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)

Однак, як запропонував Адам Маханіч, додавання параметра HASH JOIN приводить до оптимального плану виконання (лише одне сканування tblFEStatsPaperHits):

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)

Тепер це не стільки питання, як це виправити - я можу або використовувати OPTION (HASH JOIN), або створити таблицю темпів вручну. Мені більше цікаво, чому оптимізатор запитів коли-небудь буде використовувати план, який він наразі працює.

Оскільки QO не має жодної статистики в стовпці BrowserID, я здогадуюсь, що це передбачає найгірше - 50 мільйонів чітких значень, що вимагає досить великого робочого столу пам'яті / tempdb. Таким чином, найбезпечніший спосіб - виконати сканування кожного ряду в tblFEStatsBrowsers. Між стовпцями BrowserID у двох таблицях немає зовнішнього ключа, тому QO не може виводити будь-яку інформацію з tblFEStatsBrowsers.

Це, настільки просто, як це звучить, причина?

Оновлення 1
Щоб дати кілька статистичних даних: ВАРІАНТ (HASH JOIN):
208.711 логічних читання (12 сканувань)

ВАРІАНТ (LOOP JOIN, HASH GROUP):
11.008.698 логічних читань (~ сканування на BrowserID (339))

Немає варіантів:
логічне зчитування 11.008.775 (~ сканування на BrowserID (339))

Оновлення 2
Відмінні відповіді, всі ви - дякую! Важко вибрати лише одну. Хоча Мартін був першим, і Рем пропонує чудове рішення, я мушу дати його ківі, щоб розібратися в деталях :)


5
Чи можете ви скриптувати статистику відповідно до копіювання статистики з одного сервера на інший, щоб ми могли повторити?
Марк Сторі-Сміт

2
@ MarkStorey-Smith Sure - pastebin.com/9HHRPFgK Припускаючи, що ви запускаєте скрипт у порожній базі даних, це дозволяє мені відтворювати проблемні запити, включаючи показ плану виконання. Обидва запити включаються в кінці сценарію.
Марк С. Расмуссен

Відповіді:


61

"Мені більше цікаво, чому оптимізатор запитів коли-небудь буде використовувати план, який він наразі працює".

Інакше кажучи, питання полягає в тому, що наступний план виглядає найдешевшим для оптимізатора, порівняно з альтернативами (яких багато ).

Оригінальний план

Внутрішня сторона з'єднання, по суті, виконує запит наступної форми для кожного співвідносного значення BrowserID:

DECLARE @BrowserID smallint;

SELECT 
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Сканування паперу

Зауважимо, що орієнтовна кількість рядків - 185,220 (а не 289,013 ), оскільки порівняння рівності неявно виключає NULL(якщо ANSI_NULLSце не так OFF). Орієнтовна вартість вищевказаного плану становить 206,8 одиниць.

Тепер додамо TOP (1)пункт:

DECLARE @BrowserID smallint;

SELECT TOP (1)
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

З ТОП (1)

Орієнтовна вартість зараз становить 0,00452 одиниці. Додавання верхнього фізичного оператора встановлює ціль рядка в 1 рядок у верхньому операторі. Тоді виникає питання, як отримати 'мету рядка' для кластеризованого індексу сканування; тобто скільки рядків слід розглянути скануванню, перш ніж один рядок відповідає BrowserIDприсудку?

Наявна статистична інформація показує 166 різних BrowserIDзначень (1 / [Вся щільність] = 1 / 0,006024096 = 166). Кошторис передбачає, що окремі значення розподіляються рівномірно по фізичних рядках, тому ціль рядка на кластерному скануванні індексу встановлюється на 166.302 (враховуючи зміну кардинальності таблиці після збирання вибіркової статистики).

Орієнтовна вартість сканування очікуваних 166 рядків не дуже велика (навіть виконана 339 разів, один раз на кожну зміну BrowserID) - Clustered Index Scan показує орієнтовну вартість 1.3219 одиниць, показуючи ефект масштабування мети рядка. Немасштабні операторські витрати для вводу / виводу та процесора відображаються як 153.931 і 52.8698 відповідно:

Ряд цілі Масштабні орієнтовні витрати

На практиці це дуже малоймовірно , що перші 166 рядків сканується з індексу (в будь-якому порядку вони відбуваються , щоб бути повернуті) буде містити по одному з можливих BrowserIDзначень. Тим не менш, DELETEплан коштує загалом 1.40921 одиниць, і оптимізатор вибирається саме з цієї причини. Барт Данкан показує ще один приклад цього типу в недавній публікації під назвою Row Goals Gone Rogue .

Цікаво також зазначити, що оператор Top у плані виконання не асоціюється з Anti Semi Join (зокрема, згадується про "коротке замикання" Мартіна). Ми можемо почати бачити, звідки береться Топ, спочатку відключивши правило дослідження під назвою GbAggToConstScanOrTop :

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

GbAggToConstScanOrTop Disabled

Орієнтовна вартість цього плану становить 364,912 і показує, що Топ замінив групу за сукупністю (групування за корельованим стовпцем BrowserID). Сукупність не обумовлена ​​надмірністю DISTINCTтексту запиту: це оптимізація, яку можна ввести двома правилами дослідження, LASJNtoLASJNonDist та LASJOnLclDist . Відключення цих двох також створює цей план:

DBCC RULEOFF ('LASJNtoLASJNonDist');
DBCC RULEOFF ('LASJOnLclDist');
DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('LASJNtoLASJNonDist');
DBCC RULEON ('LASJOnLclDist');
DBCC RULEON ('GbAggToConstScanOrTop');

План котушки

Орієнтовна вартість цього плану - 40729,3 од.

Без перетворення від Group By to Top, оптимізатор "природно" вибирає хеш-план приєднання з BrowserIDагрегуванням перед анти-напівприєднанням:

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

Немає верхнього плану DOP 1

І без обмеження MAXDOP 1 паралельний план:

Немає верхнього паралельного плану

Іншим способом "виправити" вихідний запит було б створити відсутній індекс для BrowserIDцього звіту про виконання. Вкладені петлі найкраще працюють, коли внутрішня сторона індексується. Оцінка кардинальності для напівприєднань є кращим викликом у найкращі часи. Відсутність належної індексації (велика таблиця навіть не має унікального ключа!) Зовсім не допоможе.

Про це я писав більше в цілях рядків, Частина 4: Анти-приєднання анти-візерунка .


3
Я кланяюся тобі, ти щойно познайомив мене з кількома новими поняттями, з якими я ніколи не стикався. Тільки коли відчуєш, що щось знаєш, хтось там вас підведе - добрим способом :) Додавання індексу безумовно допоможе. Однак, окрім цієї одноразової операції, до поля ніколи не звертається / агрегується стовпець BrowserID, тому я б краще зберегти ці байти, оскільки таблиця досить велика (це лише одна з багатьох однакових баз даних). На столі немає жодного унікального ключа, оскільки немає в ньому природної унікальності. Усі вибори є PaperID та необов'язково періодом.
Марк С. Расмуссен

22

Коли я запускаю ваш сценарій, щоб створити базу даних лише для статистики та запит у питанні, я отримую наступний план.

План

Кардинальності таблиці, показані в плані, є

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Тому він підрахував, що сканування потрібно буде виконати tblFEStatsPaperHits339 разів. Кожне сканування має відповідний предикат, tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULLякий висувається в оператор сканування.

План не означає, що буде 339 повних сканів. Оскільки він знаходиться під оператором анти-напівз'єднання, як тільки знайдеться перший рядок у кожному скануванні, він може коротко замикати решту. Орієнтовна вартість піддерева для цього вузла становить 1.32603і весь план витрачається на 1.41337.

Для приєднання хеш-класів він подає план нижче

Hash Join

Загальний план обійшлися в 418.415(приблизно в 300 разів дорожче , ніж план вкладених циклів) з одного повної кластерної сканування індексу по tblFEStatsPaperHitsкошторисі витрат в 206.8поодинці. Порівняйте це з 1.32603оцінкою для 339 часткових сканувань, наведених раніше (Середня оцінка часткового сканування = 0.003911592).

Таким чином, це означатиме, що кожне часткове сканування коштує як 53 000 разів дешевше, ніж повне сканування. Якщо витрати повинні лінійно масштабуватися з кількістю рядків, то це означатиме, що припускається, що в середньому потрібно буде обробити лише 900 рядків на кожній ітерації, перш ніж вона знайде відповідний рядок і зможе коротке замикання.

Однак я не думаю, що витрати змінюються таким чином лінійно. Я думаю, що вони також містять певний елемент фіксованої вартості запуску. Спроба різних значень TOPв наступному запиті

SELECT TOP 147 BrowserID 
FROM [dbo].[tblFEStatsPaperHits] 

147дає найближчу оціночну вартість поддерева в 0.003911592в 0.0039113. Так чи інакше, зрозуміло, що це базується на витратах на припущенні, що при кожному скануванні доведеться обробляти лише мініатюрну частку таблиці в порядку сотень рядків, а не мільйонів.

Я не впевнений, на чому саме математика заснована на цьому припущенні, і це насправді не підсумовується підрахунками кількості рядків у решті плану (236 оцінених рядків, що виходять із вкладених вкладених циклів, означатиме, що їх було 236 випадки, коли не було знайдено жодного відповідного рядка і було потрібно повне сканування). Я припускаю, що це лише той випадок, коли зроблені припущення щодо моделювання дещо падають і залишають план вкладених циклів значно завищеним.


20

У моїй книзі навіть одне сканування розміром у 50 М рядів неприйнятне ... Моя звичайна хитрість полягає в тому, щоб матеріалізувати різні значення та делегувати двигун, підтримуючи його в курсі:

create view [dbo].[vwFEStatsPaperHitsBrowserID]
with schemabinding
as
select BrowserID, COUNT_BIG(*) as big_count
from [dbo].[tblFEStatsPaperHits]
group by [BrowserID];
go

create unique clustered index [cdxVwFEStatsPaperHitsBrowserID] 
  on [vwFEStatsPaperHitsBrowserID]([BrowserID]);
go

Це дає вам матеріалізований індекс один рядок на BrowserID, виключаючи необхідність сканування 50M рядків. Двигун буде підтримувати його для вас, і QO використовуватиме його як є у викладеному вами повідомленні (без будь-якого натяку чи перезапису запиту).

Мінус - це, звичайно, суперечка. Будь-яка операція вставки або видалення в tblFEStatsPaperHits(і, мабуть, це таблиця журналу із важкими вставками) повинна буде серіалізувати доступ до заданого BrowserID. Існують способи, які роблять це працездатним (запізнілі оновлення, двоступеневий журнал тощо), якщо ви готові придбати його.


Я чую, будь-яке велике сканування, безумовно, взагалі неприйнятне. У цьому випадку це для одноразових операцій очищення даних, тому я вирішую не створювати додаткові індекси (і не можу це робити тимчасово, оскільки це перерве систему). У мене немає ЕЕ, але з огляду на те, що це одноразово, підказки будуть добре. Моя головна цікавість полягала в тому, як QO встала з плану :) Таблиця - це журнал реєстрації та є важкі вставки. Існує окрема таблиця асинхронного ведення журналу, хоча вона пізніше оновлює рядки в tblFEStatsPaperHits, щоб я міг сам керувати нею, якщо потрібно.
Марк С. Расмуссен
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.