Постановка задачи

Рассмотрим достаточно распространенную ситуацию. Имеется огромная таблица примерно следующей структуры:

CREATE TABLE SomeOperations (
  OperationId bigint NOT NULL,
  OperDate datetime NOT NULL,
  SomeDataMultiplyColumns nvarchar(max)
)

Нам интересно только то, что уникальный ключ записей в таблице - OperationId и каждая операция имеет дату и, возможно, время. Ворох остальных полей в таблице я условно объединил в одно поле SomeDataMultiplyColumns. Так же будем считать, что данные в таблицу попадают совсем не обязательно по возрастанию OperDate и вполне могут добавляться задним числом.

Когда такая таблица разрастается, возникает вполне резонное желание разбить её на секции (разделы, partitions). Разбивать по OperationId можно, но очень не удобно. Намного чаще такая таблица разбивается по дате операции, например, по годам. То есть, секционирование выглядит, примерно так (создание файловых групп и файлов опущено):

CREATE PARTITION FUNCTION PF_SomeOperations_OperDate (datetime)  
AS RANGE RIGHT FOR VALUES (
  '2023-01-01', '2023-02-01', '2023-03-01', '2023-04-01',  
  '2023-05-01', '2023-06-01', '2023-07-01', '2023-08-01',   
  '2023-09-01', '2023-10-01', '2023-11-01', '2023-12-01')
GO
CREATE PARTITION SCHEME PS_SomeOperations
AS PARTITION PF_SomeOperations_OperDate TO (
  SomeOperations_FG_ARH,
  SomeOperations_FG01, SomeOperations_FG02, SomeOperations_FG03,
  SomeOperations_FG04, SomeOperations_FG05, SomeOperations_FG06,
  SomeOperations_FG07, SomeOperations_FG08, SomeOperations_FG09,
  SomeOperations_FG10, SomeOperations_FG11, SomeOperations_FG12)
GO
ALTER TABLE SomeOperations
  ADD CONSTRAINT PK_SomeOperations PRIMARY KEY CLUSTERED (
	OperationId ASC, OperDate ASC
) ON PS_SomeOperations(OperDate)
GO

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

Описание проблемы

Если бы изначально архитектура БД предусматривала секционирование таблицы SomeOperations, то все ссылки на эту таблицу содержали бы не только OperationId, но и OperDate. Но так как об этом не подумали вовремя, то это сделано не было. В результате имеем множество ссылок на OperationId без OperDate в целом ряде таблиц.

Можно производить глобальный рефакторинг БД. И даже, возможно, нужно. Но это долго и не всегда имеет смысл.

Нет ничего более постоянного, чем временное

При соединении (JOIN) с таблицей SomeOperations по OperationId миллион искомых строк выбирался и заливался во временную таблицу, примерно, 100 секунд на 8 ядрах (OPTION(MAXDOP 8)).

При соединении (JOIN) с таблицей SomeOperations по OperationId и OperDate миллион искомых строк выбирался и заливался во временную таблицу, примерно, 5 секунд на 8 ядрах. Разница очень существенная. Но откуда взять OperDate?

И тут мы вспоминаем о том, что MS SQL поддерживает COLUMNSTORE индексы. Строго говоря, это не совсем индекс, а колоночное хранилище, автоматически обновляемое при добавлении или модификации записей в связанной таблице. Создаем такой индекс:

CREATE NONCLUSTERED COLUMNSTORE INDEX
  IXCS_SomeOperations_OperationId_OperDate
  ON SomeOperations(OperationId, OperDate)
  ON PS_SomeOperations(OperDate)

Теперь сначала ищем по нему OperDate для каждого OperationId и сохраняем результат во временную таблицу #OperationIds. В примере считается, что временная таблица #OpIds уже содержит миллион OperationId, которые нужно найти.

CREATE TABLE #OperationIds(
  OperationID bigint,
  OperDate    datetime)
INSERT #OperationIds (CarOperationID, OperDate)
SELECT I.OperationId, O.OperDate
FROM #OpIds AS I
JOIN SomeOperations AS O ON O.OperationId=I.OperationId
OPTION(MAXDOP 8)

Такой поиск по COLUMNSTORE индексу занял всего лишь 5 секунд. При том что в таблице SomeOperations полтора миллиарда строк.

Итого, вместо 100 секунд, разбив запрос на два (поиск по COLUMNSTORE за 5 секунд и выборка уже по результатам этого поиска за 5 секунд), получили выигрыш в производительности на порядок.

Комментарии (3)


  1. akakoychenko
    22.09.2023 09:31

    Ох уж эти костыли, которые натирают при езде на велосипеде...

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

    Ну и да, раз уже ставка на колоночный индекс, то чего не идти до конца, и не заложиться на сильную сторону колоночного хранения - независимость производительности выборки N колонок от общего количества колонок в таблице? Зачем городить франкенштейна?


    1. ptr128 Автор
      22.09.2023 09:31

      Зачем городить франкенштейна?

      Я ответил на этот вопрос в статье:

      Можно производить глобальный рефакторинг БД. И даже, возможно, нужно. Но это долго и не всегда имеет смысл.

      Что касается

      при поиске всего 1 значения, фуллскан есть фуллскан

      Это Вы зря.

      CREATE TABLE #OperationIds(
        OperationID bigint,
        OperDate    datetime)
      INSERT #OperationIds (CarOperationID, OperDate)
      SELECT I.OperationId, O.OperDate
      FROM #OpIds AS I
      JOIN SomeOperations AS O ON O.OperationId=I.OperationId
      OPTION(MAXDOP 8)
      
      SQL Server parse and compile time: 
         CPU time = 0 ms, elapsed time = 25 ms.
      Table 'SomeOperations'. Scan count 29, logical reads 9386, physical reads 0, read-ahead reads 0, lob logical reads 1426907, lob physical reads 34, lob read-ahead reads 5443492.
      Table 'SomeOperations'. Segment reads 1239, segment skipped 0.
      Table '#OpIds___________________________________________________________________________________________________________000000181C56'. Scan count 9, logical reads 1839, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
      Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
      
       SQL Server Execution Times:
         CPU time = 14265 ms,  elapsed time = 3351 ms.
      
      (1141051 rows affected)

      А так:

      CREATE TABLE #OperationIds(
        OperationID bigint,
        OperDate    datetime)
      INSERT #OperationIds (CarOperationID, OperDate)
      SELECT TOP 1 I.OperationId, O.OperDate
      FROM #OpIds AS I
      JOIN SomeOperations AS O ON O.OperationId=I.OperationId
      ORDER BY I.OperationId DESC
      OPTION(MAXDOP 8)
      
      SQL Server parse and compile time: 
         CPU time = 0 ms, elapsed time = 9 ms.
      Table '#OperationIds____________________________________________________________________________________________________000000182ADE'. Scan count 0, logical reads 1, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
      Table 'SomeOperations'. Scan count 14, logical reads 56, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
      Table '#OpIds___________________________________________________________________________________________________________000000181C56'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
      
       SQL Server Execution Times:
         CPU time = 0 ms,  elapsed time = 1 ms.
      
      (1 row affected)
      

      То есть, планировщик запросов MS SQL далеко не так туп, как Вы думаете. И использует COLUMNSTORE индекс только когда это оправдано.

      производительность будет деградировать с ростом базы

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


    1. BeceJlb4ak
      22.09.2023 09:31

      Не могли бы вы более подробно описать ваше решение