引子:一次价值百万的库存超卖事故

2024年双十一,凌晨00:05分,我的手机突然响起刺耳的告警声。

打开监控大盘,一行红色数字让我瞬间清醒:某爆款SKU超卖217件。这意味着我们已经卖出了比实际库存多217件商品,客户投诉、赔偿成本、品牌信任危机接踵而至。

这不是我第一次遇到超卖问题,但这次的损失格外惨重——该SKU是限量联名款,成本价就超过500元,217件的赔偿成本加上品牌损失,直接损失超过百万。

事后复盘,我们发现问题的根源:在分布式环境下,单机锁完全失效了。

10个服务实例同时处理来自Amazon、Shopify、天猫国际等多个渠道的订单,每个实例内部的synchronized锁只能保证单个JVM内的线程安全,但对于跨JVM的并发请求,库存扣减的原子性荡然无存。

这次事故后,我们用了整整一周时间,重构了整个库存中心的并发控制机制,将Redis分布式锁引入生产环境。三个月后,超卖率从5%降至0.1%,系统TPS从200提升到2000。

这篇文章,就是那次重构的完整技术总结。


问题本质:分布式环境下的并发控制

业务场景

我们的渠道共享库存中心需要服务10+电商平台,这些平台的订单会同时扣减同一个商品的库存:

时间   渠道         SKU-1001  操作      当前库存
00:01  Amazon      -1        扣减      100 → 99
00:01  Shopify     -2        扣减       99 → 97
00:01  天猫国际     -1        扣减       97 → 96
00:01  独立站       -3        扣减       96 → 93

在分布式部署下(假设5个服务实例),这些请求可能被不同的实例处理。如果没有正确的并发控制机制,就会出现经典的竞态条件(Race Condition)

错误方案的代价

我们尝试过的失败方案:

❌ 方案1:数据库行锁

SELECT * FROM inventory WHERE sku = 'SKU-1001' FOR UPDATE;
UPDATE inventory SET quantity = quantity - 1 WHERE sku = 'SKU-1001';

问题

  • 性能极差:TPS<50,远低于业务需求
  • 锁等待严重:高并发时大量请求超时
  • 数据库压力大:成为系统瓶颈

❌ 方案2:乐观锁(版本号)

// 基于版本号的乐观锁
UPDATE inventory
SET quantity = quantity - 1, version = version + 1
WHERE sku = 'SKU-1001' AND version = 10;

问题

  • 高并发失败率高达70%
  • 重试风暴:大量失败请求重试,雪上加霜
  • 用户体验差:下单失败率高

❌ 方案3:单机锁(synchronized)

public synchronized boolean reduceStock(String sku, int quantity) {
    // 只能保证单个JVM内的线程安全
}

问题

  • 分布式环境完全失效
  • 导致我们那次百万级的超卖事故

技术方案:Redis分布式锁实战

方案选型

经过技术选型,我们最终选择了Redisson作为分布式锁的实现框架:

方案优点缺点是否选用
Redis SETNX + DEL简单直接需要手动处理过期、重入等问题
Redisson功能完善、久经考验引入额外依赖
Zookeeper强一致性性能较差、运维复杂
数据库无需额外组件性能最差

核心代码实现

1. Maven依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>

2. Redisson配置

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://localhost:6379")
              .setConnectionPoolSize(64)
              .setConnectionMinimumIdleSize(10)
              .setPassword("your_password");

        return Redisson.create(config);
    }
}

3. 库存服务实现

@Service
@Slf4j
public class InventoryService {

    @Autowired
    private RedissonClient redissonClient;

    @Autowired
    private InventoryRepository inventoryRepository;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 扣减库存(分布式锁版本)
     * @param sku 商品SKU
     * @param quantity 扣减数量
     * @return 是否成功
     */
    public boolean reduceStock(String sku, int quantity) {
        // 锁的粒度:每个SKU一把锁
        String lockKey = "inventory:lock:" + sku;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 尝试加锁:最多等待10秒,锁30秒后自动释放
            boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);

            if (!acquired) {
                log.warn("获取库存锁失败,SKU: {}", sku);
                return false;
            }

            // 业务逻辑:检查并扣减库存
            Inventory inventory = inventoryRepository.findBySku(sku);

            if (inventory == null) {
                log.error("商品不存在,SKU: {}", sku);
                return false;
            }

            if (inventory.getQuantity() < quantity) {
                log.warn("库存不足,SKU: {}, 当前库存: {}, 需要: {}",
                    sku, inventory.getQuantity(), quantity);
                return false;
            }

            // 扣减库存
            inventory.setQuantity(inventory.getQuantity() - quantity);
            inventoryRepository.save(inventory);

            // 发送库存变更消息(异步通知其他渠道)
            InventoryChangeEvent event = new InventoryChangeEvent(
                sku, -quantity, inventory.getQuantity());
            rocketMQTemplate.convertAndSend("inventory-change", event);

            log.info("库存扣减成功,SKU: {}, 扣减: {}, 剩余: {}",
                sku, quantity, inventory.getQuantity());
            return true;

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("库存扣减被中断,SKU: {}", sku, e);
            return false;
        } finally {
            // 释放锁(必须在finally块中)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

4. 关键细节解析

① 锁的粒度设计

String lockKey = "inventory:lock:" + sku;  // ✅ 每个SKU一把锁(细粒度)
String lockKey = "inventory:lock:global";  // ❌ 全局锁(粗粒度,性能差)

细粒度锁的优势:

  • SKU-1001和SKU-1002可以并发扣减
  • 提升系统吞吐量
  • 减少锁竞争

② 超时时间设置

lock.tryLock(10, 30, TimeUnit.SECONDS);
//          ↑   ↑
//          |   锁的过期时间(防止死锁)
//          等待获取锁的最长时间

实践经验:

  • 等待时间 = 业务正常响应时间 × 2
  • 过期时间 = 业务最长执行时间 × 2 + 缓冲时间(5s)

③ 异常处理

finally {
    if (lock.isHeldByCurrentThread()) {  // 必须检查锁是否由当前线程持有
        lock.unlock();
    }
}

为什么要检查isHeldByCurrentThread()

  • 避免释放别人的锁
  • 避免锁过期后误解锁

生产优化:从理论到实战

性能优化

1. 批量扣减优化

当多个订单包含同一个SKU时,可以合并扣减:

public Map<String, Boolean> batchReduceStock(Map<String, Integer> skuQuantityMap) {
    Map<String, Boolean> results = new ConcurrentHashMap<>();

    // 并发处理多个SKU
    skuQuantityMap.entrySet().parallelStream().forEach(entry -> {
        String sku = entry.getKey();
        Integer quantity = entry.getValue();
        results.put(sku, reduceStock(sku, quantity));
    });

    return results;
}

效果:TPS提升30%

2. 异步化非核心操作

// 发送消息改为异步
CompletableFuture.runAsync(() -> {
    rocketMQTemplate.convertAndSend("inventory-change", event);
});

效果:平均响应时间从150ms降至80ms

高可用保障

1. Redis哨兵模式

spring:
  redis:
    sentinel:
      master: mymaster
      nodes:
        - 192.168.1.10:26379
        - 192.168.1.11:26379
        - 192.168.1.12:26379

效果:主从自动切换,可用性99.9%

2. 降级方案

@HystrixCommand(fallbackMethod = "reduceStockFallback")
public boolean reduceStock(String sku, int quantity) {
    // 正常流程
}

public boolean reduceStockFallback(String sku, int quantity) {
    // 降级:记录到数据库,异步处理
    log.warn("库存扣减降级,SKU: {}", sku);
    asyncInventoryService.recordPendingReduce(sku, quantity);
    return true;  // 先让订单通过,后台异步补偿
}

边界案例处理

1. 库存预占(下单未支付)

public boolean reserveStock(String sku, int quantity, String orderId) {
    String lockKey = "inventory:lock:" + sku;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        lock.lock(30, TimeUnit.SECONDS);

        // 扣减可用库存
        inventory.setAvailableQuantity(inventory.getAvailableQuantity() - quantity);
        // 增加预占库存
        inventory.setReservedQuantity(inventory.getReservedQuantity() + quantity);

        // 设置30分钟后自动释放
        redisTemplate.opsForValue().set(
            "inventory:reserve:" + orderId,
            quantity,
            30,
            TimeUnit.MINUTES
        );

        return true;
    } finally {
        lock.unlock();
    }
}

2. 库存释放(订单取消)

@Scheduled(fixedRate = 60000)  // 每分钟执行一次
public void releaseExpiredReservations() {
    List<String> expiredOrders = findExpiredOrders();

    for (String orderId : expiredOrders) {
        String sku = getSkuByOrder(orderId);
        Integer quantity = redisTemplate.opsForValue().get("inventory:reserve:" + orderId);

        if (quantity != null) {
            // 释放库存
            releaseStock(sku, quantity, orderId);
        }
    }
}

效果数据与经验总结

上线效果

指标优化前优化后提升
超卖率5%0.1%降低50倍
系统TPS2002000提升10倍
平均响应时间300ms80ms降低73%
系统可用性98.5%99.9%提升1.4%

核心经验

✅ DO - 应该这样做

  1. 锁粒度要细:按SKU加锁而非全局锁
  2. 过期时间要合理:业务耗时 × 2 + 缓冲时间
  3. 失败要重试:结合消息队列异步补偿
  4. 监控要完善:实时告警 + 日志追踪
  5. 降级要准备:高峰期保护核心链路

❌ DON’T - 不要这样做

  1. 不要使用全局锁:严重影响并发性能
  2. 不要无限等待:必须设置超时时间
  3. 不要忘记解锁:使用try-finally保证释放
  4. 不要过度依赖锁:能异步的尽量异步
  5. 不要忽略边界情况:预占、释放、超时都要考虑

参考资料


系列文章预告

本文是《供应链系统实战》系列的第一篇,后续文章:

  • 第2篇:跨境电商关务系统 - 三单对碰的技术实现
  • 第3篇:WMS仓储系统 - 库位分配算法的演进之路
  • 第4篇:OMS订单系统 - 智能拆单规则引擎设计
  • 第5篇:供应链数据中台 - Flink实时计算架构实战

敬请期待!


如果这篇文章对你有帮助,欢迎在评论区分享你的经验或提出问题。