sql >> Base de Datos >  >> RDS >> Sqlserver

Agregación de cadenas a lo largo de los años en SQL Server

Desde SQL Server 2005, el truco de usar FOR XML PATH para desnormalizar cadenas y combinarlas en una sola lista (generalmente separada por comas) ha sido muy popular. Sin embargo, en SQL Server 2017, STRING_AGG() finalmente respondió a las súplicas generalizadas y de larga data de la comunidad para simular GROUP_CONCAT() y funcionalidad similar que se encuentra en otras plataformas. Recientemente comencé a modificar muchas de mis respuestas de Stack Overflow usando el método anterior, tanto para mejorar el código existente como para agregar un ejemplo adicional más adecuado para las versiones modernas.

Estaba un poco horrorizado por lo que encontré.

En más de una ocasión, tuve que verificar dos veces que el código fuera mío.

Un ejemplo rápido

Veamos una demostración simple del problema. Alguien tiene una tabla como esta:

CREATE TABLE dbo.FavoriteBands
(
  UserID   int,
  BandName nvarchar(255)
);
 
INSERT dbo.FavoriteBands
(
  UserID, 
  BandName
) 
VALUES
  (1, N'Pink Floyd'), (1, N'New Order'), (1, N'The Hip'),
  (2, N'Zamfir'),     (2, N'ABBA');

En la página que muestra las bandas favoritas de cada usuario, quieren que el resultado se vea así:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip
2        Zamfir, ABBA

En los días de SQL Server 2005, habría ofrecido esta solución:

SELECT DISTINCT UserID, Bands = 
      (SELECT BandName + ', '
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')) 
FROM dbo.FavoriteBands AS fb;

Pero cuando miro hacia atrás en este código ahora, veo muchos problemas que no puedo resistir solucionar.

COSAS

La falla más fatal en el código anterior es que deja una coma al final:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip, 
2        Zamfir, ABBA, 

Para resolver esto, a menudo veo personas que envuelven la consulta dentro de otra y luego rodean las Bands salida con LEFT(Bands, LEN(Bands)-1) . Pero esto es un cálculo adicional innecesario; en su lugar, podemos mover la coma al principio de la cadena y eliminar los primeros uno o dos caracteres usando STUFF . Entonces, no tenemos que calcular la longitud de la cadena porque es irrelevante.

SELECT DISTINCT UserID, Bands = STUFF(
--------------------------------^^^^^^
      (SELECT ', ' + BandName
--------------^^^^^^
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')), 1, 2, '')
--------------------------^^^^^^^^^^^
FROM dbo.FavoriteBands AS fb;

Puede ajustar esto aún más si está usando un delimitador más largo o condicional.

DISTINTO

El siguiente problema es el uso de DISTINCT . La forma en que funciona el código es que la tabla derivada genera una lista separada por comas para cada UserID valor, luego se eliminan los duplicados. Podemos ver esto mirando el plan y viendo que el operador relacionado con XML se ejecuta siete veces, aunque finalmente solo se devuelven tres filas:

Figura 1:Plan que muestra el filtro después de la agregación

Si cambiamos el código para usar GROUP BY en lugar de DISTINCT :

SELECT /* DISTINCT */ UserID, Bands = STUFF(
      (SELECT ', ' + BandName
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')), 1, 2, '')
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;
--^^^^^^^^^^^^^^^

Es una diferencia sutil y no cambia los resultados, pero podemos ver que el plan mejora. Básicamente, las operaciones XML se posponen hasta que se eliminan los duplicados:

Figura 2:Plan que muestra el filtro antes de la agregación

A esta escala, la diferencia es irrelevante. Pero, ¿y si añadimos algunos datos más? En mi sistema, esto agrega un poco más de 11 000 filas:

INSERT dbo.FavoriteBands(UserID, BandName)
  SELECT [object_id], name FROM sys.all_columns;

Si volvemos a ejecutar las dos consultas, las diferencias en duración y CPU son inmediatamente obvias:

Figura 3:resultados de tiempo de ejecución que comparan DISTINCT y GROUP BY

Pero otros efectos secundarios también son evidentes en los planes. En el caso de DISTINCT , el UDX se ejecuta una vez más para cada fila de la tabla, hay una cola de índice excesivamente ansiosa, hay una ordenación distinta (siempre es una señal de alerta para mí) y la consulta tiene una concesión de memoria alta, lo que puede afectar seriamente la concurrencia :

Figura 4:plan DISTINCT a escala

Mientras tanto, en el GROUP BY consulta, el UDX solo se ejecuta una vez para cada UserID único , el spool ansioso lee un número mucho menor de filas, no hay un operador de clasificación distinto (ha sido reemplazado por una coincidencia hash) y la concesión de memoria es pequeña en comparación:

Figura 5:plan GROUP BY a escala

Se necesita un tiempo para volver atrás y corregir un código antiguo como este, pero desde hace algún tiempo, he estado muy reglamentado sobre usar siempre GROUP BY en lugar de DISTINCT .

Prefijo N

Demasiados ejemplos de códigos antiguos con los que me encontré asumieron que nunca se utilizarían caracteres Unicode, o al menos los datos de ejemplo no sugerían la posibilidad. Ofrecería mi solución como la anterior, y luego el usuario regresaría y diría:"pero en una fila tengo 'просто красный' , y vuelve como '?????? ???????' !” A menudo les recuerdo a las personas que siempre deben anteponer el prefijo N a los posibles literales de cadena Unicode, a menos que sepan absolutamente que solo estarán tratando con varchar cadenas o números enteros. Empecé a ser muy explícito y probablemente incluso demasiado cauteloso al respecto:

SELECT UserID, Bands = STUFF(
      (SELECT N', ' + BandName
--------------^
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH(N'')), 1, 2, N'')
----------------------^ -----------^
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;

Entidad XML

Otro "¿y si?" El escenario que no siempre está presente en los datos de muestra de un usuario son los caracteres XML. Por ejemplo, ¿qué pasa si mi banda favorita se llama “Bob & Sheila <> Strawberries ”? El resultado con la consulta anterior es compatible con XML, que no es lo que siempre queremos (por ejemplo, Bob &amp; Sheila &lt;&gt; Strawberries ). Las búsquedas de Google en ese momento sugerirían "debe agregar TYPE ”, y recuerdo haber intentado algo como esto:

SELECT UserID, Bands = STUFF(
      (SELECT N', ' + BandName
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH(N''), TYPE), 1, 2, N'')
--------------------------^^^^^^
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;

Lamentablemente, el tipo de datos de salida de la subconsulta en este caso es xml . Esto conduce al siguiente mensaje de error:

Mensaje 8116, Nivel 16, Estado 1
El tipo de datos del argumento xml no es válido para el argumento 1 de la función de relleno.

Debe decirle a SQL Server que desea extraer el valor resultante como una cadena indicando el tipo de datos y que desea el primer elemento. En ese entonces, agregaría esto como lo siguiente:

SELECT UserID, Bands = STUFF(
      (SELECT N', ' + BandName
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 
--------------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
           1, 2, N'')
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;

Esto devolvería la cadena sin entidad XML. Pero, ¿es el más eficiente? El año pasado, Charlieface me recordó que el señor Magoo realizó algunas pruebas exhaustivas y encontró ./text()[1] fue más rápido que los otros enfoques (más cortos) como . y .[1] . (Originalmente escuché esto de un comentario que Mikael Eriksson me dejó aquí). Una vez más, ajusté mi código para que se viera así:

SELECT UserID, Bands = STUFF(
      (SELECT N', ' + BandName
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), 
------------------------------------------^^^^^^^^^^^
           1, 2, N'')
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;

Puede observar que extraer el valor de esta manera conduce a un plan un poco más complejo (no lo sabría solo al observar la duración, que se mantiene bastante constante a lo largo de los cambios anteriores):

Figura 6:Plan con ./text()[1]

La advertencia en la raíz SELECT El operador proviene de la conversión explícita a nvarchar(max) .

Orden

Ocasionalmente, los usuarios expresarían que ordenar es importante. A menudo, esto es simplemente ordenar por la columna que está agregando, pero a veces, se puede agregar en otro lugar. La gente tiende a creer que si vieron una orden específica salir de SQL Server una vez, es la orden que siempre verán, pero no hay confiabilidad aquí. El orden nunca está garantizado a menos que usted lo diga. En este caso, digamos que queremos ordenar por BandName alfabéticamente. Podemos agregar esta instrucción dentro de la subconsulta:

SELECT UserID, Bands = STUFF(
      (SELECT N', ' + BandName
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         ORDER BY BandName
---------^^^^^^^^^^^^^^^^^
         FOR XML PATH(N''),
          TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 2, N'')
  FROM dbo.FavoriteBands AS fb
  GROUP BY UserID;

Tenga en cuenta que esto puede agregar un poco de tiempo de ejecución debido al operador de clasificación adicional, dependiendo de si hay un índice de soporte.

STRING_AGG()

A medida que actualizo mis respuestas anteriores, que aún deberían funcionar en la versión que era relevante en el momento de la pregunta, el fragmento final anterior (con o sin ORDER BY ) es el formulario que probablemente verá. Pero es posible que también vea una actualización adicional para la forma más moderna.

STRING_AGG() es posiblemente una de las mejores funciones agregadas en SQL Server 2017. Es más simple y mucho más eficiente que cualquiera de los enfoques anteriores, lo que lleva a consultas ordenadas y de buen rendimiento como esta:

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Esto no es una broma; eso es todo. Aquí está el plan, lo más importante, solo hay un escaneo contra la mesa:

Figura 7:plan STRING_AGG()

Si desea ordenar, STRING_AGG() también es compatible con esto (siempre que esté en el nivel de compatibilidad 110 o superior, como señala Martin Smith aquí):

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
    WITHIN GROUP (ORDER BY BandName)
----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

El plan parece lo mismo que el que no ordenó, pero la consulta es un poco más lenta en mis pruebas. Todavía es mucho más rápido que cualquiera de los FOR XML PATH variaciones.

Índices

Un montón no es justo. Si tiene incluso un índice no agrupado que la consulta puede usar, el plan se ve aún mejor. Por ejemplo:

CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);

Este es el plan para la misma consulta ordenada usando STRING_AGG() —tenga en cuenta la falta de un operador de clasificación, ya que se puede ordenar el escaneo:

Figura 8:plan STRING_AGG() con un índice de soporte

Esto también reduce algo de tiempo libre, pero para ser justos, este índice ayuda a FOR XML PATH variaciones también. Este es el nuevo plan para la versión ordenada de esa consulta:

Figura 9:plan FOR XML PATH con un índice de soporte

El plan es un poco más amigable que antes, e incluye una búsqueda en lugar de un escaneo en un lugar, pero este enfoque sigue siendo significativamente más lento que STRING_AGG() .

Una advertencia

Hay un pequeño truco para usar STRING_AGG() donde, si la cadena resultante tiene más de 8000 bytes, recibirá este mensaje de error:

Mensaje 9829, Nivel 16, Estado 1
El resultado de la agregación STRING_AGG superó el límite de 8000 bytes. Utilice tipos LOB para evitar el truncamiento de resultados.

Para evitar este problema, puede inyectar una conversión explícita:

SELECT UserID, 
       Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ')
--------------------------^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Esto agrega una operación escalar de cómputo al plan, y un sorprendente CONVERT advertencia en la raíz SELECT operador, pero por lo demás, tiene poco impacto en el rendimiento.

Conclusión

Si está en SQL Server 2017+ y tiene cualquier FOR XML PATH agregación de cadenas en su base de código, le recomiendo cambiar al nuevo enfoque. Realicé algunas pruebas de rendimiento más exhaustivas durante la vista previa pública de SQL Server 2017 aquí y aquí puede volver a visitarla.

Una objeción común que he escuchado es que las personas usan SQL Server 2017 o superior, pero aún tienen un nivel de compatibilidad más antiguo. Parece que la aprensión se debe a que STRING_SPLIT() no es válido en niveles de compatibilidad inferiores a 130, por lo que piensan que STRING_AGG() funciona de esta manera también, pero es un poco más indulgente. Solo es un problema si está utilizando WITHIN GROUP y un nivel de compatibilidad inferior a 110. ¡Así que mejora!