¿Podría alguien explicar un comportamiento extraño ejecutando millones de ACTUALIZACIONES?

8

¿Podría alguien explicarme este comportamiento? Ejecuté la siguiente consulta en Postgres 9.3 ejecutándose de forma nativa en OS X. Intenté simular algún comportamiento en el que el tamaño del índice podría crecer mucho más que el tamaño de la tabla, y en su lugar encontré algo aún más extraño.

CREATE TABLE test(id int);
CREATE INDEX test_idx ON test(id);

CREATE FUNCTION test_index(batch_size integer, total_batches integer) RETURNS void AS $$
DECLARE
  current_id integer := 1;
BEGIN
FOR i IN 1..total_batches LOOP
  INSERT INTO test VALUES (current_id);
  FOR j IN 1..batch_size LOOP
    UPDATE test SET id = current_id + 1 WHERE id = current_id;
    current_id := current_id + 1;
  END LOOP;
END LOOP;
END;
$$ LANGUAGE plpgsql;

SELECT test_index(500, 10000);

Dejé que esto se ejecute durante aproximadamente una hora en mi máquina local, antes de comenzar a recibir advertencias de problemas de disco de OS X. Noté que Postgres estaba absorbiendo aproximadamente 10 MB / s de mi disco local, y que la base de datos de Postgres estaba consumiendo un gran total de 30GB desde mi máquina. Terminé cancelando la consulta. De todos modos, Postgres no me devolvió el espacio en disco y pregunté a la base de datos las estadísticas de uso con el siguiente resultado:

test=# SELECT nspname || '.' || relname AS "relation",
    pg_size_pretty(pg_relation_size(C.oid)) AS "size"
  FROM pg_class C
  LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
  WHERE nspname NOT IN ('pg_catalog', 'information_schema')
  ORDER BY pg_relation_size(C.oid) DESC
  LIMIT 20;

           relation            |    size
-------------------------------+------------
 public.test                   | 17 GB
 public.test_idx               | 14 GB

Sin embargo, la selección de la tabla no arrojó resultados.

test=# select * from test limit 1;
 id
----
(0 rows)

Ejecutar 10000 lotes de 500 es 5,000,000 filas, lo que debería producir un tamaño de tabla / índice bastante pequeño (en la escala de MB). Sospecho que Postgres está creando una nueva versión de la tabla / índice para cada INSERTAR / ACTUALIZAR que sucede con la función, pero esto parece extraño. Toda la función se ejecuta transaccionalmente y la tabla estaba vacía para comenzar.

¿Alguna idea de por qué estoy viendo este comportamiento?

Específicamente, las dos preguntas que tengo son: ¿por qué este espacio aún no ha sido reclamado por la base de datos y el segundo es por qué la base de datos requirió tanto espacio en primer lugar? 30 GB parece mucho, incluso cuando se cuenta con MVCC

Nikhil N
fuente

Respuestas:

7

Version corta

Su algoritmo se ve O (n * m) a primera vista, pero efectivamente crece O (n * m ^ 2), porque todas las filas tienen la misma ID. En lugar de filas de 5M, está obteniendo> 1.25G filas

Versión larga

Su función está dentro de una transacción implícita. Es por eso que no ve datos después de cancelar su consulta, y también por qué necesita mantener versiones distintas de las tuplas actualizadas / insertadas para ambos bucles.

Además, sospecho que tiene un error en su lógica o subestima la cantidad de actualizaciones realizadas.

Primera iteración del bucle externo: current_id comienza en 1, inserta 1 fila, luego el bucle interno realiza una actualización 10000 veces para la misma fila, finalizando con la única fila que muestra un ID de 10001 y current_id con un valor de 10001. 10001 las versiones de la fila aún se mantienen, ya que la transacción no ha finalizado.

Segunda iteración del bucle externo: como current_id es 10001, se inserta una nueva fila con ID 10001. Ahora tiene 2 filas con el mismo "ID" y 10003 versiones en total de ambas filas (10002 de la primera, 1 de el segundo). Luego, el bucle interno actualiza 10000 veces AMBAS filas, creando 20000 nuevas versiones, llegando a 30003 tuplas hasta ahora ...

Tercera iteración del bucle externo: la ID actual es 20001, se inserta una nueva fila con la ID 20001. Tiene 3 filas, todas con la misma "ID" 20001, hasta ahora versiones de 30006 filas / tuplas. Luego realiza 10000 actualizaciones de 3 filas, creando 30000 nuevas versiones, ahora 60006 ...

...

(Si su espacio lo hubiera permitido) - 500a iteración del bucle externo, crea 5 millones de actualizaciones de 500 filas, solo en esta iteración

Como puede ver, en lugar de las actualizaciones esperadas de 5M, obtuvo 1000 + 2000 + 3000 + ... + 4990000 + 5000000 actualizaciones (más cambio), que serían 10000 * (1 + 2 + 3 + ... + 499+ 500), más de 1.25G de actualizaciones. Y, por supuesto, una fila no es solo el tamaño de su int, necesita una estructura adicional, por lo que su tabla e índice supera el tamaño de diez gigabytes.

Preguntas y respuestas relacionadas:

Bruno Guardia
fuente
5

PostgreSQL solo devuelve espacio en disco después VACUUM FULL, no después de a DELETEo ROLLBACK(como resultado de la cancelación)

La forma estándar de VACUUM elimina las versiones inactivas en tablas e índices y marca el espacio disponible para su futura reutilización. Sin embargo, no devolverá el espacio al sistema operativo, excepto en el caso especial de que una o más páginas al final de una tabla se vuelvan completamente libres y se pueda obtener fácilmente un bloqueo exclusivo de la tabla. Por el contrario, VACUUM FULL compacta activamente las tablas escribiendo una nueva versión completa del archivo de la tabla sin espacios muertos. Esto minimiza el tamaño de la tabla, pero puede llevar mucho tiempo. También requiere espacio en disco adicional para la nueva copia de la tabla, hasta que se complete la operación.

Como nota al margen, toda su función parece cuestionable. No estoy seguro de lo que está intentando probar, pero si desea crear datos, puede usargenerate_series

INSERT INTO test
SELECT x FROM generate_series(1, batch_size*total_batches) AS t(x);
Evan Carroll
fuente
Genial, eso explica por qué la tabla todavía estaba marcada como consumiendo tantos datos, pero ¿por qué necesitaba todo ese espacio en primer lugar? Desde mi entendimiento de MVCC, necesita mantener versiones distintas de las tuplas actualizadas / insertadas para la transacción, pero no debería necesitar mantener versiones separadas para cada iteración del ciclo.
Nikhil N
1
Cada iteración del ciclo genera nuevas tuplas.
Evan Carroll
2
Correcto, pero mi impresión es que el MVCC no debería crear tuplas para todas las tuplas que se modificó en el transcurso de la transacción. Es decir, cuando se ejecuta el primer INSERT Postgres crea una tupla única y agrega una tupla nueva para cada ACTUALIZACIÓN. Dado que las ACTUALIZACIONES se ejecutan para cada fila 500 veces, y hay 10000 INSERTOS, esto equivale a 500 * 10000 filas = 5M tuplas en el momento en que se confirma la transacción. Ahora esto es solo una estimación, pero independientemente de 5M * digamos 50 bytes para rastrear cada tupla ~ = 250MB, que es MUCHO menos de 30GB. ¿De dónde viene todo?
Nikhil N
También re: función cuestionable, estoy tratando de probar el comportamiento de un índice cuando los campos indexados se actualizan muchas veces pero de forma monóticamente creciente, lo que produce un índice muy escaso, pero que siempre se agrega al disco.
Nikhil N
Estoy confundido en cuanto a lo que piensas. ¿Crees que una fila actualizada 18e veces en un bucle es una tupla o 1e8 tuplas?
Evan Carroll
3

Los números reales después de analizar la función son mucho más grandes porque todas las filas de la tabla obtienen el mismo valor que se actualiza varias veces en cada iteración.

Cuando lo ejecutamos con parámetros ny m:

SELECT test_index(n, m);

Hay minserciones de fila y n * (m^2 + m) / 2actualizaciones. Entonces, para n = 500y m = 10000, Postgres necesitará insertar solo 10K filas pero realizar ~ 25G (25 mil millones) de actualizaciones de tuplas.

Teniendo en cuenta que una fila en Postgres tiene unos 24 bytes de sobrecarga, una tabla con una sola intcolumna necesitará 28 bytes por fila más la sobrecarga de la página. Entonces, para que la operación finalice, necesitaríamos alrededor de 700 GB más el espacio para el índice (que también serían unos pocos cientos de GB).


Pruebas

Para probar la teoría, creamos otra tabla test_testcon una sola fila.

CREATE TABLE test_test (i int not null) ;
INSERT INTO test_test (i) VALUES (0);

Luego agregamos un activador testpara que cada actualización aumente el contador en 1. (Código omitido). Luego ejecutamos la función, con valores más pequeños, n = 50y m = 100.

Nuestra teoría predice :

  • 100 inserciones de fila,
  • 250K actualizaciones de tuplas (252500 = 50 * 100 * 101/2)
  • al menos 7 MB para la tabla en el disco
  • (+ espacio para el índice)

Prueba 1 ( testtabla original , con índice)

    SELECT test_index(50, 100) ;

Después de completar, verificamos el contenido de la tabla:

x=# SELECT COUNT(*) FROM test ;
 count 
-------
   100
(1 row)

x=# SELECT i FROM test_test ;
   i    
--------
 252500
(1 row)

Y uso del disco (consulta en Tamaño del índice / estadísticas de uso en Mantenimiento del índice ):

tablename | indexname | num_rows | table_size | index_size | unique | number_of_scans | tuples_read 
----------+-----------+----------+------------+------------+--------+-----------------+-------------
test      | test_idx  |      100 | 8944 kB    | 5440 kB    | N      |           10001 |      505003
test_test |           |        1 | 8944 kB    |            | N      |                 |            

La testtabla ha usado casi 9 MB para la tabla y 5 MB para el índice. ¡Tenga en cuenta que la test_testtabla ha usado otros 9MB! Eso es esperado ya que también pasó por 250K actualizaciones (nuestro segundo disparador actualizó la fila individual de test_testcada actualización de una fila en test).

Tenga en cuenta también el número de escaneo en la mesa test(10K) y las lecturas de tuplas (500K).

Prueba 2 ( testtabla sin índice)

Exactamente igual que el anterior, excepto que la tabla no tiene índice.

tablename | indexname | num_rows | table_size | index_size | unique | number_of_scans | tuples_read 
----------+-----------+----------+------------+------------+--------+-----------------+-------------
 test        |        |      100 | 8944 kB    |            | N      |                 |            
 test_test   |        |        1 | 8944 kB    |            | N      |                 |            

Obtenemos el mismo tamaño para el uso del disco de la tabla y, por supuesto, ningún uso del disco para los índices. Sin testembargo, el número de escaneos en la tabla es cero y las tuplas también se leen.

Prueba 3 (con factor de relleno inferior)

Probado con el factor de relleno 50 y el más bajo posible, 10. Ninguna mejora en absoluto. El uso del disco era casi idéntico a las pruebas anteriores (que usaban el factor de relleno predeterminado, 100 por ciento)

ypercubeᵀᴹ
fuente