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

Serializando exclusões de índices Columnstore clusterizados


No Stack Overflow, temos algumas tabelas usando índices columnstore clusterizados e funcionam muito bem para a maior parte de nossa carga de trabalho. Mas recentemente nos deparamos com uma situação em que “tempestades perfeitas” – vários processos, todos tentando excluir do mesmo CCI – sobrecarregariam a CPU, pois todos eram amplamente paralelos e lutavam para concluir sua operação. Veja como era no SolarWinds SQL Sentry:



E aqui estão as esperas interessantes associadas a essas consultas:



As consultas concorrentes eram todas desta forma:
DELETE dbo.LargeColumnstoreTable WHERE col1 = @p1 AND col2 = @p2;

O plano ficou assim:



E o aviso na varredura nos avisou sobre algumas E/S residuais bastante extremas:



A tabela tem 1,9 bilhão de linhas, mas tem apenas 32 GB (obrigado, armazenamento colunar!). Ainda assim, essas exclusões de linha única levariam de 10 a 15 segundos cada, com a maior parte desse tempo sendo gasto em SOS_SCHEDULER_YIELD .

Felizmente, como nesse cenário a operação de exclusão pode ser assíncrona, conseguimos resolver o problema com duas alterações (embora eu esteja simplificando muito aqui):
  • Limitamos MAXDOP no nível do banco de dados para que essas exclusões não sejam tão paralelas
  • Melhoramos a serialização dos processos vindos do aplicativo (basicamente, enfileiramos exclusões por meio de um único dispatcher)

Como um DBA, podemos controlar facilmente MAXDOP , a menos que seja substituído no nível da consulta (outra toca de coelho para outro dia). Não podemos necessariamente controlar o aplicativo nessa medida, especialmente se for distribuído ou não nosso. Como podemos serializar as gravações nesse caso sem alterar drasticamente a lógica do aplicativo?

Uma configuração simulada


Não vou tentar criar uma tabela de dois bilhões de linhas localmente - não importa a tabela exata - mas podemos aproximar algo em uma escala menor e tentar reproduzir o mesmo problema.

Vamos fingir que este é o SuggestedEdits mesa (na realidade, não é). Mas é um exemplo fácil de usar porque podemos extrair o esquema do Stack Exchange Data Explorer. Usando isso como base, podemos criar uma tabela equivalente (com algumas pequenas alterações para facilitar o preenchimento) e lançar um índice columnstore clusterizado nela:
CREATE TABLE dbo.FakeSuggestedEdits
(
  Id            int IDENTITY(1,1),
  PostId        int NOT NULL DEFAULT CONVERT(int, ABS(CHECKSUM(NEWID()))) % 200,
  CreationDate  datetime2 NOT NULL DEFAULT sysdatetime(),
  ApprovalDate  datetime2 NOT NULL DEFAULT sysdatetime(),
  RejectionDate datetime2 NULL,
  OwnerUserId   int NOT NULL DEFAULT 7,
  Comment       nvarchar (800)   NOT NULL DEFAULT NEWID(),
  Text          nvarchar (max)   NOT NULL DEFAULT NEWID(),
  Title         nvarchar (250)   NOT NULL DEFAULT NEWID(),
  Tags          nvarchar (250)   NOT NULL DEFAULT NEWID(),
  RevisionGUID  uniqueidentifier NOT NULL DEFAULT NEWSEQUENTIALID(),
  INDEX CCI_FSE CLUSTERED COLUMNSTORE
);

Para preenchê-lo com 100 milhões de linhas, podemos fazer a junção cruzada de sys.all_objects e sys.all_columns cinco vezes (no meu sistema, isso produzirá 2,68 milhões de linhas de cada vez, mas YMMV):
-- 2680350 * 5 ~ 3 minutes
 
INSERT dbo.FakeSuggestedEdits(CreationDate)
  SELECT TOP (10) /*(2000000) */ modify_date
  FROM sys.all_objects AS o
  CROSS JOIN sys.columns AS c;
GO 5

Então, podemos verificar o espaço:
EXEC sys.sp_spaceused @objname = N'dbo.FakeSuggestedEdits';

São apenas 1,3 GB, mas isso deve ser suficiente:


Imitando nossa exclusão de armazenamento de colunas em cluster


Aqui está uma consulta simples que corresponde aproximadamente ao que nosso aplicativo estava fazendo com a tabela:
DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
DELETE dbo.FakeSuggestedEdits WHERE Id = @p1 AND OwnerUserId = @p2;

O plano não é uma combinação perfeita, no entanto:



Para que ele fosse paralelo e produzisse uma contenção semelhante em meu laptop escasso, tive que coagir um pouco o otimizador com esta dica:
OPTION (QUERYTRACEON 8649);

Agora, parece certo:


Reproduzindo o problema


Em seguida, podemos criar uma onda de atividade de exclusão simultânea usando SqlStressCmd para excluir 1.000 linhas aleatórias usando 16 e 32 threads:
sqlstresscmd -s docs/ColumnStore.json -t 16
sqlstresscmd -s docs/ColumnStore.json -t 32

Podemos observar a tensão que isso coloca na CPU:



A tensão na CPU dura ao longo dos lotes de cerca de 64 e 130 segundos, respectivamente:



Observação:a saída do SQLQueryStress às ​​vezes está um pouco errada nas iterações, mas confirmei que o trabalho que você solicita é feito com precisão.

Uma possível solução alternativa:uma fila de exclusão


Inicialmente, pensei em introduzir uma tabela de filas no banco de dados, que poderíamos usar para descarregar a atividade de exclusão:
CREATE TABLE dbo.SuggestedEditDeleteQueue
(
  QueueID       int IDENTITY(1,1) PRIMARY KEY,
  EnqueuedDate  datetime2 NOT NULL DEFAULT sysdatetime(),
  ProcessedDate datetime2 NULL,
  Id            int NOT NULL,
  OwnerUserId   int NOT NULL
);

Tudo o que precisamos é de um gatilho INSTEAD OF para interceptar essas exclusões não autorizadas provenientes do aplicativo e colocá-las na fila para processamento em segundo plano. Infelizmente, você não pode criar um gatilho em uma tabela com um índice columnstore clusterizado:
Msg 35358, Level 16, State 1
CREATE TRIGGER na tabela 'dbo.FakeSuggestedEdits' falhou porque você não pode criar um gatilho em uma tabela com um índice columnstore clusterizado. Considere impor a lógica do gatilho de alguma outra maneira ou, se precisar usar um gatilho, use um índice de heap ou árvore B.
Precisaremos de uma alteração mínima no código do aplicativo, para que ele chame um procedimento armazenado para lidar com a exclusão:
CREATE PROCEDURE dbo.DeleteSuggestedEdit
  @Id          int,
  @OwnerUserId int
AS
BEGIN
  SET NOCOUNT ON;
 
  DELETE dbo.FakeSuggestedEdits 
    WHERE Id = @Id AND OwnerUserId = @OwnerUserId;
END

Este não é um estado permanente; isso é apenas para manter o mesmo comportamento enquanto altera apenas uma coisa no aplicativo. Depois que o aplicativo for alterado e estiver chamando esse procedimento armazenado com êxito em vez de enviar consultas de exclusão ad hoc, o procedimento armazenado poderá ser alterado:
CREATE PROCEDURE dbo.DeleteSuggestedEdit
  @Id          int,
  @OwnerUserId int
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.SuggestedEditDeleteQueue(Id, OwnerUserId)
    SELECT @Id, @OwnerUserId;
END

Testando o impacto da fila


Agora, se alterarmos SqlQueryStress para chamar o procedimento armazenado:
DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
EXEC dbo.DeleteSuggestedEdit @Id = @p1, @OwnerUserId = @p2;

E envie lotes semelhantes (colocando 16K ou 32K linhas na fila):
DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
EXEC dbo.@Id = @p1 AND OwnerUserId = @p2;

O impacto da CPU é um pouco maior:



Mas as cargas de trabalho terminam muito mais rapidamente — 16 e 23 segundos, respectivamente:



Esta é uma redução significativa na dor que os aplicativos sentirão quando entrarem em períodos de alta simultaneidade.

Ainda temos que executar a exclusão, embora


Ainda temos que processar essas exclusões em segundo plano, mas agora podemos introduzir lotes e ter controle total sobre a taxa e quaisquer atrasos que queremos injetar entre as operações. Aqui está a estrutura básica de um procedimento armazenado para processar a fila (reconhecidamente sem controle transacional totalmente adquirido, tratamento de erros ou limpeza da tabela de filas):
CREATE PROCEDURE dbo.ProcessSuggestedEditQueue
  @JobSize        int = 10000,
  @BatchSize      int = 100,
  @DelayInSeconds int = 2      -- must be between 1 and 59
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @d TABLE(Id int, OwnerUserId int);
  DECLARE @rc int = 1,
          @jc int = 0, 
          @wf nvarchar(100) = N'WAITFOR DELAY ' + CHAR(39) 
              + '00:00:' + RIGHT('0' + CONVERT(varchar(2), 
                @DelayInSeconds), 2) + CHAR(39);
 
  WHILE @rc > 0 AND @jc < @JobSize
  BEGIN 
    DELETE @d; 
 
    UPDATE TOP (@BatchSize) q SET ProcessedDate = sysdatetime() 
      OUTPUT inserted.Id, inserted.OwnerUserId INTO @d 
      FROM dbo.SuggestedEditDeleteQueue AS q WITH (UPDLOCK, READPAST) 
       WHERE ProcessedDate IS NULL; 
 
    SET @rc = @@ROWCOUNT; 
    IF @rc = 0 BREAK; 
 
    DELETE fse 
      FROM dbo.FakeSuggestedEdits AS fse 
      INNER JOIN @d AS d 
        ON fse.Id = d.Id 
       AND fse.OwnerUserId = d.OwnerUserId; 
 
    SET @jc += @rc; 
    IF @jc > @JobSize BREAK;
 
    EXEC sys.sp_executesql @wf;
  END
  RAISERROR('Deleted %d rows.', 0, 1, @jc) WITH NOWAIT;
END

Agora, a exclusão de linhas levará mais tempo — a média de 10.000 linhas é de 223 segundos, dos quais aproximadamente 100 é um atraso intencional. Mas nenhum usuário está esperando, então quem se importa? O perfil da CPU é quase zero, e o aplicativo pode continuar adicionando itens na fila tão simultâneos quanto desejar, com quase zero conflito com o trabalho em segundo plano. Ao processar 10.000 linhas, adicionei outras 16 mil linhas à fila e usei a mesma CPU de antes — levando apenas um segundo a mais do que quando o trabalho não estava em execução:



E o plano agora se parece com isso, com linhas estimadas/reais muito melhores:



Eu posso ver essa abordagem de tabela de filas sendo uma maneira eficaz de lidar com alta simultaneidade de DML, mas requer pelo menos um pouco de flexibilidade com os aplicativos que enviam DML - essa é uma razão pela qual eu realmente gosto que os aplicativos chamem procedimentos armazenados, pois eles nos dão muito mais controle mais próximo dos dados.

Outras opções


Se você não puder alterar as consultas de exclusão provenientes do aplicativo — ou, se não puder adiar as exclusões para um processo em segundo plano — considere outras opções para reduzir o impacto das exclusões:
  • Um índice não clusterizado nas colunas de predicado para dar suporte a pesquisas de ponto (podemos fazer isso isoladamente sem alterar o aplicativo)
  • Usar apenas exclusões reversíveis (ainda requer alterações no aplicativo)

Será interessante ver se essas opções oferecem benefícios semelhantes, mas vou guardá-las para um post futuro.