Database
 sql >> Database >  >> RDS >> Database

La serializzazione elimina dagli indici Columnstore in cluster

In Stack Overflow, abbiamo alcune tabelle che utilizzano indici columnstore cluster e funzionano alla grande per la maggior parte del nostro carico di lavoro. Ma di recente ci siamo imbattuti in una situazione in cui "tempeste perfette" - più processi che tentavano di eliminare tutti dalla stessa CCI - avrebbero sopraffatto la CPU poiché andavano tutti in parallelo e combattevano per completare la loro operazione. Ecco come appariva in SolarWinds SQL Sentry:

Ed ecco le attese interessanti associate a queste query:

Le query in competizione erano tutte di questa forma:

DELETE dbo.LargeColumnstoreTable WHERE col1 = @p1 AND col2 = @p2;

Il piano si presentava così:

E l'avviso sulla scansione ci ha avvisato di un I/O residuo piuttosto estremo:

La tabella ha 1,9 miliardi di righe ma è solo 32 GB (grazie, spazio di archiviazione a colonne!). Tuttavia, queste eliminazioni di una singola riga richiederebbero 10-15 secondi ciascuna, con la maggior parte di questo tempo speso su SOS_SCHEDULER_YIELD .

Per fortuna, poiché in questo scenario l'operazione di eliminazione potrebbe essere asincrona, siamo stati in grado di risolvere il problema con due modifiche (anche se sto semplificando grossolanamente):

  • Abbiamo limitato MAXDOP a livello di database, quindi queste eliminazioni non possono essere così parallele
  • Abbiamo migliorato la serializzazione dei processi provenienti dall'applicazione (in pratica, abbiamo messo in coda le eliminazioni tramite un unico dispatcher)

In qualità di DBA, possiamo controllare facilmente MAXDOP , a meno che non venga sovrascritto a livello di query (un'altra tana del coniglio per un altro giorno). Non possiamo necessariamente controllare l'applicazione in questa misura, soprattutto se è distribuita o non nostra. Come possiamo serializzare le scritture in questo caso senza modificare drasticamente la logica dell'applicazione?

Una configurazione simulata

Non cercherò di creare localmente una tabella di due miliardi di righe, non importa la tabella esatta, ma possiamo approssimare qualcosa su scala più piccola e provare a riprodurre lo stesso problema.

Facciamo finta che questo sia il SuggestedEdits table (in realtà non lo è). Ma è un esempio facile da usare perché possiamo estrarre lo schema da Stack Exchange Data Explorer. Usando questo come base, possiamo creare una tabella equivalente (con alcune piccole modifiche per semplificare il popolamento) e inserire su di essa un indice columnstore cluster:

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
);

Per popolarlo con 100 milioni di righe, possiamo incrociare sys.all_objects e sys.all_columns cinque volte (sul mio sistema, questo produrrà 2,68 milioni di righe ogni volta, ma 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

Quindi, possiamo controllare lo spazio:

EXEC sys.sp_spaceused @objname = N'dbo.FakeSuggestedEdits';

È solo 1,3 GB, ma dovrebbe essere sufficiente:

Imitazione dell'eliminazione di Columnstore in cluster

Ecco una semplice query che corrisponde approssimativamente a ciò che la nostra applicazione stava facendo sulla tabella:

DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
DELETE dbo.FakeSuggestedEdits WHERE Id = @p1 AND OwnerUserId = @p2;

Tuttavia, il piano non è proprio una combinazione perfetta:

Per farlo funzionare in parallelo e produrre contese simili sul mio scarso laptop, ho dovuto forzare un po' l'ottimizzatore con questo suggerimento:

OPTION (QUERYTRACEON 8649);

Ora sembra giusto:

Riproduzione del problema

Quindi, possiamo creare un aumento di attività di eliminazione simultanee utilizzando SqlStressCmd per eliminare 1.000 righe casuali utilizzando 16 e 32 thread:

sqlstresscmd -s docs/ColumnStore.json -t 16
sqlstresscmd -s docs/ColumnStore.json -t 32

Possiamo osservare lo sforzo che questo mette sulla CPU:

Lo sforzo sulla CPU dura durante i batch rispettivamente di circa 64 e 130 secondi:

Nota:l'output di SQLQueryStress a volte è un po' fuori dalle iterazioni, ma ho confermato che il lavoro che gli chiedi di fare viene svolto con precisione.

Una potenziale soluzione alternativa:una coda di eliminazione

Inizialmente, ho pensato di introdurre una tabella di coda nel database, che potremmo usare per scaricare l'attività di eliminazione:

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
);

Tutto ciò di cui abbiamo bisogno è un trigger INSTEAD OF per intercettare queste eliminazioni non autorizzate provenienti dall'applicazione e metterle in coda per l'elaborazione in background. Sfortunatamente, non puoi creare un trigger su una tabella con un indice columnstore cluster:

Msg 35358, livello 16, stato 1
CREATE TRIGGER sulla tabella 'dbo.FakeSuggestedEdits' non riuscito perché non è possibile creare un trigger su una tabella con un indice columnstore cluster. Considerare di applicare la logica del trigger in qualche altro modo oppure, se è necessario utilizzare un trigger, utilizzare invece un indice heap o B-tree.

Avremo bisogno di una modifica minima al codice dell'applicazione, in modo che chiami una stored procedure per gestire l'eliminazione:

CREATE PROCEDURE dbo.DeleteSuggestedEdit
  @Id          int,
  @OwnerUserId int
AS
BEGIN
  SET NOCOUNT ON;
 
  DELETE dbo.FakeSuggestedEdits 
    WHERE Id = @Id AND OwnerUserId = @OwnerUserId;
END

Questo non è uno stato permanente; questo è solo per mantenere il comportamento lo stesso mentre si cambia solo una cosa nell'app. Una volta che l'app è stata modificata e ha chiamato correttamente questa stored procedure invece di inviare query di eliminazione ad hoc, la stored procedure può cambiare:

CREATE PROCEDURE dbo.DeleteSuggestedEdit
  @Id          int,
  @OwnerUserId int
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.SuggestedEditDeleteQueue(Id, OwnerUserId)
    SELECT @Id, @OwnerUserId;
END

Testare l'impatto della coda

Ora, se cambiamo SqlQueryStress per chiamare invece la stored procedure:

DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
EXEC dbo.DeleteSuggestedEdit @Id = @p1, @OwnerUserId = @p2;

E invia batch simili (mettendo 16.000 o 32.000 righe nella coda):

DECLARE @p1 int = ABS(CHECKSUM(NEWID())) % 10000000, @p2 int = 7;
EXEC dbo.@Id = @p1 AND OwnerUserId = @p2;

L'impatto sulla CPU è leggermente superiore:

Ma i carichi di lavoro finiscono molto più rapidamente, rispettivamente 16 e 23 secondi:

Questa è una riduzione significativa del dolore che le applicazioni proveranno quando entrano in periodi di elevata simultaneità.

Dobbiamo ancora eseguire l'eliminazione, però

Dobbiamo ancora elaborare quelle eliminazioni in background, ma ora possiamo introdurre il batching e avere il pieno controllo sulla velocità e su eventuali ritardi che vogliamo iniettare tra le operazioni. Ecco la struttura di base di una procedura memorizzata per elaborare la coda (certamente senza il controllo transazionale, la gestione degli errori o la pulizia della tabella delle code completamente acquisiti):

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

Ora, l'eliminazione delle righe richiederà più tempo:la media di 10.000 righe è di 223 secondi, di cui ~100 è un ritardo intenzionale. Ma nessun utente sta aspettando, quindi chi se ne frega? Il profilo della CPU è quasi zero e l'app può continuare ad aggiungere elementi alla coda in modo altamente simultaneo come vuole, con quasi zero conflitti con il lavoro in background. Durante l'elaborazione di 10.000 righe, ho aggiunto altre 16.000 righe alla coda e ha utilizzato la stessa CPU di prima, impiegando solo un secondo in più rispetto a quando il lavoro non era in esecuzione:

E il piano ora si presenta così, con righe stimate/effettive molto migliori:

Vedo che questo approccio alla tabella delle code è un modo efficace per gestire un'elevata simultaneità DML, ma richiede almeno un po' di flessibilità con le applicazioni che inviano DML:questo è uno dei motivi per cui mi piace molto che le applicazioni chiamino procedure archiviate, poiché dacci molto più controllo più vicino ai dati.

Altre opzioni

Se non hai la possibilità di modificare le query di eliminazione provenienti dall'applicazione o, se non puoi rinviare le eliminazioni a un processo in background, puoi considerare altre opzioni per ridurre l'impatto delle eliminazioni:

  • Un indice non cluster sulle colonne del predicato per supportare le ricerche di punti (possiamo farlo in isolamento senza modificare l'applicazione)
  • Utilizzo solo delle eliminazioni software (richiede comunque modifiche all'applicazione)

Sarà interessante vedere se queste opzioni offrono vantaggi simili, ma le salverò per un post futuro.