Consultas paralelas en PostgreSQL

Consultas paralelas en PostgreSQL
En los procesadores modernos hay muchos núcleos. Durante años, las aplicaciones han enviado consultas a bases de datos en paralelo. Si se trata de una consulta de informes a múltiples filas en una tabla, se ejecuta más rápido cuando utiliza varios núcleos de CPU, y en PostgreSQL esto es posible a partir de la versión 9.6.

Se necesitó 3 años para implementar la función de consultas paralelas, y fue necesario reescribir el código en diferentes etapas de la ejecución de las consultas. PostgreSQL 9.6 introdujo la infraestructura para futuras mejoras en el código. En versiones posteriores, otros tipos de consultas también se ejecutan en paralelo.

Limitaciones

  • No active la ejecución paralela si todos los núcleos ya están ocupados, de lo contrario, otras consultas se ralentizarán.
  • Lo más importante es que el procesamiento paralelo con altos valores de WORK_MEM utiliza mucha memoria: cada conexión hash o cada ordenación consume memoria en la cantidad de work_mem.
  • Las consultas OLTP con baja latencia no pueden acelerarse mediante la ejecución paralela. Y si una consulta devuelve una sola fila, el procesamiento paralelo solo la ralentizará.
  • A los desarrolladores les gusta usar el benchmark TPC-H. Quizás tenga consultas similares para una ejecución paralela óptima.
  • Solo las consultas SELECT sin bloqueo de predicado se ejecutan en paralelo.
  • A veces, una correcta indexación es mejor que el escaneo secuencial de una tabla en modo paralelo.
  • Las pausas en las consultas y los cursores no son compatibles.
  • Las funciones de ventana y las funciones agregadas de conjuntos ordenados no son paralelas.
  • No obtienes ninguna ventaja en la carga de trabajo de entrada/salida.
  • No existen algoritmos de ordenación paralelos. Pero las consultas con ordenaciones pueden ejecutarse en paralelo en ciertos aspectos.
  • Reemplace CTE (WITH …) por un SELECT anidado para habilitar el procesamiento paralelo.
  • Las envolturas de datos de terceros aún no admiten el procesamiento paralelo (¡y podrían hacerlo!).
  • FULL OUTER JOIN no es compatible.
  • max_rows desactiva el procesamiento paralelo.
  • Si hay una función en la consulta que no está marcada como PARALLEL SAFE, será de un solo hilo.
  • El nivel de aislamiento de la transacción SERIALIZABLE desactiva el procesamiento paralelo.

Entorno de prueba

Los desarrolladores de PostgreSQL intentaron reducir el tiempo de respuesta de las consultas del benchmark TPC-H. Descargue el benchmark y adáptelo a PostgreSQL. Este uso no oficial del benchmark TPC-H no es para comparar bases de datos o hardware.

  1. Descargue TPC-H_Tools_v2.17.3.zip (o una versión más nueva) desde el sitio oficial de TPC.
  2. Renombre makefile.suite a Makefile y modifique según se describe aquí: https://github.com/tvondra/pg_tpch . Compile el código con el comando make.
  3. Genere los datos: ./dbgen -s 10 crea una base de datos de 23 GB. Esto es suficiente para observar la diferencia en el rendimiento de consultas paralelas y no paralelas.
  4. Convierta los archivos tbl en csv con for y sed.
  5. Clone el repositorio pg_tpch y copie los archivos csv en pg_tpch/dss/data.
  6. Cree consultas con el comando qgen.
  7. Cargue los datos en la base con el comando ./tpch.sh.

Escaneo secuencial paralelo

Puede ser más rápido no debido a la lectura paralela, sino porque los datos están distribuidos entre muchos núcleos de la CPU. En los sistemas operativos modernos, los archivos de datos de PostgreSQL se almacenan en caché de manera eficiente. Con la lectura anticipada, se puede obtener del almacenamiento un bloque mayor de lo que solicita el demonio PG. Por lo tanto, el rendimiento de la consulta no está limitado por la entrada y salida del disco. Consume ciclos de CPU para:

  • leer filas una por una de las páginas de la tabla;
  • comparar los valores de las filas y las condiciones WHERE.

Realicemos una consulta simple select:

tpch=# explain analyze select l_quantity as sum_qty from lineitem where l_shipdate <= date '1998-12-01' - interval '105' day;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------
Seq Scan on lineitem (cost=0.00..1964772.00 rows=58856235 width=5) (actual time=0.014..16951.669 rows=58839715 loops=1)
Filter: (l_shipdate <= '1998-08-18 00:00:00'::timestamp without time zone)
Rows Removed by Filter: 1146337
Planning Time: 0.203 ms
Execution Time: 19035.100 ms

El escaneo secuencial devuelve demasiadas filas sin agregación, por lo que la consulta se ejecuta con un solo núcleo de CPU.

Si agregamos SUM(), se puede ver que dos procesos de trabajo ayudan a acelerar la consulta:

explain analyze select sum(l_quantity) as sum_qty from lineitem where l_shipdate  Gather (cost=1589701.91..1589702.12 rows=2 width=32) (actual time=8553.241..8555.067 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=1588701.91..1588701.92 rows=1 width=32) (actual time=8547.546..8547.546 rows=1 loops=3)
-> Parallel Seq Scan on lineitem (cost=0.00..1527393.33 rows=24523431 width=5) (actual time=0.038..5998.417 rows=19613238 loops=3)
Filter: (l_shipdate <= '1998-08-18 00:00:00'::timestamp without time zone)
Rows Removed by Filter: 382112
Planning Time: 0.241 ms
Execution Time: 8555.131 ms

Agregación paralela

El nodo 'Parallel Seq Scan' produce filas para la agregación parcial. El nodo 'Partial Aggregate' recorta estas filas utilizando SUM(). Al final, el contador SUM de cada proceso de trabajo se recopila en el nodo 'Gather'.

El resultado final se calcula con el nodo «Finalize Aggregate». Si tiene sus propias funciones de agregación, no olvide marcarlas como «parallel safe».

Número de procesos de trabajo

El número de procesos de trabajo se puede aumentar sin reiniciar el servidor:

explain analyze select sum(l_quantity) as sum_qty from lineitem where l_shipdate  Gather (cost=1589701.91..1589702.12 rows=2 width=32) (actual time=8553.241..8555.067 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=1588701.91..1588701.92 rows=1 width=32) (actual time=8547.546..8547.546 rows=1 loops=3)
-> Parallel Seq Scan on lineitem (cost=0.00..1527393.33 rows=24523431 width=5) (actual time=0.038..5998.417 rows=19613238 loops=3)
Filter: (l_shipdate <= '1998-08-18 00:00:00'::timestamp without time zone)
Rows Removed by Filter: 382112
Planning Time: 0.241 ms
Execution Time: 8555.131 ms

¿Qué está ocurriendo aquí? El número de procesos de trabajo se ha duplicado, y la solicitud se volvió solo 1,6599 veces más rápida. Los cálculos son interesantes. Teníamos 2 procesos de trabajo y 1 líder. Después del cambio, ahora son 4+1.

Nuestra aceleración máxima por procesamiento paralelo: 5/3 = 1,66(6) veces.

¿Cómo funciona?

Procesos

La ejecución de una solicitud siempre comienza con el proceso líder. El líder realiza toda la parte no paralela y parte del procesamiento paralelo. Otros procesos que realizan las mismas solicitudes se llaman procesos de trabajo. El procesamiento paralelo utiliza la infraestructura de procesos de trabajo en segundo plano dinámicos (a partir de la versión 9.4). Dado que otras partes de PostgreSQL utilizan procesos, no hilos, una solicitud con 3 procesos de trabajo podría ser 4 veces más rápida que el procesamiento tradicional.

Interacción

Los procesos de trabajo se comunican con el líder a través de una cola de mensajes (basada en memoria compartida). Cada proceso tiene 2 colas: una para errores y otra para tuplas.

¿Cuántos procesos de trabajo se necesitan?

El límite mínimo es establecido por el parámetro max_parallel_workers_per_gather. Luego, el ejecutor de la consulta toma procesos de trabajo del conjunto, limitado por el parámetro max_parallel_workers size. El último límite es max_worker_processes, es decir, el número total de procesos en segundo plano.

Si no se puede asignar un proceso de trabajo, el procesamiento será de un solo proceso.

El planificador de consultas puede reducir los procesos de trabajo dependiendo del tamaño de la tabla o índice. Para esto, hay parámetros min_parallel_table_scan_size y min_parallel_index_scan_size.

set min_parallel_table_scan_size='8MB'
8MB tabla => 1 trabajador
24MB tabla => 2 trabajadores
72MB tabla => 3 trabajadores
x => log(x / min_parallel_table_scan_size) / log(3) + 1 trabajador

Cada vez que la tabla es 3 veces más grande que min_parallel_(index|table)_scan_size, PostgreSQL agrega un proceso de trabajo. El número de procesos de trabajo no se basa en costos. La dependencia cíclica complica las implementaciones complejas. En su lugar, el planificador usa reglas simples.

En la práctica, estas reglas no siempre son adecuadas para producción, por lo que se puede cambiar el número de procesos de trabajo para una tabla específica: ALTER TABLE … SET (parallel_workers = N).

¿Por qué no se utiliza el procesamiento paralelo?

Además de una larga lista de restricciones, hay más comprobaciones de costos:

parallel_setup_cost — para evitar el procesamiento paralelo de consultas cortas. Este parámetro estima el tiempo para la preparación de la memoria, el inicio del proceso y el intercambio inicial de datos.

parallel_tuple_cost: la comunicación del líder con los trabajadores puede prolongarse proporcionalmente al número de tuplas de los procesos de trabajo. Este parámetro calcula los costos del intercambio de datos.

Conexiones de bucles anidados — Nested Loop Join

PostgreSQL 9.6+ puede realizar bucles anidados en paralelo: es una operación sencilla.

explain (costs off) select c_custkey, count(o_orderkey)
 from customer left outer join orders on
 c_custkey = o_custkey and o_comment not like '%specialposits%'
 group by c_custkey;
 QUERY PLAN
--------------------------------------------------------------------------------------
 Finalize GroupAggregate
 Group Key: customer.c_custkey
 -> Gather Merge
 Workers Planned: 4
 -> Partial GroupAggregate
 Group Key: customer.c_custkey
 -> Nested Loop Left Join
 -> Parallel Index Only Scan using customer_pkey on customer
 -> Index Scan using idx_orders_custkey on orders
 Index Cond: (customer.c_custkey = o_custkey)
 Filter: ((o_comment)::text !~~ '%specialposits%'::text)

La recopilación ocurre en la última etapa, por lo que el Nested Loop Left Join es una operación paralela. El Parallel Index Only Scan apareció solo en la versión 10. Funciona de manera similar al escaneo secuencial paralelo. La condición c_custkey = o_custkey lee un pedido por cada fila del cliente. Por lo tanto, no es paralelo.

Unión hash — Hash Join

Cada proceso de trabajo crea su tabla hash hasta PostgreSQL 11. Y si hay más de cuatro procesos, el rendimiento no aumentará. En la nueva versión, la tabla hash es compartida. Cada proceso de trabajo puede usar WORK_MEM para crear una tabla hash.

seleccionar
        l_shipmode,
        suma(caso
                cuando o_orderpriority = '1-URGENTE'
                        o o_orderpriority = '2-ALTO'
                        entonces 1
                else 0
        fin) como high_line_count,
        suma(caso
                cuando o_orderpriority  '1-URGENTE'
                        y o_orderpriority  '2-ALTO'
                        entonces 1
                else 0
        fin) como low_line_count
de
        órdenes,
        línea
donde
        o_orderkey = l_orderkey
        y l_shipmode en ('CORREO', 'AÉREO')
        y l_commitdate < l_receiptdate
        y l_shipdate = fecha '1996-01-01'
        y l_receiptdate < fecha '1996-01-01' + intervalo '1' año
grupo por
        l_shipmode
ordenar por
        l_shipmode
LIMITE 1;
                                                                                                                                    PLAN DE CONSULTA
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Límite  (costo=1964755.66..1964961.44 filas=1 ancho=27) (tiempo real=7579.592..7922.997 filas=1 bucles=1)
   ->  Finalizar GroupAggregate  (costo=1964755.66..1966196.11 filas=7 ancho=27) (tiempo real=7579.590..7579.591 filas=1 bucles=1)
         Clave de Grupo: línea.l_shipmode
         ->  Reunir Junta  (costo=1964755.66..1966195.83 filas=28 ancho=27) (tiempo real=7559.593..7922.319 filas=6 bucles=1)
               Trabajadores Planeados: 4
               Trabajadores Lanzados: 4
               ->  Agrupar Parcial  (costo=1963755.61..1965192.44 filas=7 ancho=27) (tiempo real=7548.103..7564.592 filas=2 bucles=5)
                     Clave de Grupo: línea.l_shipmode
                     ->  Ordenar  (costo=1963755.61..1963935.20 filas=71838 ancho=27) (tiempo real=7530.280..7539.688 filas=62519 bucles=5)
                           Clave de Orden: línea.l_shipmode
                           Método de Orden: fusión externa  Disco: 2304kB
                           Trabajador 0:  Método de Orden: fusión externa  Disco: 2064kB
                           Trabajador 1:  Método de Orden: fusión externa  Disco: 2384kB
                           Trabajador 2:  Método de Orden: fusión externa  Disco: 2264kB
                           Trabajador 3:  Método de Orden: fusión externa  Disco: 2336kB
                           ->  Unión Hash Paralela  (costo=382571.01..1957960.99 filas=71838 ancho=27) (tiempo real=7036.917..7499.692 filas=62519 bucles=5)
                                 Condición Hash: (línea.l_orderkey = órdenes.o_orderkey)
                                 ->  Escaneo Secuencial Paralelo en línea  (costo=0.00..1552386.40 filas=71838 ancho=19) (tiempo real=0.583..4901.063 filas=62519 bucles=5)
                                       Filtro: ((l_shipmode = CUALQUIERA ('{CORREO,AÉREO}'::bpchar[])) Y (l_commitdate < l_receiptdate) Y (l_shipdate = '1996-01-01'::fecha) Y (l_receiptdate < '1997-01-01 00:00:00'::marca de tiempo sin zona horaria))
                                       Filas Eliminadas por Filtro: 11934691
                                 ->  Hash Paralelo  (costo=313722.45..313722.45 filas=3750045 ancho=20) (tiempo real=2011.518..2011.518 filas=3000000 bucles=5)
                                       Cubos: 65536  Lotes: 256  Uso de Memoria: 3840kB
                                       ->  Escaneo Secuencial Paralelo en órdenes  (costo=0.00..313722.45 filas=3750045 ancho=20) (tiempo real=0.029..995.948 filas=3000000 bucles=5)
 Tiempo de Planificación: 0.977 ms
 Tiempo de Ejecución: 7923.770 ms

La consulta 12 de TPC-H muestra claramente la unión hash paralela. Cada proceso de trabajo participa en la creación de una tabla hash común.

Unión de fusión — Merge Join

La unión de fusión no es paralela por su naturaleza. No te preocupes si esta es la última etapa de la consulta, aún puede ejecutarse en paralelo.

-- Consulta 2 de TPC-H
explicar (costos apagados) seleccionar s_acctbal, s_name, n_name, p_partkey, p_mfgr, s_address, s_phone, s_comment
desde parte, proveedor, partsupp, nación, región
donde
        p_partkey = ps_partkey
        y s_suppkey = ps_suppkey
        y p_size = 36
        y p_type como '%BRASS'
        y s_nationkey = n_nationkey
        y n_regionkey = r_regionkey
        y r_name = 'AMÉRICA'
        y ps_supplycost = (
                seleccionar
                        min(ps_supplycost)
                desde partsupp, proveedor, nación, región
                donde
                        p_partkey = ps_partkey
                        y s_suppkey = ps_suppkey
                        y s_nationkey = n_nationkey
                        y n_regionkey = r_regionkey
                        y r_name = 'AMÉRICA'
        )
ordenar por s_acctbal desc, n_name, s_name, p_partkey
LIMITE 100;
                                                PLAN DE CONSULTA
----------------------------------------------------------------------------------------------------------
 Límite
   -&gt;  Ordenar
         Clave de Orden: supplier.s_acctbal DESC, nation.n_name, supplier.s_name, part.p_partkey
         -&gt;  Unión de Fusiones
               Condición de fusión: (part.p_partkey = partsupp.ps_partkey)
               Filtro de unión: (partsupp.ps_supplycost = (Subplan 1))
               -&gt;  Reunir fusión
                     Trabajadores planificados: 4
                     -&gt;  Escaneo de índice paralelo utilizando <strong>part_pkey</strong> en parte
                           Filtro: (((p_type)::text ~~ '%BRASS'::text) Y (p_size = 36))
               -&gt;  Materializar
                     -&gt;  Ordenar
                           Clave de Orden: partsupp.ps_partkey
                           -&gt;  Bucle anidado
                                 -&gt;  Bucle anidado
                                       Filtro de unión: (nation.n_regionkey = region.r_regionkey)
                                       -&gt;  Escaneo secuencial en región
                                             Filtro: (r_name = 'AMÉRICA'::bpchar)
                                       -&gt;  Unión hash
                                             Condición hash: (supplier.s_nationkey = nation.n_nationkey)
                                             -&gt;  Escaneo secuencial en proveedor
                                             -&gt;  Hash
                                                   -&gt;  Escaneo secuencial en nación
                                 -&gt;  Escaneo de índice utilizando idx_partsupp_suppkey en partsupp
                                       Condición de índice: (ps_suppkey = supplier.s_suppkey)
               Subplan 1
                 -&gt;  Agregar
                       -&gt;  Bucle anidado
                             Filtro de unión: (nation_1.n_regionkey = region_1.r_regionkey)
                             -&gt;  Escaneo secuencial en región region_1
                                   Filtro: (r_name = 'AMÉRICA'::bpchar)
                             -&gt;  Bucle anidado
                                   -&gt;  Bucle anidado
                                         -&gt;  Escaneo de índice utilizando idx_partsupp_partkey en partsupp partsupp_1
                                               Condición de índice: (part.p_partkey = ps_partkey)
                                         -&gt;  Escaneo de índice utilizando supplier_pkey en proveedor supplier_1
                                               Condición de índice: (s_suppkey = partsupp_1.ps_suppkey)
                                   -&gt;  Escaneo de índice utilizando nation_pkey en nación nación_1
                                         Condición de índice: (n_nationkey = supplier_1.s_nationkey)

El nodo 'Merge Join' se encuentra por encima de 'Gather Merge'. Por lo tanto, la fusión no utiliza procesamiento paralelo. Sin embargo, el nodo 'Parallel Index Scan' aún ayuda con el segmento part_pkey.

Unión por secciones

En PostgreSQL 11 unión por secciones desactivada por defecto: tiene un costo de planificación muy alto. Las tablas con particionamiento similar se pueden unir sección por sección. Así, Postgres utilizará tablas hash más pequeñas. Cada unión de secciones puede ser paralela.

tpch=# set enable_partitionwise_join=t;
tpch=# explain (costs off) select * from prt1 t1, prt2 t2
where t1.a = t2.b and t1.b = 0 and t2.b between 0 and 10000;
                    PLAN DE CONSULTA
---------------------------------------------------
 Append
   ->  Hash Join
         Condición Hash: (t2.b = t1.a)
         ->  Secuencia de Escaneo en prt2_p1 t2
               Filtro: ((b >= 0) AND (b <= 10000))
         ->  Hash
               ->  Secuencia de Escaneo en prt1_p1 t1
                     Filtro: (b = 0)
   ->  Hash Join
         Condición Hash: (t2_1.b = t1_1.a)
         ->  Secuencia de Escaneo en prt2_p2 t2_1
               Filtro: ((b >= 0) AND (b <= 10000))
         ->  Hash
               ->  Secuencia de Escaneo en prt1_p2 t1_1
                     Filtro: (b = 0)
tpch=# set parallel_setup_cost = 1;
tpch=# set parallel_tuple_cost = 0.01;
tpch=# explain (costs off) select * from prt1 t1, prt2 t2
where t1.a = t2.b and t1.b = 0 and t2.b between 0 and 10000;
                        PLAN DE CONSULTA
-----------------------------------------------------------
 Gather
   Trabajadores Planificados: 4
   ->  Append Paralelo
         ->  Join Hash Paralelo
               Condición Hash: (t2_1.b = t1_1.a)
               ->  Secuencia de Escaneo Paralelo en prt2_p2 t2_1
                     Filtro: ((b >= 0) AND (b <= 10000))
               ->  Hash
                     ->  Secuencia de Escaneo Paralelo en prt1_p2 t1_1
                           Filtro: (b = 0)
         ->  Join Hash Paralelo
               Condición Hash: (t2.b = t1.a)
               ->  Secuencia de Escaneo Paralelo en prt2_p1 t2
                     Filtro: ((b >= 0) AND (b <= 10000))
               ->  Hash
                     ->  Secuencia de Escaneo Paralelo en prt1_p1 t1
                           Filtro: (b = 0)

Lo principal es que la unión por secciones puede ser paralela solo si estas secciones son lo suficientemente grandes.

Adición paralela — Parallel Append

Parallel Append puede ser utilizada en lugar de diferentes bloques en diferentes procesos de trabajo. Esto suele suceder con consultas UNION ALL. La desventaja es menos paralelismo, ya que cada proceso de trabajo maneja solo 1 consulta.

Aquí se están ejecutando 2 procesos de trabajo, aunque se han habilitado 4.

tpch=# explain (costs off) select sum(l_quantity) as sum_qty from lineitem where l_shipdate <= date '1998-12-01' - interval '105' day union all select sum(l_quantity) as sum_qty from lineitem where l_shipdate   Parallel Append
         ->  Aggregate
               ->  Seq Scan on lineitem
                     Filter: (l_shipdate   Aggregate
               ->  Seq Scan on lineitem lineitem_1
                     Filter: (l_shipdate <= '1998-08-18 00:00:00'::timestamp without time zone)

Las variables más importantes

  • WORK_MEM limita la cantidad de memoria para cada proceso, no solo para las consultas: work_mem procesos conexiones = mucha memoria.
  • max_parallel_workers_per_gather — cuántos procesos de trabajo utilizará el programa ejecutante para el procesamiento paralelo del plan.
  • max_worker_processes — ajusta el número total de procesos de trabajo según el número de núcleos de CPU en el servidor.
  • max_parallel_workers — lo mismo, pero para procesos de trabajo paralelos.

Resultados

A partir de la versión 9.6, el procesamiento paralelo puede mejorar significativamente el rendimiento de consultas complejas que escanean muchas filas o índices. En PostgreSQL 10, el procesamiento paralelo está habilitado por defecto. Recuerde desactivarlo en servidores con alta carga de trabajo OLTP. Los escaneos secuenciales o escaneos de índices consumen muchos recursos. Si no está ejecutando informes sobre todo el conjunto de datos, las consultas se pueden hacer más eficientes simplemente agregando índices faltantes o utilizando la partición adecuada.

Enlaces

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster