Understanding lockForUpdate and sharedLock in Laravel

https://miro.medium.com/max/1200/1*bp5NUD3p_KDDUxlgPH1exg.png

😥 Pessimistic Locking

We fixed the flaw of magically altering the inflation levels, but we face another issue: what if the balance changes mid-transaction? To better show the issue, we will have a new person that also needs money from Alice. Meet Charlie!

In this specific example, because we have a web app with thousands of HTTP web requests (slightly exaggerated, but banks do encounter this), we will ignore the fact that PHP is a blocking-IO programming language in the examples.

Let’s say that Alice has a fast sleight of hand, and both transactions where Alice sends money to Bob and Charlie are done, like at the same time, in the matter of microseconds. This means that if the odds are just right, the database will pull the records at the very same time, and send money at the very exact time.

If this scenario occurs, you will merely become stunned how from $100, you turned a total of $200 in the bank, $100 for each account.

The issue here is because we don’t have a locking mechanism in place for our database.

// FIRST REQUEST
$alice = User::find(1); // 'balance' => 100,
$bob = User::find(2); // 'balance' => 0,
Bank::sendMoney($alice, $bob, 100); // true// SECOND REQUEST
$alice = User::find(1); // 'balance' => 100,
$charlie = User::find(3); // 'balance' => 0,
Bank::sendMoney($alice, $charlie, 100); // true, but should have been false

This happens because if both queries run the SELECT statements (the ones defined by find()) at the same time, both requests will read that Alice has $100 in her account, which can be false because if the other transaction has already changed the balance, we remain with a reading saying she still has $100.

In this particular case, this is what might happen:

Request1: Reads Alice balance as $100
Request2: Reads Alice balance as $100
Request1: Subtract $100 from Alice
Request2: Subtract $100 from Alice
Request1: Add $100 to Bob
Request2: Add $100 to Charlie

But because we don’t know which runs faster, for various reasons, this can also happen:

Request1: Reads Alice balance as $100
Request2: Reads Alice balance as $100
Request1: Subtract $100 from Alice
Request1: Add $100 to Bob
Request2: Subtract $100 from Alice
Request2: Add $100 to Charlie

The ideal situation would be this one:

Request1: Reads Alice balance as $100
Request1: Subtract $100 from Alice.
Request1: Add $100 to Bob
Request2: Reads Alice balance as $0
Request2: Don't allow Alice to send money

The solution would be to be able to run the SELECT statements one-after-another, so we make sure that the request reads from the database the true balance for Alice.

✅ Implementing Pessimistic Locking

Laravel has a neat way to tackle this issue with the help of queries. Databases (like MySQL) have a thing called deadlock. Deadlocks permit the one who runs a query to specifically describe whose rows can be selected or updated within a specific query.

Laravel has a documentation section about deadlocks, but it was hard for me to digest what does what, so I came up with a better example.

Laravel documentation says:

A “for update” lock prevents the selected records from being modified or from being selected with another shared lock.

This is what we want. If we run lockForUpdate in our find() statements, they will not be selected by another shared lock.

And for the shared lock:

A shared lock prevents the selected rows from being modified until your transaction is committed.

Is this also what we want? Of course, if we apply this to the find() queries, the rows (in the first one Alice & Bob, in the second one Alice & Charlie) will not be read, nor modified until our update transaction got committed successfully.

// FIRST REQUEST
DB::statement(function () {
$alice = User::lockForUpdate()->find(1); // 'balance' => 100,
$bob = User::lockForUpdate()->find(2); // 'balance' => 0,
Bank::sendMoney($alice, $bob, 100); // true
});
// SECOND REQUEST
DB::statement(function () {
$alice = User::lockForUpdate()->find(1); // 'balance' => 0,
$charlie = User::lockForUpdate()->find(3); // 'balance' => 0,
Bank::sendMoney($alice, $charlie, 100); // false
});

Obviously, having a lockForUpdate would be just enough, because, by definition, any rows selected by it will never be selected by another shared lock, either lockForUpdate() or sharedLock().

Alternatively, just like Laravel says, you may use sharedLock() just so other queries won’t select the same rows until the transaction is finished. The use case would be for strong read consistency, making sure that if another transaction may be in process, to not get outdated rows.

Thanks to Laravel and deadlocks, we can now avoid any inflation.👏

But if you decide to run your bank in Laravel, you should use Event Sourcing, you definitely don’t want to play with the market. 🤨

Supporting our work

If you enjoyed this article or you are using one or more Renoki Co. open-source packages in your production apps, in presentation demos, hobby projects, school projects or so, spread some kind words about our work or sponsor our work via Patreon for some exclusive articles full of Laravel tips and best practices. 📦

Laravel News Links