Коли два обробники черги одночасно беруть у роботу одне й те саме відправлення, обидва позначають його як відправлене, і клієнт отримує посилку двічі. Cache::lock() вже вирішує цю проблему, але кожного разу доводиться самостійно писати формат ключа, токен власника та пам'ятати про звільнення блокування у блоці finally. Laravel Lock від Md Mahedi Zaman Zaber ховає це за зручним білдером, який приймає назву дії та ціль, і зберігає блокування або в кеші, або в таблиці бази даних.
Основні можливості
- Зручний білдер:
Lock::for('shipment_dispatch', $shipment)->ttl(120)->acquire() повертає boolean, а block() обгортає callback, тому вам не потрібно писати код для звільнення блокування.
- Блокування на рівні моделей: трейт
HasLocks додає метод $shipment->lock('dispatch'), і ключ включає morph-клас моделі та первинний ключ.
- Middleware для маршрутів: аліас
lock, який встановлює блокування перед виконанням контролера і звільняє його після, навіть якщо контролер викидає виняток.
- Два драйвери зберігання:
cache для будь-якого Laravel cache store, database для таблиці locks, яка переживає очищення кешу.
- Очікування: і
acquire(), і block() приймають кількість секунд для повторних спроб перед тим, як здатися.
- Інспекція блокувань: readonly об'єкт
LockInfo з ключем, токеном власника та часом закінчення, плюс допоміжні методи на кшталт remainingSeconds() та isOwnedBy().
Отримання та звільнення блокувань
Lock фасад створює pending lock з рядка дії та опціональної цілі. Зберігайте цей білдер у змінній, бо звільнення має відбуватися з того самого екземпляра:
use ZaberDev\Lock\Facades\Lock;
$lock = Lock::for('shipment_dispatch', $shipment)->ttl(120);
if ($lock->acquire()) {
try {
$carrier->dispatch($shipment);
} finally {
$lock->release();
}
}
Кожен білдер генерує власний UUID токен власника при першій потребі, і обидва драйвери перевіряють цей токен перед видаленням. Якщо створити другий Lock::for(...) і викликати на ньому release(), він матиме інший токен, тому звільнення нічого не зробить і поверне false. Ви можете встановити токен самостійно через owner('worker-7'), коли отримання та звільнення відбуваються у різних процесах.
Типовий TTL - 60 секунд. Поряд з ttl() є методи forSeconds() та forMinutes(), а refresh() продовжує блокування, яке ви все ще утримуєте, без попереднього звільнення.
block() виконує ту саму роботу одним викликом і повертає те, що повертає callback:
$manifest = Lock::for('shipment_dispatch', $shipment)->block(function () use ($shipment, $carrier) {
return $carrier->dispatch($shipment);
});
Якщо блокування вже зайняте, block() викидає LockAcquisitionException замість повернення null, і виняток містить LockInfo для блокування, яке його затримує. У черговому завданні це означає, що завдання провалиться замість тихого пропуску роботи.
Обидва методи можуть очікувати замість негайної невдачі. acquire() приймає кількість секунд для повторних спроб, а block() приймає це як третій аргумент, з паузою 250 мілісекунд між спробами:
$lock->acquire(blockSeconds: 5);
Lock::for('stock_allocation', $warehouse)->block($callback, 60, 5);
Для перевірки без отримання є методи isLocked(), isOwnedByCurrent(), remaining() та info(), а enforce() викидає виняток, якщо хтось інший утримує блокування. forceRelease() видаляє запис незалежно від власника, для очищення після воркера, який загинув, утримуючи блокування.
Блокування моделі
Додайте трейт HasLocks до моделі, і ціль заповниться автоматично:
use Illuminate\Database\Eloquent\Model;
use ZaberDev\Lock\HasLocks;
class Shipment extends Model
{
use HasLocks;
}
$lock = $shipment->lock('dispatch')->ttl(120);
$shipment->isLocked('dispatch');
$shipment->forceReleaseLock('dispatch');
Ключ складається з дії, потім morph-класу з backslash'ами, заміненими на підкреслення, потім первинного ключа: dispatch:App_Models_Shipment:42. Зареєструйте morph map, і ви отримаєте коротший аліас. Скалярні цілі також працюють, тому Lock::for('stock_allocation', $sku) дає вам stock_allocation:SKU-1180.
Для цілі, яка не є Eloquent моделлю, існує інтерфейс Lockable з одним методом getLockTargetIdentifier(): string. Реалізуйте його на value object, і цей рядок стане другою половиною ключа.
На database драйвері HasLocks також надає morph-відношення locks(), що вказує на таблицю locks, для отримання списку блокувань, які модель утримує:
$shipment->locks()->where('expires_at', '>', now())->get();
Захист маршруту
Сервіс-провайдер реєструє аліас middleware lock. Передайте йому дію, TTL у секундах та драйвер, якщо не хочете використовувати типовий:
Route::post('/warehouse/reconcile', [ReconcileController::class, 'store'])
->middleware('lock:warehouse_reconcile,300');
Route::post('/warehouse/reindex', [ReindexController::class, 'store'])
->middleware('lock:warehouse_reindex,600,database');
Ця форма не передає ціль, тому блокування покриває endpoint для всіх. Одна операція reconcile виконується одночасно, незалежно від того, хто її запустив.
Щоб обмежити блокування одним записом, помістіть параметр маршруту в назву дії. Middleware замінює {shipment} на значення цього параметра або на його primary key, коли параметр є прив'язаною моделлю. README не згадує цю форму, але тести пакета її використовують:
Route::post('/shipments/{shipment}/dispatch', [ShipmentController::class, 'dispatch'])
->middleware('lock:shipment_dispatch:{shipment},60');
Другий запит для того самого відправлення відхиляється, поки перший ще виконується. Запити для різних відправлень взагалі не бачать один одного. Middleware не чекає, тому тут немає черги, лише відхилення.
Коли блокування вже зайняте, middleware викидає LockAcquisitionException перед виконанням контролера. Він розширює RuntimeException, а не один з HTTP-винятків Laravel, тому необроблений виняток доходить до клієнта як 500. Код винятку - 423, що відповідає статусу 423 Locked, але нічого автоматично не застосовує це до відповіді. Обробіть виняток, щоб вибрати статус для клієнта:
use ZaberDev\Lock\Exceptions\LockAcquisitionException;
$exceptions->render(function (LockAcquisitionException $e) {
return response()->json([
'message' => 'Already processing. Try again in a moment.',
'retry_after' => $e->lockInfo?->remainingSeconds(),
], 429);
});
Звільнення відбувається у блоці finally навколо $next($request). Помилка валідації або необроблений виняток звільняє блокування так само, як і відповідь 200.
Зберігання у кеші або базі даних
config/locks.php встановлює типовий драйвер через змінну середовища LOCK_DRIVER, а using() перемикає його для одного блокування:
Lock::for('inventory_sync', $warehouse)->using('cache')->ttl(15)->acquire();
Lock::for('stock_reconciliation', $warehouse)->using('database')->ttl(600)->acquire();
Cache драйвер зберігає невеликий payload під префіксом lock: і використовує Cache::add() для атомарності - той самий примітив, що стоїть за атомарними блокуваннями кешу Laravel. Це швидший з двох варіантів і підходить для коротких блокувань на Redis або Memcached.
Database драйвер записує рядок у таблицю locks з унікальною колонкою key, і кожне отримання виконується у транзакції, яка робить select з lockForUpdate() перед вставкою. Ці рядки залишаються після очищення кешу або перезапуску Redis, і ви можете запитувати їх через Eloquent. Ви платите записом і блокуванням рядка за кожне отримання.
Прострочені рядки видаляються двома способами. Читання прострочного блокування видаляє його, а LockModel використовує трейт Prunable Laravel для решти:
use Illuminate\Support\Facades\Schedule;
use ZaberDev\Lock\Models\LockModel;
Schedule::command('model:prune', ['--model' => LockModel::class])->daily();
Три події спрацьовують, коли locks.events.dispatch увімкнено: LockAcquired з ключем, власником, TTL, expiry та LockInfo, LockFailed з ключем, власником і блокуванням, яке завадило, та LockReleased з прапорцем $forced, який відрізняє звичайне звільнення від forceRelease(). Прослуховування LockFailed показує, які дії у вашому додатку насправді конкурують, що корисно при налагодженні умов гонитви.
LockManager розширює клас Manager Laravel, тому ви реєструєте власний драйвер через Lock::extend():
Lock::extend('dynamodb', fn ($app) => new DynamoLockDriver($app['dynamodb']));
Встановлення
Laravel Lock потребує PHP 8.2 або новіше і підтримує Laravel 11, 12 та 13:
composer require zaber-dev/laravel-lock
Конфігурація та міграція публікуються окремими тегами, і міграція потрібна лише для database драйвера:
php artisan vendor:publish --tag=locks-config
php artisan vendor:publish --tag=locks-migrations
php artisan migrate
Пакет також постачається з Laravel Boost skill, що охоплює його API-патерни. Він публікується під тегом locks-skill, і сервіс-провайдер копіює його до .ai/skills/laravel-lock під час виконання Artisan команди, якщо знайде встановлений Boost або існуючу директорію .ai/skills.
Вихідний код та документація доступні у GitHub репозиторії Laravel Lock.