一、一次库存超卖让我损失了10万
2019年双十一,我负责的订单系统出了一个大Bug:库存超卖了。
当时库存只剩100件,但一下卖了300件。供应商那边打电话过来问责,财务也算不出来到底多发了多少货。
后来复盘原因:并发情况下,库存扣减的SQL没有加锁,导致重复扣减。
那一次,我花了3天时间才把数据对清楚。从此我对订单系统充满了敬畏。
二、订单系统核心设计
2.1 订单系统架构
┌─────────────────────────────────────────────────────────────────┐
│ 订单系统架构 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 用户端 │ │ 商家端 │ │ 运营端 │ │
│ │ Web/APP │ │ Web后台 │ │ 管理后台 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────────┼─────────────────────┘ │
│ ↓ │
│ ┌──────────────────┐ │
│ │ API Gateway │ │
│ └────────┬─────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 订单服务 ││
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ││
│ │ │ 订单创建 │ │ 订单支付 │ │ 订单发货 │ ││
│ │ └────────────┘ └────────────┘ └────────────┘ ││
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ││
│ │ │ 订单完成 │ │ 订单取消 │ │ 订单售后 │ ││
│ │ └────────────┘ └────────────┘ └────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ 库存服务 │ │ 支付服务 │ │ 用户服务 │ │ 消息队列 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
2.2 订单核心模型
java
/**
* 订单主表
*/
@Entity
@Table(name = "orders")
@Data
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 订单号(唯一)
*/
@Column(unique = true, nullable = false)
private String orderNo;
/**
* 用户ID
*/
private Long userId;
/**
* 订单状态
* PENDING-待支付, PAID-已支付, SHIPPED-已发货,
* CONFIRMED-已确认, COMPLETED-已完成, CANCELLED-已取消
*/
@Enumerated(EnumType.STRING)
private OrderStatus status;
/**
* 订单总金额(分)
*/
private Integer totalAmount;
/**
* 实付金额
*/
private Integer payAmount;
/**
* 运费
*/
private Integer freight;
/**
* 优惠金额
*/
private Integer discountAmount;
/**
* 支付方式
*/
@Enumerated(EnumType.STRING)
private PayType payType;
/**
* 支付时间
*/
private LocalDateTime payTime;
/**
* 发货时间
*/
private LocalDateTime shipTime;
/**
* 完成时间
*/
private LocalDateTime completeTime;
/**
* 收货地址ID
*/
private Long addressId;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
/**
* 订单商品表
*/
@Entity
@Table(name = "order_items")
@Data
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 订单ID
*/
private Long orderId;
/**
* 订单号
*/
private String orderNo;
/**
* 商品ID
*/
private Long productId;
/**
* 商品名称
*/
private String productName;
/**
* 商品图片
*/
private String productImage;
/**
* SKU ID
*/
private Long skuId;
/**
* SKU规格
*/
private String skuSpec;
/**
* 单价(分)
*/
private Integer price;
/**
* 购买数量
*/
private Integer quantity;
/**
* 小计金额
*/
private Integer totalAmount;
}
三、订单状态机设计
3.1 状态流转图
┌──────────┐
│ 待支付 │
│ PENDING │
└────┬─────┘
│ 支付成功
↓
┌───────────────┴───────────────┐
│ │
┌────▼─────┐ ┌────▼─────┐
│ 已支付 │ │ 已取消 │
│ PAID │ │CANCELLED│
└────┬─────┘ └─────────┘
│ 发货
↓
┌────▼─────┐
│ 已发货 │
│ SHIPPED │
└────┬─────┘
│ 确认收货
↓
┌────▼─────┐
│ 已确认 │
│CONFIRMED │
└────┬─────┘
│ 完成(系统/用户)
↓
┌────▼─────┐
│ 已完成 │
│COMPLETED │
└──────────┘
3.2 状态机实现
java
/**
* 订单状态机
*/
@Service
@Slf4j
public class OrderStateMachine {
/**
* 状态流转规则
*/
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
OrderStatus.PENDING, Set.of(OrderStatus.PAID, OrderStatus.CANCELLED),
OrderStatus.PAID, Set.of(OrderStatus.SHIPPED, OrderStatus.CANCELLED),
OrderStatus.SHIPPED, Set.of(OrderStatus.CONFIRMED),
OrderStatus.CONFIRMED, Set.of(OrderStatus.COMPLETED),
OrderStatus.COMPLETED, Set.of(), // 终态,不可再变
OrderStatus.CANCELLED, Set.of() // 终态,不可再变
);
/**
* 检查状态是否可以流转
*/
public boolean canTransition(OrderStatus current, OrderStatus target) {
Set<OrderStatus> allowed = TRANSITIONS.get(current);
return allowed != null && allowed.contains(target);
}
/**
* 执行状态流转
*/
@Transactional
public void transition(Order order, OrderStatus targetStatus) {
OrderStatus currentStatus = order.getStatus();
// 检查是否可以流转
if (!canTransition(currentStatus, targetStatus)) {
throw new BusinessException(
String.format("订单状态不允许从 %s 流转到 %s", currentStatus, targetStatus)
);
}
// 执行流转前的钩子
beforeTransition(order, currentStatus, targetStatus);
// 更新状态
order.setStatus(targetStatus);
order.setUpdateTime(LocalDateTime.now());
// 记录状态变更日志
saveStatusLog(order, currentStatus, targetStatus);
// 执行流转后的钩子
afterTransition(order, currentStatus, targetStatus);
}
private void beforeTransition(Order order, OrderStatus from, OrderStatus to) {
// 支付前:检查库存
if (to == OrderStatus.PAID) {
checkInventory(order);
}
// 取消前:检查是否可取消
if (to == OrderStatus.CANCELLED) {
checkCancelable(order);
}
}
private void afterTransition(Order order, OrderStatus from, OrderStatus to) {
// 支付成功:发送消息通知
if (to == OrderStatus.PAID) {
onOrderPaid(order);
}
// 发货:通知物流
if (to == OrderStatus.SHIPPED) {
onOrderShipped(order);
}
}
}
四、库存扣减方案
4.1 乐观锁方案
java
/**
* 库存服务
*/
@Service
@Slf4j
public class InventoryService {
/**
* 乐观锁扣减库存
*
* UPDATE inventory
* SET stock = stock - #{quantity}, version = version + 1
* WHERE sku_id = #{skuId} AND stock >= #{quantity} AND version = #{version}
*/
public boolean deductStockOptimistic(Long skuId, Integer quantity) {
int affected = inventoryMapper.deductWithOptimisticLock(skuId, quantity);
return affected > 0;
}
/**
* 库存扣减(带重试)
*/
public boolean deductStockWithRetry(Long skuId, Integer quantity, int maxRetries) {
for (int i = 0; i < maxRetries; i++) {
try {
if (deductStockOptimistic(skuId, quantity)) {
return true;
}
} catch (Exception e) {
log.warn("库存扣减重试 {}/{}: skuId={}, quantity={}",
i + 1, maxRetries, skuId, quantity);
}
// 短暂等待后重试
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return false;
}
}
/**
* Mapper
*/
@Mapper
public interface InventoryMapper {
@Update("UPDATE inventory SET stock = stock - #{quantity}, " +
"version = version + 1, update_time = NOW() " +
"WHERE sku_id = #{skuId} AND stock >= #{quantity} AND version = #{version}")
int deductWithOptimisticLock(@Param("skuId") Long skuId,
@Param("quantity") Integer quantity);
}
4.2 Redis分布式锁方案
java
@Service
@Slf4j
public class InventoryLockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "inventory:lock:";
private static final Duration LOCK_EXPIRE = Duration.ofSeconds(10);
/**
* 扣减库存(带分布式锁)
*/
public boolean deductStockWithLock(Long skuId, Integer quantity) {
String lockKey = LOCK_PREFIX + skuId;
// 获取锁
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", LOCK_EXPIRE);
if (!Boolean.TRUE.equals(acquired)) {
log.warn("获取库存锁失败: skuId={}", skuId);
return false;
}
try {
// 执行业务逻辑
return doDeductStock(skuId, quantity);
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
/**
* Lua脚本扣减库存(原子操作)
*
* KEYS[1]: 库存key
* ARGV[1]: 扣减数量
*/
private static final String DEDUCT_SCRIPT =
"if redis.call('exists', KEYS[1]) == 1 then " +
" local stock = tonumber(redis.call('get', KEYS[1])) " +
" if stock >= tonumber(ARGV[1]) then " +
" return redis.call('decrby', KEYS[1], ARGV[1]) " +
" end " +
" return -1 " +
"end " +
"return -2";
public Long deductStockWithLua(Long skuId, Integer quantity) {
String stockKey = "inventory:stock:" + skuId;
DefaultRedisScript<Long> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(stockKey),
String.valueOf(quantity));
return result;
}
}
五、订单幂等设计
5.1 幂等Token机制
java
/**
* 幂等服务
*/
@Service
@Slf4j
public class IdempotenceService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String IDEMPOTENCE_PREFIX = "idempotence:";
private static final Duration EXPIRE_TIME = Duration.ofHours(24);
/**
* 生成幂等Token
*/
public String generateToken(String bizType, String bizId) {
String token = UUID.randomUUID().toString().replace("-", "");
String key = IDEMPOTENCE_PREFIX + bizType + ":" + bizId + ":" + token;
redisTemplate.opsForValue().set(key, "1", EXPIRE_TIME);
return token;
}
/**
* 检查并使用幂等Token
*
* @return true: 首次执行, false: 重复请求
*/
public boolean checkAndUseToken(String bizType, String bizId, String token) {
String key = IDEMPOTENCE_PREFIX + bizType + ":" + bizId + ":" + token;
Boolean success = redisTemplate.delete(key);
return Boolean.TRUE.equals(success);
}
}
/**
* 订单Controller
*/
@RestController
@RequestMapping("/api/order")
@Slf4j
public class OrderController {
@Autowired
private IdempotenceService idempotenceService;
@Autowired
private OrderService orderService;
/**
* 获取幂等Token
*/
@GetMapping("/idempotence-token")
public Result<String> getIdempotenceToken() {
String token = idempotenceService.generateToken("create_order",
String.valueOf(System.currentTimeMillis()));
return Result.success(token);
}
/**
* 创建订单(幂等)
*/
@PostMapping("/create")
public Result<Order> createOrder(
@RequestBody @Validated CreateOrderRequest request,
@RequestHeader("X-Idempotence-Token") String token) {
// 验证幂等性
if (!idempotenceService.checkAndUseToken("create_order",
request.getUserId() + ":" + request.getIdempotenceKey(), token)) {
throw new BusinessException("重复请求,请勿重复提交");
}
Order order = orderService.createOrder(request);
return Result.success(order);
}
}
5.2 数据库唯一索引
java
/**
* 订单创建(防重)
*/
@Service
@Slf4j
public class OrderIdempotenceService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public Order createOrderIdempotent(CreateOrderRequest request) {
// 幂等Key:用户ID + 业务类型 + 业务ID
String idempotentKey = request.getUserId() + "_" + request.getBizType()
+ "_" + request.getBizId();
// 1. 检查是否已存在
Order existing = orderMapper.findByIdempotentKey(idempotentKey);
if (existing != null) {
log.info("订单已存在,返回已有订单: {}", existing.getOrderNo());
return existing;
}
// 2. 创建订单
Order order = buildOrder(request);
order.setIdempotentKey(idempotentKey);
orderMapper.insert(order);
return order;
}
}
sql
-- 订单表添加唯一索引
ALTER TABLE orders ADD UNIQUE INDEX idx_idempotent_key (idempotent_key);
六、实战:订单创建流程
6.1 完整流程
java
@Service
@Slf4j
public class OrderCreateService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryService inventoryService;
@Autowired
private PaymentService paymentService;
@Autowired
private MessageProducer messageProducer;
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
long startTime = System.currentTimeMillis();
try {
// 1. 验证请求参数
validateRequest(request);
// 2. 查询用户信息
User user = userService.getUserById(request.getUserId());
if (user == null) {
throw new BusinessException("用户不存在");
}
// 3. 校验商品和价格
List<OrderItem> items = validateAndBuildItems(request);
// 4. 计算订单金额
Integer totalAmount = calculateTotalAmount(items);
Integer payAmount = calculatePayAmount(totalAmount, request.getCouponIds());
// 5. 校验库存并扣减(乐观锁)
for (OrderItem item : items) {
boolean deducted = inventoryService.deductStockWithRetry(
item.getSkuId(), item.getQuantity(), 3);
if (!dedicated) {
throw new BusinessException("商品库存不足: " + item.getProductName());
}
}
// 6. 创建订单
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(request.getUserId());
order.setStatus(OrderStatus.PENDING);
order.setTotalAmount(totalAmount);
order.setPayAmount(payAmount);
order.setFreight(calculateFreight(totalAmount));
order.setDiscountAmount(totalAmount - payAmount);
order.setAddressId(request.getAddressId());
order.setRemark(request.getRemark());
order.setCreateTime(LocalDateTime.now());
order.setUpdateTime(LocalDateTime.now());
orderMapper.insert(order);
// 7. 保存订单商品
for (OrderItem item : items) {
item.setOrderId(order.getId());
item.setOrderNo(order.getOrderNo());
orderItemMapper.insert(item);
}
// 8. 发送订单创建消息(异步)
messageProducer.send("order-created", order);
log.info("订单创建成功: orderNo={}, userId={}, duration={}ms",
order.getOrderNo(), order.getUserId(),
System.currentTimeMillis() - startTime);
return order;
} catch (Exception e) {
log.error("订单创建失败: userId={}", request.getUserId(), e);
throw e;
}
}
}
七、踩坑实录
坑1:库存超卖
并发情况下,多个请求同时读取库存余额,都认为还有库存,导致超卖。
解决:使用乐观锁或分布式锁,确保原子性扣减。
坑2:订单重复创建
用户快速点击两次创建订单按钮,产生重复订单。
解决:前端防重 + 后端幂等Token + 数据库唯一索引。
坑3:状态机混乱
手动修改数据库状态后,系统逻辑出现问题。
解决:所有状态变更必须通过状态机,不允许直接修改。
坑4:分库分表后订单号冲突
订单号自增在分库分表后出现重复。
解决:使用雪花算法生成订单号,不依赖数据库自增。
坑5:消息重复消费
消息队列消费失败重试后,产生重复处理。
解决:消费者端实现幂等,通过Redis或数据库记录处理状态。
八、总结
订单系统是电商核心:
- 状态机:规范订单状态流转
- 库存扣减:乐观锁/分布式锁确保原子性
- 幂等设计:防止重复提交
- 消息异步:解耦和提升性能
最佳实践:
- 库存扣减必须用乐观锁或分布式锁
- 订单状态变更必须走状态机
- 核心接口必须支持幂等
- 异步消息要保证最终一致性
血的教训:
订单系统的坑太多了,每一个都可能导致资损。建议新系统上线前充分压测,并且做好对账和异常处理预案。
思考题: 你的订单系统有没有遇到过并发问题?是如何解决的?
个人观点,仅供参考