17-库存异步更新方案:解决高并发下单库存卡顿问题
作者:黒漂技术佬 系列:RocketMQ 核心原理与无人售货柜项目实战
一、问题场景:早高峰的"库存卡顿"
早上 8 点,办公楼大厅。5 台无人售货柜同时迎来购买高峰------咖啡、面包、三明治被疯狂扫码下单。每笔订单都要扣库存,而库存都在同一台 MySQL 里。200 个并发请求同时打过来,数据库的 库存表 行锁排起了长队,接口响应从 50ms 飙升到 3 秒。用户等得不耐烦,干脆不买了。
这就是经典的同步扣减库存问题。先看看传统写法为什么不行。
二、传统同步扣减库存的三大痛点
java
// 传统写法:在订单创建的事务里同步扣库存
@Transactional
public void createOrder(CreateOrderRequest req) {
// 1. 插入订单
orderMapper.insert(order);
// 2. 逐行扣库存------这里加行锁!
for (OrderItem item : req.getItems()) {
int rows = stockMapper.deduct(item.getSkuId(), item.getQty());
if (rows == 0) {
throw new BizException("库存不足");
}
}
}
这段代码有三个致命问题:
痛点一:行锁竞争。
UPDATE stock SET count = count - ? WHERE sku_id = ? AND count >= ? 这条 SQL 对同一 SKU 加了行锁。高并发下,所有买同一款咖啡的人都得排队等这把锁释放。数据库 CPU 也许不高,但锁等待时间把整个系统拖垮了。
痛点二:事务过长。
订单插入 + 库存扣减在同一个事务里,事务持续时间 = 网络延迟 + SQL 执行 + 锁等待,毫秒级变秒级。而数据库连接池是有限的,长事务占着连接不释放,其他请求只能等待。
痛点三:耦合太紧。
库存扣成不成功直接影响订单创建结果。但实际上从用户视角,他关心的是"我下单成功了没",至于库存是同步扣还是异步扣,没人在意。把这两个操作强行绑定,牺牲了响应速度。
三、方案一:Redis 预扣减 + MQ 异步同步到 DB
核心思路:下单时先扣 Redis(内存操作,微秒级),后台再通过 MQ 慢慢同步到 MySQL。
sql
用户下单
↓
Redis DECR stock:sku:1001(原子操作,极快)
↓
库存够? → 保存订单(MySQL)→ 发MQ消息 → 返回"下单成功"
库存不够? → 返回"库存不足"
↓(异步)
MQ消费者收到消息 → UPDATE MySQL库存
3.1 Redis 预扣减:Lua 脚本保证原子性
lua
-- Lua脚本:batch_deduct_stock.lua
-- KEYS[1..N]: 商品SKU的Redis key,格式 stock:sku:{skuId}
-- ARGV[1..N]: 对应扣减数量
-- 返回:1表示成功,0表示库存不足
for i = 1, #KEYS do
local current = redis.call('GET', KEYS[i])
if not current or tonumber(current) < tonumber(ARGV[i]) then
-- 库存不足,回滚前面已扣的(虽然GET不扣库存,这里只是检查)
return 0
end
end
-- 全部检查通过,统一扣减
for i = 1, #KEYS do
redis.call('DECRBY', KEYS[i], tonumber(ARGV[i]))
end
return 1
为什么要用 Lua 脚本?因为"检查库存 → 扣减库存"是两个 Redis 命令,如果不用 Lua,两个命令之间可能被其他请求插队,导致超卖。Lua 脚本在 Redis 中是原子执行的,脚本运行期间其他命令排队等待,彻底杜绝并发问题。
3.2 Java 调用 Lua 脚本
java
@Service
public class InventoryService {
@Autowired
private StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> deductScript;
@PostConstruct
public void init() {
deductScript = new DefaultRedisScript<>();
deductScript.setLocation(
new ClassPathResource("lua/batch_deduct_stock.lua"));
deductScript.setResultType(Long.class);
}
/**
* Redis预扣减库存
* @return true-成功,false-库存不足
*/
public boolean preDeduct(List<OrderItemDTO> items) {
List<String> keys = items.stream()
.map(i -> "stock:sku:" + i.getSkuId())
.collect(Collectors.toList());
// 注意:ARGV需要转为String数组
Object[] args = items.stream()
.map(i -> String.valueOf(i.getQuantity()))
.toArray();
Long result = redisTemplate.execute(deductScript, keys, args);
return result != null && result == 1;
}
/**
* 回滚Redis库存(订单取消/超时时调用)
*/
public void rollbackStock(List<OrderItemDTO> items) {
for (OrderItemDTO item : items) {
redisTemplate.opsForValue()
.increment("stock:sku:" + item.getSkuId(), item.getQuantity());
}
}
}
3.3 MQ 异步同步到 MySQL
java
// ========== 生产者:下单成功后发送库存扣减消息 ==========
public CreateOrderResult createOrder(CreateOrderRequest req) {
// 1. Redis预扣减
if (!inventoryService.preDeduct(req.getItems())) {
throw new BizException("库存不足");
}
// 2. 保存订单到MySQL
Order order = orderMapper.save(req.toOrder());
// 3. 发送MQ消息(异步同步库存到DB)
StockSyncMessage msg = StockSyncMessage.builder()
.orderId(order.getId())
.items(req.getItems())
.operation("DEDUCT")
.timestamp(System.currentTimeMillis())
.build();
rocketMQTemplate.asyncSend("StockTopic:DEDUCT", msg,
new SendCallback() {
@Override
public void onSuccess(SendResult result) {
log.info("库存同步消息发送成功,订单:{}", order.getId());
}
@Override
public void onException(Throwable e) {
// 发送失败,记录到本地消息表,定时补偿
localMsgService.save(msg);
}
});
// 4. 立即返回(不等DB同步完成!)
return new CreateOrderResult(order.getId());
}
// ========== 消费者:批量消费,合并UPDATE ==========
@Service
@RocketMQMessageListener(
topic = "StockTopic",
selectorExpression = "DEDUCT",
consumerGroup = "stock-sync-group",
consumeMessageBatchMaxSize = 50 // 批量消费,一次拉50条
)
public class StockSyncConsumer implements RocketMQListener<List<StockSyncMessage>> {
@Override
public void onMessage(List<StockSyncMessage> messages) {
if (messages.isEmpty()) return;
// 按SKU聚合:相同SKU的扣减数量相加
Map<Long, Integer> deductMap = messages.stream()
.flatMap(m -> m.getItems().stream())
.collect(Collectors.groupingBy(
OrderItemDTO::getSkuId,
Collectors.summingInt(OrderItemDTO::getQuantity)
));
// 批量UPDATE:一条SQL更新所有SKU的库存
// UPDATE stock SET count = count - CASE sku_id
// WHEN 1001 THEN 3
// WHEN 1002 THEN 5
// END
// WHERE sku_id IN (1001, 1002)
stockMapper.batchDeduct(deductMap);
}
}
批量消费和合并 UPDATE 是关键优化点。如果不合并,50 条消息就要执行 50 次 UPDATE,合并后只需要 1 次 CASE WHEN 语句,数据库压力直降两个数量级。
四、方案二:MQ 削峰 + 批量扣减
如果 Redis 成本太高(或者担心 Redis 和 DB 不一致),可以退而求其次用 MQ 削峰:
sql
下单请求 → 扔进MQ队列 → 消费者批量消费 → 批量UPDATE库存
削峰的原理很简单:MQ 像个蓄水池,瞬时的大流量先存起来,消费者按自己能承受的速度慢慢处理。原本 1000 QPS 的瞬时流量,经过 MQ 缓冲后消费者以 200 QPS 的匀速处理,数据库就轻松了。
这个方案的缺点是用户下单后不能立刻知道库存是否足够(需要异步回调或轮询),体验不如 Redis 预扣方案。
五、数据一致性保障
Redis + DB 双写的经典难题:怎么保证 Redis 里的库存和 MySQL 里的库存最终一致?
5.1 MQ 消费失败重试
RocketMQ 默认重试 16 次,间隔递增(10s → 30s → 1min → 2min → ... → 2h)。只要重试最终成功,DB 库存就能追上 Redis。
5.2 定时对账
再强的重试也有死角。比如 MQ 消息丢了(虽然概率极低),或者消费了 16 次都失败了进了死信队列。所以必须加一个定时对账任务兜底:
java
@Component
public class StockReconciliationJob {
@Scheduled(cron = "0 */10 * * * ?") // 每10分钟对账一次
public void reconcile() {
// 1. 查出Redis中所有库存
Set<String> keys = redisTemplate.keys("stock:sku:*");
// 2. 查出MySQL中所有库存
List<StockVO> dbStocks = stockMapper.selectAll();
// 3. 逐一比对
for (StockVO db : dbStocks) {
String redisKey = "stock:sku:" + db.getSkuId();
String redisVal = redisTemplate.opsForValue().get(redisKey);
int redisStock = redisVal == null ? 0 : Integer.parseInt(redisVal);
if (redisStock != db.getCount()) {
// 差异记录告警
log.warn("库存不一致!SKU={}, Redis={}, DB={}",
db.getSkuId(), redisStock, db.getCount());
// 根据业务规则决定以谁为准(一般以DB为准修复Redis)
redisTemplate.opsForValue()
.set(redisKey, String.valueOf(db.getCount()));
}
}
}
}
5.3 本地消息表补偿
MQ 发送失败时,消息写入本地数据库的 local_msg 表,另一个定时任务扫描未发送成功的消息重试。这个设计也叫事务消息的简化版------不使用 RocketMQ 的事务消息特性,而是自己实现。
六、无人售货柜早高峰性能效果
以我们实际部署的数据为例:
| 指标 | 同步扣减方案 | Redis + MQ 异步方案 |
|---|---|---|
| 接口平均响应时间 | 2800ms | 85ms |
| 数据库 CPU | 峰值 75% | 稳定 20% |
| 并发支撑 | 200 QPS 开始超时 | 2000 QPS 稳定 |
| 行锁等待 | 大量 Lock wait timeout |
几乎为零 |
85ms 的响应时间里,大部分是网络开销和订单插入 MySQL 的时间。库存扣减因为走 Redis,实际耗时不到 1ms。
库存异步更新的本质就是用空间换时间、用最终一致换强一致。Redis 承担热数据的读写压力,MySQL 退居二线做持久化存储,MQ 在中间做异步衔接。这个模式不局限于无人售货柜,电商、秒杀、抢票等场景同理。