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.
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.