Devo verificare se esiste qualcosa nel db e fallire velocemente o attendere l'eccezione db


32

Avere due lezioni:

public class Parent 
{
    public int Id { get; set; }
    public int ChildId { get; set; }
}

public class Child { ... }

Quando si assegna ChildIda Parent, devo prima verificare se esiste nel DB o attendere che il DB generi un'eccezione?

Ad esempio (utilizzando Entity Framework Core):

NOTA: questi tipi di controlli sono in TUTTA L'INTERNET anche sui documenti ufficiali di Microsoft: https://docs.microsoft.com/en-us/aspnet/mvc/overview/getting-started/getting-started-with-ef-using- mvc / handling-concurrency-with-the-entity-framework-in-an-asp-net-mvc-application # change-the-department-controller ma esiste un'ulteriore gestione delle eccezioni perSaveChanges

inoltre, si noti che l'intento principale di questo controllo era di restituire un messaggio descrittivo e lo stato HTTP noto all'utente dell'API e di non ignorare completamente le eccezioni del database. E l'unica posizione in cui viene generata l'eccezione è dentro SaveChangeso SaveChangesAsyncchiama ... quindi non ci sarà alcuna eccezione quando chiami FindAsynco Any. Pertanto, se esiste un figlio ma è stato eliminato prima, SaveChangesAsyncverrà generata un'eccezione di concorrenza.

L'ho fatto a causa del fatto che l' foreign key violationeccezione sarà molto più difficile da formattare per visualizzare "Il bambino con ID {parent.ChildId} non è stato trovato."

public async Task<ActionResult<Parent>> CreateParent(Parent parent)
{
    // is this code redundant?
   // NOTE: its probably better to use Any isntead of FindAsync because FindAsync selects *, and Any selects 1
    var child = await _db.Children.FindAsync(parent.ChildId);
    if (child == null)
       return NotFound($"Child with id {parent.ChildId} could not be found.");

    _db.Parents.Add(parent);    
    await _db.SaveChangesAsync();        

    return parent;
}

contro:

public async Task<ActionResult<Parent>> CreateParent(Parent parent)
{
    _db.Parents.Add(parent);
    await _db.SaveChangesAsync();  // handle exception somewhere globally when child with the specified id doesn't exist...  

    return parent;
}

Il secondo esempio in Postgres genererà un 23503 foreign_key_violationerrore: https://www.postgresql.org/docs/9.4/static/errcodes-appendix.html

L'aspetto negativo di gestire le eccezioni in questo modo in ORM come EF è che funzionerà solo con uno specifico back-end del database. Se hai mai voluto passare al server SQL o qualcos'altro, questo non funzionerà più perché il codice di errore cambierà.

Non formattare correttamente l'eccezione per l'utente finale potrebbe esporre alcune cose che non si desidera vedere a nessuno, ma agli sviluppatori.

Relazionato:

https://stackoverflow.com/questions/6171588/preventing-race-condition-of-if-exists-update-else-insert-in-entity-framework

https://stackoverflow.com/questions/4189954/implementing-if-not-exists-insert-using-entity-framework-without-race-conditions

https://stackoverflow.com/questions/308905/should-there-be-a-transaction-for-read-queries


2
Condividere la tua ricerca aiuta tutti . Dicci cosa hai provato e perché non ha soddisfatto le tue esigenze. Ciò dimostra che hai impiegato del tempo per cercare di aiutarti, ci salva dal ribadire risposte ovvie e soprattutto ti aiuta a ottenere una risposta più specifica e pertinente. Vedi anche Come chiedere
moscerino

5
Come altri hanno già detto, esiste la possibilità che un record possa essere inserito o eliminato contemporaneamente al controllo di NotFound. Per tale motivo, il controllo iniziale sembra una soluzione inaccettabile. Se sei preoccupato di scrivere una gestione delle eccezioni specifica di Postgres che non sia portatile per altri backend di database, prova a strutturare il gestore delle eccezioni in modo tale che la funzionalità di base possa essere estesa da classi specifiche del database (SQL, Postgres, ecc.)
billrichards

3
Guardando attraverso i commenti, devo dire questo: smetti di pensare in luoghi comuni . "Fail fast" non è una regola isolata, fuori dal contesto che può o deve essere seguita alla cieca. È una regola empirica. Analizza sempre ciò che stai effettivamente cercando di raggiungere e quindi considera qualsiasi tecnica alla luce del fatto che ti aiuti a raggiungere tale obiettivo o meno. "Fail fast" ti aiuta a prevenire effetti collaterali indesiderati. Inoltre, "fail fast" significa in realtà "fail appena riesci a rilevare che c'è un problema". Entrambe le tecniche falliscono non appena viene rilevato un problema, quindi è necessario esaminare altre considerazioni.
jpmc26,

1
@Konrad che cosa c'entra l'eccezione? Smetti di pensare alle condizioni di razza come a qualcosa che vive nel tuo codice: è una proprietà dell'universo. Qualsiasi cosa, qualunque cosa tocchi una risorsa che non controlla completamente (es. Accesso diretto alla memoria, memoria condivisa, database, API REST, filesystem, ecc. Ecc.) Più di una volta e si aspetta che rimanga invariata ha una potenziale condizione di competizione. Heck, abbiamo a che fare con questo in C che non ha nemmeno avere eccezioni. Basta non ramificarsi mai sullo stato di una risorsa che non si controlla se almeno uno dei rami fa confusione con lo stato di quella risorsa.
Jared Smith,

1
@DanielPryden Nella mia domanda, non ho detto che non voglio gestire le eccezioni del database (so che le eccezioni sono inevitabili). Penso che molte persone abbiano frainteso, volevo avere un messaggio di errore amichevole per la mia API Web (che gli utenti finali possono leggere) come Child with id {parent.ChildId} could not be found.. E la formattazione di "Violazione della chiave esterna" penso che in questo caso sia peggio.
Konrad,

Risposte:


3

Piuttosto una domanda confusa, ma , dovresti prima controllare e non solo gestire un'eccezione DB.

Prima di tutto, nel tuo esempio sei a livello di dati, usando EF direttamente sul database per eseguire SQL. Il codice equivale all'esecuzione

select * from children where id = x
//if no results, perform logic
insert into parents (blah)

L'alternativa che stai suggerendo è:

insert into parents (blah)
//if exception, perform logic

L'uso dell'eccezione per eseguire la logica condizionale è lento e universalmente disapprovato.

Hai una condizione di gara e dovresti usare una transazione. Ma questo può essere fatto completamente nel codice.

using (var transaction = new TransactionScope())
{
    var child = await _db.Children.FindAsync(parent.ChildId);
    if (child == null) 
    {
       return NotFound($"Child with id {parent.ChildId} could not be found.");
    }

    _db.Parents.Add(parent);    
    await _db.SaveChangesAsync();        
    transaction.Complete();

    return parent;
}

La cosa fondamentale è chiedersi:

"Ti aspetti che si verifichi questa situazione?"

In caso contrario, assicurati di inserire e generare un'eccezione. Ma gestisci l'eccezione come qualsiasi altro errore che potrebbe verificarsi.

Se ti aspetti che ciò accada, NON è eccezionale e dovresti verificare se il bambino esiste per primo, rispondendo con il messaggio amichevole appropriato in caso contrario.

Modifica - Sembra che ci siano molte controversie su questo. Prima di effettuare il downgrade, prendere in considerazione:

A. E se ci fossero due vincoli FK. Consiglieresti di analizzare il messaggio di eccezione per capire quale oggetto mancava?

B. In caso di mancanza, viene eseguita una sola istruzione SQL. Sono solo gli hit che comportano il costo aggiuntivo di una seconda query.

C. Di solito l'ID sarebbe una chiave surrogata, è difficile immaginare una situazione in cui ne conosci una e non sei abbastanza sicuro che sia nel DB. Il controllo sarebbe strano. E se fosse una chiave naturale digitata dall'utente? Ciò potrebbe avere un'alta probabilità di non essere presente


I commenti non sono per una discussione estesa; questa conversazione è stata spostata in chat .
maple_shaft

1
Questo è totalmente sbagliato e fuorviante! Sono risposte come questa che producono cattivi professionisti contro i quali devo sempre lottare. SELECT non blocca mai una tabella, quindi tra SELECT e INSERT, UPDATE o DELTE, il record potrebbe cambiare. Quindi è un software scadente e scadente degnarsi di un incidente in attesa che accada durante la produzione.
Daniel Lobo,

1
@DanielLobo transazionicope risolve questo problema
Ewan,

1
provalo se non mi credi
Ewan

1
@yusha Ho il codice proprio qui
Ewan il

111

Verificare l'unicità e quindi l'impostazione è un antipattern; può sempre succedere che l'ID venga inserito contemporaneamente tra il tempo di controllo e il tempo di scrittura. I database sono attrezzati per affrontare questo problema attraverso meccanismi come vincoli e transazioni; la maggior parte dei linguaggi di programmazione non lo sono. Pertanto, se apprezzi la coerenza dei dati, lasciali all'esperto (il database), ovvero esegui l'inserimento e rileva un'eccezione se si verifica.


34
controllare e fallire non è più veloce del semplice "provare" e sperare nel meglio. Ex implica che 2 operazioni siano implementate ed eseguite dal sistema e 2 dal DB, mentre l'ultima implica solo una di esse. Il controllo è delegato al server DB. Implica anche un salto in meno nella rete e un compito in meno a cui il DB parteciperà. Potremmo pensare che un'altra query al DB sia conveniente, ma spesso dimentichiamo di pensare in grande. Pensa in alta concorrenza innescando la query più e più di cento volte. Potrebbe duplicare l'intero traffico verso il DB. Se è importante, spetta a te decidere.
Laiv

6
@Konrad La mia posizione è che la scelta corretta di default è una query che fallirà da sola, ed è l' approccio pre-flight separato della query che ha l'onere della prova per giustificarsi. Per quanto riguarda "diventato un problema": così si sta utilizzando le transazioni o altrimenti assicurando che siete al sicuro contro ToCToU errori , giusto? Non è ovvio per me dal codice pubblicato che sei, ma se non lo sei, allora è già diventato un problema il modo in cui una bomba ad orologeria diventa un problema molto prima che esploda realmente.
mtraceur,

4
@Konrad EF Core non inserirà implicitamente sia il tuo assegno che l'inserzione in un'unica transazione, dovrai richiederlo esplicitamente. Senza la transazione, il controllo prima è inutile poiché lo stato del database può cambiare tra il controllo e l'inserimento comunque. Anche con una transazione, potresti non impedire che il database cambi sotto i tuoi piedi. Ci siamo imbattuti in un problema alcuni anni fa utilizzando EF con Oracle in cui sebbene il db lo supporti, Entity non stava innescando il blocco dei record letti all'interno di una transazione e solo l'inserimento è stato trattato come transazionale.
Mr.Mindor,

3
"Controllare l'unicità e quindi impostare è un antipasto" Non direi questo. Dipende fortemente dal fatto che non si possa supporre che non stiano avvenendo altre modifiche e che il controllo produca risultati più utili (anche solo un messaggio di errore che in realtà significa qualcosa per il lettore) quando non esiste. Con un database che gestisce richieste Web simultanee, no, non è possibile garantire che non si stiano verificando altre modifiche, ma ci sono casi in cui è un presupposto ragionevole.
jpmc26,

5
Verificare innanzitutto l'unicità non elimina la necessità di gestire possibili guasti. D'altra parte, se l'azione richiederebbe l'esecuzione di diverse operazioni, controllando se tutti sono probabilità di successo prima di iniziare qualsiasi di loro è spesso migliori di azioni dello spettacolo che potrebbero probabilmente avere bisogno di essere rollback. Effettuare i controlli iniziali potrebbe non evitare tutte le situazioni in cui sarebbe necessario un rollback, ma potrebbe aiutare a ridurre la frequenza di tali casi.
Supercat,

38

Penso che ciò che chiami "fallisci velocemente" e ciò che io chiamo non è lo stesso.

Raccontare il database per fare un cambiamento e gestire il fallimento, che è veloce. La tua strada è complicata, lenta e non particolarmente affidabile.

Quella tua tecnica non è un fallimento veloce, è "preflight". A volte ci sono buoni motivi, ma non quando si utilizza un database.


1
Ci sono casi in cui hai bisogno di una seconda query quando una classe dipende da un'altra, quindi non hai scelta in casi del genere.
Konrad,

4
Ma non qui. E le query sul database possono essere piuttosto intelligenti, quindi in genere dubito che “nessuna scelta”.
gnasher729,

1
Penso che dipenda anche dall'applicazione, se la crei solo per pochi utenti, non dovrebbe fare la differenza e il codice è più leggibile con 2 query.
Konrad,

21
Stai assumendo che il tuo DB stia archiviando dati incoerenti. In altre parole, sembra che non ti fidi del tuo DB e della coerenza dei dati. Se così fosse, hai un grosso problema e la tua soluzione è una soluzione alternativa. Una soluzione palliativa destinata a essere annullata prima o poi. Ci possono essere casi in cui sei costretto a consumare un DB fuori dal tuo controllo e gestione. Da altre applicazioni. In quei casi, prenderei in considerazione tali convalide. In ogni caso, @gnasher ha ragione, il tuo non sta fallendo velocemente o non è ciò che noi intendiamo come fallimento veloce.
Laiv

15

Questo è iniziato come un commento ma è diventato troppo grande.

No, come hanno affermato le altre risposte, questo schema non dovrebbe essere usato. *

Quando si ha a che fare con sistemi che utilizzano componenti asincroni, ci sarà sempre una condizione di competizione in cui il database (o il file system o altro sistema asincrono) può cambiare tra il controllo e la modifica. Un controllo di questo tipo non è semplicemente un modo affidabile per prevenire il tipo di errore che non si desidera gestire.
Peggio ancora che non essere sufficiente, a prima vista dà l'impressione che dovrebbe impedire l'errore di registrazione duplicato dando un falso senso di sicurezza.

Devi comunque gestire l'errore.

Nei commenti hai chiesto cosa succede se hai bisogno di dati da più fonti.
Ancora no

Il problema fondamentale non scompare se ciò che si desidera verificare diventa più complesso.

È comunque necessario gestire l'errore.

Anche se questo controllo fosse un modo affidabile per prevenire il particolare errore da cui si sta tentando di proteggersi, possono comunque verificarsi altri errori. Cosa succede se si perde la connessione al database o se si esaurisce lo spazio oppure?

Molto probabilmente è comunque necessario gestire altri errori relativi al database. La gestione di questo particolare errore dovrebbe probabilmente essere una piccola parte di esso.

Se hai bisogno di dati per determinare cosa cambiare, ovviamente dovrai raccoglierli da qualche parte. (a seconda degli strumenti che stai usando ci sono probabilmente modi migliori delle query separate per raccoglierli) Se, nell'esaminare i dati raccolti, determini che non è necessario apportare la modifica dopo tutto, ottimo, non effettuare il modificare. Questa determinazione è completamente separata dalle preoccupazioni relative alla gestione degli errori.

È comunque necessario gestire l'errore.

So di essere ripetitivo, ma ritengo sia importante chiarirlo. Ho pulito questo casino prima.

Alla fine fallirà. Quando fallisce, sarà difficile e richiede tempo per arrivare in fondo. Risolvere i problemi che sorgono dalle condizioni di gara è difficile. Non accadono in modo coerente, quindi sarà difficile o addirittura impossibile riprodursi in isolamento. All'inizio non hai inserito la corretta gestione degli errori, quindi probabilmente non avrai molto da fare: forse un rapporto dell'utente finale su un testo criptico (cosa che stavi cercando di impedire di vedere in primo luogo). Forse una traccia dello stack che rimanda a quella funzione che quando la guardi nega palesemente l'errore dovrebbe anche essere possibile.

* Potrebbero esserci validi motivi commerciali per eseguire questi controlli esistenti, ad esempio per impedire all'applicazione di duplicare lavori costosi, ma non è un sostituto adatto per una corretta gestione degli errori.


2

Penso che una cosa secondaria da notare qui - uno dei motivi per cui vuoi questo è in modo che tu possa formattare un messaggio di errore che l'utente possa vedere.

Consiglio vivamente di:

a) mostra all'utente finale lo stesso messaggio di errore generico per ogni errore che si verifica.

b) registrare l'eccezione effettiva da qualche parte a cui solo gli sviluppatori possono accedere (se su un server) o da qualche parte che può essere inviata all'utente tramite strumenti di segnalazione errori (se il client è distribuito)

c) non tentare di formattare i dettagli dell'eccezione di errore registrati a meno che non sia possibile aggiungere ulteriori informazioni utili. Non si desidera avere "formattato" accidentalmente l'unica informazione utile che si sarebbe potuto utilizzare per rintracciare un problema.


In breve, le eccezioni sono piene di informazioni tecniche molto utili. Niente di tutto questo dovrebbe essere per l'utente finale e perdi queste informazioni a tuo rischio e pericolo.


2
"mostra all'utente finale lo stesso messaggio di errore generico per ogni errore che si verifica." quello era il motivo principale, la formattazione dell'eccezione per l'utente finale sembra una cosa orribile da fare ..
Konrad

1
In qualsiasi ragionevole sistema di database, dovresti essere in grado di scoprire a livello di codice perché qualcosa non ha funzionato. Non dovrebbe essere necessario analizzare un messaggio di eccezione. E più in generale: chi dice che un messaggio di errore deve essere mostrato all'utente? Puoi fallire il primo inserimento e riprovare in un ciclo fino a quando non riesci (o fino a un certo limite di tentativi o tempo). E in effetti, backoff-and-retry è qualcosa che vorrai implementare alla fine comunque.
Daniel Pryden,
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.