Laravel 用 Cache::lock 实现分布式锁:拦截并发重复提交与超卖
test12026-10-075 次阅读
单机锁在多实例下会失效
PHP 进程内用变量做标记来防并发,在 FPM 单进程模型下勉强够用,但一旦上了多台应用服务器或者 Swoole/Octane 常驻内存,进程间的标记互不感知,竞态立刻出现。下单、扣库存、发券这类"同一动作不能被并发执行两次"的场景,需要一个所有实例都能看到的锁。Laravel 的 Cache::lock 底层走 Redis 或 memcached,天然是分布式的,正好补上这个缺口。
防并发重复提交
用户手快点了两次提交,或者前端重试没拦住,后端就必须自己兜底。用 Cache::lock 给"用户 ID + 动作"加一把带超时的锁,拿到锁才继续,拿不到直接拒绝。
use Illuminate\Support\Facades\Cache;
public function checkout(Request $request)
{
$userId = $request->user()->id;
$lock = Cache::lock("checkout:{$userId}", 10);
if (!$lock->get()) {
return response()->json(['message' => '操作进行中,请稍候'], 429);
}
try {
$order = $this->createOrder($userId);
return response()->json(['order_id' => $order->id]);
} finally {
$lock->release();
}
}
锁的第二个参数是生存时间,单位秒。即使代码抛异常没走到 release,10 秒后锁也会自动过期,不会把用户永久卡死。finally 里释放是为了正常路径也尽快让出锁。
block:拿不到锁就等一会儿
有些场景不适合直接拒绝,而是希望"稍等锁释放再执行"。block 方法第一个参数是等待秒数,第二个是拿到锁后执行的闭包。
public function deduct(int $skuId, int $qty): bool
{
return Cache::lock("sku:{$skuId}", 5)->block(3, function () use ($skuId, $qty) {
$stock = DB::table('stocks')
->where('sku_id', $skuId)
->lockForUpdate()
->value('qty');
if ($stock < $qty) {
return false;
}
DB::table('stocks')
->where('sku_id', $skuId)
->decrement('qty', $qty);
return true;
});
}
为什么扣库存要锁加事务再加行锁
只靠 Cache::lock 已经能拦住并发进入扣减逻辑,但数据库层面仍建议配合 lockForUpdate 的行锁:它保证读库存和扣减之间不会被别的连接插队改掉库存值,避免"超卖"。Cache 锁负责跨实例串行化,数据库行锁负责单点一致性,两层叠起来才稳。
使用边界与坑
- 锁的键要有业务唯一性,比如"用户 ID + 订单类型",粒度太粗会不必要地串行化,太细又拦不住真正的竞态。
- 缓存驱动必须支持原子加锁,Redis 和 memcached 都支持;用文件或数组驱动时锁只在当前进程有效,多实例下失效。
- 代码异常时务必释放锁,最好用 try/finally 或 block 闭包,别让锁靠过期兜底成为常态。
- 锁过期时间要大于业务最坏执行时长,否则会出现"锁已过期但旧任务还在跑、新任务又进来"的二次竞态。
小结
Cache::lock 把分布式环境下的互斥变得一行搞定:get 拿不到就拒绝,block 拿不到就等。配合数据库行锁,能稳妥解决重复提交和库存超卖这类高频并发问题,是 Laravel 工程里值得固定的标准写法。
T
test1
文章作者
单机锁在多实例下会失效 PHP 进程内用变量做标记来防并发,在 FPM 单进程模型下勉强够用,但一旦上了多台应用服务器...
- 分类
- 技术
- 发布时间
- 2026-10-07
- 字数
- 约 1901 字
- 阅读
- 5 次
