秒杀场景库存超卖的四种数据库解法

张开发
2026/4/8 13:42:28 15 分钟阅读

分享文章

秒杀场景库存超卖的四种数据库解法
秒杀场景库存超卖的四种数据库解法附 SQL 实现和压测对比背景做了一个限量秒杀活动商品库存 100 件结果订单生成了 143 笔超卖 43 件。事后查日志发现并发高峰期有大量请求几乎同时通过了库存校验然后一起扣减库存导致库存扣成了负数。复现一下超卖的产生过程-- 请求 A 查询库存SELECTstockFROMgoodsWHEREid1001;-- 返回 1-- 请求 B 查询库存几乎同时SELECTstockFROMgoodsWHEREid1001;-- 也返回 1-- 请求 A 校验通过扣减库存UPDATEgoodsSETstockstock-1WHEREid1001;-- 变成 0-- 请求 B 也校验通过继续扣减UPDATEgoodsSETstockstock-1WHEREid1001;-- 变成 -1超卖根本原因查询和扣减是两步操作中间有时间窗口并发时会同时通过校验。解法一悲观锁SELECT FOR UPDATE用SELECT FOR UPDATE加行锁保证同一时间只有一个事务能操作这一行STARTTRANSACTION;-- 加排他锁其他事务必须等待SELECTstockFROMgoodsWHEREid1001FORUPDATE;-- 业务逻辑判断-- if stock 0:UPDATEgoodsSETstockstock-1WHEREid1001;COMMIT;优点实现简单绝对不会超卖。缺点串行化执行并发性能差。1000 个并发请求要一个一个排队拿锁吞吐量极低。适用场景并发量低、对一致性要求极高的场景比如后台手动操作。压测结果1000 并发100 库存TPS约 200平均响应时间480ms超卖0解法二乐观锁版本号/时间戳不加数据库锁而是通过版本号判断数据是否被其他人修改过-- 表结构加一个 version 字段ALTERTABLEgoodsADDCOLUMNversionINTDEFAULT0;-- 查询时带上 versionSELECTstock,versionFROMgoodsWHEREid1001;-- 假设返回stock5, version8-- 更新时判断 version 是否变化UPDATEgoodsSETstockstock-1,versionversion1WHEREid1001ANDversion8ANDstock0;-- 检查影响行数-- affected_rows 1更新成功-- affected_rows 0被其他请求抢先修改了重试或返回失败PHP 实现publicfunctiondeductStock(int$goodsId):bool{$goodsDB::selectOne(SELECT stock, version FROM goods WHERE id ?,[$goodsId]);if($goods-stock0){returnfalse;// 库存不足}$affectedDB::update(UPDATE goods SET stock stock - 1, version version 1 WHERE id ? AND version ? AND stock 0,[$goodsId,$goods-version]);if($affected0){// 更新失败说明被其他请求抢先了可以重试return$this-deductStock($goodsId);// 注意控制重试次数}returntrue;}优点不加锁并发性能比悲观锁好。缺点高并发时大量请求更新失败需要重试重试风暴可能放大数据库压力。适用场景并发量中等冲突概率不是极高的场景。压测结果1000 并发100 库存TPS约 800平均响应时间120ms超卖0解法三UPDATE 原子扣减推荐把库存校验和扣减合并成一条 SQL利用数据库单条语句的原子性UPDATEgoodsSETstockstock-1WHEREid1001ANDstock0;这条 SQL 在执行层面是原子的MySQL 在更新时会对这一行加行锁判断stock 0和扣减stock - 1在同一个原子操作内完成。PHP 实现publicfunctiondeductStock(int$goodsId):bool{$affectedDB::update(UPDATE goods SET stock stock - 1 WHERE id ? AND stock 0,[$goodsId]);return$affected0;// 影响行数为 0 说明库存不足}优点实现最简单一条 SQL 搞定不需要事务性能好。缺点无法在扣减前做复杂的业务判断比如限购逻辑适合简单场景。适用场景大多数库存扣减场景的首选方案。压测结果1000 并发100 库存TPS约 1500平均响应时间65ms超卖0解法四Redis 预扣 DB 兜底秒杀专用对于真正的高并发秒杀场景数据库本身的吞吐量是瓶颈需要在数据库前面加一层 Redis请求 → Redis 原子扣减库存 → 扣减成功 → 异步写 DB → 生成订单 → 扣减失败 → 直接返回已售罄实现// 活动开始时把库存预加载到 RedisRedis::set(stock:goods:1001,100);// 秒杀请求进来publicfunctionseckill(int$goodsId,int$userId):bool{$keystock:goods:{$goodsId};// Lua 脚本保证原子性查询 扣减在一个原子操作内$luaLUAlocal stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -1 end return redis.call(decr, KEYS[1])LUA;$resultRedis::eval($lua,[$key],1);if($result0){returnfalse;// 库存不足直接返回}// 扣减成功异步写数据库MQ 或后台任务Queue::push(newDeductStockJob($goodsId,$userId));returntrue;}数据库兜底防止 Redis 和 DB 不一致-- 异步写 DB 时仍然用原子扣减防止极端情况下的超卖UPDATEgoodsSETstockstock-1WHEREid1001ANDstock0;-- 如果 affected 0说明 DB 库存已为 0-- 此时 Redis 库存可能已经超卖需要补偿处理优点抗并发能力最强Redis 可以轻松支撑每秒数万请求。缺点架构复杂需要处理 Redis 和 DB 的数据一致性还要考虑 Redis 宕机的降级方案。适用场景真正的秒杀、抢购场景并发量在万级以上。压测结果10000 并发100 库存TPS约 18000平均响应时间8ms超卖0四种方案对比方案实现复杂度并发性能适用并发量超卖风险悲观锁低差 100 QPS无乐观锁中中 1000 QPS无UPDATE 原子扣减低好 5000 QPS无Redis 预扣 DB 兜底高极好万级 QPS无需兜底选哪个普通商品库存扣减UPDATE 原子扣减够用、简单、可靠限时特卖、并发中等乐观锁加 version 字段几行代码搞定双十一级别秒杀Redis 预扣 DB 兜底但要做好降级和一致性处理不要为了技术复杂度而上复杂方案UPDATE 原子扣减能解决 90% 的场景。

更多文章