设计一个订单系统:状态机、幂等性、超时取消的工程实现
设计一个订单系统:状态机、幂等性、超时取消的工程实现
适读人群:Java中高级工程师、电商平台技术人员 | 阅读时长:约20分钟 | 难度:★★★★☆
开篇故事
我面试过很多工程师,聊到订单系统,大多数人说"就是个CRUD,没什么特别的"。然后我问:"用户重复点击支付按钮,会不会扣两次款?支付完成的回调如果超时重试,会不会重复处理?订单15分钟未支付,取消逻辑是定时扫描还是延迟消息?"
三个问题下来,多数人答不完整。
订单系统是电商的核心,这三个问题背后对应的是:幂等性设计(防重复)、状态机设计(防非法状态转换)、超时取消设计(延迟处理)。每一个做不好,轻则用户体验差,重则钱出了问题。
一、需求分析与规模估算
订单状态流转
待支付 → 支付中 → 已支付 → 发货中 → 已发货 → 已完成
↓ ↓
超时取消 售后中 → 已退款
↓
已取消(手动)规模估算
订单量:
- 每天新建订单:100万
- 平均QPS:12 QPS
- 峰值QPS(大促):约300 QPS
超时取消:
- 未支付订单15分钟取消
- 每分钟需要扫描/处理的超时订单数:最多100万/15 = 约6.7万条/分钟
存储估算:
- 每条订单记录约2KB(含商品快照)
- 100万条/天 × 2KB = 2GB/天
- 3年历史:2TB → 需要分库分表
二、状态机设计(防非法状态转换)
状态机是订单系统的核心,它定义了哪些状态转换是合法的,防止"已完成的订单被取消"这类逻辑错误。
public enum OrderStatus {
PENDING_PAYMENT(0, "待支付"),
PAYING(1, "支付中"),
PAID(2, "已支付"),
SHIPPING(3, "发货中"),
SHIPPED(4, "已发货"),
COMPLETED(5, "已完成"),
CANCELLED(6, "已取消"),
REFUNDING(7, "退款中"),
REFUNDED(8, "已退款");
private final int code;
private final String desc;
// 合法的状态转换关系
private static final Map<OrderStatus, Set<OrderStatus>> VALID_TRANSITIONS = new HashMap<>();
static {
VALID_TRANSITIONS.put(PENDING_PAYMENT,
EnumSet.of(PAYING, CANCELLED)); // 待支付→支付中或取消
VALID_TRANSITIONS.put(PAYING,
EnumSet.of(PAID, PENDING_PAYMENT, CANCELLED)); // 支付中→成功/失败回滚/取消
VALID_TRANSITIONS.put(PAID,
EnumSet.of(SHIPPING, REFUNDING)); // 已支付→发货或退款
VALID_TRANSITIONS.put(SHIPPING,
EnumSet.of(SHIPPED));
VALID_TRANSITIONS.put(SHIPPED,
EnumSet.of(COMPLETED, REFUNDING));
VALID_TRANSITIONS.put(COMPLETED,
EnumSet.of(REFUNDING)); // 完成后可以申请退款
VALID_TRANSITIONS.put(REFUNDING,
EnumSet.of(REFUNDED, PAID)); // 退款中→退款成功或退款失败回原状态
// CANCELLED和REFUNDED是终态,不能再转换
}
/**
* 检查状态转换是否合法
*/
public boolean canTransitionTo(OrderStatus target) {
Set<OrderStatus> validTargets = VALID_TRANSITIONS.get(this);
return validTargets != null && validTargets.contains(target);
}
}状态机驱动的订单服务
@Service
@Slf4j
public class OrderStateMachineService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private ApplicationEventPublisher eventPublisher;
/**
* 状态转换(核心方法,所有订单状态变更都走这里)
* @param orderId 订单ID
* @param targetStatus 目标状态
* @param operatorId 操作人ID(可选)
* @param remark 备注
*/
@Transactional
public void transition(Long orderId, OrderStatus targetStatus,
Long operatorId, String remark) {
// 1. 加行锁,防止并发状态转换
Order order = orderMapper.selectForUpdate(orderId);
if (order == null) throw new BusinessException("订单不存在");
OrderStatus currentStatus = order.getStatus();
// 2. 验证状态转换合法性
if (!currentStatus.canTransitionTo(targetStatus)) {
throw new BusinessException(
String.format("订单状态不允许从%s转换到%s",
currentStatus.getDesc(), targetStatus.getDesc())
);
}
// 3. 执行状态变更
orderMapper.updateStatus(orderId, targetStatus, currentStatus); // CAS更新
// 4. 记录状态变更日志
OrderStatusLog log = OrderStatusLog.builder()
.orderId(orderId)
.fromStatus(currentStatus)
.toStatus(targetStatus)
.operatorId(operatorId)
.remark(remark)
.createTime(LocalDateTime.now())
.build();
orderMapper.insertStatusLog(log);
// 5. 发布领域事件(触发后续业务逻辑)
eventPublisher.publishEvent(new OrderStatusChangedEvent(
orderId, currentStatus, targetStatus));
log.info("订单状态变更, orderId={}, {} -> {}",
orderId, currentStatus, targetStatus);
}
}SQL层面的CAS更新(防止并发下的状态覆盖):
UPDATE order_info
SET status = #{targetStatus}, update_time = NOW()
WHERE id = #{orderId} AND status = #{expectedCurrentStatus}三、幂等性设计
创建订单的幂等性
用户重复点击"立即购买"时,不能创建两个订单。
@Service
@Slf4j
public class OrderCreateService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 创建订单(幂等)
* 通过idempotentKey(客户端生成的唯一请求ID)保证幂等
*/
public OrderCreateResult createOrder(CreateOrderRequest request) {
String idempotentKey = request.getIdempotentKey();
// 1. 幂等检查:用Redis的SETNX
String lockKey = "order:create:lock:" + idempotentKey;
String existingOrderId = redisTemplate.opsForValue().get(lockKey);
if (existingOrderId != null) {
// 已经创建过,直接返回已有订单
log.info("重复请求,返回已有订单, idempotentKey={}, orderId={}",
idempotentKey, existingOrderId);
return OrderCreateResult.success(existingOrderId);
}
// 2. 分布式锁防并发(同一个幂等Key的并发请求)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("order:create:creating:" + idempotentKey,
"1", 30, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) {
// 有另一个请求正在创建,等待一下再检查
throw new BusinessException("订单创建中,请勿重复提交");
}
try {
// 3. 再次检查(Double Check)
existingOrderId = redisTemplate.opsForValue().get(lockKey);
if (existingOrderId != null) {
return OrderCreateResult.success(existingOrderId);
}
// 4. 真正创建订单
String orderId = doCreateOrder(request);
// 5. 记录幂等Key → 订单ID的映射(保留24小时)
redisTemplate.opsForValue().set(
lockKey, orderId, 24, TimeUnit.HOURS
);
return OrderCreateResult.success(orderId);
} finally {
redisTemplate.delete("order:create:creating:" + idempotentKey);
}
}
private String doCreateOrder(CreateOrderRequest request) {
// ... 实际创建订单逻辑(扣库存、生成订单号等)
String orderId = generateOrderNo();
// ...
return orderId;
}
private String generateOrderNo() {
// 订单号:时间戳 + 机器ID + 随机数
return "ORD" + System.currentTimeMillis() +
ThreadLocalRandom.current().nextInt(1000, 9999);
}
}支付回调的幂等性
支付回调可能被支付宝/微信重复发送(网络超时重试):
@Service
public class PaymentCallbackService {
@Autowired
private OrderStateMachineService stateMachineService;
@Autowired
private OrderMapper orderMapper;
/**
* 处理支付回调(幂等)
*/
@Transactional
public void handlePaymentCallback(PaymentCallbackRequest callback) {
String orderId = callback.getOrderId();
// 1. 查询订单当前状态
Order order = orderMapper.findByOrderNo(orderId);
if (order == null) {
log.warn("收到未知订单的支付回调, orderId={}", orderId);
return;
}
// 2. 幂等检查:如果订单已经是"已支付"状态,直接返回成功
if (order.getStatus() == OrderStatus.PAID
|| order.getStatus() == OrderStatus.SHIPPING
|| order.getStatus() == OrderStatus.SHIPPED
|| order.getStatus() == OrderStatus.COMPLETED) {
log.info("重复的支付回调,订单已处理。orderId={}", orderId);
return; // 直接告诉支付方成功,不要再重试
}
// 3. 只允许"支付中"或"待支付"状态处理回调
if (order.getStatus() != OrderStatus.PENDING_PAYMENT
&& order.getStatus() != OrderStatus.PAYING) {
log.warn("订单状态异常,无法处理支付回调, orderId={}, status={}",
orderId, order.getStatus());
return;
}
// 4. 处理支付结果
if (callback.isSuccess()) {
stateMachineService.transition(order.getId(), OrderStatus.PAID,
null, "支付成功-" + callback.getPaymentNo());
} else {
stateMachineService.transition(order.getId(), OrderStatus.PENDING_PAYMENT,
null, "支付失败-" + callback.getFailReason());
}
}
}四、超时取消设计
超时取消有两种方案:
方案一:定时扫描(简单,但有延迟) 每分钟扫描status=PENDING_PAYMENT AND create_time < now()-15min的订单,批量取消。
方案二:延迟消息(精准,推荐) 创建订单时,往消息队列发一条15分钟后触发的延迟消息,消息消费时检查订单状态,如果还是待支付则取消。
@Service
@Slf4j
public class OrderTimeoutCancelService {
@Autowired
private OrderStateMachineService stateMachineService;
@Autowired
private OrderMapper orderMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
private static final int TIMEOUT_MINUTES = 15;
/**
* 订单创建后,发送延迟消息
* 使用RocketMQ的延迟消息特性
*/
public void scheduleTimeout(String orderNo) {
OrderTimeoutMessage message = new OrderTimeoutMessage(orderNo);
// RocketMQ延迟级别:level 3 = 10秒, level 14 = 10分钟, 需要自定义
// 这里用Spring RocketMQ的延迟消息
rocketMQTemplate.syncSend(
"order-timeout-topic",
MessageBuilder.withPayload(message).build(),
3000, // 超时时间
3 // 延迟级别(3=10s,生产中需要根据业务配置合适的延迟级别)
);
// 实际中RocketMQ延迟级别有限(1s,5s,10s...),不能精确到15分钟
// 生产中常见做法:用Redis的Sorted Set模拟延迟队列
scheduleWithRedis(orderNo);
}
/**
* Redis Sorted Set实现延迟队列
* Score = 超时时间戳,定时扫描score <= now的元素
*/
public void scheduleWithRedis(String orderNo) {
long expireAt = System.currentTimeMillis() + TIMEOUT_MINUTES * 60 * 1000L;
redisTemplate.opsForZSet().add("order:timeout:queue", orderNo, expireAt);
}
/**
* 定时扫描延迟队列(每30秒一次)
*/
@Scheduled(fixedDelay = 30000)
public void processTimeoutQueue() {
long now = System.currentTimeMillis();
// 取出所有已超时的订单(score <= now)
Set<String> timeoutOrders = redisTemplate.opsForZSet()
.rangeByScore("order:timeout:queue", 0, now);
if (timeoutOrders == null || timeoutOrders.isEmpty()) return;
for (String orderNo : timeoutOrders) {
try {
cancelTimeoutOrder(orderNo);
} catch (Exception e) {
log.error("超时取消订单失败, orderNo={}", orderNo, e);
} finally {
// 无论成功失败,都从队列中移除(防止重复处理)
redisTemplate.opsForZSet().remove("order:timeout:queue", orderNo);
}
}
}
private void cancelTimeoutOrder(String orderNo) {
Order order = orderMapper.findByOrderNo(orderNo);
if (order == null) return;
// 只有"待支付"状态的订单才能超时取消
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
log.info("订单状态已变更,不执行超时取消, orderNo={}, status={}",
orderNo, order.getStatus());
return;
}
stateMachineService.transition(
order.getId(), OrderStatus.CANCELLED, null, "超时自动取消");
log.info("订单超时取消成功, orderNo={}", orderNo);
}
@Autowired
private StringRedisTemplate redisTemplate;
}五、扩展性设计
分库分表
按userId % 64分64张表。用户的所有订单在同一个分片,保证"查我的订单"不跨分片。
但有个问题:商家也需要查"我的店铺所有订单",按userId分片对商家不友好。解决方案:双写(写入时同时写用户分片和商家分片),或者用Binlog同步到按商家ID分片的副本库。
六、踩坑实录
坑1:状态机绕过导致数据不一致
有人直接写SQL UPDATE order SET status=5 WHERE id=?,绕过了状态机,导致订单直接从"待支付"跳到"已完成",后续报表统计出错,库存也没释放。
解决方案:状态变更必须通过OrderStateMachineService.transition(),禁止直接SQL修改status字段(在代码审查中强制执行,同时DB层面对status字段设置触发器或通过ORM的事件拦截器监控直接SQL变更)。
坑2:超时取消和用户支付同时发生
订单第14分59秒到期,超时任务扫到了准备取消,但就在这一秒,用户点了支付,支付成功了。结果订单被取消了,但钱已经扣了。
解决方案:状态机的CAS更新(WHERE id=? AND status=PENDING_PAYMENT)天然解决了这个问题:超时取消和支付成功都是把PENDING_PAYMENT改成其他状态,两个操作只有一个能成功(数据库行锁保证)。先支付成功的话,超时任务执行时订单已经是PAID状态,不满足WHERE条件,自然不会取消。
坑3:订单创建后库存扣减失败,但订单已入库
创建订单时,先插入订单再扣减库存(两步操作),如果扣减库存失败(超卖),需要回滚订单。早期没有做事务,订单创建成功但库存扣减失败,产生了虚假订单。
解决方案:把订单插入和库存扣减放在同一个本地事务中。如果是跨服务(订单服务+库存服务),要么用分布式事务(TCC/Saga),要么改为先扣库存后创订单。
七、总结
订单系统的三大核心设计:
| 设计 | 解决的问题 | 核心技术 |
|---|---|---|
| 状态机 | 非法状态转换、并发状态覆盖 | 枚举+CAS更新 |
| 幂等性 | 重复创建、重复支付 | Redis SETNX + 状态检查 |
| 超时取消 | 占用库存不支付 | Redis延迟队列 + 定时扫描 |
状态机是订单系统的基础,幂等性是稳定性的保障,超时取消是体验与效率的平衡。 三者缺一不可。
