The exceeding cleverness of Laravel's atomic database locks

July 22, 2021

I was having a conversation in a Slack today about MySQL's "advisory locks", which let you acquire a lock that is not table dependent. You just ask MySQL for a lock with a name and it either gives it to you or doesn't:

-- Try to get a lock, waiting up to 10 seconds for it.
SELECT GET_LOCK('my-lock-name', 10);
 
-- Returns 1 if a lock is acquired, 0 if not.
Code highlighting powered by torchlight.dev (A service I created!)

These are great for getting application-level locks. Say you've got five "worker" processes that need to "reserve" items for processing. Obviously you only want an item to be processed once, and you can't rely on just updating a reserved_at column, because two processes might grab a record at the exact same time, leading to duplicate processing.

In this case, you'd have each worker get a lock as the "leader", reserve their job, and then release the lock.

Let's say for example that you have five import processors, all running as pseudo-daemons, sitting around waiting for new user imports to work on.

In the process method of your daemon, you would want to acquire a lock while you look for imports that are ready to process:

class Process extends Command
{
use IsPseudoDaemon;
 
protected $signature = 'imports:process';
 
public function handle()
{
$this->runAsPseudoDaemon();
}
 
/**
* This is the main method that will be kept alive.
*/
public function process()
{
// Acquire a lock while we look for imports.
$import = Cache::lock('reserve-import')->get(function () {
if ($import = Import::readyToProcess()->first()) {
// Reserve the import while we hold the lock,
// so no one else can.
return tap($import)->reserve();
}
});
 
// Process the import if we secured one.
optional($import)->process();
}
}

That's a bit about why you would want a lock, let's talk about how Laravel does this.

Laravel Locks

Laravel supports atomic locks using a number of different drivers. The most common is probably going to be Redis driver, which many people use as their cache. It's something Redis is good at, and most people have Redis in their stack.

But if you don't have Redis in your stack, Laravel provides several other drivers.

  • memcached
  • dynamodb
  • database
  • file
  • array

The array driver is just for testing, since it's in memory only, and the file driver would only work for single-server setups, not across multiple.

Memcached, Redis, and DynamoDB all support locking natively, but I was surprised to just see database in there, without mention of which database driver you're using.

We already know MySQL has the GET_LOCK style advisory locks, but did the Laravel team implement different locking mechanisms for every single driver?

Turns out, they did something twice as clever and half as difficult.

Database Locks For All

If you go take a look at the DatabaseLock source code, you'll see exactly how they do it:

try {
$this->connection->table($this->table)->insert([
'key' => $this->name,
'owner' => $this->owner,
'expiration' => $this->expiresAt(),
]);
 
$acquired = true;
} catch (QueryException $e) {
// Stuff.
}

They... insert a record? That's it?

This is so unbelievably coy, because this is not a locking mechanism at all. Laravel is relying on the database to enforce uniqueness, which every single database engine is fully capable of doing.

The docs give you a schema for your cache_locks table that mandates that key is a primary key, and therefore unique:

Schema::create('cache_locks', function ($table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});

Because two records can never share a key, you have atomicity guaranteed out of the box, for every single database driver.

You also get all the nice expiration, ownership, etc because they just insert that data as columns out to the side of the lock's name. Everything you'd expect from a proper locking implementation!

When I figured out how Laravel does this, I was taken by the simplicity and the cleverness. Leveraging one of the database's inherit strengths (a primary key may exist once) to work in the context of locks is just genius.

No futzing about with the different syntaxes and behaviors of advisory locks across engines, no ongoing maintenance when they change, no edge cases.

Insert a row.

Me

Thanks for reading! My name is Aaron and I write, make videos , and generally try really hard .

If you ever have any questions or want to chat, I'm always on Twitter.

If you want to give me money (please do), you can buy my course on SQLite at HighPerformanceSQLite.com or my course on screencasting at Screencasting.com . On the off chance you're a sophomore at Texas A&M University, you can buy my accounting course at acct229.com .

You can find me on YouTube on my personal channel . If you love podcasts, I got you covered. You can listen to me on Mostly Technical .