PHP如何实现高并发库存扣减并避免数据库死锁问题

各位听众朋友们,大家好!我是你们的老朋友,那个曾经因为服务器崩溃而在凌晨三点狂奔到公司的资深PHP工程师。

今天我们不聊虚的,也不扯什么框架选型,我们来聊聊一个所有程序员——尤其是后端程序员——做梦都想避开,但一遇到双十一、618就必定会拥抱的噩梦:高并发库存扣减

如果把这个场景具象化一点,想象一下:你的系统上线了,这是公司今年最重要的产品。此时此刻,全网的流量像脱缰的野狗一样扑向你。前端的点击“购买”按钮,化作成千上万个请求,呼啸着冲向你的数据库。

在这个瞬间,如果数据库是个小池塘,那现在就是洪水决堤;如果数据库是个收费站,那现在就是收费站被一辆坦克给堵死了。

今天这场讲座,我们要讨论的核心问题是:在PHP的语境下,如何优雅地(且正确地)扣减库存,同时还要避免和别的数据库连接“对峙”导致死锁。

准备好了吗?让我们把安全带系好,我们要深入这个充满锁、事务和Race Condition的“修罗场”了。

第一部分:数据库的“杯具”——直接操作库存的灾难

首先,我们得聊聊最原始、最“简单粗暴”的方法。很多刚入行的程序员(或者甚至是一些资历尚浅的架构师),面对库存扣减,脑海里蹦出的第一个念头通常是:“这不简单吗?先查库存,再减库存,再写回数据库,完事儿!”

代码长什么样呢?大概是这样的:

// PHP伪代码示例 1:新手式写法
function purchaseItem($userId, $itemId) {
    $pdo = getDatabaseConnection();

    // 1. 开启事务
    $pdo->beginTransaction();

    try {
        // 2. 查询库存
        $stmt = $pdo->prepare("SELECT stock FROM items WHERE id = ? FOR UPDATE");
        $stmt->execute([$itemId]);
        $item = $stmt->fetch();

        if ($item['stock'] <= 0) {
            throw new Exception("库存不足");
        }

        // 3. 扣减库存
        $updateStmt = $pdo->prepare("UPDATE items SET stock = stock - 1 WHERE id = ?");
        $updateStmt->execute([$itemId]);

        // 4. 创建订单
        $orderStmt = $pdo->prepare("INSERT INTO orders (user_id, item_id) VALUES (?, ?)");
        $orderStmt->execute([$userId, $itemId]);

        $pdo->commit();
        return true;

    } catch (Exception $e) {
        $pdo->rollBack();
        return false;
    }
}

听听,这代码是不是很香?逻辑严密,ACID(原子性、一致性、隔离性、持久性)全保。但是,我要告诉你,这个代码在“秒杀”场景下,就是一颗定时炸弹。

为什么?让我们来做个思想实验。

场景: 现在的库存只剩1个了。两个用户,用户A和用户B,几乎同时点击了购买。

  1. 时间点0.00秒:用户A发起请求,执行 SELECT ... FOR UPDATE。数据库告诉他:“库存剩1个,我锁死这行记录,你等着。”
  2. 时间点0.01秒:用户B发起请求,也执行 SELECT ... FOR UPDATE。但是,数据库说:“抱歉,用户A已经锁了这个行,你排队去吧。”

此时,一切正常,只是用户B在等待。

  1. 时间点0.02秒:用户A执行扣减库存,库存变成了0,订单创建成功,事务提交,锁释放。
  2. 时间点0.03秒:用户B拿到了锁,执行查询。数据库查出来库存是0。

结果:用户B查询到库存不足,抛出异常。订单失败。

看起来没问题吧?A买到了,B没买到,系统没卖超。但这只是单线程下的理想状态。一旦引入并发,问题就来了。

死锁:两个司机互不相让

现在我们稍微改改场景,库存有2个。用户A要买1个,用户B也要买1个。并且,我们的系统有两张表:product(库存表)和 order(订单表)。

这是经典的“读-写-写”并发问题。

  1. 用户A:开启事务,查询库存表(加锁),准备扣减。
  2. 用户B:开启事务,查询订单表(加锁),准备创建订单。

然后,用户A想创建订单,需要锁订单表;用户B想扣减库存,需要锁库存表。

此时,A手里拿着库存表的锁,等着拿订单表的锁;B手里拿着订单表的锁,等着拿库存表的锁。

这就好比两个人在狭窄的走廊里相遇了,A往左走,B往右走。他们都停下了,谁也不愿意退后,谁也拿不到对方手里的钥匙。这就叫死锁

在数据库里,死锁不是什么新鲜事,数据库的InnoDB引擎通常会自动检测到死锁,然后踢掉其中一个事务,抛出 Deadlock found when trying to get lock; try restarting transaction 的错误。

但是! 在高并发场景下,如果死锁频繁发生,会发生什么?会发生连接池耗尽

PHP的FPM模式或者FastCGI模式,虽然每个进程是独立的,但如果你的数据库连接池不够大,或者你的代码里没有正确处理重试机制,一个死锁可能导致这个PHP进程死等。等了10秒没拿到锁,数据库锁超时了,事务回滚。PHP进程依然占用着连接不放(或者处理得很慢)。很快,数据库连接池就空了。

新的请求进不来,旧请求卡住。整个系统瘫痪。

所以,直接在数据库里搞库存扣减?别傻了,那是在给数据库喂饭,让它在高并发下消化不良。

第二部分:乐观锁——给库存贴个“标签”

既然悲观锁(FOR UPDATE)容易死锁,那我们能不能乐观一点?乐观锁的核心思想是:我不锁你,但我随时检查你是不是被别人动过了。

我们给库存表加一个版本号字段 version

// PHP伪代码示例 2:乐观锁
function purchaseItemOptimistic($userId, $itemId) {
    $pdo = getDatabaseConnection();

    // 1. 开启事务
    $pdo->beginTransaction();

    try {
        // 2. 先查询库存和版本号
        $stmt = $pdo->prepare("SELECT stock, version FROM items WHERE id = ?");
        $stmt->execute([$itemId]);
        $item = $stmt->fetch();

        if ($item['stock'] <= 0) {
            throw new Exception("库存不足");
        }

        // 3. 模拟业务逻辑处理,比如计算折扣、校验优惠券...
        // 这里的逻辑执行时间变长了,更容易发生并发问题

        // 4. 扣减库存,并带上版本号检查
        $updateStmt = $pdo->prepare("UPDATE items SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?");
        $affectedRows = $updateStmt->execute([$itemId, $item['version']]);

        if ($affectedRows == 0) {
            // 执行失败了!说明在读取库存之后,version已经被别人修改了
            throw new Exception("库存不足或已更新,请重试");
        }

        // 5. 创建订单...
        $pdo->commit();
        return true;

    } catch (Exception $e) {
        $pdo->rollBack();
        return false;
    }
}

这个方法好在哪里?它解决了死锁问题。因为 UPDATE 语句带上 WHERE version = ? 条件后,它不需要像 FOR UPDATE 那样一直持有行锁直到事务结束。它只是在更新那一瞬间检查一下,查完就走。

但是! 它带来了新的问题:重试风暴

当多个用户同时读取到库存=1,version=10时,他们都尝试执行更新。
用户A更新成功,version变成了11。
用户B尝试更新,发现 version=10 不等于当前的 11,返回 0行影响。
用户B抛出异常,回滚,然后怎么办?告诉他“刷新页面再试”吗?

在高并发下,如果更新失败率很高,无数个用户会被迫不断重试。虽然数据库不会死锁,但你的数据库CPU会飙升,网络带宽会被耗尽。这就像是两个人在抢一个苹果,乐观锁让抢到的人先走,没抢到的人不甘心,结果都在门口绕圈圈,把路堵死了。

第三部分:Redis——那个甚至不需要睡觉的看门人

既然数据库这把老枪在高速扫射时容易卡壳,我们得找个更快的。谁快?Redis

Redis是内存数据库,跑在内存里的,速度快得像闪电。而且,Redis支持原子操作。

这里我们要祭出今天的第二位主角:Lua脚本

为什么用Lua脚本?因为Redis是单线程的。如果我们在PHP里先 GET 库存,再在PHP代码里写 IF stock > 0 THEN DECR,这就又回到了PHP代码层面的“竞态条件”。我们需要把这段逻辑放到Redis服务端去执行,确保这一套动作在Redis内部是原子的。

这是终极方案的核心代码:

// PHP伪代码示例 3:Redis + Lua脚本终极方案
function purchaseItemRedis($userId, $itemId) {
    $redis = getRedisConnection();

    // 1. 定义Lua脚本
    // 这段脚本接收三个参数:商品ID、库存数量、购买数量
    $luaScript = "
        local stockKey = KEYS[1]
        local num = tonumber(ARGV[1])
        local userKey = ARGV[2]

        -- 获取当前库存
        local stock = tonumber(redis.call('get', stockKey))

        -- 如果库存大于0
        if stock and stock >= num then
            -- 扣减库存,原子操作
            redis.call('decrby', stockKey, num)
            -- 模拟扣减库存后,记录一下谁买了(实际业务可能存入Hash或ZSet,防止重复购买)
            -- redis.call('sadd', 'user_items_' .. stockKey, userKey) 
            return 1
        else
            return 0
        end
    ";

    // 编译脚本(为了性能,通常第一次执行后缓存)
    $luaSha = $redis->script('load', $luaScript);

    // 参数准备
    $stockKey = "stock:item:{$itemId}";
    $buyNum = 1; // 这里假设一次买一个,如果是多件商品,逻辑更复杂

    // 2. 执行Lua脚本
    // redis.call() 是Lua里调用Redis命令的方式
    // KEYS[1] 是库存Key
    // ARGV[1] 是购买数量
    // ARGV[2] 是用户ID(防止超卖)
    $result = $redis->eval($luaSha, [$stockKey, $buyNum, $userId], 1);

    if ($result == 1) {
        // 扣减成功,写数据库订单...
        return true;
    } else {
        // 扣减失败
        return false;
    }
}

这个方案为什么牛?

  1. 极高性能:所有的计算都在Redis内存里完成。PHP只需要发送一条指令,Redis瞬间返回结果。
  2. 绝对原子:Lua脚本在Redis服务端是单线程执行的,不会出现PHP代码里那种“A先看,B后看”的时间差。
  3. 防超卖:只要Redis里库存是0,返回就是0,绝对不会出现负库存。

但是! 这里还有一个巨大的坑,我们称之为“数据不一致”

Redis是内存,数据库是磁盘。Redis挂了怎么办?如果Redis扣减成功,但还没来得及写数据库订单,Redis突然宕机了(比如断电、重启),那数据就丢了。用户买了,钱扣了,订单没生成。这就是典型的“短视眼”问题

为了解决这个问题,我们通常需要引入消息队列

第四部分:消息队列——把“瞬时”变成“延时”

我们刚才的Redis方案,是“先扣减,后写库”。这太快了,快到出事了你都没反应过来。

现在我们换个思路:把扣减库存变成一个异步任务

流程是这样的:

  1. 前端点击购买
  2. PHP接收请求,发现库存充足(此时只是“预扣减”或者“预留”),向消息队列(如RabbitMQ、Kafka、RocketMQ)发一条消息。
  3. PHP立即返回给用户“正在处理,请稍候”。
  4. 消费者进程(Worker)从队列里拿出这条消息。
  5. 消费者连接数据库,执行真正的 UPDATE ...
  6. 如果数据库更新成功,标记消息为“已处理”;如果失败,重试或报警。

这里有个技巧:先扣Redis,再发MQ

如果你的Redis里库存是1,用户1拿到了,扣成0了,Redis返回成功。这时候再发MQ。
如果Redis里库存是0,直接返回失败,不需要发MQ。这样就杜绝了发送给MQ无效的消息。

结合Redis和MQ,我们的架构图大概是这样的:

// PHP伪代码示例 4:Redis预扣减 + MQ异步落库
function purchaseItemWithMQ($userId, $itemId) {
    $redis = getRedisConnection();
    $mq = getRabbitMQConnection();

    // 1. 预扣减
    $stockKey = "stock:item:{$itemId}";
    $result = $redis->eval($luaScript, [$stockKey, 1, $userId], 1);

    if ($result == 1) {
        // 2. 预扣减成功,发送消息到MQ
        $message = json_encode([
            'user_id' => $userId,
            'item_id' => $itemId,
            'timestamp' => time()
        ]);

        $mq->publish('order_queue', $message);

        return true;
    } else {
        return false;
    }
}

// 消费者端 PHP 代码
$mq->consume('order_queue', function($msg) {
    $data = json_decode($msg->body, true);

    $pdo = getDatabaseConnection();
    try {
        $pdo->beginTransaction();

        // 执行真正的数据库扣减
        $stmt = $pdo->prepare("UPDATE items SET stock = stock - 1 WHERE id = ?");
        $stmt->execute([$data['item_id']]);

        // 写入订单
        $stmt = $pdo->prepare("INSERT INTO orders ...");
        $stmt->execute(...);

        $pdo->commit();
        $msg->ack(); // 确认消息已处理
    } catch (Exception $e) {
        $msg->nack(false, true); // 失败不确认,重新入队,或者重试几次
    }
});

这种方案的优势:

  1. 削峰填谷:瞬间涌入100万个请求,Redis秒级处理,然后这100万个请求变成100万个消息排队。数据库只需要慢慢消化,不会被瞬间的高并发打死。
  2. 数据安全:虽然Redis和MQ不是强一致性的(最终一致性),但至少保证了数据库操作是可靠的,且不会因为网络抖动导致重复下单(通过消息去重机制)。
  3. 用户体验:用户不用傻傻地等数据库查询返回,几毫秒就看到“处理中”。

第五部分:如何避免“互相掐架”——死锁的进阶

即使我们用了Redis和MQ,数据库层面的库存扣减依然是核心。我们依然要面对并发更新的问题。

除了乐观锁,还有没有别的办法?

1. 利用数据库的唯一索引

这是MySQL里一个非常经典的trick。我们在库存表上加一个字段,比如 update_version,或者干脆在订单创建时。

如果我们允许多个请求同时扣减库存(因为我们要做最终一致性),我们可以利用唯一索引冲突来处理超卖。

逻辑是: 我们不直接更新库存。我们尝试插入一条“扣减记录”。

假设有一个表叫 inventory_deduction,结构如下:
id, item_id, deduct_num, version

我们在 item_id 上加唯一索引。
当一个请求来的时候,先尝试插入一条记录,数量是扣减的数量。
如果插入成功,说明之前没人扣过,执行真正的库存扣减,然后删除这条 deduction 记录。
如果插入失败(报 Duplicate Key 错误),说明有人已经抢过了,这次请求失败,提示用户重试。

-- SQL示例
-- 尝试插入扣减记录
INSERT INTO inventory_deduction (item_id, deduct_num, version) VALUES (1001, 1, 1);

-- 如果上面报主键冲突,说明库存已被其他线程处理
-- 然后执行真正的库存扣减
UPDATE items SET stock = stock - 1 WHERE id = 1001;

这种方法利用了数据库的原子性插入来作为“锁”的作用。因为唯一索引是全局唯一的,同一时间只有一个请求能成功插入。

2. 优化SQL语句

死锁往往是因为锁的顺序不一致。

  • 错误示范:用户A锁了订单表,去等库存表;用户B锁了库存表,去等订单表。
  • 正确示范:无论谁先来,我们都先锁库存表,再锁订单表。

或者,尽量使用行锁而不是表锁。确保你的查询条件能命中索引,尽量精确地锁定那一行数据,而不是把整张表都锁死。

3. 设置合理的锁等待时间

在PHP中,设置数据库事务的隔离级别,或者在连接数据库时设置 wait_timeout,防止因为一个慢查询或者死锁导致PHP进程一直挂着不释放连接。

第六部分:分布式锁的“双刃剑”

讲到这里,有些听众可能会说:“既然Redis这么快,那我在Redis里用 SETNX 加个锁,锁住库存Key,扣减完再释放锁,行不行?”

行,但这通常是杀鸡用牛刀,甚至有时候是杀敌一千自损八百

分布式锁的风险:

  1. Redis主从切换导致数据丢失:如果你对主Redis加了锁,数据写入成功。然后主Redis挂了,从Redis被选举为新主。在从Redis还没完全同步锁信息的时候,另一个请求进来了,发现从Redis里没锁(因为没同步过来),于是也扣减了库存。结果:超卖。
  2. 锁过期时间难定:如果业务逻辑执行很慢(比如需要调用第三方支付API),锁自动过期了,请求还没处理完。此时别人进来把库存扣光了,你的请求最后执行 UNLOCK 的时候,把别人的库存给减了。或者你的请求还在执行,锁过期了,被释放了,另一个请求进来把库存扣光,你的请求执行完,库存变成负数。
  3. 性能损耗:引入分布式锁意味着高并发时,大量请求需要排队等待锁,Redis会变成一个“哨兵”,而不是“加速器”。

所以,对于库存扣减这种场景,Lua脚本原子操作通常就足够了,不需要引入复杂的分布式锁。除非你的业务逻辑极其复杂,在Lua脚本里写不完,那才需要考虑在Lua脚本里调用外部服务或API。

第七部分:终极实战方案总结

好了,讲了这么多理论、漏洞和补丁,现在让我们把这些拼起来,构建一个能在双11扛住几亿流量的系统架构。

架构图(文字版):

  1. 前端层:用户点击购买。
  2. 网关层 (Nginx):做基本的限流(比如1秒只能点1000次)。
  3. 应用层 (PHP)
    • 接收请求。
    • 连接 Redis
    • 执行 Lua脚本 进行预扣减。
    • 如果成功 -> 发送消息到 消息队列 (MQ) -> 返回“处理中”。
    • 如果失败 -> 返回“库存不足”。
  4. 异步处理层 (PHP Worker)
    • 从MQ消费消息。
    • 连接 MySQL
    • 执行数据库更新(使用乐观锁或唯一索引策略)。
    • 写入订单。
    • 确认消息。

关于数据一致性(一致性最终方案):

Redis扣减了,MQ发过去了,但MySQL没写进去怎么办?Redis和MQ的数据不一致。
通常有以下策略:

  1. 定时任务扫描:写一个定时脚本,每小时扫描一次Redis里的库存,看看有没有发出去但没落库的扣减记录,补录到数据库。
  2. MQ重试机制:如果消息消费失败,MQ会自动重试(比如重试5次),5次都失败,就放到“死信队列”,人工介入或由另一个系统定时扫描处理。
  3. 全链路追踪:使用Sentry或SkyWalking,一旦发现MQ里的消息比数据库里的订单多,立马报警。

结语

各位,库存扣减看似是个简单的 UPDATE,实则是并发编程的“试金石”。

  • 死锁是因为锁的顺序乱了,就像两个人走路不看路。
  • 超卖是因为判断条件失效了,就像保险箱的密码被猜到了。
  • 性能瓶颈是因为试图用“慢郎中”去跑“百米冲刺”。

我们要学会利用Redis的内存优势作为“蓄水池”,利用Lua脚本的原子性作为“过滤器”,利用消息队列的异步特性作为“缓冲带”。

不要迷信某种单一的技术。最牛的系统,往往是把多种技术(Redis、MQ、MySQL、Lua)像做沙拉一样,巧妙地组合在一起的。

记住,优秀的代码不仅要能跑,还要在半夜两点老板突击检查时,依然稳如老狗。好了,今天的讲座就到这里,希望大家回去后,把你们的代码检查一遍,看看是不是那个拿着枪指着库存锁的人。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注