下单这条链路:Seata 事务、幂等防重、金额重算、订单快照
一句话结论
下单接口看着只是「插两行表」,实际上要同时解决四件事:跨服务一致性、重复提交、金额可信、历史数据不变。这四件事的处理方式,体现了后端最核心的工程思维。
方法一:跨服务事务 —— Seata AT
下单要同时写订单库(mall-order)和库存库(mall-product),两个库各有自己的事务。
@GlobalTransactional(name = "createOrder", rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
public OrderCreateVO createOrder(Long userId, OrderCreateDTO dto) {
...
}
为什么是 AT 而不是 TCC
TCC 要每个参与方写 Try / Confirm / Cancel 三个方法,还要自己处理空回滚、幂等、悬挂。开发成本高。
Seata AT 的思路是不写回滚方法,靠解析 SQL 自动生成回滚日志(before_image 写进同库的 undo_log),协调器决定提交还是回滚,回滚时按日志反向补偿。业务代码几乎不用改。
代价我也认:
- 每个参与方的库都要有
undo_log表 - 隔离性弱:AT 靠全局锁保证写隔离,但读是「读未提交」级别的
- 排查问题时多一层 TC/TM/RM 的日志
对「下单」这种必须一致、并发量不高的场景,这个交换划算。
隔离性这条要主动讲
两个事务同时改同一行会怎样?Seata 会让第二个事务挂起,所以不会出现脏写,但可能脏读(读到别人未提交的值)。
我的处理是:不接受脏读。扣库存的判断不依赖「先查出来的值」,而是写进 UPDATE 的 WHERE 条件里,让判断本身变成原子的——这正是防超卖那条 SQL 的写法。
方法二:重复提交 —— 服务端推导的幂等键
用户连点、网络重试,都会重复下单。
order_no 是雪花算法每次现生成的,唯一索引拦不住同一笔业务的重复提交(两单号不同)。
所以我用「用户 + 商品指纹」做幂等键:
// 同一用户 + 同一批商品(SKU 与数量一致,顺序无关)在窗口内视为同一笔下单
String fingerprint = items.stream()
.sorted(Comparator.comparing(OrderCreateDTO.OrderItemDTO::getSkuId, ...))
.map(item -> item.getSkuId() + "x" + item.getNum())
.collect(Collectors.joining(","));
String idemKey = "order:idem:" + userId + ":" + fingerprint;
排序是为了让顺序不同但内容相同的两次提交落到同一个键。
写入用 Redis 的 setIfAbsent,窗口 10 秒:
Boolean first = stringRedisTemplate.opsForValue()
.setIfAbsent(idemKey, "1", Duration.ofSeconds(IDEMPOTENT_EXPIRE_SECONDS));
关键的一点:业务失败时要把幂等键删掉
try {
return doCreateOrder(userId, dto);
} catch (Exception e) {
stringRedisTemplate.delete(idemKey); // 不删的话,用户重试会被自己上一次的失败挡住
throw e;
}
这一条我一开始漏了,现象是「用户第一次下单失败后,10 秒内怎么点都提示订单提交中」。
另外 Redis 异常时降级放行(return true):宁可放过一次重复,也不能让缓存故障导致所有人下不了单。
方法三:金额不信前端
前端传了 totalAmount,我不用,一律从 SKU 表重新算:
long totalAmount = 0;
for (OrderCreateDTO.OrderItemDTO item : dto.getOrderItems()) {
SkuInfoDTO sku = getSkuInfo(item.getSkuId());
skuMap.put(item.getSkuId(), sku);
totalAmount += sku.getPrice() * item.getNum();
}
理由很直接:前端传的价格是不可信输入,改个请求就能改成 1 分钱。金额必须以服务端查到的商品单价 × 数量为准。
顺带一个细节:查 SKU 放进循环里会重复调用下游接口,我在博客里就犯过这个错——循环里调远程接口,要么批量查,要么加缓存。
方法四:订单快照
order_item 不只存 sku_id,而是把下单那一刻的商品信息全部冗余进去:
orderItem.setSkuId(item.getSkuId());
orderItem.setSpuId(sku.getSpuId());
orderItem.setSkuName(sku.getTitle()); // 商品名快照
orderItem.setSkuPic(sku.getImages()); // 图片快照
orderItem.setPrice(sku.getPrice()); // 价格快照(服务端算的)
orderItem.setQuantity(item.getNum());
orderItem.setTotalAmount(sku.getPrice() * item.getNum());
为什么 JOIN 商品表取价是不行的:商品改名、改价、下架之后,历史订单展示的东西会跟着变;用户三个月前下单时的 99 元和今天商品页上的 129 元会同时出现,这就不叫订单快照了。
代价是冗余(同一份商品名存了 N 份),但换来的是历史订单可以独立还原。这个交换我认为很值。
事务注解的位置
doCreateOrder 是私有方法,事务注解在入口 createOrder 上。
@Transactional // 在 createOrder 上
public OrderCreateVO createOrder(...) {
return doCreateOrder(userId, dto); // 私有方法内部调用,代理不生效,但外层事务已经开了
}
把业务逻辑抽成私有方法是为了让 createOrder 只做「幂等前置 + 开事务 + 异常释放幂等键」三件事,方法职责单一。
收尾:下单成功后还要发两条消息
创建订单成功后,向 RabbitMQ 发两条消息:一条是站内信通知(给用户),一条是 30 分钟的延迟消息(给超时关单)。
private void sendNotify(...) {
try {
rabbitTemplate.convertAndSend(MqConfig.NOTIFY_QUEUE, msg);
} catch (Exception e) {
log.error("发送站内信通知失败: userId={}", userId, e);
// 吞掉异常:通知失败不影响订单本身
}
}
发通知失败绝不能回滚订单——用户看到「下单失败」但订单其实已经创建了,比少收一条通知糟糕得多。所以这里 try-catch 吞掉。
复盘
这四个方法里,最容易被忽略的是幂等键的失败释放。它藏在一个 catch 块里,不主动测很难发现:正常路径下永远是成功的,只有「业务失败 + 用户立刻重试」才会触发。
写幂等逻辑时,要问自己一个问题:如果这一步失败了,我这个键会不会把用户锁死?