Database
 sql >> база данни >  >> RDS >> Database

Може ли ColumnStore да помогне за работните натоварвания с пагинация?

Преди почти година до този ден публикувах моето решение за пагинация в SQL Server, което включваше използване на CTE за намиране само на ключовите стойности за въпросния набор от редове и след това присъединяване обратно от CTE към таблицата източник за извличане другите колони само за тази "страница" от редове. Това се оказа най-полезно, когато имаше тесен индекс, който поддържаше подреждането, поискано от потребителя, или когато подреждането се основаваше на ключа за клъстериране, но дори се представяше малко по-добре без индекс, който да поддържа необходимото сортиране.

Оттогава се чудех дали индексите на ColumnStore (както клъстерни, така и неклъстерни) могат да помогнат на някой от тези сценарии. TL;DR :Въз основа на този експеримент в изолация, отговорът на заглавието на тази публикация е категорично НЕ . Ако не искате да видите настройката на теста, кода, плановете за изпълнение или графиките, не се колебайте да прескочите към моето резюме, като имате предвид, че анализът ми се основава на много специфичен случай на употреба.

Настройка

На нова виртуална машина с инсталиран SQL Server 2016 CTP 3.2 (13.0.900.73), преминах през приблизително същата настройка както преди, само че този път с три таблици. Първо, традиционна таблица с тесен ключ за клъстериране и множество поддържащи индекси:

CREATE TABLE [dbo].[Customers]
(
	[CustomerID] [int] NOT NULL,
	[FirstName] [nvarchar](64) NOT NULL,
	[LastName] [nvarchar](64) NOT NULL,
	[EMail] [nvarchar](320) NOT NULL UNIQUE,
	[Active] [bit] NOT NULL DEFAULT 1,
	[Created] [datetime] NOT NULL DEFAULT SYSDATETIME(),
	[Updated] [datetime] NULL,
  CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED ([CustomerID])
);
 
CREATE NONCLUSTERED INDEX [Active_Customers] 
ON [dbo].[Customers]([FirstName],[LastName],[EMail])
WHERE ([Active]=1);
 
-- to support "PhoneBook" sorting (order by Last,First)
CREATE NONCLUSTERED INDEX [PhoneBook_Customers] 
ON [dbo].[Customers]([LastName],[FirstName])
INCLUDE ([EMail]);

След това таблица с клъстериран индекс на ColumnStore:

CREATE TABLE [dbo].[Customers_CCI]
(
	[CustomerID] [int] NOT NULL,
	[FirstName] [nvarchar](64) NOT NULL,
	[LastName] [nvarchar](64) NOT NULL,
	[EMail] [nvarchar](320) NOT NULL UNIQUE,
	[Active] [bit] NOT NULL DEFAULT 1,
	[Created] [datetime] NOT NULL DEFAULT SYSDATETIME(),
	[Updated] [datetime] NULL,
  CONSTRAINT [PK_CustomersCCI] PRIMARY KEY NONCLUSTERED ([CustomerID])
);
 
CREATE CLUSTERED COLUMNSTORE INDEX [Customers_CCI] 
ON [dbo].[Customers_CCI];

И накрая, таблица с неклъстериран индекс на ColumnStore, покриващ всички колони:

CREATE TABLE [dbo].[Customers_NCCI]
(
	[CustomerID] [int] NOT NULL,
	[FirstName] [nvarchar](64) NOT NULL,
	[LastName] [nvarchar](64) NOT NULL,
	[EMail] [nvarchar](320) NOT NULL UNIQUE,
	[Active] [bit] NOT NULL DEFAULT 1,
	[Created] [datetime] NOT NULL DEFAULT SYSDATETIME(),
	[Updated] [datetime] NULL,
  CONSTRAINT [PK_CustomersNCCI] PRIMARY KEY CLUSTERED 
  ([CustomerID])
);
 
CREATE NONCLUSTERED COLUMNSTORE INDEX [Customers_NCCI] 
ON [dbo].[Customers_NCCI]
(
        [CustomerID],
	[FirstName],
	[LastName],
	[EMail],
	[Active],
        [Created],
        [Updated]
);

Забележете, че и за двете таблици с индекси на ColumnStore пропуснах индекса, който би поддържал по-бързо търсене при сортиране „Телефонна книга“ (фамилия, собствено име).

Тестови данни

След това попълних първата таблица с 1 000 000 произволни реда, въз основа на скрипт, който използвах повторно от предишни публикации:

INSERT dbo.Customers WITH (TABLOCKX) 
  (CustomerID, FirstName, LastName, EMail, [Active])
SELECT rn = ROW_NUMBER() OVER (ORDER BY n), fn, ln, em, a
FROM 
(
  SELECT TOP (1000000) fn, ln, em, a = MAX(a), n = MAX(NEWID())
  FROM
  (
    SELECT fn, ln, em, a, r = ROW_NUMBER() OVER (PARTITION BY em ORDER BY em)
    FROM
    (
      SELECT TOP (2000000)
        fn = LEFT(o.name, 64), 
        ln = LEFT(c.name, 64), 
        em = LEFT(o.name, LEN(c.name)%5+1) + '.' 
             + LEFT(c.name, LEN(o.name)%5+2) + '@' 
             + RIGHT(c.name, LEN(o.name+c.name)%12 + 1) 
             + LEFT(RTRIM(CHECKSUM(NEWID())),3) + '.com', 
        a  = CASE WHEN c.name LIKE '%y%' THEN 0 ELSE 1 END
      FROM sys.all_objects AS o CROSS JOIN sys.all_columns AS c 
      ORDER BY NEWID()
    ) AS x
  ) AS y WHERE r = 1 
  GROUP BY fn, ln, em 
  ORDER BY n
) AS z 
ORDER BY rn;

След това използвах тази таблица, за да попълня другите две с точно същите данни и възстанових всички индекси:

INSERT dbo.Customers_CCI WITH (TABLOCKX)
  (CustomerID, FirstName, LastName, EMail, [Active])
SELECT CustomerID, FirstName, LastName, EMail, [Active]
FROM dbo.Customers;
 
INSERT dbo.Customers_NCCI WITH (TABLOCKX)
  (CustomerID, FirstName, LastName, EMail, [Active])
SELECT CustomerID, FirstName, LastName, EMail, [Active]
FROM dbo.Customers;
 
ALTER INDEX ALL ON dbo.Customers      REBUILD;
ALTER INDEX ALL ON dbo.Customers_CCI  REBUILD;
ALTER INDEX ALL ON dbo.Customers_NCCI REBUILD;

Общият размер на всяка таблица:

Таблица Запазено Данни Индекс
Клиенти 463 200 KB 154 344 KB 308 576 KB
Клиенти_CCI 117 280 KB 30 288 KB 86 536 KB
Клиенти_NCCI 349 480 KB 154 344 KB 194 976 KB


И броят на редовете/броят на страниците на съответните индекси (уникалният индекс на електронната поща беше повече за мен, за да гледам собствения си скрипт за генериране на данни, отколкото всичко друго):

Таблица Индекс Редове Страници
Клиенти PK_Customers 1 000 000 19 377
Клиенти Клиенти на телефонния указател 1 000 000 17 209
Клиенти Активни_клиенти 808 012 13 977
Клиенти_CCI PK_CustomersCCI 1 000 000 2737
Клиенти_CCI Клиенти_CCI 1 000 000 3826
Клиенти_NCCI PK_CustomersNCCI 1 000 000 19 377
Клиенти_NCCI Клиенти_NCCI 1 000 000 16 971

Процедури

След това, за да видя дали индексите на ColumnStore ще нахлуят и ще направят някой от сценариите по-добър, изпълних същия набор от заявки, както преди, но сега срещу всичките три таблици. Станах поне малко по-умен и направих две съхранени процедури с динамичен SQL, за да приема източника на таблицата и реда на сортиране. (Добре съм наясно с SQL инжектирането; това не бих направил в производството, ако тези низове идват от краен потребител, така че, моля, не го приемайте като препоръка да го правите. Доверявам се достатъчно в моя затворена среда, че това не е проблем за тези тестове.)

CREATE PROCEDURE dbo.P_Old
  @PageNumber  INT = 1,
  @PageSize    INT = 100,
  @Table       SYSNAME,
  @Sort        VARCHAR(32)
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @sql NVARCHAR(MAX) = N'
 
  SELECT CustomerID, FirstName, LastName,
      EMail, Active, Created, Updated
    FROM dbo.' + QUOTENAME(@Table) + N'
    ORDER BY ' + CASE @Sort 
	  WHEN 'Key'         THEN N'CustomerID'
	  WHEN 'PhoneBook'   THEN N'LastName, FirstName'
	  WHEN 'Unsupported' THEN N'FirstName DESC, EMail'
	END
	+ N'
    OFFSET @PageSize * (@PageNumber - 1) ROWS
    FETCH NEXT @PageSize ROWS ONLY OPTION (RECOMPILE);';
 
  EXEC sys.sp_executesql @sql, N'@PageSize INT, @PageNumber INT', @PageSize, @PageNumber;
END
GO
 
CREATE PROCEDURE dbo.P_CTE
  @PageNumber  INT = 1,
  @PageSize    INT = 100,
  @Table       SYSNAME,
  @Sort        VARCHAR(32)
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @sql NVARCHAR(MAX) = N'
 
  ;WITH pg AS
  (
    SELECT CustomerID
      FROM dbo.' + QUOTENAME(@Table) + N'
      ORDER BY ' + CASE @Sort 
	  WHEN 'Key'         THEN N'CustomerID'
	  WHEN 'PhoneBook'   THEN N'LastName, FirstName'
	  WHEN 'Unsupported' THEN N'FirstName DESC, EMail'
	END
	+ N' OFFSET @PageSize * (@PageNumber - 1) ROWS
      FETCH NEXT @PageSize ROWS ONLY
  )
  SELECT c.CustomerID, c.FirstName, c.LastName,
      c.EMail, c.Active, c.Created, c.Updated
  FROM dbo.' + QUOTENAME(@Table) + N' AS c
  WHERE EXISTS (SELECT 1 FROM pg WHERE pg.CustomerID = c.CustomerID)
  ORDER BY ' + CASE @Sort 
	  WHEN 'Key'         THEN N'CustomerID'
	  WHEN 'PhoneBook'   THEN N'LastName, FirstName'
	  WHEN 'Unsupported' THEN N'FirstName DESC, EMail'
	END
	+ N' OPTION (RECOMPILE);';
 
  EXEC sys.sp_executesql @sql, N'@PageSize INT, @PageNumber INT', @PageSize, @PageNumber;
END
GO

След това приготвих малко по-динамичен SQL, за да генерирам всички комбинации от повиквания, които ще трябва да направя, за да извикам както старите, така и новите съхранени процедури, в трите желани реда за сортиране и при различни номера на страници (за да симулирам нужда от страница близо до началото, средата и края на реда за сортиране). За да мога да копирам PRINT изведете и го поставете в SQL Sentry Plan Explorer, за да получите показатели по време на изпълнение, изпълних тази партида два пъти, веднъж с procedures CTE с помощта на P_Old , а след това отново с помощта на P_CTE .

DECLARE @sql NVARCHAR(MAX) = N'';
 
;WITH [tables](name) AS 
(
  SELECT N'Customers' UNION ALL SELECT N'Customers_CCI' 
  UNION ALL SELECT N'Customers_NCCI'
),
sorts(sort) AS
(
  SELECT 'Key' UNION ALL SELECT 'PhoneBook' UNION ALL SELECT 'Unsupported'
),
pages(pagenumber) AS
(
  SELECT 1 UNION ALL SELECT 500 UNION ALL SELECT 5000 UNION ALL SELECT 9999
),
procedures(name) AS
(
  SELECT N'P_CTE' -- N'P_Old'
)
SELECT @sql += N'
  EXEC dbo.' + p.name
  + N' @Table = N' + CHAR(39) + t.name
  + CHAR(39) + N', @Sort = N' + CHAR(39)
  + s.sort + CHAR(39) + N', @PageNumber = ' 
  + CONVERT(NVARCHAR(11), pg.pagenumber) + N';'
FROM tables AS t
  CROSS JOIN sorts AS s
  CROSS JOIN pages AS pg
  CROSS JOIN procedures AS p
  ORDER BY t.name, s.sort, pg.pagenumber;
 
PRINT @sql;

Това доведе до изход като този (36 извиквания общо за стария метод (P_Old ) и 36 извиквания за новия метод (P_CTE )):

  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Key', @PageNumber = 1;
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Key', @PageNumber = 500;
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Key', @PageNumber = 5000;
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Key', @PageNumber = 9999;
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'PhoneBook', @PageNumber = 1;
  ...
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'PhoneBook', @PageNumber = 9999;
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Unsupported', @PageNumber = 1;
  ...
  EXEC dbo.P_CTE @Table = N'Customers', @Sort = N'Unsupported', @PageNumber = 9999;
  EXEC dbo.P_CTE @Table = N'Customers_CCI', @Sort = N'Key', @PageNumber = 1;
  ...
  EXEC dbo.P_CTE @Table = N'Customers_CCI', @Sort = N'Unsupported', @PageNumber = 9999;
  EXEC dbo.P_CTE @Table = N'Customers_NCCI', @Sort = N'Key', @PageNumber = 1;
  ...
  EXEC dbo.P_CTE @Table = N'Customers_NCCI', @Sort = N'Unsupported', @PageNumber = 9999;

Знам, всичко това е много тромаво; скоро ще стигнем до финала, обещавам.

Резултати

Взех тези два комплекта от 36 изявления и започнах две нови сесии в Plan Explorer, като стартирах всеки набор няколко пъти, за да гарантирам, че получаваме данни от топъл кеш и вземаме средни стойности (мога да сравня и студен и топъл кеш, но мисля, че има достатъчно променливи тук).

Мога да ви кажа веднага няколко прости факта, без дори да ви показвам подкрепящи графики или планове:

  • В никакъв сценарий „старият“ метод не надмина новия метод на CTE Повиших в предишната си публикация, без значение какъв тип индекси присъстваха. Така че това улеснява практически игнорирането на половината от резултатите, поне по отношение на продължителността (което е единственият показател, за който крайните потребители се интересуват най-много).
  • Нито един индекс на ColumnStore не се представи добре при прелистване към края на резултата – дадоха ползи само в началото и то само в няколко случая.
  • При сортиране по първичен ключ (клъстерирани или не), наличието на индекси на ColumnStore не помогна – отново по отношение на продължителност.

Като изключим тези обобщения, нека да разгледаме няколко напречни сечения на данните за продължителността. Първо, резултатите от заявката, подредени по низходящо име, след това по имейл, без надежда за използване на съществуващ индекс за сортиране. Както можете да видите в диаграмата, производителността беше непостоянна – при по-ниски номера на страници, неклъстерираният ColumnStore се справи най-добре; при по-голям брой страници традиционният индекс винаги печелеше:

Продължителност (милисекунди) за различни номера на страници и различни типове индекси

И след това трите плана, представляващи трите различни типа индекси (с добавена скала на сивото от Photoshop, за да се подчертаят основните разлики между плановете):

План за традиционен индекс

Планирайте за клъстериран индекс на ColumnStore

План за неклъстериран индекс на ColumnStore

Сценарий, който ме интересуваше повече, дори преди да започна да тествам, беше подходът за сортиране на телефонния указател (фамилия, собствено име). В този случай индексите на ColumnStore всъщност бяха доста пагубни за производителността на резултата:

Плановете на ColumnStore тук са близо до огледални изображения на двата плана ColumnStore, показани по-горе за неподдържаното сортиране. Причината е една и съща и в двата случая:скъпи сканирания или сортиране поради липса на поддържащ сортиране индекс.

След това създадох поддържащи индекси „PhoneBook“ на таблиците с индексите на ColumnStore, за да видя дали мога да уговоря различен план и/или по-бързо време за изпълнение в някой от тези сценарии. Създадох тези два индекса, след което възстанових отново:

CREATE NONCLUSTERED INDEX [PhoneBook_CustomersCCI] 
ON [dbo].[Customers_CCI]([LastName],[FirstName])
INCLUDE ([EMail]);
 
ALTER INDEX ALL ON dbo.Customers_CCI  REBUILD;
 
CREATE NONCLUSTERED INDEX [PhoneBook_CustomersNCCI] 
ON [dbo].[Customers_NCCI]([LastName],[FirstName])
INCLUDE ([EMail]);
 
ALTER INDEX ALL ON dbo.Customers_NCCI REBUILD;

Ето новите продължителности:

Най-интересното тук е, че сега заявката за пейджинг към таблицата с неклъстерирания индекс на ColumnStore изглежда върви в крак с традиционния индекс, докато не излезем отвъд средата на таблицата. Разглеждайки плановете, можем да видим, че на страница 5000 се използва традиционно сканиране на индекса, а индексът ColumnStore е напълно игнориран:

Планът за телефонен указател пренебрегвайки неклъстерирания индекс на ColumnStore

Но някъде между средата от 5000 страници и „края“ на таблицата при 9999 страници, оптимизаторът е достигнал един вид преломна точка и – за точно същата заявка – сега избира да сканира неклъстерирания индекс на ColumnStore :

Съвети за планиране на телефонния указател и използва индекса на ColumnStore

Това се оказва не толкова добро решение от оптимизатора, главно поради цената на операцията по сортиране. Можете да видите колко по-добра става продължителността, ако намекнете за обикновения индекс:

-- ...
;WITH pg AS
  (
    SELECT CustomerID
      FROM dbo.[Customers_NCCI] WITH (INDEX(PhoneBook_CustomersNCCI)) -- hint here
      ORDER BY LastName, FirstName OFFSET @PageSize * (@PageNumber - 1) ROWS
      FETCH NEXT @PageSize ROWS ONLY
  )
-- ...

Това дава следния план, почти идентичен с първия план по-горе (обаче малко по-висока цена за сканирането, просто защото има повече изход):

План за телефонен указател с намекнат индекс

Можете да постигнете същото, като използвате OPTION (IGNORE_NONCLUSTERED_COLUMNSTORE_INDEX) вместо изричния намек за индекс. Само имайте предвид, че това е същото като да нямате индекса ColumnStore там на първо място.

Заключение

Въпреки че има няколко крайни случая по-горе, при които индексът на ColumnStore може (едва) да се изплати, не ми се струва, че те са подходящи за този специфичен сценарий за пагинация. Мисля, че най-важното е, че докато ColumnStore демонстрира значителни спестявания на място поради компресията, производителността по време на изпълнение не е фантастична поради изискванията за сортиране (въпреки че тези сортове се оценяват да работят в пакетен режим, нова оптимизация за SQL Server 2016).

Като цяло, това може да стане с много повече време, прекарано в изследвания и тестване; като заменя предишни статии, исках да променя възможно най-малко. Бих искал да намеря тази повратна точка, например, и също така бих искал да призная, че това не са точно мащабни тестове (поради размера на VM и ограниченията на паметта) и че ви оставих да гадаете за много показателите по време на изпълнение (най-вече за краткост, но не знам, че диаграма на четения, които не винаги са пропорционални на продължителността, наистина ще ви каже). Тези тестове също така предполагат лукса на SSD дисковете, достатъчно памет, винаги топъл кеш и среда за един потребител. Наистина бих искал да извърша по-голяма батерия от тестове срещу повече данни, на по-големи сървъри с по-бавни дискове и екземпляри с по-малко памет, през цялото време със симулиран паралелизъм.

Въпреки това, това може да бъде и просто сценарий, който ColumnStore не е предназначен да помогне за решаването на първо място, тъй като основното решение с традиционните индекси вече е доста ефективно при изтеглянето на тесен набор от редове – не точно рулевата рубка на ColumnStore. Може би друга променлива, която трябва да се добави към матрицата, е размерът на страницата – всички тестове по-горе изтеглят по 100 реда наведнъж, но какво ще стане, ако сме след 10 000 или 100 000 реда наведнъж, независимо колко голяма е основната таблица?

Имате ли ситуация, при която вашето OLTP натоварване е било подобрено просто чрез добавяне на индекси на ColumnStore? Знам, че те са предназначени за работни натоварвания в стил хранилище за данни, но ако сте виждали предимства на друго място, бих искал да чуя за вашия сценарий и да видя дали мога да включа някакви отличителни черти в моята тестова платформа.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Изявление на SQL SELECT

  2. Разбиране на анализа на големи данни

  3. Множество планове за идентична заявка

  4. SQL ОГРАНИЧЕНИЯ

  5. Как CTE може да помогне при писане на сложни, мощни заявки:Перспектива за производителност