Database
 sql >> Base de Dados >  >> RDS >> Database

Hekaton com uma reviravolta:TVPs na memória – Parte 2


Em minha última postagem, demonstrei que em pequenos volumes, um TVP com otimização de memória pode oferecer benefícios substanciais de desempenho para padrões de consulta típicos.

Para testar em uma escala um pouco maior, fiz uma cópia do SalesOrderDetailEnlarged table, que eu havia expandido para aproximadamente 5.000.000 de linhas graças a este script de Jonathan Kehayias (blog | @SQLPoolBoy)).
DROP TABLE dbo.SalesOrderDetailEnlarged;
GO
 
SELECT * INTO dbo.SalesOrderDetailEnlarged 
  FROM AdventureWorks2012.Sales.SalesOrderDetailEnlarged; -- 4,973,997 rows
 
CREATE CLUSTERED INDEX PK_SODE 
  ON dbo.SalesOrderDetailEnlarged(SalesOrderID, SalesOrderDetailID);

Também criei três versões na memória dessa tabela, cada uma com uma contagem de baldes diferente (pescando um "ponto ideal") - 16.384, 131.072 e 1.048.576. (Você pode usar números arredondados, mas eles são arredondados para a próxima potência de 2 de qualquer maneira.) Exemplo:
CREATE TABLE [dbo].[SalesOrderDetailEnlarged_InMem_16K] -- and _131K and _1MM
(
	[SalesOrderID] [int] NOT NULL,
	[SalesOrderDetailID] [int] NOT NULL,
	[CarrierTrackingNumber] [nvarchar](25) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
	[OrderQty] [smallint] NOT NULL,
	[ProductID] [int] NOT NULL,
	[SpecialOfferID] [int] NOT NULL,
	[UnitPrice] [money] NOT NULL,
	[UnitPriceDiscount] [money] NOT NULL,
	[LineTotal] [numeric](38, 6) NOT NULL,
	[rowguid] [uniqueidentifier] NOT NULL,
	[ModifiedDate] [datetime] NOT NULL
 PRIMARY KEY NONCLUSTERED HASH 
 (
	[SalesOrderID],
	[SalesOrderDetailID]
 ) WITH ( BUCKET_COUNT = 16384) -- and 131072 and 1048576
) WITH ( MEMORY_OPTIMIZED = ON , DURABILITY = SCHEMA_AND_DATA );
GO
 
INSERT dbo.SalesOrderDetailEnlarged_InMem_16K
  SELECT * FROM dbo.SalesOrderDetailEnlarged;
 
INSERT dbo.SalesOrderDetailEnlarged_InMem_131K
  SELECT * FROM dbo.SalesOrderDetailEnlarged;
 
INSERT dbo.SalesOrderDetailEnlarged_InMem_1MM
  SELECT * FROM dbo.SalesOrderDetailEnlarged;
GO

Observe que alterei o tamanho do bucket do exemplo anterior (256). Ao construir a tabela, você deseja escolher o "ponto ideal" para o tamanho do bucket – você deseja otimizar o índice de hash para pesquisas de ponto, o que significa que deseja o maior número possível de buckets com o menor número possível de linhas em cada bucket. É claro que se você criar ~5 milhões de buckets (já que nesse caso, talvez não seja um exemplo muito bom, existem ~5 milhões de combinações únicas de valores), você terá algumas trocas de utilização de memória e coleta de lixo para lidar. No entanto, se você tentar colocar cerca de 5 milhões de valores exclusivos em 256 buckets, também terá alguns problemas. De qualquer forma, essa discussão vai muito além do escopo dos meus testes para este post.

Para testar em relação à tabela padrão, fiz procedimentos armazenados semelhantes aos dos testes anteriores:
CREATE PROCEDURE dbo.SODE_InMemory
  @InMemory dbo.InMemoryTVP READONLY
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @tn NVARCHAR(25);
 
  SELECT @tn = CarrierTrackingNumber
    FROM dbo.SalesOrderDetailEnlarged AS sode
    WHERE EXISTS (SELECT 1 FROM @InMemory AS t
    WHERE sode.SalesOrderID = t.Item);
END
GO
 
CREATE PROCEDURE dbo.SODE_Classic
  @Classic dbo.ClassicTVP READONLY
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @tn NVARCHAR(25);
 
  SELECT @tn = CarrierTrackingNumber
    FROM dbo.SalesOrderDetailEnlarged AS sode
    WHERE EXISTS (SELECT 1 FROM @Classic AS t
    WHERE sode.SalesOrderID = t.Item);
END
GO

Então, primeiro, veja os planos para, digamos, 1.000 linhas sendo inseridas nas variáveis ​​da tabela e, em seguida, execute os procedimentos:
DECLARE @InMemory dbo.InMemoryTVP;
INSERT @InMemory SELECT TOP (1000) SalesOrderID
  FROM dbo.SalesOrderDetailEnlarged
  GROUP BY SalesOrderID ORDER BY NEWID();
 
DECLARE @Classic dbo.ClassicTVP;
INSERT @Classic SELECT Item FROM @InMemory;
 
EXEC dbo.SODE_Classic  @Classic  = @Classic;
EXEC dbo.SODE_InMemory @InMemory = @InMemory;

Desta vez, vemos que em ambos os casos, o otimizador escolheu uma busca de índice clusterizado contra a tabela base e uma junção de loops aninhados contra o TVP. Algumas métricas de custo são diferentes, mas os planos são bastante semelhantes:

Planos semelhantes para TVP na memória x TVP clássica em maior escala

Comparando os custos do operador de busca – Clássico à esquerda, In-Memory à direita

O valor absoluto dos custos faz parecer que o TVP clássico seria muito menos eficiente que o TVP In-Memory. Mas eu me perguntava se isso seria verdade na prática (especialmente porque o número estimado de execuções à direita parecia suspeito), então, é claro, fiz alguns testes. Resolvi comparar 100, 1.000 e 2.000 valores a serem enviados para o procedimento.
DECLARE @values INT = 100; -- 1000, 2000
 
DECLARE @Classic dbo.ClassicTVP;
DECLARE @InMemory dbo.InMemoryTVP;
 
INSERT @Classic(Item) 
SELECT TOP (@values) SalesOrderID
  FROM dbo.SalesOrderDetailEnlarged
  GROUP BY SalesOrderID ORDER BY NEWID();
 
INSERT @InMemory(Item) SELECT Item FROM @Classic;
 
DECLARE @i INT = 1;
 
SELECT SYSDATETIME();
 
WHILE @i <= 10000
BEGIN
  EXEC dbo.SODE_Classic  @Classic  = @Classic;
  SET @i += 1;
END
 
SELECT SYSDATETIME();
 
SET @i = 1;
 
WHILE @i <= 10000
BEGIN
  EXEC dbo.SODE_InMemory @InMemory = @InMemory;
  SET @i += 1;
END
 
SELECT SYSDATETIME();

Os resultados de desempenho mostram que, em um número maior de pesquisas de ponto, o uso de um In-Memory TVP leva a retornos ligeiramente decrescentes, sendo um pouco mais lento a cada vez:


Resultados de 10.000 execuções usando TVPs clássico e in-memory

Portanto, ao contrário da impressão que você pode ter tirado do meu post anterior, usar um TVP na memória não é necessariamente benéfico em todos os casos.

Anteriormente, também examinei procedimentos armazenados compilados nativamente e tabelas na memória, em combinação com TVPs na memória. Isso pode fazer a diferença aqui? Spoiler:absolutamente não. Eu criei três procedimentos assim:
CREATE PROCEDURE [dbo].[SODE_Native_InMem_16K] -- and _131K and _1MM
  @InMemory dbo.InMemoryTVP READONLY
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER 
AS 
  BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english');
 
  DECLARE @tn NVARCHAR(25);
 
  SELECT @tn = CarrierTrackingNumber
    FROM dbo.SalesOrderDetailEnlarged_InMem_16K AS sode -- and _131K and _1MM
    INNER JOIN @InMemory AS t -- no EXISTS allowed here
    ON sode.SalesOrderID = t.Item;
END
GO

Outro spoiler:não consegui executar esses 9 testes com uma contagem de iteração de 10.000 – demorou muito. Em vez disso, fiz um loop e executei cada procedimento 10 vezes, executei esse conjunto de testes 10 vezes e tirei a média. Aqui estão os resultados:


Resultados de 10 execuções usando TVPs na memória e compilados nativamente armazenados procedimentos

No geral, este experimento foi bastante decepcionante. Apenas olhando para a magnitude da diferença, com uma tabela em disco, a chamada média de procedimento armazenado foi concluída em uma média de 0,0036 segundos. No entanto, quando tudo estava usando tecnologias na memória, a chamada média de procedimento armazenado foi de 1,1662 segundos. Ai . É altamente provável que eu tenha escolhido um caso de uso ruim para demonstração geral, mas na época parecia ser uma "primeira tentativa" intuitiva.

Conclusão


Há muito mais para testar em torno deste cenário, e tenho mais posts para seguir. Ainda não identifiquei o caso de uso ideal para TVPs na memória em uma escala maior, mas espero que este post sirva como um lembrete de que, embora uma solução pareça ótima em um caso, nunca é seguro supor que seja igualmente aplicável a diferentes cenários. É exatamente assim que o OLTP In-Memory deve ser abordado:como uma solução com um conjunto restrito de casos de uso que absolutamente devem ser validados antes de serem implementados em produção.