【架构实战】库存系统架构:扣减并发10万的背后
·
一、秒杀库存扣减的噩梦
2021年618,我们做了个秒杀活动。
预期流量: QPS 1万
实际流量: QPS 10万
结果:
- 库存超卖:卖了1000件,实际库存只有500件
- 订单失败:大量用户下单成功但无法履约
- 用户投诉:微博热搜,品牌受损
从那以后,我们重新设计了库存系统,现在稳定支持10万并发扣减。
二、库存系统核心问题
2.1 库存扣减的三大挑战
┌─────────────────────────────────────────────────────────────────┐
│ 库存扣减的三大挑战 │
│ │
│ 1. 并发控制 │
│ └── 多用户同时扣减同一商品库存,需要保证原子性 │
│ │
│ 2. 数据一致性 │
│ └── 库存扣减和订单创建需要保证一致性 │
│ │
│ 3. 性能要求 │
│ └── 秒杀场景QPS可达10万+,需要极致性能 │
│ │
└──────────────────────────────────────────────────────────────────┘
2.2 库存扣减流程
正常扣减流程:
1. 查询库存:SELECT stock FROM product WHERE id = 1
2. 判断库存:if stock >= quantity
3. 扣减库存:UPDATE product SET stock = stock - quantity WHERE id = 1
问题:
- 步骤1和步骤2之间,库存可能被其他请求扣减
- 导致超卖
三、库存扣减方案演进
3.1 方案一:数据库悲观锁
-- 悲观锁扣减库存
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 判断库存是否足够
-- 如果足够,执行扣减
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
优点:
- 实现简单
- 强一致性
缺点:
- 性能差:QPS只有几百
- 锁等待:并发高时大量请求阻塞
- 死锁风险:多个商品交叉扣减可能死锁
3.2 方案二:数据库乐观锁
-- 乐观锁扣减库存
UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
-- 检查影响行数
-- 如果为0,说明库存不足或版本号不匹配
优点:
- 无锁等待
- 性能比悲观锁好
缺点:
- 高并发时大量更新失败
- 失败需要重试,增加数据库压力
- QPS仍然只有几千
3.3 方案三:Redis原子扣减
/**
* Redis库存扣减
*/
@Service
public class RedisInventoryService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 扣减库存(原子操作)
*/
public boolean deduct(Long productId, int quantity) {
String key = "inventory:" + productId;
// Lua脚本保证原子性
String script = """
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
stock = tonumber(stock)
if stock < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
""";
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(key),
String.valueOf(quantity)
);
return result != null && result == 1;
}
}
优点:
- 性能极高:单机QPS可达10万+
- 无锁竞争
- 原子操作
缺点:
- 需要保证Redis和数据库数据一致性
- Redis宕机风险
3.4 方案四:Redis + 异步同步
┌─────────────────────────────────────────────────────────────────┐
│ Redis + 异步同步架构 │
│ │
│ 用户请求 → Redis扣减库存 → MQ异步消息 → MySQL扣减库存 │
│ ↓ │
│ 返回成功 │
│ │
│ 流程: │
│ 1. Redis扣减库存(快速响应) │
│ 2. 发送MQ消息(异步同步) │
│ 3. MySQL扣减库存(数据持久化) │
│ │
│ 优点: │
│ - 性能极高(Redis单机10万QPS) │
│ - 数据最终一致 │
│ │
│ 缺点: │
│ - Redis宕机可能丢失数据 │
│ - 需要补偿机制 │
│ │
└──────────────────────────────────────────────────────────────────┘
四、高并发库存扣减实战
4.1 Redis库存初始化
/**
* 库存预热服务
*/
@Service
@Slf4j
public class InventoryWarmUpService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
/**
* 活动开始前预热库存
*/
public void warmUp(Long activityId) {
// 1. 查询活动商品库存
List<Product> products = productMapper.selectByActivityId(activityId);
// 2. 批量写入Redis
for (Product product : products) {
String key = "inventory:" + product.getId();
redisTemplate.opsForValue().set(key, product.getStock());
log.info("预热库存: productId={}, stock={}", product.getId(), product.getStock());
}
// 3. 设置过期时间(活动结束后自动清理)
redisTemplate.expire("inventory:*", Duration.ofHours(24));
}
}
4.2 库存扣减核心代码
/**
* 库存扣减服务
*/
@Service
@Slf4j
public class InventoryDeductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 扣减库存
*/
public DeductResult deduct(Long productId, Long userId, int quantity) {
// 1. Redis原子扣减
String key = "inventory:" + productId;
String script = """
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
stock = tonumber(stock)
if stock < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
""";
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(key),
String.valueOf(quantity)
);
if (result == null || result != 1) {
return DeductResult.fail("库存不足");
}
// 2. 发送异步消息同步到MySQL
InventoryDeductEvent event = new InventoryDeductEvent();
event.setProductId(productId);
event.setUserId(userId);
event.setQuantity(quantity);
event.setTimestamp(System.currentTimeMillis());
rocketMQTemplate.asyncSend("inventory-deduct-topic", event, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("库存扣减消息发送成功: {}", event);
}
@Override
public void onException(Throwable e) {
log.error("库存扣减消息发送失败,需要补偿: {}", event, e);
// 发送失败,记录到补偿表
compensateService.record(event);
}
});
return DeductResult.success();
}
}
4.3 异步同步消费者
/**
* 库存扣减消息消费者
*/
@Component
@RocketMQMessageListener(topic = "inventory-deduct-topic", consumerGroup = "inventory-group")
@Slf4j
public class InventoryDeductConsumer implements RocketMQListener<InventoryDeductEvent> {
@Autowired
private InventoryMapper inventoryMapper;
@Override
public void onMessage(InventoryDeductEvent event) {
try {
// 1. 扣减MySQL库存(乐观锁)
int rows = inventoryMapper.deductStock(
event.getProductId(),
event.getQuantity()
);
if (rows == 0) {
log.error("MySQL库存扣减失败: productId={}, quantity={}",
event.getProductId(), event.getQuantity());
// 记录异常,人工处理
alertService.sendAlert("库存扣减异常", event.toString());
}
// 2. 记录扣减日志
InventoryLog log = new InventoryLog();
log.setProductId(event.getProductId());
log.setUserId(event.getUserId());
log.setQuantity(event.getQuantity());
log.setType(InventoryLogType.DEDUCT);
inventoryMapper.insertLog(log);
log.info("库存扣减成功: {}", event);
} catch (Exception e) {
log.error("库存扣减消费失败: {}", event, e);
throw e; // 抛出异常,让MQ重试
}
}
}
五、库存预扣方案
5.1 预扣库存流程
┌─────────────────────────────────────────────────────────────────┐
│ 库存预扣流程 │
│ │
│ 下单请求 → 预扣库存 → 创建订单 → 支付 │
│ ↓ ↓ │
│ 占用库存 确认扣减 │
│ 或释放库存 │
│ │
│ 状态: │
│ - 可用库存:用户可以购买的数量 │
│ - 预扣库存:用户已下单但未支付的数量 │
│ - 实际库存 = 可用库存 - 预扣库存 │
│ │
└──────────────────────────────────────────────────────────────────┘
5.2 预扣库存实现
/**
* 库存预扣服务
*/
@Service
@Slf4j
public class InventoryPreDeductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 预扣库存
*/
public PreDeductResult preDeduct(Long productId, Long orderId, int quantity) {
String stockKey = "inventory:" + productId;
String preDeductKey = "pre_deduct:" + productId + ":" + orderId;
// Lua脚本:原子性预扣
String script = """
-- 检查是否已预扣(防止重复)
if redis.call('EXISTS', KEYS[2]) == 1 then
return -2
end
-- 获取可用库存
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
-- 检查库存是否足够
stock = tonumber(stock)
if stock < tonumber(ARGV[1]) then
return 0
end
-- 扣减可用库存
redis.call('DECRBY', KEYS[1], ARGV[1])
-- 记录预扣信息
redis.call('SET', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], ARGV[2])
return 1
""";
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(stockKey, preDeductKey),
String.valueOf(quantity),
String.valueOf(30 * 60) // 预扣30分钟
);
switch (result.intValue()) {
case 1:
return PreDeductResult.success();
case 0:
return PreDeductResult.fail("库存不足");
case -1:
return PreDeductResult.fail("商品不存在");
case -2:
return PreDeductResult.fail("重复预扣");
default:
return PreDeductResult.fail("未知错误");
}
}
/**
* 确认扣减(支付成功)
*/
public void confirmDeduct(Long productId, Long orderId) {
String preDeductKey = "pre_deduct:" + productId + ":" + orderId;
redisTemplate.delete(preDeductKey);
log.info("确认扣减: productId={}, orderId={}", productId, orderId);
}
/**
* 释放库存(取消订单或超时)
*/
public void releaseInventory(Long productId, Long orderId) {
String stockKey = "inventory:" + productId;
String preDeductKey = "pre_deduct:" + productId + ":" + orderId;
// Lua脚本:原子性释放
String script = """
local quantity = redis.call('GET', KEYS[2])
if not quantity then
return 0
end
redis.call('INCRBY', KEYS[1], tonumber(quantity))
redis.call('DEL', KEYS[2])
return 1
""";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(stockKey, preDeductKey)
);
log.info("释放库存: productId={}, orderId={}", productId, orderId);
}
}
六、踩坑实录
坑1:Redis和MySQL数据不一致
问题:Redis扣减成功,但MySQL同步失败,导致数据不一致。
踩坑场景:
Redis库存扣减后,MQ消息发送失败或消费者处理失败,导致MySQL库存没有扣减。
解决方案:
/**
* 库存同步补偿服务
*/
@Service
public class InventoryCompensateService {
@Scheduled(fixedDelay = 60000) // 每分钟检查一次
public void checkConsistency() {
// 1. 扫描需要补偿的记录
List<InventoryDeductEvent> events = compensateMapper.selectPending();
for (InventoryDeductEvent event : events) {
try {
// 2. 重试同步
inventoryMapper.deductStock(event.getProductId(), event.getQuantity());
compensateMapper.markSuccess(event.getId());
} catch (Exception e) {
log.error("补偿失败: {}", event, e);
// 3. 多次失败后告警
if (event.getRetryCount() >= 3) {
alertService.sendAlert("库存同步异常", event.toString());
}
}
}
}
}
坑2:热点商品库存扣减
问题:爆款商品库存扣减请求集中在单个Redis节点。
踩坑场景:
某爆款商品秒杀,所有扣减请求都打到同一个Redis节点,导致该节点压力过大。
解决方案:
# 热点商品库存分片:
1. 将热点商品库存分散到多个Redis分片
- productId_0, productId_1, productId_2, productId_3
- 每个分片承载1/4流量
2. 扣减时随机选择分片
- int shard = random.nextInt(4);
- key = "inventory:" + productId + "_" + shard;
3. 查询时聚合各分片库存
- 总库存 = sum(各分片库存)
坑3:库存扣减超时
问题:网络抖动导致Redis扣减超时,但实际扣减成功。
踩坑场景:
用户请求扣减库存,Redis执行成功但返回超时,用户重试导致重复扣减。
解决方案:
/**
* 扣减请求幂等性处理
*/
public DeductResult deductWithIdempotent(Long productId, Long userId, String requestId, int quantity) {
// 1. 检查是否已处理
String processedKey = "deduct_processed:" + requestId;
Boolean processed = redisTemplate.opsForValue().setIfAbsent(
processedKey, "1", Duration.ofMinutes(30));
if (!processed) {
return DeductResult.success(); // 已处理,返回成功
}
// 2. 执行扣减
return deduct(productId, userId, quantity);
}
坑4:库存预扣超时未释放
问题:用户预扣库存后未支付,库存一直被占用。
解决方案:
/**
* 预扣库存超时释放
*/
@Scheduled(fixedDelay = 60000) // 每分钟扫描一次
public void releaseExpiredPreDeduct() {
// 扫描所有预扣key
Set<String> keys = redisTemplate.keys("pre_deduct:*");
for (String key : keys) {
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
// TTL小于5分钟的,准备释放
if (ttl != null && ttl < 300) {
// 解析productId和orderId
String[] parts = key.split(":");
Long productId = Long.parseLong(parts[1]);
Long orderId = Long.parseLong(parts[2]);
// 释放库存
releaseInventory(productId, orderId);
// 取消订单
orderService.cancelOrder(orderId, "库存预扣超时");
}
}
}
坑5:大促期间Redis宕机
问题:Redis宕机导致库存扣减全部失败。
解决方案:
# Redis高可用方案:
1. Redis Cluster(推荐)
- 3主3从,自动故障转移
- 数据分片,避免单点瓶颈
2. 本地缓存降级
- Redis不可用时,切换到本地缓存
- 定时同步MySQL库存
3. 数据库兜底
- Redis全部不可用时,降级到MySQL扣减
- 性能下降但保证可用
七、最佳实践
7.1 库存扣减检查清单
库存扣减检查清单:
□ 库存预热
□ 活动前加载库存到Redis
□ 校验Redis和MySQL数据一致
□ 扣减流程
□ 使用Lua脚本保证原子性
□ 设置请求幂等性
□ 异步同步MySQL
□ 异常处理
□ Redis扣减失败有兜底
□ MySQL同步失败有补偿
□ 超时未支付自动释放
□ 监控告警
□ 库存余量告警
□ 扣减失败率告警
□ Redis健康监控
7.2 库存表设计
-- 商品库存表
CREATE TABLE product_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL COMMENT '商品ID',
total_stock INT NOT NULL COMMENT '总库存',
available_stock INT NOT NULL COMMENT '可用库存',
pre_deduct_stock INT DEFAULT 0 COMMENT '预扣库存',
sold_count INT DEFAULT 0 COMMENT '已售数量',
version INT DEFAULT 0 COMMENT '版本号(乐观锁)',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_product_id (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';
-- 库存变更日志表
CREATE TABLE inventory_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL,
order_id BIGINT,
quantity INT NOT NULL COMMENT '变更数量(正数增加,负数减少)',
type TINYINT NOT NULL COMMENT '类型:1预扣 2确认 3释放 4回滚',
remark VARCHAR(255),
create_time DATETIME NOT NULL,
INDEX idx_product_id (product_id),
INDEX idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存变更日志表';
八、总结
库存扣减核心要点:
| 方案 | QPS | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 500 | 强 | 低 | 小规模 |
| 数据库乐观锁 | 5000 | 强 | 低 | 中等规模 |
| Redis同步 | 10万+ | 弱 | 中 | 大规模 |
| Redis异步 | 10万+ | 最终 | 高 | 超大规模 |
血的教训:
库存系统是电商的心脏,设计不当会导致超卖或漏卖,直接影响收入和用户信任。
个人观点,仅供参考
更多推荐



所有评论(0)