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

Comparaison des méthodes de fractionnement / concaténation de chaînes

Plus tôt ce mois-ci, j'ai publié une astuce sur quelque chose que nous souhaiterions probablement tous ne pas avoir à faire :trier ou supprimer les doublons de chaînes délimitées, impliquant généralement des fonctions définies par l'utilisateur (UDF). Parfois, vous devez réassembler la liste (sans les doublons) dans l'ordre alphabétique, et parfois vous devrez peut-être conserver l'ordre d'origine (il peut s'agir de la liste des colonnes clés dans un mauvais index, par exemple).

Pour ma solution, qui traite les deux scénarios, j'ai utilisé une table de nombres, ainsi qu'une paire de fonctions définies par l'utilisateur (UDF) - l'une pour diviser la chaîne, l'autre pour la réassembler. Vous pouvez voir cette astuce ici :

  • Suppression des doublons des chaînes dans SQL Server

Bien sûr, il existe plusieurs façons de résoudre ce problème; Je fournissais simplement une méthode à essayer si vous êtes coincé avec ces données de structure. @Phil_Factor de Red-Gate a suivi avec un post rapide montrant son approche, qui évite les fonctions et le tableau des nombres, optant à la place pour la manipulation XML en ligne. Il dit qu'il préfère avoir des requêtes à instruction unique et éviter à la fois les fonctions et le traitement ligne par ligne :

  • Déduplication des listes délimitées dans SQL Server

Puis un lecteur, Steve Mangiameli, a posté une solution en boucle en commentaire de l'astuce. Son raisonnement était que l'utilisation d'une table de nombres lui semblait trop technique.

Nous n'avons pas tous les trois réussi à aborder un aspect de cela qui sera généralement assez important si vous effectuez la tâche assez souvent ou à n'importe quel niveau :performance .

Test

Curieux de voir à quel point les approches XML en ligne et en boucle fonctionneraient par rapport à ma solution basée sur une table de nombres, j'ai construit une table fictive pour effectuer des tests ; mon objectif était de 5 000 lignes, avec une longueur de chaîne moyenne supérieure à 250 caractères et au moins 10 éléments dans chaque chaîne. Avec un cycle d'expériences très court, j'ai pu réaliser quelque chose de très proche de cela avec le code suivant :

CREATE TABLE dbo.SourceTable
(
  [RowID]         int IDENTITY(1,1) PRIMARY KEY CLUSTERED,
  DelimitedString varchar(8000)
);
GO
 
;WITH s(s) AS 
(
 SELECT TOP (250) o.name + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
  (
   SELECT N'/column_' + c.name 
    FROM sys.all_columns AS c
    WHERE c.[object_id] = o.[object_id]
    ORDER BY NEWID()
    FOR XML PATH(N''), TYPE).value(N'.[1]', N'nvarchar(max)'
   ),
   -- make fake duplicates using 5 most common column names:
   N'/column_name/',        N'/name/name/foo/name/name/id/name/'),
   N'/column_status/',      N'/id/status/blat/status/foo/status/name/'),
   N'/column_type/',        N'/type/id/name/type/id/name/status/id/type/'),
   N'/column_object_id/',   N'/object_id/blat/object_id/status/type/name/'),
   N'/column_pdw_node_id/', N'/pdw_node_id/name/pdw_node_id/name/type/name/')
 FROM sys.all_objects AS o
 WHERE EXISTS 
 (
  SELECT 1 FROM sys.all_columns AS c 
  WHERE c.[object_id] = o.[object_id]
 )
 ORDER BY NEWID()
)
INSERT dbo.SourceTable(DelimitedString)
SELECT s FROM s;
GO 20

Cela a produit un tableau avec des exemples de lignes ressemblant à ceci (valeurs tronquées) :

RowID    DelimitedString
-----    ---------------
1        master_files/column_redo_target_fork_guid/.../column_differential_base_lsn/...
2        allocation_units/column_used_pages/.../column_data_space_id/type/id/name/type/...
3        foreign_key_columns/column_parent_object_id/column_constraint_object_id/...

Les données dans leur ensemble avaient le profil suivant, qui devrait être suffisamment bon pour découvrir d'éventuels problèmes de performances :

;WITH cte([Length], ElementCount) AS 
(
  SELECT 1.0*LEN(DelimitedString),
    1.0*LEN(REPLACE(DelimitedString,'/',''))
  FROM dbo.SourceTable
)
SELECT row_count = COUNT(*),
 avg_size     = AVG([Length]),
 max_size     = MAX([Length]),
 avg_elements = AVG(1 + [Length]-[ElementCount]),
 sum_elements = SUM(1 + [Length]-[ElementCount])
FROM cte;
 
EXEC sys.sp_spaceused N'dbo.SourceTable';
 
/* results (numbers may vary slightly, depending on SQL Server version the user objects in your database):
 
row_count    avg_size      max_size    avg_elements    sum_elements
---------    ----------    --------    ------------    ------------
5000         299.559000    2905.0      17.650000       88250.0
 
 
reserved    data       index_size    unused
--------    -------    ----------    ------
1672 KB     1648 KB    16 KB         8 KB
*/

Notez que je suis passé à varchar ici depuis nvarchar dans l'article d'origine, car les exemples fournis par Phil et Steve supposaient varchar , des chaînes ne dépassant pas 255 ou 8 000 caractères, des délimiteurs à un seul caractère, etc. possible - idéalement rien. En réalité, j'utiliserais toujours nvarchar et ne supposez rien sur la chaîne la plus longue possible. Dans ce cas, je savais que je ne perdais aucune donnée car la chaîne la plus longue ne comporte que 2 905 caractères et, dans cette base de données, je n'ai aucune table ou colonne utilisant des caractères Unicode.

Ensuite, j'ai créé mes fonctions (qui nécessitent une table de nombres). Un lecteur a repéré un problème dans la fonction dans mon conseil, où j'ai supposé que le délimiteur serait toujours un seul caractère, et j'ai corrigé cela ici. J'ai également converti à peu près tout en varchar(8000) pour uniformiser les règles du jeu en termes de types et de longueurs de cordes.

DECLARE @UpperLimit INT = 1000000;
 
;WITH n(rn) AS
(
  SELECT ROW_NUMBER() OVER (ORDER BY s1.[object_id])
  FROM sys.all_columns AS s1
  CROSS JOIN sys.all_columns AS s2
)
SELECT [Number] = rn
INTO dbo.Numbers FROM n
WHERE rn <= @UpperLimit;
 
CREATE UNIQUE CLUSTERED INDEX n ON dbo.Numbers([Number]);
GO
 
CREATE FUNCTION [dbo].[SplitString] -- inline TVF
(
  @List  varchar(8000),
  @Delim varchar(32)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
  RETURN
  (
    SELECT 
      rn, 
      vn = ROW_NUMBER() OVER (PARTITION BY [Value] ORDER BY rn), 
      [Value]
    FROM 
    ( 
      SELECT 
        rn = ROW_NUMBER() OVER (ORDER BY CHARINDEX(@Delim, @List + @Delim)),
        [Value] = LTRIM(RTRIM(SUBSTRING(@List, [Number],
                  CHARINDEX(@Delim, @List + @Delim, [Number]) - [Number])))
      FROM dbo.Numbers
      WHERE Number <= LEN(@List)
      AND SUBSTRING(@Delim + @List, [Number], LEN(@Delim)) = @Delim
    ) AS x
  );
GO
 
CREATE FUNCTION [dbo].[ReassembleString] -- scalar UDF
(
  @List  varchar(8000),
  @Delim varchar(32),
  @Sort  varchar(32)
)
RETURNS varchar(8000)
WITH SCHEMABINDING
AS
BEGIN
  RETURN 
  ( 
    SELECT newval = STUFF((
     SELECT @Delim + x.[Value] 
     FROM dbo.SplitString(@List, @Delim) AS x
     WHERE (x.vn = 1) -- filter out duplicates
     ORDER BY CASE @Sort
       WHEN 'OriginalOrder' THEN CONVERT(int, x.rn)
       WHEN 'Alphabetical'  THEN CONVERT(varchar(8000), x.[Value])
       ELSE CONVERT(SQL_VARIANT, NULL) END
     FOR XML PATH(''), TYPE).value(N'(./text())[1]',N'varchar(8000)'),1,LEN(@Delim),'')
  );
END
GO

Ensuite, j'ai créé une seule fonction table en ligne qui combinait les deux fonctions ci-dessus, ce que j'aurais aimé faire dans l'article d'origine, afin d'éviter complètement la fonction scalaire. (Bien qu'il soit vrai que tous les fonctions scalaires sont terribles à grande échelle, il y a très peu d'exceptions.)

CREATE FUNCTION [dbo].[RebuildString]
(
  @List  varchar(8000),
  @Delim varchar(32),
  @Sort  varchar(32)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
  RETURN
  ( 
    SELECT [Output] = STUFF((
     SELECT @Delim + x.[Value] 
     FROM 
	 ( 
	   SELECT rn, [Value], vn = ROW_NUMBER() OVER (PARTITION BY [Value] ORDER BY rn)
	   FROM      
	   ( 
	     SELECT rn = ROW_NUMBER() OVER (ORDER BY CHARINDEX(@Delim, @List + @Delim)),
           [Value] = LTRIM(RTRIM(SUBSTRING(@List, [Number],
                  CHARINDEX(@Delim, @List + @Delim, [Number]) - [Number])))
         FROM dbo.Numbers
         WHERE Number <= LEN(@List)
         AND SUBSTRING(@Delim + @List, [Number], LEN(@Delim)) = @Delim
	   ) AS y 
     ) AS x
     WHERE (x.vn = 1)
     ORDER BY CASE @Sort
       WHEN 'OriginalOrder' THEN CONVERT(int, x.rn)
       WHEN 'Alphabetical'  THEN CONVERT(varchar(8000), x.[Value])
       ELSE CONVERT(sql_variant, NULL) END
     FOR XML PATH(''), TYPE).value(N'(./text())[1]',N'varchar(8000)'),1,LEN(@Delim),'')
  );
GO

J'ai également créé des versions distinctes du TVF en ligne dédiées à chacun des deux choix de tri, afin d'éviter la volatilité du CASE expression, mais il s'est avéré qu'elle n'avait pas du tout d'impact dramatique.

Ensuite, j'ai créé les deux fonctions de Steve :

CREATE FUNCTION [dbo].[gfn_ParseList] -- multi-statement TVF
  (@strToPars VARCHAR(8000), @parseChar CHAR(1))
RETURNS @parsedIDs TABLE
   (ParsedValue VARCHAR(255), PositionID INT IDENTITY)
AS
BEGIN
DECLARE 
  @startPos INT = 0
  , @strLen INT = 0
 
WHILE LEN(@strToPars) >= @startPos
  BEGIN
    IF (SELECT CHARINDEX(@parseChar,@strToPars,(@startPos+1))) > @startPos
      SELECT @strLen  = CHARINDEX(@parseChar,@strToPars,(@startPos+1))  - @startPos
    ELSE
      BEGIN
        SET @strLen = LEN(@strToPars) - (@startPos -1)
 
        INSERT @parsedIDs
        SELECT RTRIM(LTRIM(SUBSTRING(@strToPars,@startPos, @strLen)))
 
        BREAK
      END
 
    SELECT @strLen  = CHARINDEX(@parseChar,@strToPars,(@startPos+1))  - @startPos
 
    INSERT @parsedIDs
    SELECT RTRIM(LTRIM(SUBSTRING(@strToPars,@startPos, @strLen)))
    SET @startPos = @startPos+@strLen+1
  END
RETURN
END  
GO
 
CREATE FUNCTION [dbo].[ufn_DedupeString] -- scalar UDF
(
  @dupeStr VARCHAR(MAX), @strDelimiter CHAR(1), @maintainOrder BIT
)
-- can't possibly return nvarchar, but I'm not touching it
RETURNS NVARCHAR(MAX)
AS
BEGIN  
  DECLARE @tblStr2Tbl  TABLE (ParsedValue VARCHAR(255), PositionID INT);
  DECLARE @tblDeDupeMe TABLE (ParsedValue VARCHAR(255), PositionID INT);
 
  INSERT @tblStr2Tbl
  SELECT DISTINCT ParsedValue, PositionID FROM dbo.gfn_ParseList(@dupeStr,@strDelimiter);  
 
  WITH cteUniqueValues
  AS
  (
    SELECT DISTINCT ParsedValue
    FROM @tblStr2Tbl
  )
  INSERT @tblDeDupeMe
  SELECT d.ParsedValue
    , CASE @maintainOrder
        WHEN 1 THEN MIN(d.PositionID)
      ELSE ROW_NUMBER() OVER (ORDER BY d.ParsedValue)
    END AS PositionID
  FROM cteUniqueValues u
    JOIN @tblStr2Tbl d ON d.ParsedValue=u.ParsedValue
  GROUP BY d.ParsedValue
  ORDER BY d.ParsedValue
 
  DECLARE 
    @valCount INT
  , @curValue VARCHAR(255) =''
  , @posValue INT=0
  , @dedupedStr VARCHAR(4000)=''; 
 
  SELECT @valCount = COUNT(1) FROM @tblDeDupeMe;
  WHILE @valCount > 0
  BEGIN
    SELECT @posValue=a.minPos, @curValue=d.ParsedValue
    FROM (SELECT MIN(PositionID) minPos FROM @tblDeDupeMe WHERE PositionID  > @posValue) a
      JOIN @tblDeDupeMe d ON d.PositionID=a.minPos;
 
    SET @dedupedStr+=@curValue;
    SET @valCount-=1;
 
    IF @valCount > 0
      SET @dedupedStr+='/';
  END
  RETURN @dedupedStr;
END
GO

Ensuite, j'ai mis les requêtes directes de Phil dans mon banc de test (notez que ses requêtes encodent &lt; comme &lt; pour les protéger des erreurs d'analyse XML, mais ils n'encodent pas > ou & - J'ai ajouté des espaces réservés au cas où vous auriez besoin de vous prémunir contre les chaînes susceptibles de contenir ces caractères problématiques) :

-- Phil's query for maintaining original order
 
SELECT /*the re-assembled list*/
  stuff(
    (SELECT  '/'+TheValue  FROM
            (SELECT  x.y.value('.','varchar(20)') AS Thevalue,
                row_number() OVER (ORDER BY (SELECT 1)) AS TheOrder
                FROM XMLList.nodes('/list/i/text()') AS x ( y )
         )Nodes(Thevalue,TheOrder)
       GROUP BY TheValue
         ORDER BY min(TheOrder)
         FOR XML PATH('')
        ),1,1,'')
   as Deduplicated
FROM (/*XML version of the original list*/
  SELECT convert(XML,'<list><i>'
         --+replace(replace(
         +replace(replace(ASCIIList,'<','&lt;') --,'>','&gt;'),'&','&amp;')
	 ,'/','</i><i>')+'</i></list>')
   FROM (SELECT DelimitedString FROM dbo.SourceTable
   )XMLlist(AsciiList)
 )lists(XMLlist);
 
 
-- Phil's query for alpha
 
SELECT 
  stuff( (SELECT  DISTINCT '/'+x.y.value('.','varchar(20)')
                  FROM XMLList.nodes('/list/i/text()') AS x ( y )
                  FOR XML PATH('')),1,1,'') as Deduplicated
  FROM (
  SELECT convert(XML,'<list><i>'
         --+replace(replace(
         +replace(replace(ASCIIList,'<','&lt;') --,'>','&gt;'),'&','&amp;')
	 ,'/','</i><i>')+'</i></list>')
   FROM (SELECT AsciiList FROM 
	 (SELECT DelimitedString FROM dbo.SourceTable)ListsWithDuplicates(AsciiList)
   )XMLlist(AsciiList)
 )lists(XMLlist);

Le banc d'essai était essentiellement ces deux requêtes, ainsi que les appels de fonction suivants. Une fois que j'ai validé qu'ils renvoyaient tous les mêmes données, j'ai intercalé le script avec DATEDIFF résultat et l'a consigné dans une table :

-- Maintain original order
 
  -- My UDF/TVF pair from the original article
  SELECT UDF_Original = dbo.ReassembleString(DelimitedString, '/', 'OriginalOrder') 
  FROM dbo.SourceTable ORDER BY RowID;
 
  -- My inline TVF based on the original article
  SELECT TVF_Original = f.[Output] FROM dbo.SourceTable AS t
    CROSS APPLY dbo.RebuildString(t.DelimitedString, '/', 'OriginalOrder') AS f
    ORDER BY t.RowID;
 
  -- Steve's UDF/TVF pair:
  SELECT Steve_Original = dbo.ufn_DedupeString(DelimitedString, '/', 1) 
  FROM dbo.SourceTable;
 
  -- Phil's first query from above
 
-- Reassemble in alphabetical order
 
  -- My UDF/TVF pair from the original article
  SELECT UDF_Alpha = dbo.ReassembleString(DelimitedString, '/', 'Alphabetical') 
  FROM dbo.SourceTable ORDER BY RowID;
 
  -- My inline TVF based on the original article
  SELECT TVF_Alpha = f.[Output] FROM dbo.SourceTable AS t
    CROSS APPLY dbo.RebuildString(t.DelimitedString, '/', 'Alphabetical') AS f
    ORDER BY t.RowID;
 
  -- Steve's UDF/TVF pair:
  SELECT Steve_Alpha = dbo.ufn_DedupeString(DelimitedString, '/', 0) 
  FROM dbo.SourceTable;
 
  -- Phil's second query from above

Et puis j'ai effectué des tests de performances sur deux systèmes différents (un quadricœur avec 8 Go et une machine virtuelle à 8 cœurs avec 32 Go), et dans chaque cas, sur SQL Server 2012 et SQL Server 2016 CTP 3.2 (13.0.900.73).

Résultats

Les résultats que j'ai observés sont résumés dans le tableau suivant, qui montre la durée en millisecondes de chaque type de requête, en moyenne sur l'ordre alphabétique et d'origine, les quatre combinaisons serveur/version et une série de 15 exécutions pour chaque permutation. Cliquez pour agrandir :

Cela montre que la table des nombres, bien que jugée sur-conçue, a en fait donné la solution la plus efficace (au moins en termes de durée). C'était mieux, bien sûr, avec le TVF unique que j'ai implémenté plus récemment qu'avec les fonctions imbriquées de l'article original, mais les deux solutions tournent en rond autour des deux alternatives.

Pour entrer plus en détail, voici les répartitions pour chaque machine, version et type de requête, pour maintenir l'ordre d'origine :

…et pour remonter la liste par ordre alphabétique :

Ceux-ci montrent que le choix de tri a peu d'impact sur le résultat - les deux graphiques sont pratiquement identiques. Et cela a du sens car, compte tenu de la forme des données d'entrée, je ne peux imaginer aucun index qui rendrait le tri plus efficace - c'est une approche itérative, peu importe comment vous les découpez ou comment vous renvoyez les données. Mais il est clair que certaines approches itératives peuvent être généralement pires que d'autres, et ce n'est pas nécessairement l'utilisation d'une FDU (ou d'une table de nombres) qui les rend ainsi.

Conclusion

Jusqu'à ce que nous ayons une fonctionnalité native de fractionnement et de concaténation dans SQL Server, nous allons utiliser toutes sortes de méthodes non intuitives pour faire le travail, y compris des fonctions définies par l'utilisateur. Si vous manipulez une seule chaîne à la fois, vous ne verrez pas beaucoup de différence. Mais à mesure que vos données évoluent, il vaudra la peine de tester différentes approches (et je ne suggère en aucun cas que les méthodes ci-dessus sont les meilleures que vous trouverez - je n'ai même pas regardé CLR, par exemple, ou autres approches T-SQL de cette série).