sql >> Base de Datos >  >> RDS >> Database

Sorpresas y suposiciones de rendimiento:STRING_SPLIT()

Hace más de tres años, publiqué una serie de tres partes sobre cómo dividir hilos:

  • Dividir cadenas de la manera correcta, o de la siguiente mejor manera
  • Dividiendo hilos:un seguimiento
  • Dividir cadenas:ahora con menos T-SQL

Luego, en enero, asumí un problema un poco más elaborado:

  • Comparación de métodos de división/concatenación de cadenas

En todo momento, mi conclusión ha sido:DEJA DE HACER ESTO EN T-SQL . Use CLR o, mejor aún, pase parámetros estructurados como DataTables desde su aplicación a parámetros con valores de tabla (TVP) en sus procedimientos, evitando toda la construcción y deconstrucción de cadenas por completo, que es realmente la parte de la solución que causa problemas de rendimiento.

Y luego apareció SQL Server 2016...

Cuando se lanzó RC0, se documentó una nueva función sin mucha fanfarria:STRING_SPLIT . Un ejemplo rápido:

SELECT * FROM STRING_SPLIT('a,b,cd', ',');
 
/* result:
 
    value
    --------
    a
    b
    cd
*/

Atrajo la atención de algunos colegas, incluido Dave Ballantyne, quien escribió sobre las características principales, pero tuvo la amabilidad de ofrecerme el derecho de preferencia en una comparación de rendimiento.

Este es principalmente un ejercicio académico, porque con un gran conjunto de limitaciones en la primera iteración de la función, probablemente no sea factible para una gran cantidad de casos de uso. Aquí está la lista de las observaciones que Dave y yo hicimos, algunas de las cuales pueden ser motivo de ruptura en ciertos escenarios:

  • la función requiere que la base de datos esté en el nivel de compatibilidad 130;
  • solo acepta delimitadores de un solo carácter;
  • no hay forma de agregar columnas de salida (como una columna que indica la posición ordinal dentro de la cadena);
    • relacionado, no hay forma de controlar la ordenación:las únicas opciones son arbitrarias y alfabéticas ORDER BY value;
  • hasta ahora, siempre estima 50 filas de salida;
  • al usarlo para DML, en muchos casos obtendrá un carrete de mesa (para la protección de Halloween);
  • NULL la entrada conduce a un resultado vacío;
  • no hay forma de reducir predicados, como eliminar duplicados o cadenas vacías debido a delimitadores consecutivos;
  • no hay forma de realizar operaciones contra los valores de salida hasta después del hecho (por ejemplo, muchas funciones de división realizan LTRIM/RTRIM o conversiones explícitas para usted:STRING_SPLIT escupe todo lo feo, como los espacios iniciales).

Entonces, con esas limitaciones a la vista, podemos pasar a algunas pruebas de rendimiento. Dada la trayectoria de Microsoft con funciones integradas que aprovechan CLR bajo las sábanas (tos FORMAT() tos ), era escéptico acerca de si esta nueva función podría acercarse a los métodos más rápidos que había probado hasta la fecha.

Usemos divisores de cadenas para separar cadenas de números separados por comas, de esta manera nuestro nuevo amigo JSON también puede venir y jugar. Y diremos que ninguna lista puede superar los 8000 caracteres, por lo que no hay MAX se requieren tipos, y dado que son números, no tenemos que lidiar con nada exótico como Unicode.

Primero, creemos nuestras funciones, varias de las cuales adapté del primer artículo anterior. Dejé fuera un par que no sentí que pudieran competir; Lo dejaré como ejercicio para que el lector los pruebe.

    Tabla de números

    Esta nuevamente necesita algo de configuración, pero puede ser una mesa bastante pequeña debido a las limitaciones artificiales que estamos poniendo:

    SET NOCOUNT ON;
     
    DECLARE @UpperLimit INT = 8000;
     
    ;WITH n AS
    (
        SELECT
            x = ROW_NUMBER() OVER (ORDER BY s1.[object_id])
        FROM       sys.all_objects AS s1
        CROSS JOIN sys.all_objects AS s2
    )
    SELECT Number = x
      INTO dbo.Numbers
      FROM n
      WHERE x BETWEEN 1 AND @UpperLimit;
    GO
    CREATE UNIQUE CLUSTERED INDEX n ON dbo.Numbers(Number);

    Entonces la función:

    CREATE FUNCTION dbo.SplitStrings_Numbers
    (
      @List       varchar(8000), 
      @Delimiter  char(1)
    )
    RETURNS TABLE WITH SCHEMABINDING
    AS
      RETURN
      (
          SELECT [Value] = SUBSTRING(@List, [Number],
    	CHARINDEX(@Delimiter, @List + @Delimiter, [Number]) - [Number])
          FROM dbo.Numbers WHERE Number <= LEN(@List)
          AND SUBSTRING(@Delimiter + @List, [Number], 1) = @Delimiter
      );

    JSON

    Basado en un enfoque revelado por primera vez por el equipo del motor de almacenamiento, creé un contenedor similar alrededor de OPENJSON , solo tenga en cuenta que el delimitador tiene que ser una coma en este caso, o tiene que hacer una sustitución de cadena de trabajo pesado antes de pasar el valor a la función nativa:

    CREATE FUNCTION dbo.SplitStrings_JSON
    (
      @List       varchar(8000),
      @Delimiter  char(1) -- ignored but made automated testing easier
    )
    RETURNS TABLE WITH SCHEMABINDING
    AS
        RETURN (SELECT value FROM OPENJSON( CHAR(91) + @List + CHAR(93) ));

    El CHAR(91)/CHAR(93) solo está reemplazando [ y ] respectivamente debido a problemas de formato.

    XML

    CREATE FUNCTION dbo.SplitStrings_XML
    (
       @List       varchar(8000),
       @Delimiter  char(1)
    )
    RETURNS TABLE WITH SCHEMABINDING
    AS
       RETURN (SELECT [value] = y.i.value('(./text())[1]', 'varchar(8000)')
          FROM (SELECT x = CONVERT(XML, '<i>' 
              + REPLACE(@List, @Delimiter, '</i><i>') 
              + '</i>').query('.')
          ) AS a CROSS APPLY x.nodes('i') AS y(i));

    CLR

    Una vez más tomé prestado el confiable código de división de Adam Machanic de hace casi siete años, aunque es compatible con Unicode, MAX tipos y delimitadores de varios caracteres (y, de hecho, como no quiero meterme con el código de la función, esto limita nuestras cadenas de entrada a 4000 caracteres en lugar de 8000):

    CREATE FUNCTION dbo.SplitStrings_CLR
    (
       @List      nvarchar(MAX),
       @Delimiter nvarchar(255)
    )
    RETURNS TABLE ( value nvarchar(4000) )
    EXTERNAL NAME CLRUtilities.UserDefinedFunctions.SplitString_Multi;

    STRING_SPLIT

    Solo por consistencia, puse un envoltorio alrededor de STRING_SPLIT :

    CREATE FUNCTION dbo.SplitStrings_Native
    (
      @List       varchar(8000),
      @Delimiter  char(1)
    )
    RETURNS TABLE WITH SCHEMABINDING
    AS
        RETURN (SELECT value FROM STRING_SPLIT(@List, @Delimiter));

Datos de origen y verificación de integridad

Creé esta tabla para que sirviera como fuente de cadenas de entrada para las funciones:

CREATE TABLE dbo.SourceTable
(
  RowNum      int IDENTITY(1,1) PRIMARY KEY,
  StringValue varchar(8000)
);
 
;WITH x AS 
(
  SELECT TOP (60000) x = STUFF((SELECT TOP (ABS(o.[object_id] % 20))
   ',' + CONVERT(varchar(12), c.[object_id]) FROM sys.all_columns AS c
  WHERE c.[object_id] < o.[object_id] ORDER BY NEWID() FOR XML PATH(''), 
    TYPE).value(N'(./text())[1]', N'varchar(8000)'),1,1,'')
  FROM sys.all_objects AS o CROSS JOIN sys.all_objects AS o2
  ORDER BY NEWID()
) 
INSERT dbo.SourceTable(StringValue) 
  SELECT TOP (50000) x 
  FROM x WHERE x IS NOT NULL
  ORDER BY NEWID();

Solo como referencia, validemos que 50,000 filas llegaron a la tabla y verifiquemos la longitud promedio de la cadena y el número promedio de elementos por cadena:

SELECT 
  [Values] = COUNT(*),
  AvgStringLength = AVG(1.0*LEN(StringValue)),
  AvgElementCount = AVG(1.0*LEN(StringValue)-LEN(REPLACE(StringValue, ',','')))
 FROM dbo.SourceTable;
 
/* result:
 
    Values    AvgStringLength    AbgElementCount
    ------    ---------------    ---------------
     50000         108.476380           8.911840
*/

Y finalmente, asegurémonos de que cada función devuelva los datos correctos para cualquier RowNum dado. , así que elegiremos uno al azar y compararemos los valores obtenidos a través de cada método. Por supuesto, sus resultados variarán.

SELECT f.value
  FROM dbo.SourceTable AS s
  CROSS APPLY dbo.SplitStrings_/* method */(s.StringValue, ',') AS f
  WHERE s.RowNum = 37219
  ORDER BY f.value;

Efectivamente, todas las funciones funcionan como se esperaba (la ordenación no es numérica; recuerde, las funciones generan cadenas):

Conjunto de salida de muestra de cada una de las funciones

Pruebas de rendimiento

SELECT SYSDATETIME();
GO
DECLARE @x VARCHAR(8000);
SELECT @x = f.value 
  FROM dbo.SourceTable AS s
  CROSS APPLY dbo.SplitStrings_/* method */(s.StringValue,',') AS f;
GO 100
SELECT SYSDATETIME();

Ejecuté el código anterior 10 veces para cada método y promedié los tiempos para cada uno. Y aquí es donde vino la sorpresa para mí. Dadas las limitaciones en el STRING_SPLIT nativo función, mi suposición era que se armó rápidamente, y que el rendimiento daría crédito a eso. Chico, el resultado fue diferente de lo que esperaba:

Duración promedio de STRING_SPLIT en comparación con otros métodos

Actualización 2016-03-20

Según la siguiente pregunta de Lars, realicé las pruebas nuevamente con algunos cambios:

  • Supervisé mi instancia con SQL Sentry Performance Advisor para capturar el perfil de la CPU durante la prueba;
  • Capturé estadísticas de espera a nivel de sesión entre cada lote;
  • Inserté un retraso entre lotes para que la actividad se distinguiera visualmente en el panel de Performance Advisor.

Creé una nueva tabla para capturar información de estadísticas de espera:

CREATE TABLE dbo.Timings
(
  dt                  datetime,
  test                varchar(64),
  point               varchar(64),
  session_id          smallint,
  wait_type           nvarchar(60),
  wait_time_ms        bigint,
);

Luego, el código de cada prueba cambió a esto:

WAITFOR DELAY '00:00:30';
 
DECLARE @d DATETIME = SYSDATETIME();
 
INSERT dbo.Timings(dt, test, point, wait_type, wait_time_ms)
SELECT @d, test = /* 'method' */, point  = 'Start', wait_type, wait_time_ms
FROM sys.dm_exec_session_wait_stats WHERE session_id = @@SPID;
GO
 
DECLARE @x VARCHAR(8000);
SELECT @x = f.value 
  FROM dbo.SourceTable AS s
  CROSS APPLY dbo.SplitStrings_/* method */(s.StringValue, ',') AS f
GO 100
 
DECLARE @d DATETIME = SYSDATETIME();
 
INSERT dbo.Timings(dt, test, point, wait_type, wait_time_ms)
SELECT @d, /* 'method' */, 'End', wait_type, wait_time_ms
FROM sys.dm_exec_session_wait_stats WHERE session_id = @@SPID;

Ejecuté la prueba y luego ejecuté las siguientes consultas:

-- validate that timings were in same ballpark as previous tests
SELECT test, DATEDIFF(SECOND, MIN(dt), MAX(dt)) 
FROM dbo.Timings WITH (NOLOCK)
GROUP BY test ORDER BY 2 DESC;
 
-- determine window to apply to Performance Advisor dashboard
SELECT MIN(dt), MAX(dt) FROM dbo.Timings;
 
-- get wait stats registered for each session
SELECT test, wait_type, delta FROM
(
  SELECT f.test, rn = RANK() OVER (PARTITION BY f.point ORDER BY f.dt), 
    f.wait_type, delta = f.wait_time_ms - COALESCE(s.wait_time_ms, 0)
  FROM dbo.Timings AS f 
  LEFT OUTER JOIN dbo.Timings AS s
    ON s.test = f.test
    AND s.wait_type = f.wait_type
    AND s.point = 'Start'
  WHERE f.point = 'End'
) AS x 
WHERE delta > 0
ORDER BY rn, delta DESC;

Desde la primera consulta, los tiempos se mantuvieron consistentes con las pruebas anteriores (los graficaría nuevamente pero eso no revelaría nada nuevo).

A partir de la segunda consulta, pude resaltar este rango en el panel de Performance Advisor, y desde allí fue fácil identificar cada lote:

Lotes capturados en el gráfico de CPU en el panel de Performance Advisor

Claramente, todos los métodos *excepto* STRING_SPLIT fijó un solo núcleo durante la duración de la prueba (esta es una máquina de cuatro núcleos, y la CPU estaba constantemente en 25%). Es probable que Lars estuviera insinuando debajo de eso STRING_SPLIT es más rápido a costa de machacar la CPU, pero no parece que sea así.

Finalmente, a partir de la tercera consulta, pude ver las siguientes estadísticas de espera acumuladas después de cada lote:

Esperas por sesión, en milisegundos

Las esperas capturadas por el DMV no explican completamente la duración de las consultas, pero sirven para mostrar dónde adicional se incurre en esperas.

Conclusión

Si bien CLR personalizado todavía muestra una gran ventaja sobre los enfoques T-SQL tradicionales, y el uso de JSON para esta funcionalidad parece ser nada más que una novedad, STRING_SPLIT fue el claro ganador, por una milla. Entonces, si solo necesita dividir una cadena y puede lidiar con todas sus limitaciones, parece que esta es una opción mucho más viable de lo que esperaba. Con suerte, en versiones futuras veremos funciones adicionales, como una columna de salida que indica la posición ordinal de cada elemento, la capacidad de filtrar duplicados y cadenas vacías, y delimitadores de varios caracteres.

Abordo varios comentarios a continuación en dos publicaciones de seguimiento:

  • STRING_SPLIT() en SQL Server 2016:seguimiento n.º 1
  • STRING_SPLIT() en SQL Server 2016:seguimiento n.º 2