В данной статье рассматриваются рекомендации по разработке оптимальной структуры БД. В первой части статьи «Оптимизация SQL-запросов (Часть 1)» рассматриваются особенности запросов на выборку данных, виды индексов, использование планов запросов, различные подходы к оптимизации запросов.

6. Представления, временные таблицы, хранимые процедуры

Иногда сложный запрос можно оптимизировать, разбив его на части и сохранив промежуточные результаты во временную таблицу. Можно также использовать представления и/или хранимые процедуры, что также в некоторых ситуациях снизит дублирование кода.

Некоторые рекомендации по оптимизации хранимых процедур:

  1. Включать в процедуры строку — SET NOCOUNT ON. С каждым DML выражением SQL Server возвращает количество обработанных записей. Устанавливая SET NOCOUNT ON, эта функцию будет отключена. Для хранимых процедур, содержащих множество запросов и/или циклов, данное действие может дать значительный прирост производительности, потому как количество трафика будет значительно снижено.
  2. Использование имя схемы для доступа к объекту. Данная операция подсказывает серверу где искать объекты. При большом количестве баз, таблиц и хранимых процедур может повысить быстродействие.
  3. Не использовать префикс «sp_» в имени процедур: если имя нашей процедуры начинается с «sp_», SQL Server в первую очередь будет искать в своей главной базе данных.

7. Рекомендации по разработке структуры БД

Лучший способ решения проблем с производительностью — просто не допустить их.

Оптимизация производительности базы данных SQL Server начинается с выбора корректной конфигурации базы данных и модели данных. Можно повысить быстродействие, дополнив базу данных индексами различных типов и более мощными аппаратными средствами, но полностью ликвидировать недостатки модели данных все равно не удастся. Следствием неудачной конфигурации базы данных или модели данных может стать слишком большое время отклика системы, блокированные или зависшие транзакции, неверные или неточные результаты при подготовке бизнес-отчетов, рассинхронизация данных, несогласованность данных и невозможность составить запрос для извлечения нужных данных.

7.1. Выбор первичного ключа

Исторически сложилось так, что наиболее общим подходом в проектировке БД для идентификации конкретной строки использовалась целочисленная последовательность. Как правило, такая последовательность генерируется на стороне сервера в момент вставки новой строки. Такой подход подходит для многих приложений.

Тем не менее возникают ситуации, когда ключ необходимо сгенерировать на стороне клиента. Тогда альтернативой становится использование GUID в роли первичного ключа. Основным преимуществом GUID является возможность генерации его на лету, на стороне клиента, без необходимости проверки уникальности в базе данных. На первый взгляд это идеальное решение проблемы уникальных ключей, однако возникает проблема в производительности. Т. к. в MS SQL для первичного ключа используется кластеризованный индекс, где строки хранятся в индексе, то использование GUID в первичном ключе замедляет вставку записей и приводит к фрагментации БД. По этой причине практика использования в качестве первичного ключа в больших базах ключей на основе GUID противопоказана.

7.2. Создание индексов по внешним ключам

MS SQL автоматически НЕ создает индексы по внешним ключам.

Однако индексация таблиц по внешнему ключу может оказаться полезна по следующим причинам:

  • Лучшая поддержка реляционных отношений для удалений из первичного/уникального ключа.
  • Если отношения внешнего ключа определены как NO ACTION (для изменений/удалений), тогда строка, на которую ссылаются, не может быть удалена, поскольку это оставило бы такие строки-ссылки «осиротевшими». Поиск строк эффективнее с индексом по столбцу внешнего ключа!
  • Если отношения внешнего ключа определены как CASCADE (для изменений/удалений) тогда если строка, на которую ссылаются, изменяется, все ссылающиеся на неё строки тоже должны измениться точно также (измениться, чтобы принять новое значение, или каскадно удалиться). Поиск изменённых строк эффективнее с индексом по столбцу внешнего ключа!
  • Улучшение работы соединений. По многим описанным выше причинам, SQL Server может более эффективно находить соединяемые строки, когда таблицы соединяются по отношениям первичных/внешних ключей. Однако, это не всегда будет лучшим выбором индексов для соединений.

7.3. Денормализация БД

Иногда для оптимизации запросов может быть оправдана денормализация БД.

В запросах к полностью нормализованной базе нередко приходится соединять до десятка, а то и больше, таблиц. А каждое соединение — операция весьма ресурсоемкая. Как следствие, такие запросы кушают ресурсы сервера и выполняются медленно.

В такой ситуации может помочь:

  • Денормализация путем сокращения количества таблиц. Лучше объединять в одну несколько таблиц, имеющих небольшой размер, содержащих редко изменяемую (как часто говорят, условно-постоянную или нормативно-справочную) информацию, причем информацию, по смыслу тесно связанную между собой.
  • В общем случае, если в большом количестве запросов требуется объединять более пяти или шести таблиц, следует рассмотреть вариант денормализации базы данных.
  • Денормализация путём ввода дополнительного поля в одну из таблиц. При этом появляется избыточность данных, требуются дополнительные действия для сохранения целостности БД.

Расчетные значения в запросе зачастую медленно выполняются, и потребляют много ресурсов запросы, в которых производятся какие-то сложные вычисления, особенно при использовании группировок и агрегатных функций (Sum, Max и т. п.). Иногда имеет смысл добавить в таблицу 1-2 дополнительных столбца, содержащих часто используемые (и сложно вычисляемые) расчетные данные.

В рассматриваемом примере можно добавить поле «общая стоимость заказа» и хранить его отдельным полем в таблице Order.

8. Примеры оптимизации запроса

8.1. Использование правильных типов данных

Пример запроса:

select * 
from 
  [Order] 
where 
  OrderNumber=500

SQL Server Execution Times:
   CPU time = 250 ms,  elapsed time = 252 ms.

Поле «OrderNumber» объявлено на типе varchar, поэтому при выполнении приведенного выше запроса, СУБД выполняет приведение типов.

Если переписать запрос следующим образом:

select * 
from 
  [Order] 
where 
  OrderNumber='500'

То запрос будет эффективнее.

8.2. Использование функций при составлении условий

Пример:

select 
  OrderNumber 
from 
  [Order] 
where 
  LEFT(OrderNumber, 1) = '5';

SQL Server Execution Times:
   CPU time = 390 ms,  elapsed time = 1686 ms.

Запрос отбирает все заказы с номером, начинающимся на 5.
Если переписать запрос следующим образом:

select 
  OrderNumber 
from 
  [Order] 
where 
  OrderNumber Like '5%';

То в плане Index Scan меняется на Index Seek, и запрос становится эффективнее.

8.3. Оптимизация с использованием индекса

Допустим, необходимо отобрать список заказов по определенному поставщику за определенный период.

select
    OrderNumber
   ,OrderDate
   ,CustomerId
from
    [Order]
where
    CustomerId = '36e45241-b4d7-44d3-bfe4-1759fa9a7255'
    and OrderDate between '01/01/2014'
    and '01/31/2014'

(6649 row(s) affected)
 SQL Server Execution Times:
   CPU time = 436 ms,  elapsed time = 3461 ms.

Добавим индекс по дате заказа.

(6649 row(s) affected)
 SQL Server Execution Times:
   CPU time = 187 ms,  elapsed time = 339 ms.

Добавим составной индекс.

(6649 row(s) affected)
 SQL Server Execution Times:
   CPU time = 15 ms,  elapsed time = 163 ms.

Таким образом, добавление нужного индекса может ускорить запрос в десятки раз.

8.4. Оптимизация с изменением текста запроса

Допустим, необходимо отобрать список завершенных заказов (по номеру заказа), имеющих общую стоимость более 1 млн, и получить следующие данные: номер заказа, наименование клиента и стоимость заказа.

Напишем такой запрос:

select 
  [Order].OrderNumber
  ,[Customer].CustomerName
  ,sum([Product].Price * [OrderProduct].Quantity) 
from 
  [Order]
inner join [OrderProduct] 
  on [order].OrderId = [OrderProduct].OrderId
inner join [Product] 
  on [Product].ProductId = [OrderProduct].ProductId
inner join [Customer] 
  on [Customer].CustomerId = [Order].CustomerId
where (select 
         top 1 [OrderStatus].StatusId 
       from 
         [OrderStatus]
       where 
         [OrderStatus].OrderId = [order].OrderId
       order by 
         [OrderStatus].StatusDate DESC
         ,[OrderStatus].StatusId DESC) = 3
group by 
  [Order].OrderNumber
  ,[Customer].CustomerName
having 
  sum([Product].Price * [OrderProduct].Quantity) > 1000000

Статистика выполнения:

(7 row(s) affected)

 SQL Server Execution Times:
   CPU time = 67034 ms,  elapsed time = 68534 ms.

Посмотрев план, обнаруживаем, что самая затратная операция — поиск статуса заказа через короткий SELECT.

Перепишем запрос через вложенные запросы.

select 
  [Order].OrderNumber
  ,[Customer].CustomerName
  ,sum([Product].Price * [OrderProduct].Quantity) 
from 
  [Order]
inner join [OrderProduct] 
  on [order].OrderId = [OrderProduct].OrderId
inner join [Product] 
  on [Product].ProductId = [OrderProduct].ProductId
inner join [Customer] 
  on [Customer].CustomerId = [Order].CustomerId
inner join (select 
              max([OrderStatus].StatusDate) as MaxStatusDate
              ,OrderId
            from 
              [OrderStatus] 
            group by 
              OrderId) as OrderStatusDate
  on [order].OrderId = [OrderStatusDate].OrderId
inner join [OrderStatus] 
  on [OrderStatus].OrderId = [OrderStatusDate].OrderId 
  and [OrderStatusDate].MaxStatusDate = [OrderStatus].StatusDate
where 
  [OrderStatus].StatusId = 3
group by 
  [Order].OrderNumber
  ,[Customer].CustomerName
having 
  sum([Product].Price * [OrderProduct].Quantity) > 1000000

Результат:

(7 row(s) affected)

 SQL Server Execution Times:
   CPU time = 23197 ms,  elapsed time = 24001 ms.

Отбор по статусу теперь является менее затратной операцией, чем все остальное. Чтобы убедится в этом, удалим из запроса выборку статуса заказа:

select 
  [Order].OrderNumber
  ,[Customer].CustomerName
  ,sum([Product].Price * [OrderProduct].Quantity) 
from 
  [Order]
inner join [OrderProduct] 
  on [order].OrderId = [OrderProduct].OrderId
inner join [Product] 
  on [Product].ProductId = [OrderProduct].ProductId
inner join [Customer] 
  on [Customer].CustomerId = [Order].CustomerId
group by 
  [Order].OrderNumber
  ,[Customer].CustomerName
having 
  sum([Product].Price * [OrderProduct].Quantity) > 1000000

Результат:

SQL Server Execution Times:
   CPU time = 17160 ms,  elapsed time = 17743 ms.

Т. е. определение статуса занимает порядка 20 % запроса. О чем и свидетельствует план:

Основное время уходит на расчет стоимости заказа: поиск всех элементов и умножение на цену.

Если бы общая стоимость заказа хранилась непосредственно в Order, то запрос был бы оптимальнее?

Проведем эксперимент: добавим стоимость заказа в таблицу Order.

alter table [Order] add
  TotalCost numeric(18,2)
Go

Update [Order] 
Set TotalCost = (
    select 
      sum([Product].Price * [OrderProduct].Quantity) 
    from 
      [OrderProduct]
    inner join [Product]
      on [Product].ProductId = [OrderProduct].ProductId    
    where 
      [Order].OrderId = [OrderProduct].OrderId
    )

Перепишем запрос:

select 
  [Order].OrderNumber
  ,[Customer].CustomerName
  ,sum([Order].TotalCost)
from 
  [Order]
inner join [Customer] 
  on [Customer].CustomerId = [Order].CustomerId
inner join (select 
              max([OrderStatus].StatusDate) as MaxStatusDate
              ,OrderId
            from 
              [OrderStatus] 
            group by 
              OrderId) as OrderStatusDate
  on [order].OrderId = [OrderStatusDate].OrderId
inner join [OrderStatus]
  on [OrderStatus].OrderId = [OrderStatusDate].OrderId 
  and [OrderStatusDate].MaxStatusDate = [OrderStatus].StatusDate
where 
  [OrderStatus].StatusId = 3
group by 
  [Order].OrderNumber
  ,[Customer].CustomerName
having 
  sum([Order].TotalCost) > 1000000

Результат:

(7 row(s) affected)

 SQL Server Execution Times:
   CPU time = 20467 ms,  elapsed time = 21066 ms.

Как видим, производительности особо не прибавилось. Основное время по прежнему занимает группировка. Попробуем группировать не по имени клиента, а по Id:

select 
  [Order].OrderNumber
  ,[Order].CustomerId
  ,sum([Product].Price * [OrderProduct].Quantity) 
from 
  [Order]
inner join [OrderProduct]
  on [order].OrderId = [OrderProduct].OrderId
inner join [Product]
  on [Product].ProductId = [OrderProduct].ProductId
inner join (select 
              max([OrderStatus].StatusDate) as MaxStatusDate
              ,OrderId             
            from 
              [OrderStatus] 
            group by 
              OrderId) as OrderStatusDate
  on [order].OrderId = [OrderStatusDate].OrderId
inner join [OrderStatus]
  on [OrderStatus].OrderId = [OrderStatusDate].OrderId 
  and [OrderStatusDate].MaxStatusDate = [OrderStatus].StatusDate
where 
  [OrderStatus].StatusId = 3
group by 
  [Order].OrderNumber
  ,[Order].CustomerId
having 
  sum([Product].Price * [OrderProduct].Quantity) > 1000000

(7 row(s) affected)

 SQL Server Execution Times:
   CPU time = 17893 ms,  elapsed time = 22562 ms.

Добавим индекс по номеру заказа и коду клиента:

 SQL Server Execution Times:
   CPU time = 13276 ms,  elapsed time = 13876 ms.

Эффект появился.
Поменяем местами поля в индексе:

CREATE NONCLUSTERED INDEX IDX_OrderNumber
ON [Order] (CustomerId asc, OrderNumber Asc)
Go

 SQL Server Execution Times:
   CPU time = 10577 ms,  elapsed time = 11159 ms.

Эффект усилился.

Для получения наименования поставщика обернем все во внешний запрос:

select 
  CustomerName
  ,OrderNumber
  ,TotalCost
from 
  [Customer]
    inner join
    (
    select 
      [Order].OrderNumber
      ,[Order].CustomerId
      ,sum([Product].Price * [OrderProduct].Quantity) as TotalCost 
    from 
      [Order]
    inner join [OrderProduct]
        on [order].OrderId = [OrderProduct].OrderId
    inner join [Product]
        on [Product].ProductId = [OrderProduct].ProductId
    inner join (select 
                  max([OrderStatus].StatusDate) as MaxStatusDate
                  ,OrderId 
                from 
                  [OrderStatus] 
                group by 
                  OrderId) as OrderStatusDate
        on [order].OrderId = [OrderStatusDate].OrderId
    inner join [OrderStatus]
       on [OrderStatus].OrderId = [OrderStatusDate].OrderId 
       and [OrderStatusDate].MaxStatusDate = [OrderStatus].StatusDate
    where 
      [OrderStatus].StatusId = 3
    group by 
      [Order].OrderNumber
      ,[Order].CustomerId
    having 
      sum([Product].Price * [OrderProduct].Quantity) > 1000000
    ) as OrderCost
      on OrderCost.CustomerId = [Customer].CustomerId

 SQL Server Execution Times:
   CPU time = 10780 ms,  elapsed time = 11616 ms.

Таким образом, исходный запрос был оптимизирован с 67 секунд до 10 секунд.

9. Оптимизация БД

9.1. Обслуживание статистики индексов

Статистика индексов — ключевой момент для оптимизатора запроса в выборе плана выполнения.

Если статистика устареет и не будет отражать реальную картину с данными в таблице, то выбор индекса может быть неверен. Чтобы быть полезной, статистика должна содержать текущие реальные данные.

SQL Server позволяет обновлять статистику автоматически, без привлечения дополнительных усилий со стороны DBA. Проверить, установлен ли флаг автоматического обновления статистики, можно командой:

SELECT DATABASEPROPERTYEX('<dbname>','IsAutoUpdateStatistics')

Если результат равен 1 — опция автоматического обновления статистики включена. Установить опцию можно командой:

ALTER DATABASE dbname SET AUTO_UPDATE_STATISTICS ON

А выключить:

ALTER DATABASE dbname SET AUTO_UPDATE_STATISTICS OFF

В большинстве случаев рекомендуется оставлять эту функцию включенной. В этом случает SQL Server будет сам обновлять статистику, когда посчитает ее устаревшей. Алгоритм обновления полностью определяется SQL Server и зависит от количества обновлений, удалений и добавлений записей в таблицу. Если таблица имеет один миллион записей, и только 100 из них были изменены (0.01 %), вряд ли имеет смысл обновлять статистику, поскольку такие изменения в таблице вряд ли драматически поменяли общую картину данных.

Кроме того, если размер таблицы более 8 МБ (1 000 страниц), SQL Server не будет использовать все данные для вычисления статистики. Все эти ограничения разработаны для того, чтобы работа со обновлением статистики наносила как можно меньший удар на быстродействие сервера.

Если автоматическое обновление все же будет отключено, то необходимо обновлять статистику вручную. Эта операция для любого индекса может быть выполнена командой:

UPDATE STATISTICS [Order] [IDX_OrderNumber]

9.2. Дефрагментация БД

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

В SQL Server выделяют два типа фрагментации: внутренняя и внешняя.
Внутренняя подразумевает пустоты внутри страницы. Внешняя — непоследовательность связей страниц.

Если страницы не полностью заполнены данными, это приводит к дополнительным операциям I/O и «повышенному расходу» оперативной памяти, т. к. страницы в оперативной памяти есть зеркальное отражение страниц на диске.

В SQL Server есть несколько путей преодоления внутренней фрагментации. Один из этих методов состоит в том, чтобы использовать команду DBCC REINDEX для перестройки кластеризованных и некластеризованных индексов. После перестройки индексов страницы данных становятся логически непрерывными, и дисковый ввод/вывод минимизирован. К сожалению, внутренняя фрагментация — это только лишь часть проблемы фрагментации: выполнение DBCC REINDEX не сказывается на внешней фрагментации.

Внешняя фрагментация — это фрагментация физических файлов на дисках вашего сервера, которая может вызвать такое же большое количество ненужных операций ввода/вывода, как и внутренняя, если не больше. Ненужные операции ввода-вывода, приводят к снижению производительности SQL Server.

Для устранения внешней фрагментации используются специализированные утилиты.

10. Резюме

  • Стратегии оптимизации запросов: можно использовать индексы, другие варианты запроса, сохранение промежуточных результатов.
  • Выбор варианта оптимизации зависит от конкретного случая. При этом могут быть использованы сразу несколько подходов.
  • Не все индексы одинаково полезны. При разработке индексов необходимо учитывать их селективность.
  • Для проверки быстродействия запроса:
    SET STATISTICS TIME ON
    GO
  • Для проверки статистики ввода/вывода:
    SET STATISTICS IO ON
    GO
  • Для вывода плана запроса:
    SET STATISTICS XML ON
    GO

11. Источники

technet.microsoft.com/ru-ru/magazine/2007.11.sqlquery.aspx

www.lektorium.tv/lecture/?id=14561

В первой части статьи «Оптимизация SQL-запросов (Часть 1)» рассматриваются особенности запросов на выборку данных, виды индексов, использование планов запросов, различные подходы к оптимизации запросов.