下单请求处理流程:商品维度分布式锁与 Redis 预扣库存

一、整体流程概览

下单是电商系统中最核心也最容易出问题的链路之一。一个典型的下单请求需要依次完成参数校验、并发控制、订单号生成和库存预扣,其中商品维度的分布式锁是防止超卖的关键环节。整体流程如下:

flowchart TD A[接收下单请求] --> B[校验参数] B -->|校验失败| B1[返回参数错误] B -->|校验通过| C[争抢分布式锁 商品维度] C -->|加锁失败| C1[快速失败或重试] C -->|加锁成功| D[生成唯一订单号] D --> E[Redis 预扣库存] E -->|库存不足| E1[回滚并释放锁] E -->|预扣成功| F[落库创建订单] F --> G[释放分布式锁] E1 --> G G --> H[返回下单结果]

二、商品维度锁的加锁参数

商品维度锁的核心思想是:同一商品在同一时刻只允许一个下单请求进入库存扣减逻辑。因此锁的 key 必须由商品标识构成,而不是用户或订单维度。

2.1 锁 Key 的组成

参数 说明 示例
业务前缀 区分不同业务场景的锁,避免 key 冲突 order:lock:sku
商品 ID(skuId) 锁的粒度主体,通常用 SKU 而非 SPU 10086
可选:仓库/门店 ID 多仓场景下库存按仓隔离,需加入仓维度 wh_001

最终 key 形如:

text 复制代码
order:lock:sku:{skuId}
order:lock:sku:{warehouseId}:{skuId}

2.2 锁 Value 与过期时间

  • value:使用 UUID 或「请求 ID + 线程 ID」作为唯一标识,保证释放锁时只删除自己加的锁,避免误删他人锁。
  • 过期时间(TTL):必须设置,防止业务异常导致死锁,一般取业务最大耗时的 2~3 倍,例如 3~5 秒。
  • 加锁方式 :使用 SET key value NX PX ttl 原子命令,禁止先 SETNX 再 EXPIRE 的两步操作。
  • 释放方式:通过 Lua 脚本比对 value 后再删除,保证原子性。

注意:如果一次下单包含多个商品,需要按 skuId 排序后依次加锁,避免多个请求交叉加锁产生死锁。

三、伪代码实现

3.1 主流程

java 复制代码
public OrderResult createOrder(CreateOrderRequest request) {
    // 1. 校验参数
    validate(request);

    // 2. 商品维度锁 key,多商品按 skuId 升序排序,避免死锁
    List<Long> skuIds = request.getItems().stream()
            .map(OrderItem::getSkuId)
            .distinct()
            .sorted()
            .collect(Collectors.toList());

    List<String> lockKeys = skuIds.stream()
            .map(skuId -> "order:lock:sku:" + skuId)
            .collect(Collectors.toList());

    List<String> lockValues = new ArrayList<>();
    try {
        // 3. 依次争抢分布式锁
        for (String key : lockKeys) {
            String value = UUID.randomUUID().toString();
            boolean locked = redisLock.tryLock(key, value, 5000L);
            if (!locked) {
                throw new BizException("商品正在被抢购,请稍后重试");
            }
            lockValues.add(value);
        }

        // 4. 生成唯一订单号
        String orderNo = orderNoGenerator.generate();

        // 5. Redis 预扣库存
        for (OrderItem item : request.getItems()) {
            boolean deducted = stockService.preDeduct(item.getSkuId(), item.getQuantity());
            if (!deducted) {
                throw new BizException("库存不足:" + item.getSkuId());
            }
        }

        // 6. 落库创建订单
        return orderService.save(orderNo, request);
    } finally {
        // 7. 逆序释放锁
        for (int i = lockKeys.size() - 1; i >= 0; i--) {
            redisLock.unlock(lockKeys.get(i), lockValues.get(i));
        }
    }
}

3.2 加锁与释放锁

java 复制代码
public boolean tryLock(String key, String value, long ttlMillis) {
    // SET key value NX PX ttl,原子加锁
    String result = jedis.set(key, value, "NX", "PX", ttlMillis);
    return "OK".equals(result);
}

private static final String UNLOCK_SCRIPT =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('del', KEYS[1]) " +
        "else return 0 end";

public void unlock(String key, String value) {
    // Lua 脚本保证「比对 + 删除」原子性
    jedis.eval(UNLOCK_SCRIPT, Collections.singletonList(key),
            Collections.singletonList(value));
}

3.3 Redis 预扣库存

java 复制代码
private static final String DEDUCT_SCRIPT =
        "local stock = tonumber(redis.call('get', KEYS[1])) " +
        "if stock == nil then return -1 end " +
        "if stock < tonumber(ARGV[1]) then return 0 end " +
        "redis.call('decrby', KEYS[1], ARGV[1]) " +
        "return 1";

public boolean preDeduct(Long skuId, Integer quantity) {
    String stockKey = "stock:sku:" + skuId;
    Long result = (Long) jedis.eval(DEDUCT_SCRIPT,
            Collections.singletonList(stockKey),
            Collections.singletonList(String.valueOf(quantity)));
    return result != null && result == 1L;
}

四、关键设计要点

  • 锁粒度选择 SKU:库存通常按 SKU 管理,用 SKU 加锁粒度最细,并发度最高;若按 SPU 加锁会误伤同款不同规格商品。
  • 锁顺序一致:多商品下单必须排序加锁,否则 A 请求锁 1 再锁 2、B 请求锁 2 再锁 1 会互相等待。
  • 预扣库存用 Lua:判断库存与扣减必须原子执行,避免「查完还有库存、扣的时候已被扣光」的并发问题。
  • 失败补偿:预扣成功后若落库失败,需要回补 Redis 库存,并记录补偿日志或走消息队列异步对账。
  • 锁超时兜底:业务耗时可能超过 TTL,建议配合看门狗续期或缩短临界区,只把库存扣减放进锁内。
相关推荐
我不会起名字3221 小时前
Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器
redis·go·慢查询·排查·slowlog
挖掘狂人1 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化
codigger2 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化·程序员
我不会起名字3222 小时前
Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操
redis·消息队列·go·stream·pub/sub
你看我名字2 小时前
Redis 高可用、持久化与故障切换:从缓存组件到云上基础设施
数据库·redis·缓存
努力努力再努力wz2 小时前
【Lua 入门系列】模块与包(一):从多文件组织到require 运行时加载机制、模块缓存、预加载搜索与 Lua 文件搜索机制
开发语言·junit·lua
罗四强2 小时前
第10篇:打包上架:签名、打包 HAP、发布到华为应用市场
分布式·操作系统·harmonyos·arkts·鸿蒙系统
秋饼2 小时前
Redis 全面解析:从数据结构到主从/哨兵/集群、分布式锁与面试高频考点,代码到原理一次讲透
数据库·redis·分布式·面试·后端开发