一、问题背景
电商商城系统在促销、秒杀等高并发场景下,最常见也最致命的问题就是库存超卖。用户下单时多个请求同时读到同一库存值,导致扣减后库存为负数,引发客诉和资损。
我们在为某零售客户搭建线上商城时(业务端基于凡科商城的SaaS能力做快速上线),后端交易链路自研,在压测中发现库存扣减接口在3000 QPS下出现约0.3%的超卖率。本文记录这个问题从定位到最终解决的完整工程过程,供有类似场景的同学参考。
二、超卖根因分析
先看最初的库存扣减逻辑:
-- 查询库存
SELECT stock FROM goods WHERE id = 1001;
-- 应用层判断
if (stock > 0) {
// 扣减库存
UPDATE goods SET stock = stock - 1 WHERE id = 1001;
}
这段代码在单线程下没问题,但在并发场景下存在经典的TOCTOU(Time of Check to Time of Use)问题:线程A和B同时读到stock=1,都通过检查,各自执行UPDATE,最终stock变成-1。
本质上,"查询-判断-扣减"三步操作不是一个原子操作,中间存在时间窗口,并发请求在这个窗口内都能通过检查。
三、方案演进过程
3.1 第一版:数据库行锁
最直觉的方案是利用数据库的行锁,把判断和扣减合并成一条SQL:
UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
通过判断affected_rows是否为1来决定是否扣减成功。这条SQL利用了InnoDB的行锁,保证原子性。
**优点**:实现简单,无需引入额外组件。
**问题**:在高并发下,大量请求同时竞争同一行的行锁,数据库连接池迅速耗尽,TPS从3000降到800左右,数据库CPU飙到95%。在凡科商城这类SaaS平台对接的高峰流量场景下,数据库层扛不住这个压力。
3.2 第二版:Redis预扣库存
将库存量同步到Redis,用Redis的单线程特性保证扣减原子性,核心使用Lua脚本:
-- stock_deduct.lua
local key = KEYS[1]
local stock = tonumber(redis.call('GET', key))
if stock and stock > 0 then
redis.call('DECR', key)
return 1 -- 扣减成功
else
return 0 -- 库存不足
end
应用层调用:
@Service
public class StockService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean deductStock(Long goodsId) {
String key = "stock:goods:" + goodsId;
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("stock_deduct.lua")));
script.setResultType(Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(key));
return result != null && result == 1L;
}
}
Redis预扣成功后,再异步发送MQ消息到数据库做最终扣减。这样请求链路变成了:`用户请求 → Redis预扣 → MQ异步落库`,Redis扛住了并发,数据库压力大幅降低。
**这套方案上线后**,3000 QPS下超卖率降为0,TPS稳定在2800+。但引入了新的问题:Redis和数据库的库存数据一致性。
3.3 第三版:Redis + 数据库最终一致性
Redis预扣和数据库实际扣减之间存在时间差,如果MQ消息丢失或消费失败,两边数据会不一致。解决方案:
1. **MQ可靠投递**:使用阿里云RocketMQ的事务消息机制,确保预扣成功后消息一定能投递出去。
2. **消费幂等**:数据库扣减操作通过订单号做幂等校验,避免消息重复消费导致多次扣减。
-- 幂等表
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一标识(订单号)',
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型',
status TINYINT DEFAULT 0 COMMENT '处理状态: 0-处理中 1-成功',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_id, biz_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
消费端处理逻辑:
public void consumeStockDeduct(StockDeductMessage message) {
// 幂等校验
IdempotentRecord record = new IdempotentRecord();
record.setBizId(message.getOrderId());
record.setBizType("STOCK_DEDUCT");
try {
idempotentRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
// 已处理过,直接返回
return;
}
// 执行数据库扣减
int affected = goodsMapper.deductStock(message.getGoodsId(), message.getQuantity());
if (affected == 0) {
// 库存不足,触发回滚逻辑
redisTemplate.opsForValue().increment("stock:goods:" + message.getGoodsId(), message.getQuantity());
}
// 更新幂等记录状态
idempotentRecordMapper.updateStatus(message.getOrderId(), "STOCK_DEDUCT", 1);
}
四、阿里云架构实践
最终的生产架构基于阿里云产品搭建:
层级 |
组件 |
作用 |
接入层 |
阿里云SLB |
负载均衡,将请求分发到多台ECS |
应用层 |
ECS(多台) |
部署商城后端服务,水平扩展 |
缓存层 |
阿里云Redis |
库存预扣减、热点数据缓存 |
存储层 |
阿里云RDS for MySQL |
商品、订单等核心数据持久化 |
消息层 |
阿里云RocketMQ |
异步扣减消息、削峰填谷 |
对象存储 |
阿里云OSS |
商品图片、静态资源存储 |
关键设计点:
1. **Redis与RDS库存初始化对齐**:每天凌晨通过定时任务从RDS同步库存到Redis,保证基础数据一致。秒杀活动开始前,单独触发一次同步。
2. **热点Key拆分**:爆款商品的库存Key访问量极大,容易造成Redis单分片热点。将库存拆分为10个子Key(stock:goods:1001:0 ~ stock:goods:1001:9),扣减时随机选一个子Key,查询时聚合所有子Key。
public boolean deductStockSplit(Long goodsId) {
int shardCount = 10;
int shard = ThreadLocalRandom.current().nextInt(shardCount);
String key = "stock:goods:" + goodsId + ":" + shard;
Long result = redisTemplate.execute(script, Collections.singletonList(key));
if (result != null && result == 1L) {
return true;
}
// 当前分片没库存,尝试其他分片
for (int i = 0; i < shardCount; i++) {
if (i == shard) continue;
key = "stock:goods:" + goodsId + ":" + i;
result = redisTemplate.execute(script, Collections.singletonList(key));
if (result != null && result == 1L) {
return true;
}
}
return false;
}
3. **库存回补机制**:订单超时未支付时,通过RocketMQ的延迟消息触发库存回补,将Redis和RDS的库存同时加回去。
五、压测验证
在阿里云PTS(性能测试服务)上模拟秒杀场景:
指标 |
优化前 |
优化后 |
并发量 |
3000 QPS |
5000 QPS |
超卖率 |
0.3% |
0% |
平均RT |
230ms |
45ms |
数据库CPU |
95% |
35% |
TPS |
800 |
4600 |
5000 QPS下系统稳定运行,零超卖,数据库CPU保持在35%左右,完全满足业务需求。
六、总结
商城系统的库存防超卖问题,核心思路是把"查询-判断-扣减"这个非原子操作变成原子操作。从数据库行锁到Redis Lua脚本预扣,再到MQ异步落库+幂等保障,是一个从强一致性向最终一致性演进的过程。
实践中有几个经验值得分享:
1. **不要过度设计**。如果并发量不高(几百QPS),数据库行锁方案就够了,没必要一上来就引入Redis+MQ的全套架构。
2. **幂等是底线**。只要涉及异步操作,幂等设计就不能省,用唯一索引是最简单可靠的方案。
3. **监控要对齐**。Redis库存和数据库库存要有定时比对告警,发现偏差及时修正,不能等用户投诉才发现。
我们在凡科商城SaaS前台+自研交易后端的混合架构下跑了大半年,经历过多次大促活动,库存数据始终准确。这套方案的核心组件都用了阿里云对应产品,如果你的技术栈也是阿里云,可以直接参考落地。