Richieste parallele in PostgreSQL

Richieste parallele in PostgreSQL
Nei moderni CP ci sono moltissimi core. Per anni, le applicazioni hanno inviato richieste ai database in parallelo. Se si tratta di una richiesta di report su molte righe nella tabella, viene eseguita più rapidamente quando utilizza più CP, e in PostgreSQL ciò è possibile a partire dalla versione 9.6.

Ci sono voluti 3 anni per implementare la funzione delle richieste parallele — è stato necessario riscrivere il codice in diverse fasi dell'esecuzione delle richieste. In PostgreSQL 9.6 è stata introdotta un'infrastruttura per un ulteriore miglioramento del codice. Nelle versioni successive, anche altri tipi di richieste vengono eseguiti in parallelo.

Limitazioni

  • Non attivare l'esecuzione parallela se tutti i core sono già occupati, altrimenti altre richieste saranno rallentate.
  • La cosa più importante è che l'elaborazione parallela con valori elevati di WORK_MEM utilizza molta memoria: ogni connessione hash o ordinamento occupa memoria per un ammontare di work_mem.
  • Le richieste OLTP con bassa latenza non possono essere accelerate mediante l'esecuzione parallela. Se una richiesta restituisce una sola riga, l'elaborazione parallela non farà altro che rallentarla.
  • Gli sviluppatori amano utilizzare il benchmark TPC-H. Forse hai richieste simili per un'esecuzione parallela ottimale.
  • Solo le richieste SELECT senza blocchi predicati vengono eseguite in parallelo.
  • A volte una corretta indicizzazione è migliore della scansione sequenziale della tabella in modalità parallela.
  • La sospensione delle richieste e i cursori non sono supportati.
  • Le funzioni finestra e le funzioni aggregate dei set ordinati non sono parallele.
  • Non guadagnate nulla in carico di I/O.
  • Non esistono algoritmi di ordinamento paralleli. Tuttavia, le richieste con ordinamenti possono essere eseguite in parallelo in alcuni aspetti.
  • Sostituisci CTE (WITH …) con un SELECT annidato per includere l'elaborazione parallela.
  • I wrapper di dati di terze parti non supportano ancora l'elaborazione parallela (anche se potrebbero!).
  • FULL OUTER JOIN non è supportato.
  • max_rows disabilita l'elaborazione parallela.
  • Se nella richiesta è presente una funzione non contrassegnata come PARALLEL SAFE, essa sarà monothread.
  • Il livello di isolamento della transazione SERIALIZABLE disabilita l'elaborazione parallela.

Ambiente di test

Gli sviluppatori di PostgreSQL hanno cercato di ridurre i tempi di risposta delle richieste del benchmark TPC-H. Scarica il benchmark e adattalo a PostgreSQL. Questo utilizzo non ufficiale del benchmark TPC-H non è per il confronto di database o hardware.

  1. Carica TPC-H_Tools_v2.17.3.zip (o una versione più recente) dal sito ufficiale TPC.
  2. Rinomina makefile.suite in Makefile e modifica come descritto qui: https://github.com/tvondra/pg_tpch . Compila il codice con il comando make.
  3. Genera i dati: .\/dbgen -s 10 crea un database di 23 GB. Questo è sufficiente per vedere la differenza nelle prestazioni delle query parallele e non parallele.
  4. Converti i file tbl in csv con for e sed.
  5. Clona il repository pg_tpch e copia i file csv in pg_tpch\/dss\/data.
  6. Crea le query con il comando qgen.
  7. Carica i dati nel database con il comando .\/tpch.sh.

Scansione sequenziale parallela

Può risultare più veloce non a causa della lettura parallela, ma perché i dati sono distribuiti su molti core della CPU. Nei sistemi operativi moderni, i file di dati PostgreSQL vengono ben memorizzati nella cache. Con la lettura anticipata è possibile ottenere dal magazzino un blocco più grande rispetto a quello richiesto dal demone PG. Pertanto, le prestazioni della query non sono limitate dall'I/O del disco. Consuma cicli della CPU per:

  • leggere le righe una ad una dalle pagine della tabella;
  • confrontare i valori delle righe e le condizioni DOVE.

Eseguiamo una semplice query 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 senza fuso orario)
Righe rimosse dal filtro: 1146337
Tempo di pianificazione: 0.203 ms
Tempo di esecuzione: 19035.100 ms

La scansione sequenziale restituisce troppe righe senza aggregazione, quindi la query viene eseguita da un solo core della CPU.

Se aggiungiamo SUM(), si nota che due processi di lavoro aiutano ad accelerare la query:

explain analyze select sum(l_quantity) as sum_qty from lineitem where l_shipdate <= date '1998-12-01' - interval '105' day;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=1589702.14..1589702.15 rows=1 width=32) (actual time=8553.365..8553.365 rows=1 loops=1)
-> Gather (cost=1589701.91..1589702.12 rows=2 width=32) (actual time=8553.241..8555.067 rows=3 loops=1)
Workers pianificati: 2
Workers lanciati: 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 senza fuso orario)
Righe rimosse dal filtro: 382112
Tempo di pianificazione: 0.241 ms
Tempo di esecuzione: 8555.131 ms

Aggregazione parallela

Il nodo «Parallel Seq Scan» produce righe per l'aggregazione parziale. Il nodo «Partial Aggregate» riduce queste righe usando SUM(). Alla fine, il contatore SUM di ciascun processo di lavoro viene raccolto dal nodo «Gather».

Il risultato finale è calcolato dal nodo «Finalize Aggregate». Se hai le tue funzioni di aggregazione, assicurati di contrassegnarle come «parallel safe».

Numero di processi di lavoro

Il numero di processi di lavoro può essere aumentato senza riavviare il server:

explain analyze select sum(l_quantity) as sum_qty from lineitem where l_shipdate <= date '1998-12-01' - interval '105' day;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=1589702.14..1589702.15 rows=1 width=32) (actual time=8553.365..8553.365 rows=1 loops=1)
-> Gather (cost=1589701.91..1589702.12 rows=2 width=32) (actual time=8553.241..8555.067 rows=3 loops=1)
Workers pianificati: 2
Workers lanciati: 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 senza fuso orario)
Righe rimosse dal filtro: 382112
Tempo di pianificazione: 0.241 ms
Tempo di esecuzione: 8555.131 ms

Cosa sta succedendo qui? I processi di lavoro sono raddoppiati, mentre la richiesta è diventata solo 1,6599 volte più veloce. I calcoli sono interessanti. Avevamo 2 processi di lavoro e 1 leader. Dopo la modifica, sono diventati 4+1.

La nostra massima accelerazione dalla lavorazione parallela: 5/3 = 1,66(6) volte.

Come funziona?

Processi

L'esecuzione della richiesta inizia sempre con il processo guida. Il leader gestisce tutto ciò che non è paralellizzato e parte del trattamento parallelo. Gli altri processi, che eseguono le stesse richieste, vengono chiamati processi di lavoro. L'elaborazione parallela utilizza l'infrastruttura dei processi di lavoro in background dinamici (dalla versione 9.4). Poiché altre parti di PostgreSQL utilizzano processi e non thread, una richiesta con 3 processi di lavoro potrebbe essere 4 volte più veloce rispetto all'elaborazione tradizionale.

Interazione

I processi di lavoro comunicano con il leader tramite una coda di messaggi (basata su memoria condivisa). Ogni processo ha 2 code: per gli errori e per le tuple.

Quanti processi di lavoro sono necessari?

Il limite minimo è impostato dal parametro max_parallel_workers_per_gather. Poi l'esecutore delle query prende i processi di lavoro dal pool, limitato dal parametro max_parallel_workers size. L'ultimo limite è dato da max_worker_processes, cioè il numero totale di processi in background.

Se non è stato possibile allocare un processo di lavoro, l'elaborazione sarà monoprocessuale.

Il pianificatore delle query può ridurre i processi di lavoro a seconda delle dimensioni della tabella o dell'indice. Ci sono parametri per questo min_parallel_table_scan_size e min_parallel_index_scan_size.

set min_parallel_table_scan_size='8MB'
8MB tabella => 1 lavoratore
24MB tabella => 2 lavoratori
72MB tabella => 3 lavoratori
x => log(x / min_parallel_table_scan_size) / log(3) + 1 lavoratore

Ogni volta che la tabella è 3 volte più grande di min_parallel_(index|table)_scan_size, Postgres aggiunge un processo di lavoro. Il numero di processi di lavoro non si basa sui costi. La dipendenza circolare rende difficili le implementazioni complesse. Invece, il pianificatore utilizza regole semplici.

Nella pratica queste regole non sono sempre adatte per la produzione, quindi è possibile modificare il numero di processi di lavoro per una tabella specifica: ALTER TABLE … SET (parallel_workers = N).

Perché non viene utilizzata l'elaborazione parallela?

Oltre a un lungo elenco di limitazioni, ci sono anche controlli sui costi:

parallel_setup_cost — per evitar di gestire in parallelo richieste brevi. Questo parametro stima il tempo per la preparazione della memoria, l'avvio del processo e lo scambio iniziale di dati.

parallel_tuple_cost: la comunicazione tra il leader e i lavoratori può allungarsi proporzionalmente al numero di tuple dai processi di lavoro. Questo parametro calcola i costi per lo scambio di dati.

Join a cicli annidati — Nested Loop Join

PostgreSQL 9.6+ può eseguire cicli annidati in parallelo: si tratta di un'operazione semplice.

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)

Il raccolto avviene nell'ultima fase, quindi il Nested Loop Left Join è un'operazione in parallelo. Il Parallel Index Only Scan è apparso solo nella versione 10. Funziona in modo analogo alla scansione sequenziale parallela. La condizione c_custkey = o_custkey legge un ordine per ciascuna riga del cliente. Quindi non è parallela.

Hash Join — Hash Join

Ogni processo di lavoro crea la propria tabella hash fino a PostgreSQL 11. E se ci sono più di quattro di questi processi, le prestazioni non aumenteranno. Nella nuova versione, la tabella hash è condivisa. Ogni processo di lavoro può utilizzare WORK_MEM per creare una tabella hash.

seleziona
        l_shipmode,
        somma(caso
                quando o_orderpriority = '1-URGENTE'
                        o o_orderpriority = '2-ALTA'
                        allora 1
                altro 0
        fine) come high_line_count,
        somma(caso
                quando o_orderpriority  '1-URGENTE'
                        e o_orderpriority  '2-ALTA'
                        allora 1
                altro 0
        fine) come low_line_count
da
        ordini,
        lineitem
dove
        o_orderkey = l_orderkey
        e l_shipmode in ('MAIL', 'ARIA')
        e l_commitdate < l_receiptdate
        e l_shipdate = data '1996-01-01'
        e l_receiptdate < data '1996-01-01' + intervallo '1' anno
gruppo per
        l_shipmode
ordine per
        l_shipmode
LIMIT 1;
                                                                                                                                    PIANO QUERY
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (costo=1964755.66..1964961.44 righe=1 larghezza=27) (tempo effettivo=7579.592..7922.997 righe=1 cicli=1)
   ->  Finalizza GroupAggregate  (costo=1964755.66..1966196.11 righe=7 larghezza=27) (tempo effettivo=7579.590..7579.591 righe=1 cicli=1)
         Chiave di gruppo: lineitem.l_shipmode
         ->  Raggruppa Merge  (costo=1964755.66..1966195.83 righe=28 larghezza=27) (tempo effettivo=7559.593..7922.319 righe=6 cicli=1)
               Lavoratori pianificati: 4
               Lavoratori lanciati: 4
               ->  Gruppo Parziale Aggregato  (costo=1963755.61..1965192.44 righe=7 larghezza=27) (tempo effettivo=7548.103..7564.592 righe=2 cicli=5)
                     Chiave di gruppo: lineitem.l_shipmode
                     ->  Ordina  (costo=1963755.61..1963935.20 righe=71838 larghezza=27) (tempo effettivo=7530.280..7539.688 righe=62519 cicli=5)
                           Chiave di ordinamento: lineitem.l_shipmode
                           Metodo di ordinamento: fusione esterna  Disco: 2304kB
                           Lavoratore 0:  Metodo di ordinamento: fusione esterna  Disco: 2064kB
                           Lavoratore 1:  Metodo di ordinamento: fusione esterna  Disco: 2384kB
                           Lavoratore 2:  Metodo di ordinamento: fusione esterna  Disco: 2264kB
                           Lavoratore 3:  Metodo di ordinamento: fusione esterna  Disco: 2336kB
                           ->  Unione Hash parallela  (costo=382571.01..1957960.99 righe=71838 larghezza=27) (tempo effettivo=7036.917..7499.692 righe=62519 cicli=5)
                                 Condizione Hash: (lineitem.l_orderkey = orders.o_orderkey)
                                 ->  Scansione Seq parallela su lineitem  (costo=0.00..1552386.40 righe=71838 larghezza=19) (tempo effettivo=0.583..4901.063 righe=62519 cicli=5)
                                       Filtro: ((l_shipmode = QUALSIASI ('{MAIL,ARIA}'::bpchar[])) E (l_commitdate < l_receiptdate) E (l_shipdate = '1996-01-01'::data) E (l_receiptdate < '1997-01-01 00:00:00'::timestamp senza fuso orario))
                                       Righe rimosse dal filtro: 11934691
                                 ->  Hash parallelo  (costo=313722.45..313722.45 righe=3750045 larghezza=20) (tempo effettivo=2011.518..2011.518 righe=3000000 cicli=5)
                                       Focolari: 65536  Batches: 256  Utilizzo memoria: 3840kB
                                       ->  Scansione Seq parallela su ordini  (costo=0.00..313722.45 righe=3750045 larghezza=20) (tempo effettivo=0.029..995.948 righe=3000000 cicli=5)
 Tempo di pianificazione: 0.977 ms
 Tempo di esecuzione: 7923.770 ms

La Query 12 di TPC-H mostra chiaramente la connessione hash parallela. Ogni processo di lavoro partecipa alla creazione di una tabella hash comune.

Unione per fusione — Merge Join

L'unione per fusione è intrinsecamente non parallela. Non preoccupatevi se questo è l'ultimo passaggio della query, può comunque essere eseguito in parallelo.

-- Query 2 da TPC-H
spiega (costi disattivati) seleziona s_acctbal, s_name, n_name, p_partkey, p_mfgr, s_address, s_phone, s_comment
da    part, supplier, partsupp, nation, region
dove
        p_partkey = ps_partkey
        e s_suppkey = ps_suppkey
        e p_size = 36
        e p_type like '%BRASS'
        e s_nationkey = n_nationkey
        e n_regionkey = r_regionkey
        e r_name = 'AMERICA'
        e ps_supplycost = (
                seleziona
                        min(ps_supplycost)
                da    partsupp, supplier, nation, region
                dove
                        p_partkey = ps_partkey
                        e s_suppkey = ps_suppkey
                        e s_nationkey = n_nationkey
                        e n_regionkey = r_regionkey
                        e r_name = 'AMERICA'
        )
ordina per s_acctbal desc, n_name, s_name, p_partkey
LIMIT 100;
                                                PIANTA DELLA QUERY
----------------------------------------------------------------------------------------------------------
 Limit
   -&gt;  Ordina
         Chiave di ordinamento: supplier.s_acctbal DESC, nation.n_name, supplier.s_name, part.p_partkey
         -&gt;  Merge Join
               Condizione di unione: (part.p_partkey = partsupp.ps_partkey)
               Filtro di unione: (partsupp.ps_supplycost = (SubPiano 1))
               -&gt;  Raccogli Merge
                     Lavoratori pianificati: 4
                     -&gt;  Scansione Indice Parallela usando <strong>part_pkey</strong> su parte
                           Filtro: (((p_type)::text ~~ '%BRASS'::text) E (p_size = 36))
               -&gt;  Materializza
                     -&gt;  Ordina
                           Chiave di ordinamento: partsupp.ps_partkey
                           -&gt;  Ciclo annidato
                                 -&gt;  Ciclo annidato
                                       Filtro di unione: (nation.n_regionkey = region.r_regionkey)
                                       -&gt;  Scansione sequenziale su region
                                             Filtro: (r_name = 'AMERICA'::bpchar)
                                       -&gt;  Hash Join
                                             Condizione Hash: (supplier.s_nationkey = nation.n_nationkey)
                                             -&gt;  Scansione sequenziale su supplier
                                             -&gt;  Hash
                                                   -&gt;  Scansione sequenziale su nation
                                 -&gt;  Scansione Indice usando idx_partsupp_suppkey su partsupp
                                       Condizione indice: (ps_suppkey = supplier.s_suppkey)
               SubPiano 1
                 -&gt;  Aggregato
                       -&gt;  Ciclo annidato
                             Filtro di unione: (nation_1.n_regionkey = region_1.r_regionkey)
                             -&gt;  Scansione sequenziale su region region_1
                                   Filtro: (r_name = 'AMERICA'::bpchar)
                             -&gt;  Ciclo annidato
                                   -&gt;  Ciclo annidato
                                         -&gt;  Scansione Indice usando idx_partsupp_partkey su partsupp partsupp_1
                                               Condizione indice: (part.p_partkey = ps_partkey)
                                         -&gt;  Scansione Indice usando supplier_pkey su supplier supplier_1
                                               Condizione indice: (s_suppkey = partsupp_1.ps_suppkey)
                                   -&gt;  Scansione Indice usando nation_pkey su nation nation_1
                                         Condizione indice: (n_nationkey = supplier_1.s_nationkey)

Il nodo «Merge Join» è posizionato sopra «Gather Merge». Quindi la fusione non utilizza l'elaborazione parallela. Tuttavia, il nodo «Parallel Index Scan» aiuta comunque nel segmento part_pkey.

Unione per sezioni

In PostgreSQL 11 unione per sezioni è disattivata per impostazione predefinita: ha una pianificazione molto costosa. Le tabelle con sezioni simili possono essere unite sezione per sezione. In questo modo Postgres utilizzerà tabelle hash più piccole. Ogni unione di sezioni può essere parallela.

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;
                    QUERY PLAN
---------------------------------------------------
 Append
   ->  Hash Join
         Hash Cond: (t2.b = t1.a)
         ->  Seq Scan on prt2_p1 t2
               Filter: ((b >= 0) AND (b <= 10000))
         ->  Hash
               ->  Seq Scan on prt1_p1 t1
                     Filter: (b = 0)
   ->  Hash Join
         Hash Cond: (t2_1.b = t1_1.a)
         ->  Seq Scan on prt2_p2 t2_1
               Filter: ((b >= 0) AND (b <= 10000))
         ->  Hash
               ->  Seq Scan on prt1_p2 t1_1
                     Filter: (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;
                        QUERY PLAN
-----------------------------------------------------------
 Gather
   Workers Planned: 4
   ->  Parallel Append
         ->  Parallel Hash Join
               Hash Cond: (t2_1.b = t1_1.a)
               ->  Parallel Seq Scan on prt2_p2 t2_1
                     Filter: ((b >= 0) AND (b <= 10000))
               ->  Parallel Hash
                     ->  Parallel Seq Scan on prt1_p2 t1_1
                           Filter: (b = 0)
         ->  Parallel Hash Join
               Hash Cond: (t2.b = t1.a)
               ->  Parallel Seq Scan on prt2_p1 t2
                     Filter: ((b >= 0) AND (b <= 10000))
               ->  Parallel Hash
                     ->  Parallel Seq Scan on prt1_p1 t1
                           Filter: (b = 0)

Soprattutto, l'unione per sezioni può essere parallela solo se queste sezioni sono abbastanza grandi.

Appendice parallela — Parallel Append

Parallel Append può essere utilizzata al posto di diversi blocchi in diversi processi di lavoro. Di solito questo avviene con query UNION ALL. Il difetto è che c'è meno parallelismo, poiché ogni processo di lavoro gestisce solo 1 query.

Qui sono in esecuzione 2 processi di lavoro, nonostante siano previsti 4.

tpch=# spiega (costi disattivati) seleziona somma(l_quantity) come sum_qty da lineitem dove l_shipdate <= data '1998-12-01' - intervallo '105' giorni unione tutto seleziona somma(l_quantity) come sum_qty da lineitem dove l_shipdate <= data '2000-12-01' - intervallo '105' giorni;
                                           PIANO DI QUERY
------------------------------------------------------------------------------------------------
 Raccolta
   Lavoratori Pianificati: 2
   ->  Appendice Parallela
         ->  Aggregato
               ->  Scansione Sequenziale su lineitem
                     Filtro: (l_shipdate <= '2000-08-18 00:00:00'::timestamp senza fuso orario)
         ->  Aggregato
               ->  Scansione Sequenziale su lineitem lineitem_1
                     Filtro: (l_shipdate <= '1998-08-18 00:00:00'::timestamp senza fuso orario)

Le variabili più importanti

  • WORK_MEM limita la quantità di memoria per ogni processo, non solo per le query: work_mem processi connessioni = molta memoria.
  • max_parallel_workers_per_gather — quanti processi di lavoro utilizzerà il programma in esecuzione per l'elaborazione parallela dal piano.
  • max_worker_processes — adatta il numero totale di processi di lavoro al numero di core della CPU sul server.
  • max_parallel_workers — lo stesso, ma per i processi di lavoro paralleli.

Conclusioni

A partire dalla versione 9.6, l'elaborazione parallela può migliorare significativamente le prestazioni delle query complesse che esaminano molte righe o indici. In PostgreSQL 10, l'elaborazione parallela è abilitata per impostazione predefinita. Ricorda di disabilitarla sui server con un carico di lavoro OLTP elevato. Le scansioni sequenziali o le scansioni degli indici consumano molte risorse. Se non stai eseguendo un report su tutto il set di dati, le query possono diventare più efficienti semplicemente aggiungendo gli indici mancanti o utilizzando una corretta partizionamento.

Link

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster