PHP项目Laravel悲观锁与乐观锁

wen PHP项目 4

本文目录导读:

PHP项目Laravel悲观锁与乐观锁

  1. 悲观锁 (Pessimistic Locking)
  2. 乐观锁 (Optimistic Locking)
  3. 进阶:Laravel 内置的 updateOrCreate / firstOrCreate
  4. 锁的实现细节对比表
  5. 实战:扣库存示例(高并发)
  6. 最佳实践建议
  7. 排查锁问题常用 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('库存不足');
}

最佳实践建议

  1. 优先使用乐观锁 在大多数 Web 应用中,冲突率较低,乐观锁性能更好,代码更简单。
  2. 高风险数据用悲观锁 涉及资金、库存强一致性,且事务时间短,用 lockForUpdate
  3. 避免长事务 锁持有时间越长,死锁和阻塞概率越大。
  4. 设置重试机制 乐观锁冲突时,应设计重试逻辑(循环或队列重试)。
  5. 创建索引 锁查询条件(where id)必须有索引,否则全表锁。

排查锁问题常用 SQL

-- 查看当前锁等待
SHOW ENGINE INNODB STATUS;
-- 查看锁超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 查看当前事务
SELECT * FROM information_schema.innodb_trx;

抱歉,评论功能暂时关闭!