前几天上线了一个秒杀活动,刚放出去没到一分钟,用户就反馈说“明明看到有库存,点购买却提示已抢光”,还有人说“抢到了两个,扣了我三次款”。后台一看数据库,库存变成负数了。这肯定是并发下单时候没处理好,超卖了。
这个问题我一开始也以为很简单:用ThinkPHP8的事务,把库存检查、扣减、订单创建全部包起来不就行了?实际测下来并不是这样,还牵扯到锁的粒度、死锁、以及事务隔离级别。这篇文章就把自己踩过的坑和最终能落地的方案写出来。
先说最直觉的做法:事务包一切
很多人包括我一开始会这样写:
Db::transaction(function () use ($goodsId, $userId) {
// 查库存
$goods = Goods::find($goodsId);
if ($goods->stock <= 0) {
throw new Exception('库存不足');
}
// 扣库存
$goods->stock = $goods->stock - 1;
$goods->save();
// 创建订单
Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});
看着好像没问题,操作都在一个事务里,要么全部成功,要么全部回滚。但实际一压测,还是超卖。因为两个请求同时读到库存都是1,然后都认为自己能扣,都去执行扣减,最后库存变成0,但两个订单都创建了。
原因在于事务默认的隔离级别下,普通SELECT不会加锁(MySQL默认是可重复读的MVCC),两个事务可以同时读到库存=1,然后各自更新,造成超卖。
所以光靠事务不够,还得加锁。
方案一:悲观锁(行锁)
所谓悲观锁,就是在读取库存的时候就把这一行锁住,直到事务结束才释放。这样其他事务只能等当前事务提交后才能再读。
在ThinkPHP8里,可以用 lock(true) 方法给查询加上排他锁:
Db::transaction(function () use ($goodsId, $userId) {
// 加行锁查出商品
$goods = Goods::where('id', $goodsId)->lock(true)->find();
if (!$goods || $goods->stock <= 0) {
throw new Exception('库存不足');
}
// 扣库存
$goods->stock = $goods->stock - 1;
$goods->save();
// 创建订单
Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});
这里用了 lock(true),ThinkPHP会生成 SELECT ... FOR UPDATE。事务A锁住这条商品记录后,事务B再来执行同样的查询,就会被阻塞住,必须等A提交或回滚后才能继续读。这样库存就不会被两个事务同时扣了。
但是,悲观锁有个副作用:如果事务里干了太多别的事情(比如调远程接口、写日志),锁的持有时间就会很长,并发量一大,数据库连接容易被打满,接口响应也变慢。而且如果你用的是MySQL默认的索引,锁的是主键,效率挺高。但如果你的表结构没设计好,锁的是整张表,就别提性能了。
另外还要注意事务隔离级别,FOR UPDATE 生效的前提是当前事务没有提前提交。所以必须保证整个流程都在同一个事务里。如果中间你调了 Db::commit(),那锁就没了。
方案二:乐观锁(CAS)
悲观锁太保守,很多场景下我们其实可以用乐观锁。思路是不加锁,而是在更新的时候检查期望的库存值是否没变,如果没变就更新成功,变了就说明被别人改过,重试或者失败。
具体做法是在更新库存时加上条件 stock > 0 或者 stock = 之前查到的值。我用的是 stock > 0 的方式,因为只要判断库存没扣成负数就行。
Db::transaction(function () use ($goodsId, $userId) {
// 先查一下当前库存(不加锁)
$goods = Goods::find($goodsId);
if (!$goods || $goods->stock <= 0) {
throw new Exception('库存不足');
}
// 尝试扣减库存,条件必须是 stock > 0
$affected = Goods::where('id', $goodsId)
->where('stock', '>', 0)
->dec('stock', 1)
->update();
if (!$affected) {
throw new Exception('库存不足');
}
// 创建订单
Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});
dec('stock', 1) 是ThinkPHP的原子递减方法,它最终生成的SQL是 UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0。这一步是原子操作,数据库行锁会保证并发时只有一个请求能成功执行,即使两个请求同时执行这条SQL,也只会有一个因为影响行数为1而成功,另一个影响行数为0,从而抛出“库存不足”。
这种方法不用手动加锁,也不需要持锁等待,性能比悲观锁好了不少。但有个前提:业务上扣库存可以允许部分失败。如果你还要同时校验其他条件(比如限购数量),就得把那些条件也放在UPDATE的WHERE子句里。
实际项目中的组合用法
我后来没有单独用某一种锁,而是结合了一下。因为单纯的乐观锁在极端情况下(比如库存还有20个,同时来50个请求)会有大量失败,用户体验不好。所以我在入口处用Redis做一个简单的计数限流,先挡掉大部分流量,再用乐观锁扣库存,最后下单。Redis的具体用处就是给每个商品设置一个“令牌桶”,但这个不是本文重点,跳过。
代码流程大概这样:
// 1. Redis预减库存
$redis = new Redis();
$redis->connect('127.0.0.1');
$res = $redis->hIncrBy('seckill_stock', $goodsId, -1);
if ($res < 0) {
$redis->hIncrBy('seckill_stock', $goodsId, 1); // 加回去
return '已抢光';
}
// 2. 数据库事务内扣减
try {
Db::transaction(function () use ($goodsId, $userId) {
$goods = Goods::find($goodsId);
if (!$goods || $goods->stock <= 0) {
throw new Exception('库存不足');
}
$affected = Goods::where('id', $goodsId)
->where('stock', '>', 0)
->dec('stock', 1)
->update();
if (!$affected) {
throw new Exception('库存不足');
}
Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});
} catch (Exception $e) {
// 事务回滚后,Redis库存要加回来
$redis->hIncrBy('seckill_stock', $goodsId, 1);
return $e->getMessage();
}
// 3. 发送消息提醒、异步计算秒杀成功人数之类的,可以放在事务外面做
return '秒杀成功';
这里有一个注意点:Redis预减库存只用于挡住超高并发的请求,真正的库存扣减还是以MySQL为准。如果数据库扣减失败,必须把Redis里的值加回来。
坑:事务里用了锁后,千万别在里面做耗时的外呼
我第一次用悲观锁时,在事务里顺便调了一个赠送积分的外部接口,结果那个接口超时了5秒,导致事务一直不提交,后续请求全部堆积在行锁上。最后数据库连接池爆了,整个服务都卡死了。后来我把所有外部调用全部移到了事务外面,事务里只做库存扣减和订单插入,速度一下就上来了。所以用锁的时候务必保持事务尽量短。
还有一个容易忽略的点:索引
行锁要起作用,查询条件必须用到索引。如果 where('id', $goodsId) 没走主键索引,MySQL可能会升级成表锁,那样并发量一大性能就会很差。ThinkPHP里还要注意避免对字段做表达式运算,比如 where('id', (int)$goodsId) 倒是无所谓,但是 whereRaw('id + 1 = ?') 这种就没法走索引了。
另外如果你用的是悲观锁,一定要确认你的MySQL版本和引擎是InnoDB,MyISAM不支持事务和行锁,别搞混了。
最终压测结果
用上面乐观锁+Redis限流的方案,我本地用200个并发请求去抢10个库存商品,最终数据库里只有10个订单,库存也正确归零,没有再出现负数。平均响应时间从悲观锁的1.2秒降到200毫秒左右。虽然看起来还是有点慢,但比之前好多了。如果还想再快,可以把订单表做成异步队列,事务里只扣库存,订单数据放到MQ里慢慢消费,性能会更高。但那样会增加复杂度,看业务需求吧。
以上是我这次高并发扣库存的完整解决过程,没有什么高深理论,都是实操。希望能帮到同样在坑里的兄弟。

