Основы языка SQL

Индексирование таблиц и хранимые процедуры

В лекции разбирается практическая работа индексов в SQL Server: как они ускоряют выборку и почему замедляют вставку. На примере таблицы клиентов с перекосом данных показаны планы выполнения запросов с фильтрацией по индексированному и неиндексированному столбцу. Далее демонстрируется явление parameter sniffing – когда хранимая процедура кэширует план для первого значения параметра, и это влияет на все последующие вызовы. Рассматриваются способы управления компиляцией: WITH RECOMPILE, OPTION (RECOMPILE) и влияние внутренней переменной на стабильность плана. Материал даёт целостное представление о балансе между производительностью чтения и записи, а также о том, как нюансы параметризации меняют поведение оптимизатора.

Основные мысли

В результате изучения лекции слушатель будет способен:
1. Объяснять разницу между кластеризованным и некластеризованным индексом.
2. Описывать, как индексы ускоряют операции чтения и замедляют вставку/обновление.
3. Анализировать планы выполнения и выявлять использование конкретных индексов.
4. Определять ситуации, когда некластеризованный индекс эффективен для фильтрации редких значений.
5. Объяснять механизм parameter sniffing и его влияние на производительность.
6. Применять опцию WITH RECOMPILE для принудительной перекомпиляции хранимой процедуры.
7. Использовать OPTION (RECOMPILE) на уровне отдельного запроса.
8. Распознавать, как введение внутренней переменной снижает качество плана.
9. Интерпретировать подсказки SQL Server о создании недостающих индексов.
Показывать лекцию целиком
Краткое изложение

Для лекции используйте файл  Test_Sniffing.sql (необходимо распаковать).

Приложения

Test_Sniffing.sql
Оптимизация запросов с помощью индексов и управление планами выполнения

Подготовка тестовой базы и таблиц

Создаётся база данных TestDB с простой моделью логирования, чтобы минимизировать использование дискового пространства. Внутри неё создаются две таблицы.

CustomerCategory – категории покупателей:
• CustomerCategoryID CHAR(1) PRIMARY KEY (значения 'A' и 'B');
• CategoryDescription – текстовое описание.

Customers – покупатели:
• CustomerID INT IDENTITY(1,1) PRIMARY KEY (автоинкремент);
• CustomerName NOT NULL;
• CustomerAddress NOT NULL;
• State – двухсимвольный код штата;
• CustomerCategoryID CHAR(1) FOREIGN KEY REFERENCES CustomerCategory(CustomerCategoryID);
• LastBuyDate – дата последней покупки.

Первичный ключ автоматически создаёт кластеризованный индекс (clustered index).

Индексы: назначение и виды

Индекс – структура, обеспечивающая быстрый доступ к строкам без полного сканирования таблицы. В основе лежит B-дерево, позволяющее быстро локализовать нужный диапазон ключей.

Кластеризованный индекс (clustered index) – определяет физический порядок строк; создаётся автоматически для первичного ключа. Хранит сами данные.
Некластеризованный индекс (nonclustered index) – создаётся вручную на любых полях; хранит значения ключа и ссылки на строки данных.

На таблице Customers создаётся некластеризованный индекс по столбцу CustomerCategoryID:

sql
CREATE INDEX IX_Customers_Category ON Customers(CustomerCategoryID);

Этот индекс позволит быстро находить всех покупателей конкретной категории без полного перебора таблицы.

Компромисс: скорость чтения против накладных расходов на изменение

Преимущества индексов:
• выборка (SELECT) выполняется значительно быстрее.
Недостатки:
• индексы потребляют много памяти (иногда больше, чем сама таблица);
• любая вставка, обновление или удаление строки требует перестроения индексов, что замедляет операции изменения данных.

Поэтому индексная стратегия – это поиск баланса. Рекомендуется создавать индексы под наиболее частые запросы, игнорируя редко используемые, чтобы не жертвовать скоростью модификации данных. SQL Server может предлагать недостающие индексы на основе собранной статистики.

Пример с перекосом данных

В таблицу CustomerCategory вставляются две строки: категория 'A' («Первая категория») и категория 'B' («Вторая категория»).

В таблицу Customers добавляется один покупатель категории 'B' и 15 000 одинаковых покупателей категории 'A'. Таким образом, 15 000 клиентов принадлежат категории A, и только один – категории B.

Планы выполнения для разных значений фильтра

Выполняются два запроса (перед каждым очищается кэш и оперативная память):

sql
-- Запрос 1: все покупатели категории A
SELECT c.CustomerName, c.LastBuyDate, cat.CategoryDescription
FROM Customers c
INNER JOIN CustomerCategory cat ON c.CustomerCategoryID = cat.CustomerCategoryID
WHERE c.CustomerCategoryID = 'A';

-- Запрос 2: все покупатели категории B
SELECT c.CustomerName, c.LastBuyDate, cat.CategoryDescription
FROM Customers c
INNER JOIN CustomerCategory cat ON c.CustomerCategoryID = cat.CustomerCategoryID
WHERE c.CustomerCategoryID = 'B';

План для A:
Сканирование кластеризованного индекса таблицы Customers возвращает все строки (оба запрошенных поля). Кластеризованный индекс таблицы CustomerCategory используется для поиска категории 'A'. Затем выполняется соединение вложенными циклами (Nested Loops Join). Фактическая фильтрация происходит уже при соединении. Индекс по CustomerCategoryID не используется, потому что значение 'A' охватывает почти всю таблицу и оптимизатор выбирает полное сканирование.

План для B:
Здесь активируется некластеризованный индекс IX_Customers_Category. Он мгновенно находит единственный CustomerID со значением 'B'. Затем по кластеризованному индексу извлекаются остальные поля (CustomerName, LastBuyDate) и происходит соединение с таблицей категорий. Такой план эффективен именно потому, что целевое множество очень мало по сравнению с общим объёмом таблицы.

Parameter sniffing в хранимых процедурах

Создаётся хранимая процедура без опции перекомпиляции:

sql
CREATE PROCEDURE TestSniffing
@CustomerCategoryID CHAR(1)
AS
BEGIN
SELECT c.CustomerName, c.LastBuyDate, cat.CategoryDescription
FROM Customers c
INNER JOIN CustomerCategory cat ON c.CustomerCategoryID = cat.CustomerCategoryID
WHERE c.CustomerCategoryID = @CustomerCategoryID;
END;

Сначала вызывается процедура с параметром 'A', затем – с параметром 'B'. Без очистки кэша между вызовами оба выполнения используют план, скомпилированный для 'A' (сканирование кластеризованного индекса). Причина – parameter sniffing: SQL Server при первом запуске строит план под переданное значение и кэширует его. Последующие вызовы с другими значениями применяют тот же план, даже если он неоптимален. Очистка кэша перед запуском восстанавливает индивидуальные планы для каждого значения.

Меняя порядок запуска (сначала 'B', затем 'A'), можно наблюдать обратную картину: план для 'B' (с некластеризованным индексом) будет использован и для 'A'.

Управление компиляцией: WITH RECOMPILE

Создаётся новая процедура с опцией WITH RECOMPILE:

sql
CREATE PROCEDURE TestSniffingRecompile
@CustomerCategoryID CHAR(1)
WITH RECOMPILE
AS
BEGIN
SELECT c.CustomerName, c.LastBuyDate, cat.CategoryDescription
FROM Customers c
INNER JOIN CustomerCategory cat ON c.CustomerCategoryID = cat.CustomerCategoryID
WHERE c.CustomerCategoryID = @CustomerCategoryID;
END;

При каждом вызове процедура перекомпилируется, создавая план, оптимальный для конкретного значения параметра. Теперь последовательность 'A' → 'B' даёт для 'B' некластеризованный индекс, а обратная последовательность восстанавливает кластеризованное сканирование для 'A'. План больше не «прилипает».

Опция OPTION (RECOMPILE) на уровне запроса

Можно управлять перекомпиляцией не на уровне всей процедуры, а внутри отдельного запроса:

sql
SELECT ...
WHERE c.CustomerCategoryID = @CustomerCategoryID
OPTION (RECOMPILE);

Результат аналогичен WITH RECOMPILE, но решение о перекомпиляции принимается точечно. Оптимизатор заново строит план для этого запроса, ориентируясь на текущее значение параметра.

Влияние внутренней переменной

Создаётся процедура, в которой значение параметра присваивается локальной переменной:

sql
CREATE PROCEDURE TestSniffingLocalVar
@CustomerCategoryID CHAR(1)
AS
BEGIN
DECLARE @LocalCategoryID CHAR(1);
SET @LocalCategoryID = @CustomerCategoryID;

SELECT ...
WHERE c.CustomerCategoryID = @LocalCategoryID;
END;

При таком подходе SQL Server не знает конкретного значения переменной на этапе компиляции и использует усреднённую статистику. В результате план всегда остаётся одинаковым (кластеризованное сканирование), независимо от переданного параметра, и оптимизация для редких значений не срабатывает. Внутренняя переменная полностью нивелирует parameter sniffing, но ценой потери потенциального выигрыша.

Итоги и практические рекомендации

SQL Server предоставляет встроенные подсказки о недостающих индексах (missing index), вычисляя ожидаемый прирост производительности. Однако задача построения индексов лежит на администраторе базы данных. Бизнес-аналитику достаточно понимать, что один и тот же запрос может выполняться с разной скоростью в зависимости от индексов и параметризации. Осознание феноменов parameter sniffing, перекомпиляции и влияния локальных переменных помогает интерпретировать непредсказуемое поведение отчётов и взаимодействовать со специалистами по оптимизации.

Краткие итоги

Индексация в SQL Server – это классический компромисс между скоростью извлечения данных и стоимостью поддержки структур при изменениях. Кластеризованные индексы, автоматически возникающие на первичных ключах, упорядочивают хранение строк и делают поиск по ключу мгновенным. Некластеризованные индексы, напротив, требуют осознанного создания и предназначены для ускорения фильтрации по непервичным столбцам. Однако плата за это ускорение двойная: индексы занимают дополнительное место на диске и замедляют операции вставки, обновления и удаления, так как каждая модификация данных вынуждает перестраивать индексные структуры.

Главная инженерная мысль – индексы должны создаваться адресно под самые частые запросы, а не «на всякий случай». Когда распределение значений в столбце неравномерно, важность индекса возрастает для фильтрации редких ключей. Это наглядно проявляется в ситуации с перекосом данных: если почти все строки принадлежат одной категории, полное сканирование для неё эффективнее, а для миноритарной категории некластеризованный индекс резко сокращает объём обрабатываемых данных. Понимание этой асимметрии критично для настройки производительности.

Не менее важны тонкости параметризации. Хранимые процедуры без управления компиляцией подвержены parameter sniffing – явлению, когда план, созданный для первого фактического значения, фиксируется и применяется ко всем последующим вызовам. При изменении характера данных это приводит к деградации скорости. Инструменты WITH RECOMPILE и OPTION (RECOMPILE) дают контроль над этим поведением, инициируя перекомпиляцию под каждое конкретное значение, восстанавливая оптимальность планов. Однако здесь возникает обратная сторона – частые перекомпиляции создают дополнительную нагрузку на процессор, что тоже требует взвешенного подхода.

Любопытный эффект демонстрирует введение промежуточной локальной переменной: присвоение значения параметра внутренней переменной лишает оптимизатор точной информации о значении, и он вынужден строить план на основе среднестатистических ожиданий. Это полностью устраняет sniffing, но одновременно лишает возможности получить план, заточенный под конкретную фильтрацию. Такой приём может быть полезен для стабилизации планов, однако чаще он просто прячет симптом, не решая проблему распределения данных.

Практическое применение этих знаний выходит за рамки административных задач. Аналитик, сталкиваясь с нестабильным временем выполнения отчётов, может выявить причину в параметризации или отсутствии нужного индекса. Понимание рекомендаций missing index и умение читать планы запросов помогают формулировать требования к администраторам более предметно. В конечном счёте осознание внутренних механизмов работы SQL Server превращает интуитивные догадки о производительности в управляемый и прогнозируемый процесс настройки.
Индексы: назначение и виды

Индексы создаются для ускорения доступа к данным. Кластеризованный индекс определяет физический порядок строк и автоматически строится на первичном ключе. Некластеризованный индекс создаётся вручную на любых столбцах и хранит ключи со ссылками на строки. Оба основаны на B-деревьях, обеспечивающих быстрый спуск по диапазонам ключей без полного сканирования таблицы.

Цена индексов: они занимают память и замедляют операции INSERT, UPDATE, DELETE, так как при каждом изменении данных индекс должен обновляться.

Практический пример

Созданы таблицы CustomerCategory (поля CustomerCategoryID CHAR(1) PK, CategoryDescription) и Customers (CustomerID INT PK, CustomerName, CustomerAddress, State, CustomerCategoryID FK, LastBuyDate). На Customers создан некластеризованный индекс IX_Customers_Category по столбцу CustomerCategoryID.

Вставлены данные: 15 000 покупателей категории 'A', один покупатель категории 'B'.

Выполняются два запроса с фильтром по категории. После очистки кэша:
• Для категории 'A' оптимизатор использует сканирование кластеризованного индекса Customers (почти все строки принадлежат этой категории, индекс не даёт выигрыша).
• Для категории 'B' включается некластеризованный индекс — ищется единственная строка, затем через кластеризованный индекс извлекаются остальные столбцы.

Таким образом, некластеризованный индекс эффективен только для редких значений.

Parameter sniffing

Создана хранимая процедура TestSniffing с параметром @CustomerCategoryID. При первом запуске с 'A' план компилируется под массовое значение (сканирование). Если сразу за этим вызвать процедуру с 'B' (без очистки кэша), план остаётся тем же — сканирование, что неэффективно для одной строки. Это явление называется parameter sniffing: сервер фиксирует план по первому переданному значению и применяет его ко всем последующим вызовам.

Очистка кэша (DBCC FREEPROCCACHE) сбрасывает кэшированный план, и при новом запуске с 'B' строится план с некластеризованным индексом. При изменении порядка первого вызова ('B' → 'A') оба запроса используют план, оптимизированный под 'B', включая поиск по некластеризованному индексу.

Управление компиляцией

WITH RECOMPILE – опция создания процедуры, при которой план перестраивается при каждом вызове. Процедура TestSniffingRecompile с этой опцией всегда генерирует оптимальный план для конкретного параметра: для 'A' — сканирование, для 'B' — некластеризованный индекс, независимо от порядка вызовов.

OPTION (RECOMPILE) – директива внутри отдельного запроса. Она позволяет перекомпилировать только один запрос, оставляя остальную логику процедуры нетронутой. Результат аналогичен WITH RECOMPILE, но тоньше в управлении.

Влияние внутренней переменной

Если в процедуре объявить локальную переменную, присвоить ей значение параметра и использовать её в WHERE, оптимизатор теряет знание о конкретном значении и опирается на усреднённую статистику. План фиксируется (обычно сканирование) для любых входных данных. Parameter sniffing исчезает, но плата — невозможность специализации под редкие значения.

Итоги

Стратегия индексации базируется на анализе частоты запросов и селективности предикатов. SQL Server подсказывает недостающие индексы (missing index). Разработчику-аналитику важно понимать влияние индексов и параметризации на время выполнения, чтобы корректно интерпретировать поведение отчётов и ставить задачи администраторам.

Выводы

1. Первичный ключ автоматически создаёт кластеризованный индекс, определяющий физический порядок строк в таблице.
2. Некластеризованные индексы строятся вручную и ускоряют поиск по столбцам, не являющимся первичным ключом.
3. Индексы ускоряют чтение, но замедляют вставку и обновление из-за необходимости перестроения индексных структур.
4. Индексы занимают дополнительное дисковое пространство, иногда превышающее объём самих данных.
5. Индексы целесообразно создавать только под наиболее частые запросы, чтобы не снижать скорость модификаций данных.
6. При сильном перекосе данных некластеризованный индекс эффективен для редких значений, но бесполезен для доминирующих.
7. План выполнения запроса показывает, какие индексы реально используются оптимизатором и сколько ресурсов потребляет каждая операция.
8. Parameter sniffing приводит к использованию единого плана для процедуры на основе значения, переданного при первом вызове.
9. Опция WITH RECOMPILE заставляет сервер перекомпилировать процедуру при каждом вызове, создавая план под конкретное значение.
10. OPTION (RECOMPILE) позволяет гибко перекомпилировать только отдельный запрос внутри процедуры.
11. Присвоение параметра внутренней переменной лишает оптимизатор информации о конкретном значении и фиксирует усреднённый план.
12. SQL Server может предлагать missing index – рекомендации по созданию индексов, способных существенно ускорить конкретный запрос.

Вопросы для самопроверки

1. Какую роль играет кластеризованный индекс на первичном ключе в плане соединения таблиц?
2. Почему индекс не всегда используется, даже если он создан по столбцу, участвующему в фильтре WHERE?
3. Каковы основные издержки сопровождения индексов при модификации данных?
4. В каких ситуациях некластеризованный индекс даёт максимальный выигрыш?
5. Что такое parameter sniffing и как он может ухудшить производительность хранимой процедуры?
6. Каким образом очистка кэша запросов меняет поведение оптимизатора после parameter sniffing?
7. Чем отличается поведение процедуры с опцией WITH RECOMPILE от обычной процедуры?
8. Когда имеет смысл использовать OPTION (RECOMPILE) вместо WITH RECOMPILE?
9. Почему введение внутренней переменной, дублирующей параметр, «ломает» оптимизацию под конкретное значение?
10. Как интерпретировать подсказку SQL Server о недостающем индексе (missing index) в плане выполнения?
11. Почему сканирование кластеризованного индекса может быть предпочтительнее использования некластеризованного индекса для частых значений?
12. Какие факторы нужно учитывать при выборе между стабильностью плана и его идеальной адаптацией к каждому входному параметру?
Вернуться к учебному плану