Restablecer Total acumulado basado en otra columna


10

Estoy tratando de calcular el total acumulado. Pero debería restablecerse cuando la suma acumulativa sea mayor que otro valor de columna

create table #reset_runn_total
(
id int identity(1,1),
val int, 
reset_val int,
grp int
)

insert into #reset_runn_total
values 
(1,10,1),
(8,12,1),(6,14,1),(5,10,1),(6,13,1),(3,11,1),(9,8,1),(10,12,1)


SELECT Row_number()OVER(partition BY grp ORDER BY id)AS rn,*
INTO   #test
FROM   #reset_runn_total

Detalles del índice:

CREATE UNIQUE CLUSTERED INDEX ix_load_reset_runn_total
  ON #test(rn, grp) 

Data de muestra

+----+-----+-----------+-----+
| id | val | reset_val | Grp |
+----+-----+-----------+-----+
|  1 |   1 |        10 | 1   |
|  2 |   8 |        12 | 1   |
|  3 |   6 |        14 | 1   |
|  4 |   5 |        10 | 1   |
|  5 |   6 |        13 | 1   |
|  6 |   3 |        11 | 1   |
|  7 |   9 |         8 | 1   |
|  8 |  10 |        12 | 1   |
+----+-----+-----------+-----+ 

Resultado Esperado

+----+-----+-----------------+-------------+
| id | val |    reset_val    | Running_tot |
+----+-----+-----------------+-------------+
|  1 |   1 | 10              |       1     |  
|  2 |   8 | 12              |       9     |  --1+8
|  3 |   6 | 14              |       15    |  --1+8+6 -- greater than reset val
|  4 |   5 | 10              |       5     |  --reset 
|  5 |   6 | 13              |       11    |  --5+6
|  6 |   3 | 11              |       14    |  --5+6+3 -- greater than reset val
|  7 |   9 | 8               |       9     |  --reset -- greater than reset val 
|  8 |  10 | 12              |      10     |  --reset
+----+-----+-----------------+-------------+

Consulta:

Obtuve el resultado usando Recursive CTE. La pregunta original está aquí /programming/42085404/reset-running-total-based-on-another-column

;WITH cte
     AS (SELECT rn,id,
                val,
                reset_val,
                grp,
                val                   AS running_total,
                Iif (val > reset_val, 1, 0) AS flag
         FROM   #test
         WHERE  rn = 1
         UNION ALL
         SELECT r.*,
                Iif(c.flag = 1, r.val, c.running_total + r.val),
                Iif(Iif(c.flag = 1, r.val, c.running_total + r.val) > r.reset_val, 1, 0)
         FROM   cte c
                JOIN #test r
                  ON r.grp = c.grp
                     AND r.rn = c.rn + 1)
SELECT *
FROM   cte 

¿Hay alguna alternativa mejor T-SQLsin usar CLR?


Mejor como? ¿Esta consulta muestra un bajo rendimiento? ¿Usando qué métricas?
Aaron Bertrand

@AaronBertrand: para una mejor comprensión, he publicado datos de muestra para solo un grupo. Tengo que hacer lo mismo para 50000grupos con 60 identificaciones . entonces el conteo total de registros estará alrededor 3000000. Estoy seguro de Recursive CTEque no escalará bien 3000000. Actualizará las métricas cuando regrese a la oficina. ¿Podemos lograr esto usando sum()Over(Order by)como lo ha usado en este artículo sqlperformance.com/2012/07/t-sql-queries/running-totals
P ரதீப்

Un cursor podría funcionar mejor que un CTE recursivo
paparazzo

Respuestas:


6

He analizado problemas similares y nunca he podido encontrar una solución de función de ventana que haga un solo paso sobre los datos. No creo que sea posible. Las funciones de ventana deben poder aplicarse a todos los valores de una columna. Eso hace que los cálculos de reinicio como este sean muy difíciles, porque un reinicio cambia el valor de todos los siguientes valores.

Una forma de pensar sobre el problema es que puede obtener el resultado final que desea si calcula un total acumulado básico siempre que pueda restar el total acumulado de la fila anterior correcta. Por ejemplo, en sus datos de muestra, el valor de id4 es el running total of row 4 - the running total of row 3. El valor de id6 es running total of row 6 - the running total of row 3porque todavía no se ha reiniciado. El valor de id7 es el running total of row 7 - the running total of row 6y así sucesivamente.

Enfocaría esto con T-SQL en un bucle. Me dejé llevar y creo que tengo una solución completa. Durante 3 millones de filas y 500 grupos, el código terminó en 24 segundos en mi escritorio. Estoy probando con SQL Server 2016 Developer Edition con 6 vCPU. Aprovecho las inserciones paralelas y la ejecución paralela en general, por lo que es posible que deba cambiar el código si tiene una versión anterior o tiene limitaciones de DOP.

Debajo del código que usé para generar los datos. Los rangos en VALy RESET_VALdeben ser similares a sus datos de muestra.

drop table if exists reset_runn_total;

create table reset_runn_total
(
id int identity(1,1),
val int, 
reset_val int,
grp int
);

DECLARE 
@group_num INT,
@row_num INT;
BEGIN
    SET NOCOUNT ON;
    BEGIN TRANSACTION;

    SET @group_num = 1;
    WHILE @group_num <= 50000 
    BEGIN
        SET @row_num = 1;
        WHILE @row_num <= 60
        BEGIN
            INSERT INTO reset_runn_total WITH (TABLOCK)
            SELECT 1 + ABS(CHECKSUM(NewId())) % 10, 8 + ABS(CHECKSUM(NewId())) % 8, @group_num;

            SET @row_num = @row_num + 1;
        END;
        SET @group_num = @group_num + 1;
    END;
    COMMIT TRANSACTION;
END;

El algoritmo es como sigue:

1) Comience insertando todas las filas con un total acumulado estándar en una tabla temporal.

2) En un bucle:

2a) Para cada grupo, calcule la primera fila con un total acumulado por encima del valor reset_valor restante en la tabla y almacene la identificación, el total acumulado que era demasiado grande y el total acumulado anterior que era demasiado grande en una tabla temporal.

2b) Eliminar filas de la primera tabla temporal en una tabla temporal de resultados que tenga un valor IDmenor o igual al IDde la segunda tabla temporal. Use las otras columnas para ajustar el total acumulado según sea necesario.

3) Después de que la eliminación ya no procese filas, ejecute un adicional DELETE OUTPUTen la tabla de resultados. Esto es para filas al final del grupo que nunca exceden el valor de reinicio.

Revisaré una implementación del algoritmo anterior en T-SQL paso a paso.

Comience creando algunas tablas temporales. #initial_resultscontiene los datos originales con el total acumulado estándar, #group_bookkeepingse actualiza cada ciclo para determinar qué filas se pueden mover y #final_resultscontiene los resultados con el total acumulado ajustado para restablecimientos.

CREATE TABLE #initial_results (
id int,
val int, 
reset_val int,
grp int,
initial_running_total int
);

CREATE TABLE #group_bookkeeping (
grp int,
max_id_to_move int,
running_total_to_subtract_this_loop int,
running_total_to_subtract_next_loop int,
grp_done bit, 
PRIMARY KEY (grp)
);

CREATE TABLE #final_results (
id int,
val int, 
reset_val int,
grp int,
running_total int
);

INSERT INTO #initial_results WITH (TABLOCK)
SELECT ID, VAL, RESET_VAL, GRP, SUM(VAL) OVER (PARTITION BY GRP ORDER BY ID) RUNNING_TOTAL
FROM reset_runn_total;

CREATE CLUSTERED INDEX i1 ON #initial_results (grp, id);

INSERT INTO #group_bookkeeping WITH (TABLOCK)
SELECT DISTINCT GRP, 0, 0, 0, 0
FROM reset_runn_total;

Luego creo el índice agrupado en la tabla temporal para que la inserción y la creación del índice se puedan hacer en paralelo. Hice una gran diferencia en mi máquina, pero puede que no en la tuya. La creación de un índice en la tabla de origen no pareció ayudar, pero eso podría ayudar en su máquina.

El siguiente código se ejecuta en el bucle y actualiza la tabla de contabilidad. Para cada grupo, necesitamos obtener el máximo de búsqueda IDque se debe mover a la tabla de resultados. Necesitamos el total acumulado de esa fila para poder restarlo del total acumulado inicial. La grp_donecolumna se establece en 1 cuando no hay más trabajo que hacer para a grp.

WITH UPD_CTE AS (
        SELECT 
        #grp_bookkeeping.GRP
        , MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN ID ELSE NULL END) max_id_to_update
        , MIN(#group_bookkeeping.running_total_to_subtract_next_loop) running_total_to_subtract_this_loop
        , MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN initial_running_total ELSE NULL END) additional_value_next_loop
        , CASE WHEN MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN ID ELSE NULL END) IS NULL THEN 1 ELSE 0 END grp_done
        FROM #group_bookkeeping 
        INNER JOIN #initial_results IR ON #group_bookkeeping.grp = ir.grp
        WHERE #group_bookkeeping.grp_done = 0
        GROUP BY #group_bookkeeping.GRP
    )
    UPDATE #group_bookkeeping
    SET #group_bookkeeping.max_id_to_move = uv.max_id_to_update
    , #group_bookkeeping.running_total_to_subtract_this_loop = uv.running_total_to_subtract_this_loop
    , #group_bookkeeping.running_total_to_subtract_next_loop = uv.additional_value_next_loop
    , #group_bookkeeping.grp_done = uv.grp_done
    FROM UPD_CTE uv
    WHERE uv.GRP = #group_bookkeeping.grp
OPTION (LOOP JOIN);

Realmente no soy fanático de la LOOP JOINpista en general, pero esta es una consulta simple y fue la forma más rápida de obtener lo que quería. Para optimizar realmente el tiempo de respuesta, quería combinaciones de bucles anidados paralelos en lugar de combinaciones de combinación DOP 1.

El siguiente código se ejecuta en el bucle y mueve los datos de la tabla inicial a la tabla de resultados final. Observe el ajuste al total acumulado inicial.

DELETE ir
OUTPUT DELETED.id,  
    DELETED.VAL,  
    DELETED.RESET_VAL,  
    DELETED.GRP ,
    DELETED.initial_running_total - tb.running_total_to_subtract_this_loop
INTO #final_results
FROM #initial_results ir
INNER JOIN #group_bookkeeping tb ON ir.GRP = tb.GRP AND ir.ID <= tb.max_id_to_move
WHERE tb.grp_done = 0;

Para su comodidad, a continuación se encuentra el código completo:

DECLARE @RC INT;
BEGIN
SET NOCOUNT ON;

CREATE TABLE #initial_results (
id int,
val int, 
reset_val int,
grp int,
initial_running_total int
);

CREATE TABLE #group_bookkeeping (
grp int,
max_id_to_move int,
running_total_to_subtract_this_loop int,
running_total_to_subtract_next_loop int,
grp_done bit, 
PRIMARY KEY (grp)
);

CREATE TABLE #final_results (
id int,
val int, 
reset_val int,
grp int,
running_total int
);

INSERT INTO #initial_results WITH (TABLOCK)
SELECT ID, VAL, RESET_VAL, GRP, SUM(VAL) OVER (PARTITION BY GRP ORDER BY ID) RUNNING_TOTAL
FROM reset_runn_total;

CREATE CLUSTERED INDEX i1 ON #initial_results (grp, id);

INSERT INTO #group_bookkeeping WITH (TABLOCK)
SELECT DISTINCT GRP, 0, 0, 0, 0
FROM reset_runn_total;

SET @RC = 1;
WHILE @RC > 0 
BEGIN
    WITH UPD_CTE AS (
        SELECT 
        #group_bookkeeping.GRP
        , MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN ID ELSE NULL END) max_id_to_move
        , MIN(#group_bookkeeping.running_total_to_subtract_next_loop) running_total_to_subtract_this_loop
        , MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN initial_running_total ELSE NULL END) additional_value_next_loop
        , CASE WHEN MIN(CASE WHEN initial_running_total - #group_bookkeeping.running_total_to_subtract_next_loop > RESET_VAL THEN ID ELSE NULL END) IS NULL THEN 1 ELSE 0 END grp_done
        FROM #group_bookkeeping 
        CROSS APPLY (SELECT ID, RESET_VAL, initial_running_total FROM #initial_results ir WHERE #group_bookkeeping.grp = ir.grp ) ir
        WHERE #group_bookkeeping.grp_done = 0
        GROUP BY #group_bookkeeping.GRP
    )
    UPDATE #group_bookkeeping
    SET #group_bookkeeping.max_id_to_move = uv.max_id_to_move
    , #group_bookkeeping.running_total_to_subtract_this_loop = uv.running_total_to_subtract_this_loop
    , #group_bookkeeping.running_total_to_subtract_next_loop = uv.additional_value_next_loop
    , #group_bookkeeping.grp_done = uv.grp_done
    FROM UPD_CTE uv
    WHERE uv.GRP = #group_bookkeeping.grp
    OPTION (LOOP JOIN);

    DELETE ir
    OUTPUT DELETED.id,  
        DELETED.VAL,  
        DELETED.RESET_VAL,  
        DELETED.GRP ,
        DELETED.initial_running_total - tb.running_total_to_subtract_this_loop
    INTO #final_results
    FROM #initial_results ir
    INNER JOIN #group_bookkeeping tb ON ir.GRP = tb.GRP AND ir.ID <= tb.max_id_to_move
    WHERE tb.grp_done = 0;

    SET @RC = @@ROWCOUNT;
END;

DELETE ir 
OUTPUT DELETED.id,  
    DELETED.VAL,  
    DELETED.RESET_VAL,  
    DELETED.GRP ,
    DELETED.initial_running_total - tb.running_total_to_subtract_this_loop
    INTO #final_results
FROM #initial_results ir
INNER JOIN #group_bookkeeping tb ON ir.GRP = tb.GRP;

CREATE CLUSTERED INDEX f1 ON #final_results (grp, id);

/* -- do something with the data
SELECT *
FROM #final_results
ORDER BY grp, id;
*/

DROP TABLE #final_results;
DROP TABLE #initial_results;
DROP TABLE #group_bookkeeping;

END;

simplemente increíble, te recompensaré con recompensa
P ரதீப்

En nuestro servidor, para 50000 grp's y 60 id's el tuyo tomó 1 minuto y 10 segundos. Recursive CTEtomó 2 minutos y 15 segundos
P ரதீப்

Probé los dos códigos con los mismos datos. Tu fue genial. ¿Se puede mejorar aún más?
P ரதீப்

Es decir, ejecuté su código en nuestros datos reales y lo probé. El cálculo se procesa en tablas temporales en mi procedimiento real, lo más probable es que esté bien empaquetado. Será bueno si se puede reducir a algo alrededor de 30 segundos
P ரதீப்

@Prdp Probé un enfoque rápido que usaba una actualización pero parecía ser peor. No podremos investigar esto más por un tiempo. Intente registrar cuánto tiempo lleva cada operación para poder determinar qué parte se ejecuta más lentamente en su servidor. Definitivamente es posible que haya una forma de acelerar este código o un mejor algoritmo en general.
Joe Obbish

4

Usando un CURSOR:

ALTER TABLE #reset_runn_total ADD RunningTotal int;

DECLARE @id int, @val int, @reset int, @acm int, @grp int, @last_grp int;
SET @acm = 0;

DECLARE curRes CURSOR FAST_FORWARD FOR 
SELECT id, val, reset_val, grp
FROM #reset_runn_total
ORDER BY grp, id;

OPEN curRes;
FETCH NEXT FROM curRes INTO @id, @val, @reset, @grp;
SET @last_grp = @grp;

WHILE @@FETCH_STATUS = 0  
BEGIN
    IF @grp <> @last_grp SET @acm = 0;
    SET @last_grp = @grp;
    SET @acm = @acm + @val;
    UPDATE #reset_runn_total
    SET RunningTotal = @acm
    WHERE id = @id;
    IF @acm > @reset SET @acm = 0;
    FETCH NEXT FROM curRes INTO @id, @val, @reset, @grp;
END

CLOSE curRes;
DEALLOCATE curRes;

+----+-----+-----------+-------------+
| id | val | reset_val | RunningTotal|
+----+-----+-----------+-------------+
| 1  | 1   | 10        |     1       |
+----+-----+-----------+-------------+
| 2  | 8   | 12        |     9       |
+----+-----+-----------+-------------+
| 3  | 6   | 14        |     15      |
+----+-----+-----------+-------------+
| 4  | 5   | 10        |     5       |
+----+-----+-----------+-------------+
| 5  | 6   | 13        |     11      |
+----+-----+-----------+-------------+
| 6  | 3   | 11        |     14      |
+----+-----+-----------+-------------+
| 7  | 9   | 8         |     9       |
+----+-----+-----------+-------------+
| 8  | 10  | 12        |     10      |
+----+-----+-----------+-------------+

Consulte aquí: http://rextester.com/WSPLO95303


3

No en ventana, sino pura versión SQL:

WITH x AS (
    SELECT TOP 1 id,
           val,
           reset_val,
           val AS running_total,
           1 AS level 
      FROM reset_runn_total
    UNION ALL
    SELECT r.id,
           r.val,
           r.reset_val,
           CASE WHEN x.running_total < x.reset_val THEN x.running_total + r.val ELSE r.val END,
           level = level + 1
      FROM x JOIN reset_runn_total AS r ON (r.id > x.id)
) SELECT
  *
FROM x
WHERE NOT EXISTS (
        SELECT 1
        FROM x AS x2
        WHERE x2.id = x.id
        AND x2.level > x.level
    )
ORDER BY id, level DESC
;

No soy especialista en dialecto de SQL Server. Esta es una versión inicial para PostrgreSQL (si entiendo correctamente no puedo usar LIMIT 1 / TOP 1 en la parte recursiva en SQL Server):

WITH RECURSIVE x AS (
    (SELECT id, val, reset_val, val AS running_total
       FROM reset_runn_total
      ORDER BY id
      LIMIT 1)
    UNION
    (SELECT r.id, r.val, r.reset_val,
            CASE WHEN x.running_total < x.reset_val THEN x.running_total + r.val ELSE r.val END
       FROM x JOIN reset_runn_total AS r ON (r.id > x.id)
      ORDER BY id
      LIMIT 1)
) SELECT * FROM x;

@JoeObbish para ser honesto, eso no está del todo claro en la pregunta. Los resultados esperados, por ejemplo, no muestran grpcolumna.
ypercubeᵀᴹ

@JoeObbish, eso es lo que entendí también. Sin embargo, la pregunta podría beneficiarse de una declaración explícita al respecto. El código en la pregunta (con el CTE) tampoco lo usa (e incluso tiene columnas con nombres diferentes). Sería obvio para cualquiera que lea la pregunta, no tendrían, y no deberían, tener que leer las otras respuestas o comentarios.
ypercubeᵀᴹ

@ ypercubeᵀᴹ Se agregó la información requerida en la pregunta.
P ரதீப்

1

Parece que tiene varias consultas / métodos para atacar el problema, pero no nos ha proporcionado, ¿ni siquiera lo ha considerado? - los índices sobre la mesa.

¿Qué índices hay en la tabla? ¿Es un montón o tiene un índice agrupado?

Probaría las diversas soluciones sugeridas después de agregar este índice:

(grp, id) INCLUDE (val, reset_val)

O simplemente cambie (o cree) que el índice agrupado sea (grp, id).

Tener un índice que se dirija a la consulta específica debería mejorar la eficiencia de la mayoría, si no de todos los métodos.


Se agregó la información requerida en la pregunta.
P ரதீப்
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.