前两天我们商城做了一波秒杀,活动刚开始 10 秒,订单系统就炸了,库存扣成了负数。后台一查日志,好家伙,同一个商品同时段有多个请求都通过了库存判断。这就是超卖。这次我用 ThinkPHP8 加 Redis 分布式锁重写了库存扣减逻辑,才把问题压下去。
超卖到底是怎么发生的
传统的扣库存代码多半是这么写的:先查库存是否大于 0,然后再 update 扣减。但高并发下两个请求同时读到库存 = 1,两个都觉得自己能买,然后都去 update,最后库存变成了 -1。这就是经典的“检查再更新”竞态条件。
解决办法很多,数据库乐观锁、悲观锁都能搞。但更适合抗高并发的是 Redis 分布式锁,因为 Redis 单线程执行命令,天然原子性。今天就用 ThinkPHP8 干这个事。
环境准备
你的项目需要已经安装 ThinkPHP8,并且装好 Redis 扩展。我这里是 PHP 8.2 + ThinkPHP 8.0,Redis 扩展已启用。再用 Composer 装一个 predis 或者直接用 phpredis 都行,我用的是 phpredis 扩展,所以代码里直接用 Redis 类。
先在 config/cache.php 里配置 Redis 为默认缓存驱动:
return [
'default' => 'redis',
'stores' => [
'redis' => [
'type' => 'redis',
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
'select' => 0,
'timeout' => 0,
'expire' => 0,
'persistent' => false,
'prefix' => '',
],
],
];
撸一个 Redis 分布式锁服务类
别整那些花里胡哨的框架包,自己手写一个最清晰的锁类。我新建了一个 app/service/RedisLock.php,内容如下:
<?php
namespace appservice;
use thinkfacadeCache;
class RedisLock
{
/**
* 获取锁
* @param string $key 锁唯一标识
* @param int $expire 锁自动过期时间(秒)
* @return bool|string 成功返回锁的 value,失败返回 false
*/
public static function lock($key, $expire = 5)
{
// 这里的 value 要唯一,防止误删别人的锁
$token = md5(uniqid((string)mt_rand(), true));
// 使用 setnx 尝试加锁,同时设置过期时间,防止死锁
$redis = Cache::store('redis')->handler();
$result = $redis->set("lock:$key", $token, ['NX', 'EX' => $expire]);
if ($result) {
return $token;
}
return false;
}
/**
* 释放锁
* @param string $key
* @param string $token 加锁时返回的 token
* @return bool
*/
public static function unlock($key, $token)
{
$redis = Cache::store('redis')->handler();
// Lua 脚本原子性判断:只有 value 匹配才删除,防止删掉别人的锁
$script = <<eval($script, ["lock:$key", $token], 1);
return $result == 1;
}
}
有几个重点:
- 加锁的时候用
set key value NX EX。NX 表示 key 不存在才设置,EX 表示过期时间。这操作是原子的,不用怕中间出幺蛾子。 - 锁的 value 一定要唯一。我用的是 md5(uniqid + mt_rand)。为什么?防止释放锁的时候误删了别人的锁。比如线程 A 执行太久锁过期了,线程 B 拿到锁,然后 A 执行完了去释放锁,如果不判断 value,A 就会把 B 的锁删掉。Lua 脚本判断 value 再删,就能避免这种惨案。
- 过期时间必须设置,否则进程崩了锁就永久不释放。
在商品扣库存场景里使用
现在模拟一个商品表:id, name, stock。我用一个简单的控制器来模拟下单流程。
<?php
namespace appcontroller;
use thinkfacadeDb;
use appserviceRedisLock;
class Order
{
public function buy($goodsId)
{
// 需要操作的库存数字
$lockKey = "goods_stock_$goodsId";
$token = RedisLock::lock($lockKey, 3);
if (!$token) {
return json(['code' => 429, 'msg' => '操作太频繁,请稍后再试']);
}
try {
// 开启事务,保证数据库操作原子性
Db::startTrans();
// 读取当前库存(此时已在锁的保护下)
$goods = Db::name('goods')->where('id', $goodsId)->lock(true)->find();
if (!$goods || $goods['stock'] 400, 'msg' => '库存不足']);
}
// 扣减库存
Db::name('goods')->where('id', $goodsId)->dec('stock', 1)->update();
// 模拟其他业务操作,比如创建订单
Db::name('orders')->insert([
'goods_id' => $goodsId,
'create_time' => time(),
]);
Db::commit();
return json(['code' => 0, 'msg' => '购买成功']);
} catch (Exception $e) {
Db::rollback();
// 记日志...
return json(['code' => 500, 'msg' => '服务器繁忙']);
} finally {
// 释放锁(用finally确保任何情况都释放)
RedisLock::unlock($lockKey, $token);
}
}
}
注意几个细节:
- 在查询商品时加了
lock(true),也就是数据库的悲观锁FOR UPDATE。锁机制是防并发的第一道门,数据库锁是第二道保险。双重保障。 - 锁的超时时间要设置得比业务执行时间长一点。我这里设置 3 秒,如果业务超过 3 秒,锁自动失效,另一个请求就可以进来,这时数据库锁会兜底。一般商城扣库存很快,几十毫秒就完事,3 秒绝对够。
- 释放锁放在 finally 里,保证异常时锁也能释放,不会死锁。
压测一下看效果
我自己用 Apache 的 ab 工具模拟 100 并发请求同一个商品:
ab -n 100 -c 100 "http://yourdomain/order/buy?goods_id=1"
库存我预先设置成了 10。跑完看日志,成功下单 10 个,剩余的 90 个请求全部返回“操作太频繁”或者“库存不足”。没有一条超卖记录,数据库库存稳稳停在 0。
没加锁之前,100 并发下库存能变成 -12。加了锁之后,虽然部分请求会提示稍后再试,但数据是准的,这才叫一致性。
锁在 ThinkPHP8 里还可以怎么玩
用锁不光能防超卖,还能防用户重复提交表单,防定时任务重复执行,防缓存击穿(只允许一个请求去重建缓存)。我把这个 RedisLock 类抽象出来,哪里需要就在哪里调用。
比如防止重复提交订单,可以在前端传入一个唯一的幂等键,然后在后端尝试加锁:
$lockKey = 'order_submit_' . $userId . '_' . $goodsId;
$token = RedisLock::lock($lockKey, 5);
if (!$token) {
return json(['code' => 429, 'msg' => '正在处理中,请勿重复提交']);
}
// 处理订单...
RedisLock::unlock($lockKey, $token);
这套方案的坑与注意
首先,Redis 必须开启持久化?其实不需要。锁只是临时性的,就算 Redis 重启,锁也会消失,但数据库的悲观锁会兜住一致性。不过如果 Redis 挂了,锁全部失效,并发会直接打到数据库,那就考验数据库的能力了。最好给 Redis 搞个哨兵或集群。
其次,锁的时间不是越长越好,太长会导致大量请求失败,降低吞吐。根据业务实际耗时设置一个合理值,并尽量缩短事务内的耗时。
最后,ThinkPHP8 里如果你用了多个 Redis 库,要注意连接配置一致。我用的是 Cache 门面,它会根据 config/cache.php 里的当前默认存储来获取 redis 句柄,不会乱套。
有没有更简单的方式?
如果你不想自己写 Lua 脚本,也可以用 TP 自带的锁机制(thinkfacadeCache::lock),但那个锁的粒度比较粗,用法也不太一样。我还是喜欢直接操作 Redis 更灵活。
另外有的人会推荐用 RedLock 算法,但对于大多数业务场景,单机 Redis 锁已经够用了。RedLock 需要多个独立 Redis 节点,反而会增加复杂度,而且业内对 RedLock 一直有争议。
把上述代码集成进你的项目
把 RedisLock 类放到 app/service 下,然后在控制器里 use 它。别忘了在数据库里建好 goods 表和 orders 表,字段可以简单点,主要看逻辑。
CREATE TABLE goods (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
stock INT UNSIGNED NOT NULL
);
INSERT INTO goods (name, stock) VALUES ('限量款T恤', 10);
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
goods_id INT UNSIGNED NOT NULL,
create_time INT UNSIGNED NOT NULL
);
跑起来后,你也可以用 jmeter 模拟多线程。把并发线程数调高,观察日志中成功和失败的分布,你会看到锁的威力。
最后说点实在话
不要以为用了 Redis 锁就万事大吉。如果你的库存操作涉及多步、跨服务,甚至需要回滚,那还需要考虑分布式事务。但大多数单服务商城,Redis 锁 + 数据库悲观锁已经能抗住几十万级流量。
这周我还在继续优化我们的秒杀模块,下一步打算把库存预热到 Redis,然后使用 Lua 脚本原子扣减库存,完全避开数据库操作。到时候再写一篇分享。
希望这篇文章能让你别像我之前那样被超卖坑惨。

