一、为什么高并发下会出现超卖
做电商的同学应该都遇到过这样的场景:明明库存只有 10 件,结果用户下单却成功了 12 单。问题出在哪?说白了就是多个请求同时读到库存,然后各自扣减,最后把库存扣成了负数。
举个最简单的例子,两个用户同时发起购买请求,都读到库存是 1,然后都执行了减库存操作,结果库存变成了 -1。这就是典型的并发竞争问题。在单机环境下,我们可以用数据库的行锁或者乐观锁来解决,但一旦流量上来、服务做了多实例部署,单靠数据库锁就不太够用了。
这时候就需要引入分布式锁,让多个进程之间互相协调,保证同一时刻只有一个请求能执行扣减库存的操作。
二、分布式锁的基本思路
分布式锁的核心思想其实很简单:让所有请求去争抢同一个资源,谁抢到了谁才能继续往下走,没抢到的要么等待、要么直接返回失败。
在 PHP 生态里,最常用的方案就是借助 Redis 的原子操作来实现。Redis 单线程执行命令的特性,天然保证了操作的原子性,非常适合做分布式锁。
实现分布式锁通常需要满足几个条件:
- 互斥性:同一时刻只能有一个客户端持有锁。
- 安全性:锁必须设置过期时间,防止持有锁的进程崩溃后锁永远不释放。
- 可重入性:同一个客户端在持有锁期间可以再次获取锁(视业务需要)。
三、基于 Redis 的分布式锁实现
下面直接看代码。这里我用 PHP 配合 Predis 客户端来写一个简单的分布式锁工具类。
<?php class RedisLock { private $redis; private $lockKey; private $lockValue; private $expireTime; public function __construct($redis, $lockKey, $expireTime = 10) { $this->redis = $redis; $this->lockKey = $lockKey; $this->expireTime = $expireTime; $this->lockValue = uniqid('', true); } // 尝试获取锁 public function acquire() { // 使用 setnx 加过期时间,保证原子性 $result = $this->redis->set( $this->lockKey, $this->lockValue, 'NX', 'EX', $this->expireTime ); return $result === true; } // 释放锁 public function release() { // 用 Lua 脚本保证判断和删除的原子性 $script = <<<LUA if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end LUA; return $this->redis->eval($script, 1, $this->lockKey, $this->lockValue); } } ?>这里有几个细节值得注意。获取锁的时候,我用的是 set 命令的 NX 和 EX 参数,一条命令同时完成「加锁」和「设置过期时间」,避免分成两步操作带来的原子性问题。
释放锁的时候,我用了 Lua 脚本。为什么要这么麻烦?因为要防止一个常见的坑:线程 A 持有锁,结果执行时间太长锁过期了,这时候线程 B 拿到了锁。如果 A 直接 del 锁,就会把 B 的锁给删掉。所以释放锁之前必须先判断 value 是不是自己的,这个判断和删除必须放在同一个 Lua 脚本里保证原子性。
四、在秒杀场景中防止超卖
有了锁工具类,接下来就是怎么在业务代码里用了。下面是一个简单的秒杀下单流程示例。
<?php function seckill($userId, $goodsId) { $redis = new Predis\Client(); $lock = new RedisLock($redis, 'lock:goods:' . $goodsId, 10); // 尝试获取锁,失败直接返回 if (!$lock->acquire()) { return ['code' => 400, 'msg' => '系统繁忙,请稍后再试']; } try { // 检查库存 $stock = $redis->get('stock:' . $goodsId); if ($stock <= 0) { return ['code' => 400, 'msg' => '已售罄']; } // 扣减库存 $redis->decr('stock:' . $goodsId); // 创建订单(省略数据库操作) // ... return ['code' => 200, 'msg' => '抢购成功']; } finally { // 释放锁 $lock->release(); } } ?>这段代码的逻辑很清晰:先抢锁,抢到了就检查库存、扣库存、下单,最后在 finally 里释放锁。用 finally 是为了保证即使中间抛了异常,锁也能被正常释放,不会造成死锁。
五、实际使用中的几个坑
代码写完了,但真正上线前还有几个问题需要想清楚。
第一个坑:锁的过期时间怎么定。如果业务执行时间超过了锁的过期时间,锁会自动释放,这时候其他请求就能进来,还是会出现并发问题。所以过期时间一定要大于业务的最大执行时间,同时要留出一定的缓冲。
第二个坑:Redis 主从切换。如果 Redis 做了主从架构,客户端往主节点写了锁,但主节点还没来得及同步到从节点就挂了,这时候从节点升级为主节点,锁就丢了。对于要求极高的场景,可以考虑用 Redlock 算法,但实现复杂度会高不少。
第三个坑:锁粒度。锁的粒度越小,并发能力越强。比如上面例子中锁的 key 是 goodsId,也就是每个商品一把锁,不同商品的购买请求互不影响,这样并发能力就比全局一把锁高很多。
六、小结
用 Redis 实现分布式锁来防止超卖,是目前 PHP 项目里比较成熟的做法。核心就两点:一是用 set NX EX 保证加锁的原子性,二是用 Lua 脚本保证释放锁的安全性。代码本身不复杂,但真正用好还需要结合业务场景去考虑过期时间、锁粒度这些细节。
如果你的项目并发量不是特别大,这套方案完全够用。如果量级真的很大,那就要考虑消息队列削峰、库存预扣减等更复杂的方案了。希望这篇文章能帮到你,有问题欢迎在评论区交流。