Database
 sql >> Base de données >  >> RDS >> Database

S'amuser avec la compression (columnstore) sur une très grande table - partie 3

[ Partie 1 | Partie 2 | Partie 3 ]

Dans la partie 1 de cette série, j'ai essayé plusieurs façons de compresser une table de 1 To. Bien que j'ai obtenu des résultats décents lors de ma première tentative, je voulais voir si je pouvais améliorer les performances dans la partie 2. Là, j'ai décrit quelques-unes des choses que je pensais être des problèmes de performances et expliqué comment je ferais mieux de partitionner la table de destination pour une compression optimale du columnstore. J'ai déjà :

  • partitionné la table en 8 partitions (une par cœur) ;
  • placez le fichier de données de chaque partition sur son propre groupe de fichiers ; et,
  • définit la compression des archives sur toutes les partitions sauf la partition "active".

Je dois encore faire en sorte que chaque planificateur écrive exclusivement sur sa propre partition.

Tout d'abord, je dois apporter des modifications à la table de lots que j'ai créée. J'ai besoin d'une colonne pour stocker le nombre de lignes ajoutées par lot (une sorte de vérification d'intégrité par auto-audit) et les heures de début/fin pour mesurer les progrès.

ALTER TABLE dbo.BatchQueue ADD 
  RowsAdded int,
  StartTime datetime2, 
  EndTime   datetime2;

Ensuite, je dois créer une table pour fournir une affinité - nous ne voulons jamais plus d'un processus en cours d'exécution sur un planificateur, même si cela signifie perdre du temps pour réessayer la logique. Nous avons donc besoin d'une table qui gardera une trace de toute session sur un planificateur spécifique et empêchera l'empilement :

CREATE TABLE dbo.OpAffinity
(
  SchedulerID int NOT NULL,
  SessionID   int NULL,
  CONSTRAINT  PK_OpAffinity PRIMARY KEY CLUSTERED (SchedulerID)
);

L'idée est que j'aurais huit instances d'une application (SQLQueryStress) qui s'exécuteraient chacune sur un planificateur dédié, ne traitant que les données destinées à une partition / groupe de fichiers / fichier de données spécifique, ~ 100 millions de lignes à la fois (cliquez pour agrandir) :

L'application 1 obtient le planificateur 0 et écrit dans la partition 1 sur le groupe de fichiers 1, et ainsi de suite …

Ensuite, nous avons besoin d'une procédure stockée qui permettra à chaque instance de l'application de réserver du temps sur un seul planificateur. Comme je l'ai mentionné dans un article précédent, ce n'est pas mon idée originale (et je ne l'aurais jamais trouvée dans ce guide sans Joe Obbish). Voici la procédure que j'ai créée dans Utility :

CREATE PROCEDURE dbo.DoMyBatch
  @PartitionID   int,    -- pass in 1 through 8
  @BatchID       int     -- pass in 1 through 4
AS
BEGIN
  DECLARE @BatchSize       bigint, 
          @MinID           bigint, 
          @MaxID           bigint, 
          @rc              bigint,
          @ThisSchedulerID int = 
          (
            SELECT scheduler_id 
	      FROM sys.dm_exec_requests 
    	      WHERE session_id = @@SPID
          );
 
  -- try to get the requested scheduler, 0-based
  IF @ThisSchedulerID <> @PartitionID - 1 
  BEGIN
    -- surface the scheduler we got to the application, but force a delay
    RAISERROR('Got wrong scheduler %d.', 11, 1, @ThisSchedulerID);
    WAITFOR DELAY '00:00:05';
    RETURN -3;
  END
  ELSE
  BEGIN
    -- we are on our scheduler, now serializibly make sure we're exclusive
    INSERT Utility.dbo.OpAffinity(SchedulerID, SessionID)
      SELECT @ThisSchedulerID, @@SPID
        WHERE NOT EXISTS 
        (
          SELECT 1 FROM Utility.dbo.OpAffinity WITH (TABLOCKX) 
            WHERE SchedulerID = @ThisSchedulerID
        );
 
    -- if someone is already using this scheduler, raise roar:
    IF @@ROWCOUNT <> 1
    BEGIN
      RAISERROR('Wrong scheduler %d, try again.',11,1,@ThisSchedulerID) WITH NOWAIT;
      RETURN @ThisSchedulerID;
    END
 
    -- checkpoint twice to clear log
    EXEC OCopy.sys.sp_executesql N'CHECKPOINT; CHECKPOINT;';
 
    -- get our range of rows for the current batch
    SELECT @MinID = MinID, @MaxID = MaxID
      FROM Utility.dbo.BatchQueue 
      WHERE PartitionID = @PartitionID
        AND BatchID = @BatchID
        AND StartTime IS NULL;
 
    -- if we couldn't get a row here, must already be done:
    IF @@ROWCOUNT <> 1
    BEGIN
      RAISERROR('Already done.', 11, 1) WITH NOWAIT;
      RETURN -1;
    END
 
    -- update the BatchQueue table to indicate we've started:
    UPDATE msdb.dbo.BatchQueue 
      SET StartTime = sysdatetime(), EndTime = NULL
      WHERE PartitionID = @PartitionID
        AND BatchID = @BatchID;
 
    -- do the work - copy from Original to Partitioned
    INSERT OCopy.dbo.tblPartitionedCCI 
      SELECT * FROM OCopy.dbo.tblOriginal AS o
        WHERE o.CostID >= @MinID AND o.CostID <= @MaxID
        OPTION (MAXDOP 1); -- don't want parallelism here!
 
    /*
        You might think, don't I want a TABLOCK hint on the insert, 
        to benefit from minimal logging? I thought so too, but while 
        this leads to a BULK UPDATE lock on rowstore tables, it is a 
        TABLOCKX with columnstore. This isn't going to work well if 
        we want to have multiple processes inserting into separate 
        partitions simultaneously. We need a PARTITIONLOCK hint!
    */
 
    SET @rc = @@ROWCOUNT;
 
    -- update BatchQueue that we've finished and how many rows:
    UPDATE Utility.dbo.BatchQueue 
      SET EndTime = sysdatetime(), RowsAdded = @rc
      WHERE PartitionID = @PartitionID
        AND BatchID = @BatchID;
 
    -- remove our lock to this scheduler:
    DELETE Utility.dbo.OpAffinity 
      WHERE SchedulerID = @ThisSchedulerID 
        AND SessionID = @@SPID;
  END
END

Simple, non ? Lancez 8 instances de SQLQueryStress et placez ce lot dans chacune :

EXEC dbo.DoMyBatch @PartitionID = /* PartitionID - 1 through 8 */, @BatchID = 1;
EXEC dbo.DoMyBatch @PartitionID = /* PartitionID - 1 through 8 */, @BatchID = 2;
EXEC dbo.DoMyBatch @PartitionID = /* PartitionID - 1 through 8 */, @BatchID = 3;
EXEC dbo.DoMyBatch @PartitionID = /* PartitionID - 1 through 8 */, @BatchID = 4;

Le parallélisme du pauvre

Sauf que ce n'est pas si simple, car l'affectation d'un ordonnanceur est un peu comme une boîte de chocolats. Il a fallu de nombreux essais pour obtenir chaque instance de l'application sur le planificateur attendu ; J'inspecterais les exceptions sur n'importe quelle instance donnée de l'application et changerais le PartitionID correspondre. C'est pourquoi j'ai utilisé plus d'une itération (mais je ne voulais toujours qu'un seul thread par instance). Par exemple, cette instance de l'application s'attendait à être sur le planificateur 3, mais elle a reçu le planificateur 4 :

Si au début vous ne réussissez pas…

J'ai changé les 3 dans la fenêtre de requête en 4 et j'ai réessayé. Si j'étais rapide, l'affectation du planificateur était suffisamment "collante" pour qu'elle la récupère immédiatement et commence à s'en aller. Mais je n'étais pas toujours assez rapide, donc c'était un peu comme un coup de taupe pour y aller. J'aurais probablement pu concevoir une meilleure routine de réessai/boucle pour rendre le travail moins manuel ici, et raccourcir le délai afin que je sache immédiatement si cela fonctionnait ou non, mais c'était assez bon pour mes besoins. Cela a également entraîné un échelonnement involontaire des heures de démarrage de chaque processus, un autre conseil de M. Obbish.

Surveillance

Pendant que la copie affinitaire est en cours d'exécution, je peux obtenir un indice sur l'état actuel avec les deux requêtes suivantes :

SELECT r.session_id, r.[status], r.scheduler_id, partition_id = o.SchedulerID + 1, 
  r.logical_reads, r.total_elapsed_time, r.last_wait_type, longest_wait_type = 
  (
    SELECT TOP (1) wait_type 
      FROM sys.dm_exec_session_wait_stats
      WHERE session_id = r.session_id AND wait_type <> 'WAITFOR' 
      ORDER BY wait_time_ms - signal_wait_time_ms DESC
  )
  FROM sys.dm_exec_requests AS r 
  INNER JOIN Utility.dbo.OpAffinity AS o
      ON o.SessionID = r.session_id
  WHERE r.command = N'INSERT'
  ORDER BY r.scheduler_id;
 
SELECT SchedulerID = PartitionID - 1, Duration = DATEDIFF(SECOND, StartTime, EndTime), *
  FROM Utility.dbo.BatchQueue WITH (NOLOCK) 
  WHERE StartTime IS NOT NULL -- AND EndTime IS NULL
  ORDER BY PartitionID;

Si je faisais tout correctement, les deux requêtes renverraient 8 lignes et afficheraient des lectures logiques et une durée incrémentielles. Les types d'attente basculeront entre PAGEIOLATCH_SH , SOS_SCHEDULER_YIELD , et occasionnellement RESERVED_MEMORY_ALLOCATION_EXT. Lorsqu'un lot était terminé (je pouvais les revoir en décommentant -- AND EndTime IS NULL , je confirme que RowsAdded = RowsInRange .

Une fois les 8 instances de SQLQueryStress terminées, je pouvais simplement effectuer un SELECT INTO <newtable> FROM dbo.BatchQueue pour enregistrer les résultats finaux pour une analyse ultérieure.

Autres tests

En plus de copier les données dans l'index clustered columnstore partitionné qui existait déjà, en utilisant l'affinité, je voulais aussi essayer quelques autres choses :

  • Copier les données dans la nouvelle table sans essayer de contrôler l'affinité. J'ai retiré la logique d'affinité de la procédure et j'ai laissé au hasard tout le truc "j'espère que vous obtenez le bon planificateur". Cela a pris plus de temps car, bien sûr, l'empilement du planificateur l'a fait se produire. Par exemple, à ce stade précis, le planificateur 3 exécutait deux processus, tandis que le planificateur 0 était en pause déjeuner :

    Où es-tu, planificateur numéro 0 ?

  • Application de la page ou ligne compression (en ligne/hors ligne) vers la source avant la copie affinitaire (hors ligne), pour voir si la compression préalable des données pourrait accélérer la destination. Notez que la copie peut également être effectuée en ligne mais, comme le int d'Andy Mallon en bigint conversion, cela demande un peu de gymnastique. Notez que dans ce cas, nous ne pouvons pas tirer parti de l'affinité CPU (bien que nous le puissions si la table source était déjà partitionnée). J'étais intelligent et j'ai fait une sauvegarde de la source d'origine et j'ai créé une procédure pour rétablir la base de données à son état initial. Beaucoup plus rapide et facile que d'essayer de revenir manuellement à un état spécifique.

    -- refresh source, then do page online:
    ALTER TABLE dbo.tblOriginal REBUILD WITH (DATA_COMPRESSION = PAGE, ONLINE = ON);
    -- then run SQLQueryStress
     
    -- refresh source, then do page offline:
    ALTER TABLE dbo.tblOriginal REBUILD WITH (DATA_COMPRESSION = PAGE, ONLINE = OFF);
    -- then run SQLQueryStress
     
    -- refresh source, then do row online:
    ALTER TABLE dbo.tblOriginal REBUILD WITH (DATA_COMPRESSION = ROW, ONLINE = ON);
    -- then run SQLQueryStress
     
    -- refresh source, then do row offline:
    ALTER TABLE dbo.tblOriginal REBUILD WITH (DATA_COMPRESSION = ROW, ONLINE = OFF);
    -- then run SQLQueryStress
  • Et enfin, reconstruisez d'abord l'index clusterisé sur le schéma de partition, puis construisez l'index clustered columnstore par-dessus. L'inconvénient de ce dernier est que, dans SQL Server 2017, vous ne pouvez pas l'exécuter en ligne… mais vous pourrez le faire en 2019.

    Ici, nous devons d'abord supprimer la contrainte PK ; vous ne pouvez pas utiliser DROP_EXISTING , car la contrainte d'unicité d'origine ne peut pas être appliquée par l'index cluster columnstore et vous ne pouvez pas remplacer un index cluster unique par un index cluster non unique.

    Msg 1907, Niveau 16, État 1
    Impossible de recréer l'index 'pk_tblOriginal'. La nouvelle définition d'index ne correspond pas à la contrainte appliquée par l'index existant.

    Tous ces détails en font un processus en trois étapes, seule la deuxième étape en ligne. La première étape, j'ai seulement testé explicitement OFFLINE; qui a duré trois minutes, tandis que ONLINE J'ai arrêté au bout de 15 minutes. Une de ces choses qui ne devrait peut-être pas être une opération de taille de données dans les deux cas, mais je vais laisser ça pour un autre jour.

    ALTER TABLE dbo.tblOriginal DROP CONSTRAINT PK_tblOriginal WITH (ONLINE = OFF);
    GO
     
    CREATE CLUSTERED INDEX CCI_tblOriginal -- yes, a bad name, but only temporarily
      ON dbo.tblOriginal(OID)
      WITH (ONLINE = ON)
      ON PS_OID (OID); -- this moves the data
     
     
    CREATE CLUSTERED COLUMNSTORE INDEX CCI_tblOriginal
      ON dbo.tblOriginal
      WITH                 
      (
        DROP_EXISTING = ON,
        DATA_COMPRESSION = COLUMNSTORE_ARCHIVE ON PARTITIONS (1 TO 7),
        DATA_COMPRESSION = COLUMNSTORE ON PARTITIONS (8)
        -- in 2019, CCI can be ONLINE = ON as well
      )
      ON PS_OID (OID);
    GO

Résultats

Timings et taux de compression :

Certaines options sont meilleures que d'autres

Notez que j'ai arrondi au Go car il y aurait des différences mineures dans la taille finale après chaque exécution, même en utilisant la même technique. De plus, les délais des méthodes d'affinité étaient basés sur la moyenne planificateur individuel/exécution par lots, car certains planificateurs se sont terminés plus rapidement que d'autres.

Il est difficile d'imaginer une image exacte à partir de la feuille de calcul comme indiqué, car certaines tâches ont des dépendances, je vais donc essayer d'afficher les informations sous forme de chronologie et de montrer la compression que vous obtenez par rapport au temps passé :

Temps passé (minutes) par rapport au taux de compression

Quelques observations à partir des résultats, avec la mise en garde que vos données peuvent être compressées différemment (et que les opérations en ligne ne s'appliquent qu'à vous si vous utilisez Enterprise Edition) :

  • Si votre priorité est de économiser de l'espace le plus rapidement possible , le mieux est d'appliquer la compression de ligne en place. Si vous souhaitez minimiser les perturbations, utilisez en ligne ; si vous souhaitez optimiser la vitesse, utilisez hors ligne.
  • Si vous souhaitez maximiser la compression sans interruption , vous pouvez approcher une réduction de stockage de 90 % sans aucune interruption, en utilisant la compression de page en ligne.
  • Si vous voulez maximiser la compression et les perturbations, c'est bien , copiez les données dans une nouvelle version partitionnée de la table, avec un index cluster columnstore, et utilisez le processus d'affinité décrit ci-dessus pour migrer les données. (Et encore une fois, vous pouvez éliminer cette perturbation si vous êtes un meilleur planificateur que moi.)

La dernière option a fonctionné le mieux pour mon scénario, même si nous devrons encore écraser les charges de travail (oui, au pluriel). Notez également que dans SQL Server 2019, cette technique peut ne pas fonctionner aussi bien, mais vous pouvez y créer des index clustered columnstore en ligne, donc cela peut ne pas avoir autant d'importance.

Certaines de ces approches peuvent être plus ou moins acceptables pour vous, car vous pouvez préférer « rester disponible » plutôt que « finir le plus rapidement possible », ou « minimiser l'utilisation du disque » plutôt que « rester disponible », ou simplement équilibrer les performances de lecture et la surcharge d'écriture. .

Si vous voulez plus de détails sur n'importe quel aspect de cela, il suffit de demander. J'ai coupé une partie de la graisse pour équilibrer les détails avec la digestibilité, et je me suis trompé sur cet équilibre auparavant. Une pensée d'adieu est que je suis curieux de savoir à quel point cela est linéaire - nous avons une autre table avec une structure similaire qui dépasse 25 To, et je suis curieux de savoir si nous pouvons avoir un impact similaire là-bas. En attendant, bonne compression !

[ Partie 1 | Partie 2 | Partie 3 ]