一、秒杀库存扣减的噩梦

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万+ 最终 超大规模

血的教训:

库存系统是电商的心脏,设计不当会导致超卖或漏卖,直接影响收入和用户信任。


个人观点,仅供参考

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐