07 如何设计一个支持10万QPS的库存扣减系统

vvEcho 2025-02-19 18:36:41
Categories: > Tags:

假设您要设计一个库存扣减系统,要求支持秒杀场景下的高并发扣减(10万QPS),且保证不超卖。请对比Redis Lua脚本、分布式锁+数据库扣减、以及Redis+MQ异步扣减三种方案的优劣。您会如何选择?请结合您简历中的高并发经验说明

方案 优势 劣势 适用场景
Redis Lua脚本 原子性保证,无竞态条件;毫秒级响应(内存操作) 集群热key压力大,数据持久化风险(宕机丢数据),无异步流程扩展性 秒杀初期(库存量小,如1000件以内)
分布式锁+数据库扣减 强一致性(ACID),无需额外组件 锁竞争严重(如CAS失败重试),数据库写入瓶颈(QPS难以突破2万),死锁风险高 低频高价值商品(如奢侈品,QPS<1万)
Redis+MQ异步扣减 削峰填谷(支撑10万QPS+),业务解耦,可扩展性强 最终一致性(短暂超卖风险),架构复杂度高,需处理消息堆积 大规模秒杀(如双11,库存百万级)

推荐redis+MQ异步方案
架构图:

1
2
3
[用户请求][API网关][Redis库存预扣减][MQ][DB最终扣减+订单创建]
↑ ↓ (返回预扣结果) ↓ (异步保证最终一致性)
└─[响应前端] [库存回滚机制] [监控报警]

实现步骤:

1.库存预热

1
2
3
4
// 将10000件库存拆分为100个桶(避免单个key过热)
for (int i = 0; i < 100; i++) {
redisTemplate.opsForValue().set("stock:sku_123:bucket_" + i, 100);
}

优势:分散热key压力(参考您简历中Redis集群经验)。
路由算法:bucketId = userId.hashCode() % 100(相同用户始终访问同一桶,避免重复扣减)

2.Lua脚本原子扣减

1
2
3
4
5
6
7
8
-- KEYS=bucket_key, ARGV=扣减数量
local stock = tonumber(redis.call('get', KEYS))
if stock >= tonumber(ARGV) then
redis.call('decrby', KEYS, ARGV)
return 1 -- 成功
else
return 0 -- 库存不足
end

性能优化:每个分桶支撑约1000 QPS,100个分桶总QPS可达10万

3.MQ异步写库

1
2
3
4
5
6
7
8
//mq消息结构
{
"messageId": "uuid",
"skuId": "123",
"userId": "456",
"bucketId": 78,
"preReduceAmount": 1 // 预扣数量
}
1
2
3
UPDATE stock SET available = available - 1
WHERE sku_id = 123 AND available >= 1;
-- 通过影响行数判断是否成功

异常处理:
DB扣减失败:通过messageId幂等重试,3次失败后触发Redis库存回补(INCRBY)。
消息堆积:动态扩容Consumer实例(参考您简历中的水平分库经验)。

4.一致性机制保障

定时对账:

1
2
3
-- 每小时检查Redis预扣总量与DB实际扣减量
SELECT SUM(pre_reduced) FROM mq_message WHERE status = 'SUCCESS'; -- Redis侧
SELECT (total_stock - available) FROM stock WHERE sku_id = 123; -- DB侧

异常补偿:
若Redis预扣总量 > DB实际扣减量:自动回补差额(如网络分区导致MQ消息丢失)。
若Redis预扣总量 < DB实际扣减量:触发人工审核(消息重复消费,可能发生超卖)。

接着探讨下超卖的根本原因

1.MQ消息重复消费

消费者处理消息后redis打标失败,导致消息被重复消费

解决办法,数据库一致性校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 数据库幂等检查(原子操作)
@Transactional
public void handleMessage(Message msg) {
// 尝试插入处理记录(唯一约束)
try {
jdbcTemplate.update(
"INSERT INTO mq_processed (id, status) VALUES (?, 'PROCESSING')",
msg.getId()
);
} catch (DuplicateKeyException e) {
return; // 已处理
}

// 业务处理
reduceDBStock(msg.getSkuId(), msg.getAmount());

// 更新状态
jdbcTemplate.update(
"UPDATE mq_processed SET status='SUCCESS' WHERE id=?",
msg.getId()
);
}

2.异步消息丢失

Redis扣减成功后,MQ消息未成功发送(如网络闪断)。此时Redis库存已减,但DB未扣减,导致后续请求继续扣减Redis(实际DB库存已不足)
解决办法,本地事务表+定时补偿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Redis扣减与消息发送原子化
@Transactional(rollbackFor = Exception.class)
public void preReduceStock(String skuId, int amount) {
// 1. Redis扣减
boolean success = redisLuaReduce(skuId, amount);
if (!success) throw new RuntimeException("库存不足");

// 2. 写入本地消息表
jdbcTemplate.update(
"INSERT INTO local_message (id, sku_id, amount, status) VALUES (?, ?, ?, 'UNSENT')",
UUID.randomUUID().toString(), skuId, amount
);
}

// 定时任务扫描本地消息表并发送到MQ
@Scheduled(fixedDelay = 5000)
public void compensateMessages() {
List<Message> messages = jdbcTemplate.query(
"SELECT * FROM local_message WHERE status='UNSENT'",
new MessageRowMapper()
);
messages.forEach(msg -> {
if (sendToMQ(msg)) {
jdbcTemplate.update(
"UPDATE local_message SET status='SENT' WHERE id=?",
msg.getId()
);
}
});
}

3.对账延迟及补偿失效

定时对账任务每小时运行一次,但在此期间若Redis预扣量 < DB实际量(如消息重复消费导致DB多扣),系统未能及时回补Redis库存,导致后续请求继续扣减已超卖的库存
解决方法,实时对账+动态限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在每次扣减请求时触发实时对账
public void preReduceStock(String skuId, int amount) {
// Redis预扣
boolean success = redisLuaReduce(skuId, amount);
if (!success) throw new RuntimeException("库存不足");

// 实时检查DB库存
int dbStock = getDBStock(skuId);
if (dbStock < 0) {
// 触发报警并回补Redis
redis.incrBy(skuId, amount);
throw new RuntimeException("库存超卖,已回补");
}
}

方案加固

1.链路追踪

在消息体中增加全链路追踪ID,记录Redis扣减、MQ发送、DB处理的各阶段状态。

1
2
3
4
5
6
7
8
9
10
{
"messageId": "msg_123",
"traceId": "trace_456",
"skuId": "789",
"amount": 1,
"steps": [
{"stage": "redis_pre_reduce", "time": "2025-02-19T20:00:00Z"},
{"stage": "mq_sent", "time": "2025-02-19T20:00:01Z"}
]
}

2.多级库存水位

3.快速熔断

当DB实际库存 < 0时,自动熔断,拒绝所有扣减请求。
当MQ堆积超过阈值(如10万条),触发降级,直接写数据库(借助Sentinel限流至1万QPS)

4.监控告警

Drift = |redis与扣减库存量 -(初始库存量-DB最终库存量)|
阈值:Drift > 0时触发告警,人工介入修复

总结:即使采用Redis+MQ方案,超卖风险仍存在于消息可靠性、幂等性、对账延迟等环节。通过本地事务表、实时对账、
多级库存水位等组合策略,可将超卖概率降至0.001%以下,满足绝大多数电商场景需求