本文目录导读:

- 悲观锁 (Pessimistic Locking)
- 乐观锁 (Optimistic Locking)
- 进阶:Laravel 内置的
updateOrCreate/firstOrCreate - 锁的实现细节对比表
- 实战:扣库存示例(高并发)
- 最佳实践建议
- 排查锁问题常用 SQL
在 Laravel 中处理并发下的数据一致性问题,悲观锁和乐观锁是两种核心策略,它们的核心区别在于冲突检测的时机和锁的持有时间。
以下是针对 Laravel 项目的详细实战指南。
悲观锁 (Pessimistic Locking)
核心思想:“我修改之前,谁都不许动”,在读取数据时立即加锁,直到事务提交或回滚才释放。
适用场景:高冲突、数据一致性要求极高(如金融订单扣款)、短事务。
1 原生 SQL 实现
Laravel 使用 lockForUpdate()(排他锁)和 lock(共享锁)别名。
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
// 获取行锁(排他锁)
// 其他事务读会被阻塞(取决于隔离级别)或写会等待
$user = DB::table('users')
->where('id', 1)
->lockForUpdate() // 相当于 SELECT ... FOR UPDATE
->first();
// 业务逻辑:检查余额
if ($user->balance < 100) {
throw new \Exception('余额不足');
}
// 扣款操作
DB::table('users')
->where('id', 1)
->update(['balance' => $user->balance - 100]);
});
2 Eloquent ORM 高级用法
在 Model 查询中使用:
use App\Models\Product;
DB::transaction(function () {
// 加载商品并加锁(行级锁)
$product = Product::where('id', 1)
->lockForUpdate()
->first();
if ($product->stock <= 0) {
throw new \Exception('库存不足');
}
// 扣减库存
$product->decrement('stock', 1);
});
3 注意死锁问题
- 始终按照固定的顺序加锁(例如先锁ID小的)。
- 事务尽量保持短小,避免锁等待超时。
- 设置合理的锁等待超时时间(MySQL
innodb_lock_wait_timeout)。
乐观锁 (Optimistic Locking)
核心思想:“我在提交时检查有没有人改过”,不加数据库锁,利用版本号或时间戳检测冲突。
适用场景:低冲突、长事务、读多写少。
1 使用版本号实现
数据库设计:
Schema::table('posts', function ($table) {
$table->unsignedInteger('version')->default(1);
});
模型层实现:
use App\Models\Post;
$post = Post::find(1);
$post->title = '新的标题';
// 使用版本号作为 update 查询条件
$affectedRows = Post::where('id', $post->id)
->where('version', $post->version) // 当前版本
->update([
'title' => $post->title,
'version' => $post->version + 1, // 版本+1
]);
if ($affectedRows === 0) {
// 更新失败,说明别人已经改过了
throw new \Exception('数据已被他人修改,请刷新重试');
}
2 使用时间戳实现(Laravel 内置 trait)
Laravel 提供了 Laravel\SerializesModels,但更常用的是 Laravel\Scout\Builder,不过最标准的是 Laravel\Concurrency?不用记这些,直接用 updated_at 做判断:
$user = User::find(1);
// 假设前端传了 last_updated_at(即当前模型的 updated_at)
$lastUpdatedAt = $user->updated_at;
$affectedRows = User::where('id', $user->id)
->where('updated_at', $lastUpdatedAt)
->update([
'name' => '新名字',
'updated_at' => now(), // 手动更新,让 Laravel 自动处理
]);
if ($affectedRows === 0) {
throw new \Exception('数据已过期');
}
注意:Eloquent 默认的
updated_at会自动变更,因此你需要传入修改前的updated_at作为条件。
进阶:Laravel 内置的 updateOrCreate / firstOrCreate
这种方法可以缓解部分并发问题,但不能保证原子性,不推荐用于复杂业务。
DB::transaction(function () {
$affected = User::where('email', 'test@example.com')
->update(['login_count' => DB::raw('login_count + 1')]);
if ($affected === 0) {
// 创建新用户
User::create(['email' => 'test@example.com', 'login_count' => 1]);
}
});
锁的实现细节对比表
| 特性 | 悲观锁 (lockForUpdate) |
乐观锁 (version) |
|---|---|---|
| 数据库支持 | 需要 MySQL/Postgres 事务支持 | 纯业务逻辑,支持任何数据库 |
| 性能开销 | 高(持有锁,需等待) | 低(无锁,直接更新) |
| 冲突处理 | 等待(阻塞) | 失败重试(返回冲突) |
| 灵巧性 | 适合短操作 | 适合长时间操作(如用户上传文件) |
| 死锁风险 | 高(需谨慎处理锁顺序) | 无(不需要持有锁) |
| 典型代码 | lockForUpdate() + transaction |
where('version', $oldVersion) |
实战:扣库存示例(高并发)
悲观锁(简单直接,性能差)
DB::transaction(function () {
$product = Product::where('id', 1)->lockForUpdate()->first();
if ($product->stock >= 1) {
$product->stock -= 1;
$product->save();
// 创建订单等...
}
});
乐观锁(高效,需重试)
for ($retry = 0; $retry < 3; $retry++) {
DB::transaction(function () {
$product = Product::find(1);
$originalVersion = $product->version;
$affected = Product::where('id', 1)
->where('version', $originalVersion)
->update([
'stock' => DB::raw('stock - 1'),
'version' => $originalVersion + 1,
]);
if ($affected === 0) {
throw new \Exception('库存已变化,重试');
}
// 下单逻辑...
});
}
原子操作(无锁,性能最强)
如果只是简单的数值增减,直接使用 decrement,它会生成 UPDATE ... SET stock = stock - 1,本身具有原子性,不需要锁。
$affected = Product::where('id', 1)
->where('stock', '>', 0)
->decrement('stock', 1);
if ($affected === 0) {
throw new \Exception('库存不足');
}
最佳实践建议
- 优先使用乐观锁 在大多数 Web 应用中,冲突率较低,乐观锁性能更好,代码更简单。
- 高风险数据用悲观锁 涉及资金、库存强一致性,且事务时间短,用
lockForUpdate。 - 避免长事务 锁持有时间越长,死锁和阻塞概率越大。
- 设置重试机制 乐观锁冲突时,应设计重试逻辑(循环或队列重试)。
- 创建索引 锁查询条件(
where id)必须有索引,否则全表锁。
排查锁问题常用 SQL
-- 查看当前锁等待 SHOW ENGINE INNODB STATUS; -- 查看锁超时时间 SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 查看当前事务 SELECT * FROM information_schema.innodb_trx;