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

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,建议配合看门狗续期或缩短临界区,只把库存扣减放进锁内。