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

Vergleich von Methoden zum Aufteilen und Verketten von Zeichenfolgen

Anfang dieses Monats habe ich einen Tipp zu etwas veröffentlicht, von dem wir wahrscheinlich alle wünschen, dass wir es nicht tun müssten:Sortieren oder Entfernen von Duplikaten aus getrennten Zeichenfolgen, normalerweise mit benutzerdefinierten Funktionen (UDFs). Manchmal müssen Sie die Liste (ohne die Duplikate) in alphabetischer Reihenfolge neu zusammenstellen, und manchmal müssen Sie möglicherweise die ursprüngliche Reihenfolge beibehalten (z. B. die Liste der Schlüsselspalten in einem fehlerhaften Index).

Für meine Lösung, die beide Szenarien abdeckt, habe ich eine Zahlentabelle zusammen mit zwei benutzerdefinierten Funktionen (UDFs) verwendet – eine, um die Zeichenfolge zu teilen, die andere, um sie wieder zusammenzusetzen. Sie können diesen Tipp hier sehen:

  • Entfernen von Duplikaten aus Zeichenfolgen in SQL Server

Natürlich gibt es mehrere Möglichkeiten, dieses Problem zu lösen; Ich habe lediglich eine Methode bereitgestellt, um zu versuchen, ob Sie mit diesen Strukturdaten nicht weiterkommen. @Phil_Factor von Red-Gate folgte mit einem kurzen Beitrag, der seinen Ansatz zeigte, der die Funktionen und die Zahlentabelle vermeidet und sich stattdessen für die Inline-XML-Manipulation entscheidet. Er sagt, dass er Abfragen mit nur einer Anweisung bevorzugt und sowohl Funktionen als auch zeilenweise Verarbeitung vermeidet:

  • Deduplizieren von durch Trennzeichen getrennten Listen in SQL Server

Dann hat ein Leser, Steve Mangiameli, eine Schleifenlösung als Kommentar zum Tipp gepostet. Seine Begründung war, dass ihm die Verwendung einer Zahlentabelle übertrieben erschien.

Wir drei haben es alle drei versäumt, einen Aspekt davon anzusprechen, der normalerweise sehr wichtig ist, wenn Sie die Aufgabe oft genug oder in einem beliebigen Umfang ausführen:Leistung .

Testen

Neugierig zu sehen, wie gut die Inline-XML- und Schleifenansätze im Vergleich zu meiner auf Zahlentabellen basierenden Lösung funktionieren würden, habe ich eine fiktive Tabelle erstellt, um einige Tests durchzuführen. Mein Ziel waren 5.000 Zeilen mit einer durchschnittlichen Zeichenfolgenlänge von mehr als 250 Zeichen und mindestens 10 Elementen in jeder Zeichenfolge. Mit einem sehr kurzen Zyklus von Experimenten konnte ich mit dem folgenden Code etwas erreichen, das dem sehr nahe kommt:

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

Dies erzeugte eine Tabelle mit Beispielzeilen, die so aussehen (Werte abgeschnitten):

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/...

Die Daten insgesamt hatten das folgende Profil, das ausreichen sollte, um potenzielle Leistungsprobleme aufzudecken:

;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
*/

Beachten Sie, dass ich zu varchar gewechselt bin hier von nvarchar im ursprünglichen Artikel, da die von Phil und Steve bereitgestellten Beispiele varchar voraussetzten , Zeichenfolgen, die auf nur 255 oder 8000 Zeichen begrenzt sind, Einzelzeichen-Trennzeichen usw. Ich habe meine Lektion auf die harte Tour gelernt, dass, wenn Sie die Funktion von jemandem nehmen und sie in Leistungsvergleiche einbeziehen, Sie sich so wenig wie möglich ändern möglich – idealerweise nichts. In Wirklichkeit würde ich immer nvarchar verwenden und nichts über die längste mögliche Saite annehmen. In diesem Fall wusste ich, dass ich keine Daten verliere, da die längste Zeichenfolge nur 2.905 Zeichen lang ist und ich in dieser Datenbank keine Tabellen oder Spalten habe, die Unicode-Zeichen verwenden.

Als nächstes habe ich meine Funktionen erstellt (die eine Zahlentabelle erfordern). Ein Leser hat ein Problem in der Funktion in meinem Tipp entdeckt, bei dem ich davon ausgegangen bin, dass das Trennzeichen immer ein einzelnes Zeichen sein würde, und das hier korrigiert. Ich habe auch so ziemlich alles in varchar(8000) konvertiert um das Spielfeld in Bezug auf Saitentypen und -längen anzugleichen.

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

Als Nächstes habe ich eine einzelne Inline-Tabellenwertfunktion erstellt, die die beiden obigen Funktionen kombiniert, etwas, das ich mir jetzt wünschte, ich hätte es im Originalartikel getan, um die Skalarfunktion vollständig zu vermeiden. (Es stimmt zwar, dass nicht alle Skalarfunktionen sind im Maßstab schrecklich, es gibt nur sehr wenige Ausnahmen.)

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

Ich habe auch separate Versionen des Inline-TVF erstellt, die jeder der beiden Sortiermöglichkeiten gewidmet waren, um die Volatilität des CASE zu vermeiden Ausdruck, aber es stellte sich heraus, dass es überhaupt keine dramatischen Auswirkungen hatte.

Dann habe ich Steves zwei Funktionen erstellt:

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

Dann füge ich Phils direkte Abfragen in meine Testumgebung ein (beachten Sie, dass seine Abfragen &lt; kodieren als &lt; um sie vor XML-Parsing-Fehlern zu schützen, aber sie kodieren nicht > oder & – Ich habe Platzhalter hinzugefügt, falls Sie sich vor Zeichenfolgen schützen müssen, die möglicherweise diese problematischen Zeichen enthalten):

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

Der Prüfstand waren im Wesentlichen diese beiden Abfragen und auch die folgenden Funktionsaufrufe. Nachdem ich bestätigt hatte, dass sie alle die gleichen Daten zurückgaben, fügte ich das Skript mit DATEDIFF ein ausgegeben und in einer Tabelle protokolliert:

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

Und dann habe ich Leistungstests auf zwei verschiedenen Systemen (einem Quad-Core mit 8 GB und einer 8-Core-VM mit 32 GB) und jeweils sowohl auf SQL Server 2012 als auch auf SQL Server 2016 CTP 3.2 (13.0.900.73) durchgeführt /P>

Ergebnisse

Die von mir beobachteten Ergebnisse sind im folgenden Diagramm zusammengefasst, das die Dauer in Millisekunden für jeden Abfragetyp, gemittelt über die alphabetische und ursprüngliche Reihenfolge, die vier Server-/Versionskombinationen und eine Reihe von 15 Ausführungen für jede Permutation zeigt. Zum Vergrößern anklicken:

Dies zeigt, dass die Zahlentabelle, obwohl sie als überentwickelt gilt, tatsächlich die effizienteste Lösung (zumindest in Bezug auf die Dauer) lieferte. Das war natürlich mit dem Single-TVF, das ich vor kurzem implementiert habe, besser als mit den verschachtelten Funktionen aus dem ursprünglichen Artikel, aber beide Lösungen drehen sich um die beiden Alternativen.

Um mehr ins Detail zu gehen, sind hier die Aufschlüsselungen für jeden Computer, jede Version und jeden Abfragetyp, um die ursprüngliche Reihenfolge beizubehalten:

…und zum erneuten Zusammenstellen der Liste in alphabetischer Reihenfolge:

Diese zeigen, dass die Wahl der Sortierung wenig Einfluss auf das Ergebnis hatte – beide Diagramme sind praktisch identisch. Und das macht Sinn, denn angesichts der Form der Eingabedaten kann ich mir keinen Index vorstellen, der das Sortieren effizienter machen würde – es ist ein iterativer Ansatz, egal wie Sie es aufteilen oder wie Sie die Daten zurückgeben. Aber es ist klar, dass einige iterative Ansätze im Allgemeinen schlechter sein können als andere, und es ist nicht unbedingt die Verwendung einer UDF (oder einer Zahlentabelle), die sie so macht.

Schlussfolgerung

Bis wir eine native Teilungs- und Verkettungsfunktionalität in SQL Server haben, werden wir alle Arten von nicht intuitiven Methoden verwenden, um die Arbeit zu erledigen, einschließlich benutzerdefinierter Funktionen. Wenn Sie jeweils nur eine Saite bearbeiten, werden Sie keinen großen Unterschied feststellen. Aber wenn Ihre Daten skalieren, lohnt es sich, verschiedene Ansätze zu testen (und ich behaupte keineswegs, dass die oben genannten Methoden die besten sind, die Sie finden werden – ich habe mir zum Beispiel nicht einmal CLR angesehen, oder andere T-SQL-Ansätze aus dieser Reihe).