Database locks i Laravel

Moderne web applikationer byder op til dans for mange samtidige brugere, der alle har mulighed for at læse, redigere, oprette og slette database ressourcer. Dette kan give problemer med overskrevne database updates.

Af Jens Just Iversen

29. DEC 2018

Moderne web applikationer byder op til dans for mange samtidige brugere, der alle har mulighed for at læse, redigere, oprette og slette database ressourcer.

Nogle sider er simple og tilbyder hovedsageligt læse-adgang for hovedparten af brugerne. Skal der redigeres i database ressourcer - f.eks. en produktbeskrivelse - er det ud fra en filosofi om, at den der først trykker gem, får deres opdateringer skrevet til databasen. Redigerer to brugere på samme tid samme produktbeskrivelse, overskrives den andens rettelser blot.

Dette kan være fint, og vil i mange tilfælde ikke blive et reelt problem, enten fordi mængden af samtidige brugere er minimal, der ikke er mulighed for at køre mere end en samtidig process, database forespørgslerne er simple eller noget fjerde.

Andre sider kræver dog større hensyntagen til denne slags udfordringer. Det kan være fordi en større mængde brugere har skriveadgang til de samme database ressourcer på samme tid, kø-kørsel af jobs eksekveres på flere processer eller de database-handlinger der foretages er af en sådan natur, at flere samtidige kald kunne betyde data bliver uheldigt overskrevet.

De klassiske eksempler på dette er pengeoverførsler mellem to konti eller fratrækkelse af en vare i et lagersystem. I begge tilfælde vil to samtidige forespørgsler kunne betyde, den ene opdatering overskrives af den anden opdatering.

Eksempel på en tabt update

Test Image 1

Som det fremgår af illustrationen, kan to ens forespørgsler - i dette tilfælde samme produkt, som skal fratrækkes lageret - betyde, at det endelige lagerantal (kolonnen quantity) bliver forkert, hvis den ene forespørgsel er hurtigere end den anden. Det er en såkaldt race condition, altså at forskellig timing kan give uheldigt varierende resultater.

Da forespørgsel A trækker quantity ud fra products tabellen er det tilgængelige lagerantal 10. Forespørgsel B begynder lige bagefter, og lagerantallet er stadig 10. Forespørgsel B opdaterer nu lagerantallet til kun at være 9. Det samme gør forespørgsel A.

Lagerantallet står altså nu som 9 i databasen, men burde være 8.

Forespørgsel B’s UPDATE-statement kaldes også en tabt update.

Denne forespørgsel tager muligvis kun 100ms, hvilket også er grunden til, at problemet oftest aldrig kommer op til overfladen. For hvad er oddsene for, at 2 brugere bestiller samme produkt i samme splitsekund? Og hvis de faktisk gør, så er der ikke én person på lageret, der vil løfte så meget som et øjenbryn over en lille fejl i lagerantallet. Det har de prøvet så ofte.

Disse fejl kan derfor leve i bedste velgående uden at nogen bemærker dem.

Men i nogle systemer - f.eks. ved pengeoverførsler - kan disse fejl være kritiske. Det kan i andre tilfælde også skabe inkonsistens i dataen, og dette vil kunne foranledige deciderede fejl ved eksekvering af relateret kode.

Dette eksempel kunne også omskrives til én forespørgsel i stedet for to, men i andre tilfælde kan det være nødvendigt at have flere forespørgsler, der afhænger af hinanden, og her er udfordringen den samme som i det simple valgte eksempel.

What to do?

En af løsningerne kan være database locks.

PHP frameworket Laravel tilbyder 2 typer såkaldte pessimistic locks out-of-the-box. En pessimistic lock bruges i situationer, hvor man forventer, at chancen for konflikt er høj. En optimistic lock bruges i situationer, hvor man forventer, at chancen for konflikt er lav. Den optimistike laver en mindre streng låsning, men skulle en konflikt opstå, er “prisen” højere, da hele forespørgslen afbrydes.

Da Laravel blot tilbyder pessimistic locking, vil vi kun koncentrere os om den i denne artikel. De to typer pessimistic locks der er til rådighed, er “shared lock” og “for update lock”.

En “shared lock” sikrer, at udvalgte rækker ikke kan blive ændret indtil den givne transaction, der har bedt om locking, bliver committed. En “for update lock” sikrer, at udvalgte rækker ikke kan blive ændret eller læst af andre locks indtil den givne transaction, der har bedt om locking, bliver committed.

Det er her værd at nævne, at det er normalt, at en SQL-database har autocommit sat til. Det vil sige, at alle forespørgsler til databasen egentlig er en transaction, der så bare bliver committed med det samme.

Alternativt kan man danne sin egen transaction, der kan indeholde en række forskellige forespørgsler. Disse forespørgsler vil så alle blive committed på en gang, hvis de ikke fejler. Hvis de fejler, vil intet blive committed.

Et eksempel på en transaction:

DB::transaction(function () {
    $product = Product::find(1);
    $product->quantity = $product->quantity-1;
    $product->save();
});

I ovenstående eksempel udføres opdateringen af lagerantallet kun, hvis det faktisk lykkedes at trække det nuværende lagerantal ud i første forespørgsel. Men der er stadig ikke sket nogen lås af rækkerne. En race condition kan derfor i teorien stadig forekomme og vil i dette specifikke eksempel betyde en tabt update.

Lad os tilføje en lock.

DB::transaction(function () {
    $product = Product::lockForUpdate()->find(1);
    $product->quantity = $product->quantity-1;
    $product->save();
});

Nu låses rækken med id = 1 i tabellen products indtil transactionen committes. To samtidige forespørgsler til at fratrække produkt 1 fra lageret vil derfor ikke kunne køre samtidig. Den forespørgsel, der får tildelt en lock sidst, vil derfor blive nødt til at vente indtil databasen igen giver læse- og skriveadgang til rækken.

Pas på

Som det fremgår af ovenstående afsnit kan locks løse en række problemer, der potentielt kan være meget kritiske.

Men locks tilfører også en række nye problemer, som er vigtige at tage stilling.

Da en lock låser rækker i databasen for skrivning og læsning fra andre locks, betyder det flaskehals og det kan give ventetid i dit projekt. Det er derfor altid en afvejning mellem, hvor kritisk en tabt update er og hvor vigtig tilgængeligheden af disse rækker i databasen er.

Er det ikke muligt at tilføje en lock på grund af potentiel massekø ved den ene kasse, der maksimalt kan være åbent, så overvej om du kan omskrive funktionen, eller om hele forretningsprocesser skal ændres.

Hvad gør du?

Vi vil her på ephort.dk altid gerne vide, hvad I læsere mener og hvordan I gør i praksis. Så hvis du har været udsat for tabte updates, race conditions eller pludselige problemer, som er opstået ifm. øget trafik på jeres PHP applikation, så lad mig vide i kommentarerne herunder :-)

Tak til Nick Nissen for revidering af artiklen.