Laravel 包

Laravel Lock: Distributed Locks for Models and Routes

发布
Laravel Lock: Distributed Locks for Models and Routes image

Two queue workers picking up the same shipment at the same moment will both mark it dispatched, and the customer gets the box twice. Cache::lock() already solves that, but you write the key format and the owner token yourself every time, and you remember to release it in a finally . Laravel Lock, by Md Mahedi Zaman Zaber , puts that behind a builder that takes an action name and a target, and stores the lock in either the cache or a database table.

Main features

  • Fluent builder : Lock::for('shipment_dispatch', $shipment)->ttl(120)->acquire() returns a boolean, and block() wraps a callback so you never write the release.
  • Model-scoped locks : 这 HasLocks trait adds $shipment->lock('dispatch') , and the key includes the model's morph class and primary key.
  • 路由中间件 : A lock alias that takes the lock before your controller runs and releases it afterwards, even when the controller throws.
  • Two storage drivers : cache for any Laravel cache store, database 对于 locks table that survives a cache flush.
  • Waiting : acquire()block() both take a number of seconds to keep retrying before they give up.
  • Lock inspection : A readonly LockInfo object with the key, owner token and expiry, plus helpers like remainingSeconds()isOwnedBy()

Acquiring and Releasing Locks

Lock facade builds a pending lock from an action string and an optional target. Keep that builder in a variable, because the release has to come from the same instance:

使用 ZaberDev\Lock\Facades\Lock ;
$lock = :: 为了 'shipment_dispatch' , $shipment) -> 生存时间 120 (英文):
如果 ($lock -> 获得 ()){
尝试 {
$carrier -> 派遣 ($shipment);
} 最后 {
$lock -> 发布 ();
}
}

Each builder makes its own UUID owner token the first time it needs one, and both drivers check that token before they delete anything. Build a second Lock::for(...) 并调用 release() on that instead, and it carries a different token, so the release does nothing and hands back false . You can set the token yourself with owner('worker-7') when the acquire and the release happen in different processes.

The default TTL is 60 seconds. There is forSeconds()forMinutes() next to ttl() , 和 refresh() extends a lock you still hold without releasing it first.

block() does the same job in one call and returns whatever the callback returns:

$manifest = :: 为了 'shipment_dispatch' , $shipment) -> 堵塞 功能 () 使用 ($shipment, $carrier) {
返回 $carrier -> 派遣 ($shipment);
});

If the lock is already held, block() 抛出 LockAcquisitionException rather than returning null, and the exception carries the LockInfo for the lock that is holding it up. In a queue job that means the job fails instead of quietly skipping the work.

Both methods can wait instead of failing straight away. acquire() takes the number of seconds to keep trying, and block() takes it as a third argument, with a 250 millisecond pause between attempts:

$lock -> 获得 blockSeconds : 5 (英文):
:: 为了 'stock_allocation' , $warehouse) -> 堵塞 ($callback, 60 , 5 (英文):

To look without acquiring, there is isLocked() , isOwnedByCurrent() , remaining()info() , 和 enforce() throws if someone else holds the lock. forceRelease() deletes the record no matter who owns it, for cleaning up after a worker that died holding one.

Locking a Model

添加 HasLocks trait to a model and the target is filled in for you:

使用 照亮\数据库\雄辩\模型 ;
使用 ZaberDev\Lock\HasLocks ;
班级 Shipment 延伸 模型
{
使用 HasLocks ;
}
$lock = $shipment -> 'dispatch' -> 生存时间 120 (英文):
$shipment -> isLocked 'dispatch' (英文):
$shipment -> forceReleaseLock 'dispatch' (英文):

The key is the action, then the morph class with backslashes swapped for underscores, then the primary key: dispatch:App_Models_Shipment:42 . Register a morph map and you get the shorter alias instead. Scalar targets work too, so Lock::for('stock_allocation', $sku) gives you stock_allocation:SKU-1180

For a target that is not an Eloquent model, there is a Lockable interface with one method, getLockTargetIdentifier(): string . Implement it on a value object and that string becomes the second half of the key.

On the database driver, HasLocks also gives you a locks() morph relationship pointing at the locks table, for listing what a model currently holds:

$shipment -> locks () -> 在哪里 'expires_at' , '>' , 现在 ()) -> 得到 ();

Protecting a Route

The service provider registers a lock middleware alias. Pass it the action, a TTL in seconds, and a driver if you do not want the default:

路线 :: 邮政 '/warehouse/reconcile' ,[ ReconcileController ::班级 , '店铺' ])
-> 中间件 'lock:warehouse_reconcile,300' (英文):
路线 :: 邮政 '/warehouse/reindex' ,[ ReindexController ::班级 , '店铺' ])
-> 中间件 'lock:warehouse_reindex,600,database' (英文):

That form passes no target, so the lock covers the endpoint for everyone. One reconcile runs at a time no matter who started it.

To scope the lock to a single record, put a route parameter in the action name. The middleware swaps {shipment} for that parameter's value, or for its primary key when the parameter is a bound model. The README does not mention this form, but the package's tests use it:

路线 :: 邮政 '/shipments/{shipment}/dispatch' ,[ ShipmentController ::班级 , 'dispatch' ])
-> 中间件 'lock:shipment_dispatch:{shipment},60' (英文):

A second request for the same shipment is rejected while the first one is still running. Requests for different shipments do not see each other at all. The middleware does not wait, so there is no queueing here, only a rejection.

When the lock is already held, the middleware throws LockAcquisitionException before the controller runs. It extends RuntimeException rather than one of Laravel's HTTP exceptions, so an unhandled one reaches the client as a 500. The exception code is 423, matching the 423 Locked status, but nothing applies that to the response for you. Handle the exception to pick the status the caller gets:

使用 ZaberDev\Lock\Exceptions\LockAcquisitionException ;
$异常 -> 使成为 功能 LockAcquisitionException $e) {
返回 回复 () -> json ([
'信息' => 'Already processing. Try again in a moment.' ,
'retry_after' => $e -> lockInfo ?-> remainingSeconds (),
], 429 (英文):
});

The release happens in a finally around $next($request) . A validation error or an unhandled exception releases the lock the same way a 200 response does.

Cache or Database Storage

config/locks.php sets the default driver through the LOCK_DRIVER environment variable, and using() switches it for a single lock:

:: 为了 'inventory_sync' , $warehouse) -> 使用 '缓存' -> 生存时间 15 -> 获得 ();
:: 为了 'stock_reconciliation' , $warehouse) -> 使用 '数据库' -> 生存时间 600 -> 获得 ();

The cache driver stores a small payload under a lock: prefix and uses Cache::add() for atomicity, the same primitive behind Laravel's atomic cache locks . It is the faster of the two and fine for short locks on Redis or Memcached.

The database driver writes a row to a locks table with a unique key column, and every acquire runs in a transaction that selects with lockForUpdate() before it inserts. Those rows are still there after a cache flush or a Redis restart, and you can query them with Eloquent. You pay a write and a row lock for every acquisition.

Expired rows go away two ways. Reading an expired lock deletes it, and LockModel uses Laravel's Prunable trait for the rest:

使用 照明\支持\立面\日程安排 ;
使用 ZaberDev\Lock\Models\LockModel ;
日程 :: 命令 'model:prune' ,[ '--model' => LockModel ::班级 ]) -> 日常的 ();

Three events fire when locks.events.dispatch is on: LockAcquired with the key, owner, TTL, expiry and LockInfo , LockFailed with the key, owner and whatever lock got in the way, and LockReleased$forced flag that tells a normal release apart from a forceRelease() . Listening for LockFailed tells you which actions in your app actually contend, which is useful data when you are debugging race conditions

LockManager extends Laravel's Manager class, so you register your own driver with Lock::extend() :

:: 延长 'dynamodb' , fn ($app) => 新的 DynamoLockDriver ($app[ 'dynamodb' ]));

安装

Laravel Lock needs PHP 8.2 or newer and supports Laravel 11, 12, and 13:

作曲家 要求 zaber-dev/laravel-lock

The config and migration publish under separate tags, and you only need the migration for the database driver:

php 工匠 供应商:发布 --tag=locks-config
php 工匠 供应商:发布 --tag=locks-migrations
php 工匠 迁移

The package also ships a Laravel Boost skill covering its API patterns. It publishes under the locks-skill tag, and the service provider copies it to .ai/skills/laravel-lock during an Artisan command if it finds Boost installed or an existing .ai/skills 目录。

Source and documentation are on the Laravel Lock GitHub repository

Yannick Lyn Fatt 的照片

Laravel News 的特约撰稿人和全栈 Web 开发人员。

Filed in

赞助

masteringlaravel logo
Laravel 代码审查

几天内即可获得 Laravel 代码审查方面的专家指导

访问 Laravel 代码审查
Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites image

Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites

阅读文章
Collections chunkBy() and Storage Path Hardening in Laravel 13.30 image

Collections chunkBy() and Storage Path Hardening in Laravel 13.30

阅读文章
Forte: Parse and Rewrite Laravel Blade Templates image

Forte: Parse and Rewrite Laravel Blade Templates

阅读文章
Compoships: Eloquent Relationships on Multiple Columns image

Compoships: Eloquent Relationships on Multiple Columns

阅读文章
MKSine: A Filament CMS with Plugins, Themes, and Blocks image

MKSine: A Filament CMS with Plugins, Themes, and Blocks

阅读文章
Cancel In-Flight Form Submissions in Inertia.js v3.7 image

Cancel In-Flight Form Submissions in Inertia.js v3.7

阅读文章