In un precedente post sul blog Le mie query PostgreSQL preferite e il motivo per cui sono importanti, ho visitato query interessanti per me significative mentre imparo, sviluppo e cresco in un ruolo di sviluppatore SQL.
Uno di questi, in particolare, un UPDATE su più righe con un'unica espressione CASE, ha acceso un'interessante conversazione su Hacker News.
In questo post del blog, voglio osservare i confronti tra quella particolare query e quella che coinvolge più istruzioni UPDATE singole. Nel bene o nel male.
Specifiche macchina/ambiente:
- CPU Intel® Core™ i5-6200U a 2,30 GHz
- 8 GB di RAM
- 1 TB di spazio di archiviazione
- Xubuntu Linux 16.04.3 LTS (Xenial Xerus)
- PostgreSQL 10.4
Nota:per iniziare, ho creato una tabella di "staging" con tutte le colonne di tipo TESTO per caricare i dati.
Il set di dati di esempio che sto utilizzando si trova a questo link qui.
Ma tieni presente che i dati stessi vengono utilizzati in questo esempio perché è un set di dimensioni decenti con più colonne. Qualsiasi "analisi" o AGGIORNAMENTO/INSERIMENTO di questo set di dati non riflette le effettive operazioni GPS/GIS del "mondo reale" e non è inteso come tale.
location=# \d data_staging;
Table "public.data_staging"
Column | Type | Collation | Nullable | Default
---------------+---------+-----------+----------+---------
segment_num | text | | |
point_seg_num | text | | |
latitude | text | | |
longitude | text | | |
nad_year_cd | text | | |
proj_code | text | | |
x_cord_loc | text | | |
y_cord_loc | text | | |
last_rev_date | text | | |
version_date | text | | |
asbuilt_flag | text | | |
location=# SELECT COUNT(*) FROM data_staging;
count
--------
546895
(1 row)
Abbiamo circa mezzo milione di righe di dati in questa tabella.
Per questo primo confronto, aggiornerò la colonna proj_code.
Ecco una query esplorativa per determinarne i valori correnti:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
"70"
""
"72"
"71"
"51"
"15"
"16"
(7 rows)
Userò trim per rimuovere le virgolette dai valori e trasmettere a un INT e determinare quante righe esistono per ogni singolo valore:
Usiamo un CTE per quello, quindi SELECT da esso:
location=# WITH cleaned_nums AS (
SELECT NULLIF(trim(both '"' FROM proj_code), '') AS p_code FROM data_staging
)
SELECT COUNT(*),
CASE
WHEN p_code::int = 70 THEN '70'
WHEN p_code::int = 72 THEN '72'
WHEN p_code::int = 71 THEN '71'
WHEN p_code::int = 51 THEN '51'
WHEN p_code::int = 15 THEN '15'
WHEN p_code::int = 16 THEN '16'
ELSE '00'
END AS proj_code_num
FROM cleaned_nums
GROUP BY p_code
ORDER BY p_code DESC;
count | proj_code_num
--------+---------------
353087 | 0
139057 | 72
25460 | 71
3254 | 70
1 | 51
12648 | 16
13388 | 15
(7 rows)
Prima di eseguire questi test, andrò avanti e altererò la colonna proj_code per digitare INTEGER:
BEGIN;
ALTER TABLE data_staging ALTER COLUMN proj_code SET DATA TYPE INTEGER USING NULLIF(trim(both '"' FROM proj_code), '')::INTEGER;
SAVEPOINT my_save;
COMMIT;
E ripulisci il valore della colonna NULL (rappresentato da ELSE '00' nell'espressione CASE esplorativa sopra), impostandolo su un numero arbitrario, 10, con questo UPDATE:
UPDATE data_staging
SET proj_code = 10
WHERE proj_code IS NULL;
Ora tutte le colonne proj_code hanno un valore INTEGER.
Andiamo avanti ed eseguiamo una singola espressione CASE aggiornando tutti i valori della colonna proj_code e vediamo cosa riporta il timing. Metterò tutti i comandi in un file sorgente .sql per facilità di gestione.
Ecco il contenuto del file:
BEGIN;
\timing on
UPDATE data_staging
SET proj_code =
(
CASE proj_code
WHEN 72 THEN 7272
WHEN 71 THEN 7171
WHEN 15 THEN 1515
WHEN 51 THEN 5151
WHEN 70 THEN 7070
WHEN 10 THEN 1010
WHEN 16 THEN 1616
END
)
WHERE proj_code IN (72, 71, 15, 51, 70, 10, 16);
SAVEPOINT my_save;
Eseguiamo questo file e controlliamo cosa riporta la tempistica:
location=# \i /case_insert.sql
BEGIN
Time: 0.265 ms
Timing is on.
UPDATE 546895
Time: 6779.596 ms (00:06.780)
SAVEPOINT
Time: 0.300 ms
Poco più di mezzo milione di righe in più di 6 secondi.
Ecco le modifiche riflesse nella tabella finora:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7070
1616
1010
7171
1515
7272
5151
(7 rows)
Eseguirò il ROLLBACK (non mostrato) di queste modifiche in modo da poter eseguire singole istruzioni INSERT per testare anche quelle.
Di seguito sono riportate le modifiche al file sorgente .sql per questa serie di confronti:
BEGIN;
\timing on
UPDATE data_staging
SET proj_code = 7222
WHERE proj_code = 72;
UPDATE data_staging
SET proj_code = 7171
WHERE proj_code = 71;
UPDATE data_staging
SET proj_code = 1515
WHERE proj_code = 15;
UPDATE data_staging
SET proj_code = 5151
WHERE proj_code = 51;
UPDATE data_staging
SET proj_code = 7070
WHERE proj_code = 70;
UPDATE data_staging
SET proj_code = 1010
WHERE proj_code = 10;
UPDATE data_staging
SET proj_code = 1616
WHERE proj_code = 16;
SAVEPOINT my_save;
E quei risultati,
location=# \i /case_insert.sql
BEGIN
Time: 0.264 ms
Timing is on.
UPDATE 139057
Time: 795.610 ms
UPDATE 25460
Time: 116.268 ms
UPDATE 13388
Time: 239.007 ms
UPDATE 1
Time: 72.699 ms
UPDATE 3254
Time: 162.199 ms
UPDATE 353087
Time: 1987.857 ms (00:01.988)
UPDATE 12648
Time: 321.223 ms
SAVEPOINT
Time: 0.108 ms
Controlliamo i valori:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7222
1616
7070
1010
7171
1515
5151
(7 rows)
E i tempi (Nota:farò i calcoli in una query poiché \timing non ha riportato interi secondi in questa corsa):
location=# SELECT round((795.610 + 116.268 + 239.007 + 72.699 + 162.199 + 1987.857 + 321.223) / 1000, 3) AS seconds;
seconds
---------
3.695
(1 row)
I singoli INSERT hanno impiegato circa la metà del tempo del singolo CASE.
Questo primo test ha incluso l'intera tabella, con tutte le colonne. Sono curioso di sapere eventuali differenze in una tabella con lo stesso numero di righe, ma meno colonne, da qui la prossima serie di test.
Creerò una tabella con 2 colonne (composta da un tipo di dati SERIAL per la PRIMARY KEY e un INTEGER per la colonna proj_code) e mi sposterò sui dati:
location=# CREATE TABLE proj_nums(n_id SERIAL PRIMARY KEY, proj_code INTEGER);
CREATE TABLE
location=# INSERT INTO proj_nums(proj_code) SELECT proj_code FROM data_staging;
INSERT 0 546895
(Da notare:i comandi SQL del primo set di operazioni vengono utilizzati con le modifiche appropriate. Li sto omettendo qui per brevità e visualizzazione sullo schermo )
Eseguirò prima la singola espressione CASE:
location=# \i /case_insert.sql
BEGIN
Timing is on.
UPDATE 546895
Time: 4355.332 ms (00:04.355)
SAVEPOINT
Time: 0.137 ms
E poi i singoli UPDATE:
location=# \i /case_insert.sql
BEGIN
Time: 0.282 ms
Timing is on.
UPDATE 139057
Time: 1042.133 ms (00:01.042)
UPDATE 25460
Time: 123.337 ms
UPDATE 13388
Time: 212.698 ms
UPDATE 1
Time: 43.107 ms
UPDATE 3254
Time: 52.669 ms
UPDATE 353087
Time: 2787.295 ms (00:02.787)
UPDATE 12648
Time: 99.813 ms
SAVEPOINT
Time: 0.059 ms
location=# SELECT round((1042.133 + 123.337 + 212.698 + 43.107 + 52.669 + 2787.295 + 99.813) / 1000, 3) AS seconds;
seconds
---------
4.361
(1 row)
La tempistica è in qualche modo uniforme tra i due insiemi di operazioni sulla tabella con solo 2 colonne.
Dirò che l'uso dell'espressione CASE è un po' più facile da digitare, ma non necessariamente la scelta migliore in tutte le occasioni. Come per quanto affermato in alcuni dei commenti sul thread di Hacker News a cui si fa riferimento sopra, normalmente "dipende solo" da molti fattori dai quali potrebbe essere o meno la scelta ottimale.
Mi rendo conto che questi test sono nel migliore dei casi soggettivi. Uno di questi, su una tabella con 11 colonne mentre l'altro aveva solo 2 colonne, entrambe di tipo numerico.
L'espressione CASE per gli aggiornamenti di più righe è ancora una delle mie query preferite, se non altro per la facilità di digitazione in un ambiente controllato in cui molte singole query UPDATE sono l'altra alternativa.
Tuttavia, ora posso vedere dove non è sempre la scelta ottimale mentre continuo a crescere e imparare.
Come dice un vecchio proverbio, "Mezza dozzina in una mano, 6 nell'altra ."
Un'ulteriore query preferita - Utilizzo di PLpgSQL CURSOR
Ho iniziato a memorizzare e tenere traccia di tutte le mie statistiche di esercizio (trail hiking) con PostgreSQL sulla mia macchina di sviluppo locale. Sono coinvolte più tabelle, come con qualsiasi database normalizzato.
Tuttavia, alla fine dei mesi, voglio memorizzare le statistiche di colonne specifiche, in una tabella separata.
Ecco la tabella "mensile" che userò:
fitness=> \d hiking_month_total;
Table "public.hiking_month_total"
Column | Type | Collation | Nullable | Default
-----------------+------------------------+-----------+----------+---------
day_hiked | date | | |
calories_burned | numeric(4,1) | | |
miles | numeric(4,2) | | |
duration | time without time zone | | |
pace | numeric(2,1) | | |
trail_hiked | text | | |
shoes_worn | text | | |
Mi concentrerò sui risultati di maggio con questa query SELECT:
fitness=> SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
fitness-> FROM hiking_stats AS hs
fitness-> INNER JOIN hiking_trail AS ht
fitness-> ON hs.hike_id = ht.th_id
fitness-> INNER JOIN trail_route AS tr
fitness-> ON ht.tr_id = tr.trail_id
fitness-> INNER JOIN shoe_brand AS sb
fitness-> ON hs.shoe_id = sb.shoe_id
fitness-> WHERE extract(month FROM hs.day_walked) = 5
fitness-> ORDER BY hs.day_walked ASC;
Ed ecco 3 righe di esempio restituite da quella query:
day_walked | cal_burned | miles_walked | duration | mph | name | name_brand
------------+------------+--------------+----------+-----+------------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
2018-05-03 | 320.8 | 3.38 | 00:58:59 | 3.4 | Sandy Trail-Drive | New Balance Trail Runners-All Terrain
2018-05-04 | 291.3 | 3.01 | 00:53:33 | 3.4 | House-Power Line Route | Keen Koven WP(keen-dry)
(3 rows)
A dire il vero, posso popolare la tabella di destinazione hiking_month_total utilizzando la query SELECT sopra in un'istruzione INSERT.
Ma dov'è il divertimento?
Rinuncerò alla noia per una funzione PLpgSQL con un CURSOR invece.
Mi è venuta in mente questa funzione per eseguire INSERT con un CURSOR:
CREATE OR REPLACE function monthly_total_stats()
RETURNS void
AS $month_stats$
DECLARE
v_day_walked date;
v_cal_burned numeric(4, 1);
v_miles_walked numeric(4, 2);
v_duration time without time zone;
v_mph numeric(2, 1);
v_name text;
v_name_brand text;
v_cur CURSOR for SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
WHERE extract(month FROM hs.day_walked) = 5
ORDER BY hs.day_walked ASC;
BEGIN
OPEN v_cur;
<<get_stats>>
LOOP
FETCH v_cur INTO v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand;
EXIT WHEN NOT FOUND;
INSERT INTO hiking_month_total(day_hiked, calories_burned, miles,
duration, pace, trail_hiked, shoes_worn)
VALUES(v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand);
END LOOP get_stats;
CLOSE v_cur;
END;
$month_stats$ LANGUAGE PLpgSQL;
Chiamiamo la funzione month_total_stats() per eseguire l'INSERT:
fitness=> SELECT monthly_total_stats();
monthly_total_stats
---------------------
(1 row)
Poiché la funzione è definita RETURNS void, possiamo vedere che nessun valore viene restituito al chiamante.
Al momento, non sono particolarmente interessato a nessun valore restituito,
solo che la funzione esegue l'operazione definita, popolando la tabella hiking_month_total.
Eseguirò una query per un conteggio di record nella tabella di destinazione, confermando che contiene dati:
fitness=> SELECT COUNT(*) FROM hiking_month_total;
count
-------
25
(1 row)
La funzione month_total_stats() funziona, ma forse un caso d'uso migliore per un CURSOR è scorrere un gran numero di record. Forse una tabella con circa mezzo milione di record?
Il prossimo CURSOR è legato a una query indirizzata alla tabella data_staging dalla serie di confronti nella sezione precedente:
CREATE OR REPLACE FUNCTION location_curs()
RETURNS refcursor
AS $location$
DECLARE
v_cur refcursor;
BEGIN
OPEN v_cur for SELECT segment_num, latitude, longitude, proj_code, asbuilt_flag FROM data_staging;
RETURN v_cur;
END;
$location$ LANGUAGE PLpgSQL;
Quindi, per utilizzare questo CURSORE, operare all'interno di una TRANSAZIONE (indicata nella documentazione qui).
location=# BEGIN;
BEGIN
location=# SELECT location_curs();
location_curs
--------------------
<unnamed portal 1>
(1 row)
Quindi cosa puoi fare con questo "
Ecco solo alcune cose:
Possiamo restituire la prima riga dal CURSORE usando first o ABSOLUTE 1:
location=# FETCH first FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
location=# FETCH ABSOLUTE 1 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
Vuoi una riga quasi a metà del set di risultati? (Supponendo di sapere che circa mezzo milione di righe sono legate al CURSOR.)
Puoi essere così "specifico" con un CURSORE?
Già.
Possiamo posizionare e FETCH i valori per il record alla riga 234888 (solo un numero casuale che ho scelto):
location=# FETCH ABSOLUTE 234888 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Una volta posizionato lì, possiamo spostare il CURSORE 'indietro di uno':
location=# FETCH BACKWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
Che è uguale a:
location=# FETCH ABSOLUTE 234887 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
Quindi possiamo riportare il CURSORE su ABSOLUTE 234888 con:
location=# FETCH FORWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Suggerimento utile:per riposizionare il CURSOR, usa MOVE invece di FETCH se non ti servono i valori di quella riga.
Vedi questo passaggio dalla documentazione:
"MOVE riposiziona un cursore senza recuperare alcun dato. MOVE funziona esattamente come il comando FETCH, tranne per il fatto che posiziona solo il cursore e non restituisce righe."
Il nome "
Rivisiterò i dati delle mie statistiche di fitness per scrivere una funzione e nominare il CURSOR, insieme a un potenziale caso d'uso "reale".
Il CURSOR punterà a questa tabella aggiuntiva, che memorizza i risultati non limitati al mese di maggio (praticamente tutto ciò che ho raccolto finora) come nell'esempio precedente:
fitness=> CREATE TABLE cp_hiking_total AS SELECT * FROM hiking_month_total WITH NO DATA;
CREATE TABLE AS
Quindi popolalo con i dati:
fitness=> INSERT INTO cp_hiking_total
SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
ORDER BY hs.day_walked ASC;
INSERT 0 51
Ora con la seguente funzione PLpgSQL, CREATE un CURSORE 'denominato':
CREATE OR REPLACE FUNCTION stats_cursor(refcursor)
RETURNS refcursor
AS $$
BEGIN
OPEN $1 FOR
SELECT *
FROM cp_hiking_total;
RETURN $1;
END;
$$ LANGUAGE plpgsql;
Chiamerò questo CURSORE 'statistiche':
fitness=> BEGIN;
BEGIN
fitness=> SELECT stats_cursor('stats');
stats_cursor
--------------
stats
(1 row)
Supponiamo di volere la riga '12' associata al CURSORE.
Posso posizionare il CURSOR su quella riga, recuperando quei risultati con il comando seguente:
fitness=> FETCH ABSOLUTE 12 FROM stats;
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
Ai fini di questo post del blog, immagina di sapere di prima mano che il valore della colonna del ritmo per questa riga non è corretto.
Ricordo in particolare che quel giorno ero "morto in piedi stanco" e durante quell'escursione ho mantenuto un ritmo di 3,0. (Ehi, succede.)
Ok, aggiornerò semplicemente la tabella cp_hiking_total per riflettere tale modifica.
Relativamente semplice senza dubbio. Noioso...
Che ne dici invece delle statistiche CURSOR?
fitness=> UPDATE cp_hiking_total
fitness-> SET pace = 3.0
fitness-> WHERE CURRENT OF stats;
UPDATE 1
Per rendere permanente questa modifica, emetti COMMIT:
fitness=> COMMIT;
COMMIT
Esaminiamo e vediamo che UPDATE si riflette nella tabella cp_hiking_total:
fitness=> SELECT * FROM cp_hiking_total
fitness-> WHERE day_hiked = '2018-05-02';
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.0 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
Quanto è bello?
Spostarsi all'interno del set di risultati del CURSOR ed eseguire un AGGIORNAMENTO se necessario.
Abbastanza potente se me lo chiedi. E conveniente.
Alcune "precauzioni" e informazioni dalla documentazione su questo tipo di CURSORE:
"Si consiglia generalmente di utilizzare FOR UPDATE se il cursore deve essere utilizzato con UPDATE ... WHERE CURRENT OF o DELETE ... WHERE CURRENT OF. L'utilizzo di FOR UPDATE impedisce ad altre sessioni di modificare le righe tra l'ora vengono recuperati e l'ora in cui vengono aggiornati. Senza FOR UPDATE, un successivo comando WHERE CURRENT OF non avrà effetto se la riga è stata modificata da quando è stato creato il cursore.
Un altro motivo per utilizzare FOR UPDATE è che senza di esso, un successivo WHERE CURRENT OF potrebbe non riuscire se la query del cursore non soddisfa le regole dello standard SQL per essere "semplicemente aggiornabile" (in particolare, il cursore deve fare riferimento a una sola tabella e non utilizzare il raggruppamento o ORDER BY). I cursori che non sono semplicemente aggiornabili potrebbero funzionare o meno, a seconda dei dettagli della scelta del piano; quindi nel peggiore dei casi, un'applicazione potrebbe funzionare in fase di test e quindi fallire in produzione."
Con il CURSOR che ho usato qui, ho seguito le regole standard SQL (dai passaggi precedenti) nell'aspetto di:ho fatto riferimento a una sola tabella, senza raggruppamento o ORDER by clausola.
Perché è importante.
Come per numerose operazioni, query o attività in PostgreSQL (e SQL in generale), in genere esiste più di un modo per raggiungere e raggiungere il tuo obiettivo finale. Questo è uno dei motivi principali per cui sono attratto da SQL e mi sforzo di saperne di più.
Spero che attraverso questo post di follow-up sul blog, ho fornito alcune informazioni sul motivo per cui l'AGGIORNAMENTO su più righe con CASE è stato incluso come una delle mie query preferite, in quel primo post sul blog di accompagnamento. Per me vale la pena averlo come opzione.
Inoltre, esplorare i CURSORI, per attraversare set di risultati di grandi dimensioni. L'esecuzione di operazioni DML, come UPDATES e/o DELETES, con il tipo corretto di CURSOR, è solo la "ciliegina sulla torta". Sono ansioso di studiarli ulteriormente per ulteriori casi d'uso.