PHP悲观锁乐观锁怎么选

wen PHP项目 2

本文目录导读:

PHP悲观锁乐观锁怎么选

  1. 核心判断标准(怎么选?)
  2. 悲观锁(数据库层 FOR UPDATE
  3. 乐观锁(版本号/状态字段)
  4. 进阶建议(大厂生产环境策略)
  5. 总结(决策表)

在 PHP 中(尤其是在处理高并发、秒杀、下单等场景时),选择悲观锁还是乐观锁,核心取决于业务场景的冲突频率、并发量以及对数据一致性的要求

没有绝对最好的锁,只有最适合当前业务的锁。

以下是详细的选型指南和实战代码示例:


核心判断标准(怎么选?)

维度 悲观锁 乐观锁 怎么选)
冲突频率 适合冲突多(写多读少)的场景 适合冲突少(读多写少)的场景 冲突多选悲观,冲突少选乐观
并发量 并发量较低(依赖数据库连接) 并发量较高(无锁,轻量级) 大流量秒杀优先乐观
响应速度 较慢(需要等待获取锁,可能阻塞) 较快(无需等待,直接CAS) 对延迟敏感选乐观
锁粒度 锁住整个行/表 锁住一个版本号字段 粒度越细越利于并发
死锁风险 有(必须统一加锁顺序) 无(靠重试解决) 避免复杂事务选乐观
实现复杂度 简单(SELECT FOR UPDATE 中等(需要加版本字段或状态字段) 团队技术水平一般选悲观

一句话总结

如果你不确定,先从“乐观锁”开始(因为成本低、无死锁),只有当系统出现严重的“同一条数据频繁被多人同时修改,导致重试率过高”时,再切换为悲观锁。


悲观锁(数据库层 FOR UPDATE

原理:在事务中,使用 SELECT ... FOR UPDATE 将数据行锁定,其他事务必须等待该事务提交或回滚后才能操作。

适用场景:银行转账、库存扣减(但库存极低)、后台管理员修改关键配置。

实战代码(ThinkPHP/Laravel 框架风格):

use Illuminate\Support\Facades\DB;
public function deductStockWithPessimisticLock($productId, $quantity)
{
    DB::beginTransaction();
    try {
        // 1. 锁定该行数据(关键:必须开启事务)
        $product = DB::table('products')
            ->where('id', $productId)
            ->lockForUpdate() // 悲观锁
            ->first();
        if (!$product || $product->stock < $quantity) {
            DB::rollBack();
            throw new \Exception('库存不足');
        }
        // 2. 模拟耗时操作(比如扣款接口调用)
        sleep(1);
        // 3. 扣减库存
        DB::table('products')
            ->where('id', $productId)
            ->decrement('stock', $quantity);
        DB::commit();
        return true;
    } catch (\Exception $e) {
        DB::rollBack();
        throw $e;
    }
}

致命缺点

  • 极度依赖数据库连接:高并发下,1000个请求只有100个在排队,其余900个都在等待数据库连接池耗尽报错。
  • 死锁风险:如果两个事务加锁顺序不一致(A锁行1,B锁行2,A又等行2,B又等行1),会死锁。

乐观锁(版本号/状态字段)

原理:不锁数据库,而是记录一个版本号(version字段),更新时检查版本号是否一致,一致则更新并version+1,不一致则更新失败返回0行,然后重试。

适用场景:秒杀抢购(并发极高但只有少数人能抢到)、文章点赞、Feeds流热度更新。

实战代码(原生 SQL 模拟 CAS):

use Illuminate\Support\Facades\DB;
public function deductStockWithOptimisticLock($productId, $quantity)
{
    $maxRetries = 3;
    $attempt = 0;
    while ($attempt < $maxRetries) {
        // 1. 读取当前版本号(不需要锁)
        $product = DB::table('products')->where('id', $productId)->first();
        if (!$product || $product->stock < $quantity) {
            return false; // 库存不足,无需重试
        }
        // 2. 尝试更新(核心:WHERE 条件加上 version)
        $affected = DB::table('products')
            ->where('id', $productId)
            ->where('version', $product->version) // 关键:乐观锁条件
            ->update([
                'stock'   => $product->stock - $quantity,
                'version' => $product->version + 1,
            ]);
        // 3. 如果影响行数为1,说明更新成功
        if ($affected) {
            return true;
        }
        // 4. 如果影响行数为0,说明version被改了,重试
        $attempt++;
        usleep(100000); // 延迟100ms避免空转风暴
    }
    throw new \Exception('系统繁忙,请稍后重试');
}

性能陷阱

  • 如果冲突率太高,重试会浪费CPU,必须设置最大重试次数(通常3次)。
  • 如果业务逻辑在更新后还要做其他操作(如写订单表),乐观锁必须配合事务,但锁粒度是基于版本的,不是基于行锁,所以死锁概率极低。

进阶建议(大厂生产环境策略)

混合使用(推荐)

  • 入口用乐观锁:秒杀时,先用Redis或数据库乐观锁快速拦截绝大多数无效请求(库存=0直接返回)。
  • 出口用悲观锁:只有真正抢到了“名额”的少数请求,进入支付/扣款阶段,对预扣库存行加悲观锁确保数据绝对一致。

Redis Lua 脚本(首选方案)

在超高并发场景下,数据库锁(无论悲观乐观)都扛不住,通常直接改用 Redis 原子操作:

-- KEYS[1] = 库存key
-- ARGV[1] = 扣减数量
local stock = tonumber(redis.call('get', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
    return -1
end
redis.call('decrby', KEYS[1], ARGV[1])
return 0

PHP 调用:

$result = Redis::eval($luaScript, 1, 'product_stock_'.$productId, 1);
if ($result === 0) {
    // 扣减成功,然后异步用消息队列去更新数据库(最终一致)
}

如何选择加锁的字段?

  • 悲观锁:不需要额外字段。
  • 乐观锁:除了version,还可以用库存量本身作为版本(WHERE stock = $old_stock AND stock >= $quantity)。

决策表)

场景实例 推荐方案 理由补充
后台管理员修改商品价格(100并发) 悲观锁 冲突极高,重试成本大于等待成本
用户评论点赞(1万并发,同一个视频) 乐观锁(Redis Incr) 写入冲突极低,且要求毫秒级响应
电商秒杀(单SKU库存10件,1万并发) Redis Lua + 数据库乐观锁兜底 数据库锁会直接打爆连接池
系统内部订单状态流转(如待支付->已支付) 乐观锁(WHERE status = '待支付' 防止ABA问题,状态本身就是锁
分布式任务调度(多台机器抢同一批任务) 悲观锁(SELECT FOR UPDATE SKIP LOCKED 数据库提供的跳过锁机制,避免队列挤压

最后提醒:锁只是手段,一定要给读取数据的接口加缓存(如Redis),或者用消息队列削峰,否则无论用哪种锁,系统最终都会因为数据库IO瓶颈而崩溃。

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