应使用 lockforupdate() 配合 db::transaction() 对数据库行加锁,避免“读-改-写”竞态;redis 锁需按业务粒度设计唯一 key 并设合理过期时间;禁用 get_lock() 等不安全方案,锁逻辑须统一收口于业务层而非模型事件。

并发写入时数据被覆盖,用 DB::transaction() 不够稳
事务只能保证操作原子性,但不阻止其他请求同时读到旧值再写回——这就是典型的“读-改-写”竞态。比如两个请求同时读到库存为 10,各自减 1 后都写 9,结果变成 9 而不是预期的 8。
真正要防覆盖,得在读的时候就加锁。Laravel 提供了 sharedLock() 和 lockForUpdate(),后者才是写场景该用的:
-
lockForUpdate()在 MySQL 中对应SELECT ... FOR UPDATE,会锁住选中的行,其他事务无法修改或再次加锁,直到当前事务提交 - 必须搭配
DB::transaction()使用,否则锁会在查询后立刻释放 - 不能在模型的
find()或first()后链式调用锁方法——它们已执行完查询;得用where()->lockForUpdate()->first()
DB::transaction(function () {
$order = Order::where('id', 123)->lockForUpdate()->first();
if ($order->status === 'pending') {
$order->status = 'processing';
$order->save();
}
});
cache()->lock() 的 key 命名容易引发死锁或失效
全局锁(如 Redis 锁)适合跨进程、跨机器协调,但 key 设计不对会导致锁失效或误拦。常见错误是用固定字符串当 key,比如所有订单都锁 'order_lock',那根本不是“按订单隔离”,而是全站串行。
正确做法是让 key 具备业务粒度和唯一性:
- key 必须包含可识别的业务标识,比如
'order_lock:123'、'stock_lock:sku_456' - 避免在 key 中拼接用户 ID 等可能含特殊字符的值,先做
urlencode()或哈希处理 - 设置合理的过期时间(
ttl),防止进程崩溃后锁永远不释放;Laravel 默认是 10 秒,高延迟操作建议设为 30–60 秒 - 不要依赖
lock()->get()返回布尔值判断是否抢到锁——它可能因网络抖动返回 false,但锁其实已生效;应配合block()或重试逻辑
队列任务里用 DB::select('SELECT GET_LOCK(...)') 是危险的兼容方案
有些老项目用原生 MySQL 分布式锁函数 GET_LOCK(),看似绕过 Redis 依赖,实则隐患很多:MySQL 服务重启锁全丢、锁名长度限制(默认 64 字符)、不支持自动续期、且 GET_LOCK() 是会话级的——队列 worker 进程复用连接时,锁状态可能混乱。
如果真没法上 Redis,至少做到:
- 锁名强制截断并哈希,例如
md5('order_123'),避免超长或冲突 - 每次操作前用
IS_USED_LOCK()检查是否已被自己持有,防止重复加锁失败 - 务必在 finally 块里调用
RELEASE_LOCK(),不能只靠连接关闭自动释放 - 注意 Laravel 队列 worker 默认长驻内存,
GET_LOCK()的会话生命周期可能远超单个任务,极易误锁
模型事件里触发锁逻辑,常忽略事务边界和异步上下文
在 saving 或 updating 这类模型事件里加锁,很容易掉进两个坑:一是事件发生在事务开启前,锁提前释放;二是队列任务中模型事件触发时,Redis 连接可能未初始化或配置不同。
稳妥的做法是把锁逻辑显式收口到业务方法中,而不是散落在事件里:
- 模型事件只做轻量校验(如字段格式),不涉及资源协调
- 锁 + 数据变更必须包裹在同一事务内,且由控制器或 service 层统一发起
- 如果要用事件通知其他系统(如发消息),确保监听器是异步的,并单独建立连接和锁上下文,别复用主事务的 DB 或 Cache 实例
- 测试时特别注意:tinker 或单元测试里手动调用
save()不会触发队列事件,但会触发同步事件——锁行为不一致,容易漏测











