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

Générer des entiers aléatoires sans collisions

De temps en temps, je vois quelqu'un exprimer l'exigence de créer un nombre aléatoire pour une clé. Il s'agit généralement de créer un type de CustomerID ou UserID de substitution qui est un numéro unique dans une certaine plage, mais qui n'est pas émis de manière séquentielle, et est donc beaucoup moins facile à deviner qu'un IDENTITY valeur.

Laissons de côté, pour le moment, le fait que quelqu'un puisse deviner un CustomerID ou un UserID devrait être largement hors de propos - ces valeurs de substitution ne doivent pas être exposées en dehors de l'application, et un utilisateur final ne devrait pas pouvoir faire quoi que ce soit différemment avec la connaissance (ou devinez !) de l'identité de quelqu'un d'autre. Après tout, même si vous générez un nombre "aléatoire" entre 1 et 100 000 ou 1 et 1 000 000, je pourrais toujours deviner toutes les valeurs d'ID qui ont déjà été générées, par exemple par force brute. Et si je peux faire quelque chose avec une correspondance positive, il y a quelque chose de sérieusement cassé avec l'application.

NEWID() résout le problème de devinette, mais la pénalité de performance est généralement un facteur déterminant, en particulier lorsqu'elle est regroupée :des clés beaucoup plus larges que les entiers et des fractionnements de page dus à des valeurs non séquentielles. NEWSEQUENTIALID() résout le problème de fractionnement de page, mais reste une clé très large, et réintroduit le problème selon lequel vous pouvez deviner la valeur suivante (ou les valeurs récemment émises) avec un certain niveau de précision.

En conséquence, ils veulent une technique pour générer un et aléatoire entier unique. Générer un nombre aléatoire par lui-même n'est pas difficile, en utilisant des méthodes comme RAND() ou CHECKSUM(NEWID()) . Le problème survient lorsque vous devez détecter les collisions. Examinons rapidement une approche typique, en supposant que nous voulons des valeurs CustomerID comprises entre 1 et 1 000 000 :

DECLARE @rc INT = 0,
  @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1;
              -- or ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1;
              -- or CONVERT(INT, RAND() * 1000000) + 1;
WHILE @rc = 0
BEGIN
  IF NOT EXISTS (SELECT 1 FROM dbo.Customers WHERE CustomerID = @CustomerID)
  BEGIN
    INSERT dbo.Customers(CustomerID) SELECT @CustomerID;
    SET @rc = 1; 
  END
  ELSE
  BEGIN
    SELECT @CustomerID = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
      @rc = 0;
  END
END

Au fur et à mesure que le tableau s'agrandit, non seulement la recherche de doublons devient plus coûteuse, mais vos chances de générer un doublon augmentent également. Cette approche peut donc sembler fonctionner correctement lorsque la table est petite, mais je soupçonne que cela doit faire de plus en plus mal avec le temps.

Une approche différente

Je suis un grand fan des tables auxiliaires; J'écris publiquement sur les tableaux de calendrier et les tableaux de nombres depuis une décennie, et je les utilise depuis bien plus longtemps. Et c'est un cas où je pense qu'un tableau pré-rempli pourrait être très utile. Pourquoi compter sur la génération de nombres aléatoires au moment de l'exécution et sur la gestion des doublons potentiels, alors que vous pouvez remplir toutes ces valeurs à l'avance et savoir - avec une certitude à 100 %, si vous protégez vos tables contre le DML non autorisé - que la prochaine valeur que vous sélectionnez n'a jamais été déjà utilisé ?

CREATE TABLE dbo.RandomNumbers1
(
  RowID INT,
  Value INT, --UNIQUE,
  PRIMARY KEY (RowID, Value)
);
 
;WITH x AS 
(
  SELECT TOP (1000000) s1.[object_id]
  FROM sys.all_objects AS s1
  CROSS JOIN sys.all_objects AS s2
  ORDER BY s1.[object_id]
)
INSERT dbo.RandomNumbers(RowID, Value)
SELECT
    r = ROW_NUMBER() OVER (ORDER BY [object_id]),
    n = ROW_NUMBER() OVER (ORDER BY NEWID())
FROM x
ORDER BY r;

Cette population a pris 9 secondes à créer (dans une machine virtuelle sur un ordinateur portable) et a occupé environ 17 Mo sur disque. Les données du tableau ressemblent à ceci :

(Si nous étions inquiets de la façon dont les nombres étaient remplis, nous pourrions ajouter une contrainte unique sur la colonne Valeur, ce qui ferait 30 Mo de table. Si nous avions appliqué la compression de page, cela aurait été de 11 Mo ou 25 Mo, respectivement. )

J'ai créé une autre copie de la table et l'ai remplie avec les mêmes valeurs, afin de pouvoir tester deux méthodes différentes pour dériver la valeur suivante :

CREATE TABLE dbo.RandomNumbers2
(
  RowID INT,
  Value INT, -- UNIQUE
  PRIMARY KEY (RowID, Value)
);
 
INSERT dbo.RandomNumbers2(RowID, Value)
  SELECT RowID, Value FROM dbo.RandomNumbers1;

Maintenant, chaque fois que nous voulons un nouveau nombre aléatoire, nous pouvons simplement en retirer un de la pile de nombres existants et le supprimer. Cela nous évite d'avoir à nous soucier des doublons et nous permet d'extraire des nombres - à l'aide d'un index clusterisé - qui sont en fait déjà dans un ordre aléatoire. (Strictement parlant, nous n'avons pas à supprimer les nombres tels que nous les utilisons; nous pourrions ajouter une colonne pour indiquer si une valeur a été utilisée - cela faciliterait le rétablissement et la réutilisation de cette valeur dans le cas où un client serait supprimé ultérieurement ou si quelque chose se passait mal en dehors de cette transaction mais avant qu'ils ne soient entièrement créés.)

DECLARE @holding TABLE(CustomerID INT);
 
DELETE TOP (1) dbo.RandomNumbers1
OUTPUT deleted.Value INTO @holding;
 
INSERT dbo.Customers(CustomerID, ...other columns...)
  SELECT CustomerID, ...other params...
    FROM @holding;

J'ai utilisé une variable de table pour contenir la sortie intermédiaire, car il existe diverses limitations avec DML composable qui peuvent rendre impossible l'insertion dans la table Customers directement à partir de DELETE (par exemple, la présence de clés étrangères). Néanmoins, reconnaissant que cela ne sera pas toujours possible, j'ai également voulu tester cette méthode :

DELETE TOP (1) dbo.RandomNumbers2
  OUTPUT deleted.Value, ...other params...
  INTO dbo.Customers(CustomerID, ...other columns...);

Notez qu'aucune de ces solutions ne garantit vraiment un ordre aléatoire, en particulier si la table des nombres aléatoires a d'autres index (comme un index unique sur la colonne Valeur). Il n'y a aucun moyen de définir une commande pour un DELETE en utilisant TOP; de la documentation :

Lorsque TOP est utilisé avec INSERT, UPDATE, MERGE ou DELETE, les lignes référencées ne sont organisées dans aucun ordre et la clause ORDER BY ne peut pas être directement spécifiée dans ces instructions. Si vous devez utiliser TOP pour insérer, supprimer ou modifier des lignes dans un ordre chronologique significatif, vous devez utiliser TOP avec une clause ORDER BY spécifiée dans une instruction de sous-sélection.

Donc, si vous voulez garantir un ordre aléatoire, vous pouvez plutôt faire quelque chose comme ceci :

DECLARE @holding TABLE(CustomerID INT);
 
;WITH x AS 
(
  SELECT TOP (1) Value 
  FROM dbo.RandomNumbers2
  ORDER BY RowID
)
DELETE x OUTPUT deleted.Value INTO @holding;
 
INSERT dbo.Customers(CustomerID, ...other columns...)
  SELECT CustomerID, ...other params...
    FROM @holding;

Une autre considération ici est que, pour ces tests, les tables Customers ont une clé primaire en cluster sur la colonne CustomerID; cela entraînera certainement des fractionnements de page lorsque vous insérez des valeurs aléatoires. Dans le monde réel, si vous aviez cette exigence, vous finiriez probablement par vous regrouper sur une colonne différente.

Notez que j'ai également omis les transactions et la gestion des erreurs ici, mais celles-ci devraient également être prises en compte pour le code de production.

Tests de performances

Pour établir des comparaisons de performances réalistes, j'ai créé cinq procédures stockées, représentant les scénarios suivants (vitesse de test, distribution et fréquence de collision des différentes méthodes aléatoires, ainsi que la vitesse d'utilisation d'une table prédéfinie de nombres aléatoires) :

  • Génération d'exécution à l'aide de CHECKSUM(NEWID())
  • Génération d'exécution à l'aide de CRYPT_GEN_RANDOM()
  • Génération d'exécution à l'aide de RAND()
  • Tableau de nombres prédéfinis avec variable de tableau
  • Tableau de nombres prédéfinis avec insertion directe

Ils utilisent une table de journalisation pour suivre la durée et le nombre de collisions :

CREATE TABLE dbo.CustomerLog
(
  LogID INT IDENTITY(1,1) PRIMARY KEY, 
  pid INT, 
  collisions INT, 
  duration INT -- microseconds
);

Le code des procédures suit (cliquez pour afficher/masquer) :

/* Runtime using CHECKSUM(NEWID()) */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_Checksum]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE 
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_Checksum
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_Checksum(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 1, @collisions, @duration;
END
GO
 
/* runtime using CRYPT_GEN_RANDOM() */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_CryptGen]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_CryptGen
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_CryptGen(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 2, @collisions, @duration;
END
GO
 
/* runtime using RAND() */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_Rand]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = CONVERT(INT, RAND() * 1000000) + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_Rand
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_Rand(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = CONVERT(INT, RAND() * 1000000) + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 3, @collisions, @duration;
END
GO
 
/* pre-defined using a table variable */
 
CREATE PROCEDURE [dbo].[AddCustomer_Predefined_TableVariable]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @start DATETIME2(7) = SYSDATETIME(), @duration INT;
 
  DECLARE @holding TABLE(CustomerID INT);
 
  DELETE TOP (1) dbo.RandomNumbers1
    OUTPUT deleted.Value INTO @holding;
 
  INSERT dbo.Customers_Predefined_TableVariable(CustomerID)
    SELECT CustomerID FROM @holding;
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, duration) SELECT 4, @duration;
END
GO
 
/* pre-defined using a direct insert */
 
CREATE PROCEDURE [dbo].[AddCustomer_Predefined_Direct]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @start DATETIME2(7) = SYSDATETIME(), @duration INT;
 
  DELETE TOP (1) dbo.RandomNumbers2
    OUTPUT deleted.Value INTO dbo.Customers_Predefined_Direct;
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, duration) SELECT 5, @duration;
END
GO

Et pour tester cela, j'exécuterais chaque procédure stockée 1 000 000 fois :

EXEC dbo.AddCustomer_Runtime_Checksum;
EXEC dbo.AddCustomer_Runtime_CryptGen;
EXEC dbo.AddCustomer_Runtime_Rand;
EXEC dbo.AddCustomer_Predefined_TableVariable;
EXEC dbo.AddCustomer_Predefined_Direct;
GO 1000000

Sans surprise, les méthodes utilisant la table prédéfinie de nombres aléatoires ont pris un peu plus de temps *au début du test*, car elles devaient effectuer à la fois des E/S de lecture et d'écriture à chaque fois. En gardant à l'esprit que ces chiffres sont en microsecondes , voici les durées moyennes pour chaque procédure, à différents intervalles en cours de route (moyenne sur les 10 000 premières exécutions, les 10 000 exécutions intermédiaires, les 10 000 dernières exécutions et les 1 000 dernières exécutions) :


Durée moyenne (en microsecondes) de génération aléatoire en utilisant différentes approches

Cela fonctionne bien pour toutes les méthodes lorsqu'il y a peu de lignes dans la table Customers, mais à mesure que la table devient de plus en plus grande, le coût de la vérification du nouveau nombre aléatoire par rapport aux données existantes à l'aide des méthodes d'exécution augmente considérablement, à la fois en raison de l'augmentation de I /O et aussi parce que le nombre de collisions augmente (vous obligeant à essayer et réessayer). Comparez la durée moyenne dans les plages suivantes de nombre de collisions (et rappelez-vous que ce modèle n'affecte que les méthodes d'exécution) :


Durée moyenne (en microsecondes) pendant différentes plages de collisions

J'aimerais qu'il y ait un moyen simple de représenter graphiquement la durée par rapport au nombre de collisions. Je vous laisse avec cette friandise :lors des trois dernières insertions, les méthodes d'exécution suivantes ont dû effectuer autant de tentatives avant de finalement tomber sur le dernier identifiant unique qu'elles recherchaient, et voici le temps que cela a pris :

Nombre de collisions Durée (microsecondes)
CHECKSUM(NEWID()) 3e à la dernière ligne 63 545 639 358
2ème à dernière ligne 164 807 1 605 695
Dernière ligne 30 630 296 207
CRYPT_GEN_RANDOM() 3e à la dernière ligne 219 766 2 229 166
2ème à dernière ligne 255 463 2 681 468
Dernière ligne 136 342 1 434 725
RAND() 3e à la dernière ligne 129 764 1 215 994
2ème à dernière ligne 220 195 2 088 992
Dernière ligne 440 765 4 161 925

Durée excessive et collisions près de la fin de la ligne

Il est intéressant de noter que la dernière ligne n'est pas toujours celle qui produit le plus grand nombre de collisions, cela peut donc devenir un vrai problème bien avant que vous ayez utilisé plus de 999 000 valeurs.

Une autre considération

Vous pouvez envisager de configurer une sorte d'alerte ou de notification lorsque la table RandomNumbers commence à descendre en dessous d'un certain nombre de lignes (à ce stade, vous pouvez remplir à nouveau la table avec un nouvel ensemble de 1 000 001 à 2 000 000, par exemple). Vous auriez à faire quelque chose de similaire si vous génériez des nombres aléatoires à la volée - si vous maintenez cela dans une plage de 1 à 1 000 000, vous devrez alors modifier le code pour générer des nombres à partir d'une plage différente une fois que vous ' J'ai utilisé toutes ces valeurs.

Si vous utilisez la méthode du nombre aléatoire à l'exécution, vous pouvez éviter cette situation en modifiant constamment la taille du pool à partir duquel vous tirez un nombre aléatoire (ce qui devrait également se stabiliser et réduire considérablement le nombre de collisions). Par exemple, au lieu de :

DECLARE @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1;

Vous pouvez baser le pool sur le nombre de lignes déjà présentes dans le tableau :

DECLARE @total INT = 1000000 + ISNULL(
   (SELECT SUM(row_count) FROM sys.dm_db_partition_stats
    WHERE [object_id] = OBJECT_ID('dbo.Customers') AND index_id = 1),0);

Maintenant, votre seul véritable souci est lorsque vous approchez de la limite supérieure pour INT

Remarque :J'ai également écrit récemment une astuce à ce sujet sur MSSQLTips.com.