Problema di blocco con DELETE / INSERT simultaneo in PostgreSQL


35

Questo è piuttosto semplice, ma sono sconcertato da ciò che fa PG (v9.0). Iniziamo con una semplice tabella:

CREATE TABLE test (id INT PRIMARY KEY);

e alcune righe:

INSERT INTO TEST VALUES (1);
INSERT INTO TEST VALUES (2);

Usando il mio strumento di query JDBC preferito (ExecuteQuery), collego due finestre di sessione al database in cui risiede questa tabella. Entrambi sono transazionali (ovvero auto-commit = false). Chiamiamoli S1 e S2.

Lo stesso bit di codice per ciascuno:

1:DELETE FROM test WHERE id=1;
2:INSERT INTO test VALUES (1);
3:COMMIT;

Ora, eseguilo al rallentatore, eseguendolo uno alla volta nelle finestre.

S1-1 runs (1 row deleted)
S2-1 runs (but is blocked since S1 has a write lock)
S1-2 runs (1 row inserted)
S1-3 runs, releasing the write lock
S2-1 runs, now that it can get the lock. But reports 0 rows deleted. HUH???
S2-2 runs, reports a unique key constraint violation

Ora funziona perfettamente in SQL Server. Quando S2 esegue l'eliminazione, segnala 1 riga eliminata. E poi l'inserto di S2 funziona bene.

Sospetto che PostgreSQL stia bloccando l'indice nella tabella in cui esiste quella riga, mentre SQLServer blocca il valore della chiave effettiva.

Ho ragione? Questo può essere fatto funzionare?

Risposte:


39

Mat ed Erwin hanno entrambi ragione, e sto solo aggiungendo un'altra risposta per espandere ulteriormente ciò che hanno detto in un modo che non si adatta a un commento. Dal momento che le loro risposte non sembrano soddisfare tutti, e c'è stato un suggerimento che gli sviluppatori PostgreSQL dovrebbero essere consultati, e io sono uno, lo elaborerò.

Il punto importante qui è che sotto lo standard SQL, all'interno di una transazione in esecuzione a READ COMMITTEDlivello di isolamento della transazione, la restrizione è che il lavoro delle transazioni senza commit non deve essere visibile. Quando il lavoro delle transazioni impegnate diventa visibile dipende dall'implementazione. Quello che stai sottolineando è una differenza nel modo in cui due prodotti hanno scelto di implementarlo. Nessuna implementazione viola i requisiti della norma.

Ecco cosa succede in PostgreSQL, in dettaglio:

Esecuzioni S1-1 (1 riga eliminata)

La vecchia riga viene lasciata in posizione, perché S1 potrebbe ancora tornare indietro, ma S1 ora mantiene un blocco sulla riga in modo che qualsiasi altra sessione che tenti di modificare la riga attenda per vedere se S1 esegue il commit o il rollback. Qualsiasi lettura della tabella può ancora vedere la vecchia riga, a meno che non tenti di bloccarla con SELECT FOR UPDATEo SELECT FOR SHARE.

S2-1 funziona (ma è bloccato poiché S1 ha un blocco in scrittura)

S2 ora deve aspettare per vedere il risultato di S1. Se S1 dovesse eseguire il rollback anziché il commit, S2 eliminerebbe la riga. Si noti che se S1 avesse inserito una nuova versione prima del rollback, la nuova versione non sarebbe mai stata lì dal punto di vista di qualsiasi altra transazione, né la vecchia versione sarebbe stata eliminata dal punto di vista di qualsiasi altra transazione.

Percorsi S1-2 (1 riga inserita)

Questa riga è indipendente da quella precedente. Se ci fosse stato un aggiornamento della riga con id = 1, le versioni vecchie e nuove sarebbero state correlate e S2 potrebbe eliminare la versione aggiornata della riga quando è stata sbloccata. Il fatto che una nuova riga abbia gli stessi valori di alcune righe esistenti in passato non lo rende uguale a una versione aggiornata di quella riga.

S1-3 funziona, rilasciando il blocco di scrittura

Quindi i cambiamenti di S1 ​​sono persistenti. Una riga è sparita. È stata aggiunta una riga.

S2-1 funziona, ora che può ottenere il blocco. Ma segnala 0 righe eliminate. HUH ???

Ciò che accade internamente è che c'è un puntatore da una versione di una riga alla versione successiva di quella stessa riga se viene aggiornata. Se la riga viene eliminata, non esiste una versione successiva. Quando una READ COMMITTEDtransazione si risveglia da un blocco in un conflitto di scrittura, segue quella catena di aggiornamento fino alla fine; se la riga non è stata eliminata e se soddisfa ancora i criteri di selezione della query verrà elaborata. Questa riga è stata eliminata, quindi la query di S2 va avanti.

S2 può o meno accedere alla nuova riga durante la scansione della tabella. In tal caso, vedrà che la nuova riga è stata creata dopo l' DELETEavvio dell'istruzione S2 e quindi non fa parte dell'insieme di righe visibili ad essa.

Se PostgreSQL dovesse riavviare l'intera istruzione DELETE di S2 dall'inizio con una nuova istantanea, si comporterebbe come SQL Server. La comunità PostgreSQL non ha scelto di farlo per motivi di prestazioni. In questo semplice caso non noteresti mai la differenza nelle prestazioni, ma se eri in dieci milioni di righe in un DELETEblocco, lo faresti sicuramente. C'è un compromesso qui in cui PostgreSQL ha scelto le prestazioni, poiché la versione più veloce è ancora conforme ai requisiti dello standard.

S2-2 funziona, segnala una violazione del vincolo chiave unica

Certo, la fila esiste già. Questa è la parte meno sorprendente dell'immagine.

Mentre qui c'è un comportamento sorprendente, tutto è conforme allo standard SQL e nei limiti di ciò che è "specifico dell'implementazione" secondo lo standard. Può certamente sorprendere se si presume che il comportamento di qualche altra implementazione sarà presente in tutte le implementazioni, ma PostgreSQL si impegna molto per evitare errori di serializzazione nel READ COMMITTEDlivello di isolamento e consente alcuni comportamenti che differiscono da altri prodotti per raggiungere questo obiettivo.

Ora, personalmente non sono un grande fan del READ COMMITTEDlivello di isolamento delle transazioni nell'implementazione di qualsiasi prodotto. Tutti consentono alle condizioni di gara di creare comportamenti sorprendenti dal punto di vista transazionale. Una volta che qualcuno si abitua ai comportamenti strani consentiti da un prodotto, tende a considerare quello "normale" e gli scambi scelti da un altro prodotto strano. Ma ogni prodotto deve fare una sorta di compromesso per qualsiasi modalità non effettivamente implementata come SERIALIZABLE. Dove gli sviluppatori di PostgreSQL hanno scelto di tracciare la linea READ COMMITTEDè minimizzare il blocco (le letture non bloccano le scritture e le scritture non bloccano le letture) e minimizzare la possibilità di errori di serializzazione.

Lo standard richiede che le SERIALIZABLEtransazioni siano predefinite, ma la maggior parte dei prodotti non lo fa perché causa un calo delle prestazioni rispetto ai livelli di isolamento delle transazioni più lassisti. Alcuni prodotti non forniscono nemmeno transazioni realmente serializzabili quando SERIALIZABLEviene scelto, in particolare Oracle e versioni di PostgreSQL precedenti alla 9.1. Ma utilizzare le SERIALIZABLEtransazioni autentiche è l'unico modo per evitare effetti sorprendenti dalle condizioni di gara e le SERIALIZABLEtransazioni devono sempre bloccare per evitare le condizioni di gara o ripristinare alcune transazioni per evitare lo sviluppo di condizioni di gara. L'implementazione più comune delle SERIALIZABLEtransazioni è il rigoroso blocco a due fasi (S2PL) che presenta sia errori di blocco che di serializzazione (sotto forma di deadlock).

Informativa completa: ho collaborato con Dan Ports del MIT per aggiungere transazioni veramente serializzabili a PostgreSQL versione 9.1 usando una nuova tecnica chiamata Serializable Snapshot Isolation.


Mi chiedo se un modo davvero economico (di formaggio?) Di fare questo lavoro sia di emettere due DELET seguite dall'INSERT. Nel mio test limitato (2 thread), ha funzionato bene, ma ho bisogno di testare di più per vedere se ciò sarebbe valido per molti thread.
DaveyBob,

Finché si utilizzano le READ COMMITTEDtransazioni, si ha una condizione di competizione: cosa accadrebbe se un'altra transazione inserisse una nuova riga dopo l' DELETEinizio della prima e prima della seconda DELETE? Con le transazioni meno rigide SERIALIZABLEdei due modi principali per chiudere le condizioni di gara sono attraverso la promozione di un conflitto (ma ciò non aiuta quando la riga viene eliminata) e la materializzazione di un conflitto. È possibile materializzare il conflitto facendo eliminare una tabella "id" che è stata aggiornata per ogni riga o bloccando esplicitamente la tabella. Oppure usa i tentativi in ​​caso di errore.
kgrittn,

Ci riprova. Grazie mille per il prezioso approfondimento!
DaveyBob,

21

Credo che ciò avvenga in base alla progettazione, secondo la descrizione del livello di isolamento con commit della lettura per PostgreSQL 9.2:

I comandi AGGIORNA, ELIMINA, SELEZIONA PER AGGIORNA e SELEZIONA PER CONDIVIDI si comportano allo stesso modo di SELEZIONA in termini di ricerca di righe di destinazione: troveranno solo le righe di destinazione che sono state impegnate a partire dall'ora di inizio del comando 1 . Tuttavia, tale riga di destinazione potrebbe essere già stata aggiornata (o eliminata o bloccata) da un'altra transazione simultanea al momento del rilevamento. In questo caso, il potenziale aggiornamento attenderà il commit o il rollback della prima transazione di aggiornamento (se è ancora in corso). Se il primo programma di aggiornamento torna indietro, i suoi effetti vengono annullati e il secondo programma di aggiornamento può procedere con l'aggiornamento della riga trovata originariamente. Se viene eseguito il commit del primo programma di aggiornamento, il secondo programma di aggiornamento ignorerà la riga se il primo programma di aggiornamento lo ha eliminato 2, altrimenti tenterà di applicare la sua operazione alla versione aggiornata della riga.

La riga si inserisce nel S1non esisteva ancora quando S2l' DELETEiniziato. Quindi non sarà visto dall'eliminazione S2come da ( 1 ) sopra. Quello che S1è stato eliminato viene ignorato da S2's DELETEsecondo ( 2 ).

Quindi S2, l'eliminazione non fa nulla. Quando l'inserto viene avanti, però, che si fa vedere S1inserto 's:

Poiché la modalità di lettura confermata avvia ogni comando con una nuova istantanea che include tutte le transazioni impegnate fino a quell'istante, i comandi successivi nella stessa transazione vedranno comunque gli effetti della transazione concorrente impegnata . Il punto in questione sopra è se un singolo comando vede o meno una vista assolutamente coerente del database.

Quindi il tentativo di inserimento S2fallisce con la violazione del vincolo.

Continuare a leggere quel documento, usando una lettura ripetibile o anche serializzabile non risolverebbe completamente il tuo problema: la seconda sessione fallirebbe con un errore di serializzazione sull'eliminazione.

Ciò ti consentirebbe di riprovare la transazione.


Grazie Mat. Mentre quello sembra essere ciò che sta accadendo, sembra esserci un difetto in quella logica. Mi sembra che, in un livello iso READ_COMMITTED, queste due istruzioni debbano avere successo all'interno di una tx: DELETE FROM test WHERE ID = 1 INSERT INTO test VALUES (1) Voglio dire, se cancello la riga e inserisco la riga, allora quell'inserto dovrebbe avere successo. SQLServer ha ragione. Così com'è, ho difficoltà a gestire questa situazione in un prodotto che deve funzionare con entrambi i database.
DaveyBob,

11

Sono completamente d'accordo con l'ottima risposta di @ Mat . Scrivo solo un'altra risposta, perché non si adatta a un commento.

In risposta al tuo commento: DELETEin S2 è già agganciato a una particolare versione di riga. Dal momento che questo viene ucciso da S1 nel frattempo, S2 si considera riuscito. Sebbene non ovvio da una rapida occhiata, la serie di eventi è praticamente così:

   S1 ELIMINA riuscita  
S2 DELETE (riuscito dal proxy - DELETE da S1)  Nel frattempo 
   S1 reinserisce virtualmente il valore cancellato  
S2 INSERT ha esito negativo con violazione del vincolo chiave univoca

È tutto di design. È necessario utilizzare le SERIALIZABLEtransazioni per le proprie esigenze e assicurarsi di riprovare in caso di errore di serializzazione.


1

Utilizzare una chiave primaria DEFERRABLE e riprovare.


grazie per il suggerimento, ma l'utilizzo di DEFERRABLE non ha fatto alcuna differenza. Il documento legge come dovrebbe, ma non lo è.
DaveyBob,

-2

Abbiamo anche affrontato questo problema. La nostra soluzione sta aggiungendo select ... for updateprima delete from ... where. Il livello di isolamento deve essere confermato in lettura.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.