欢迎光临
我们一直在努力

【架构实战】库存系统架构:扣减并发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)
未经允许不得转载:171主机测评 » 【架构实战】库存系统架构:扣减并发10万的背后
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址