阿飞的博客

下单这条链路: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 块里,不主动测很难发现:正常路径下永远是成功的,只有「业务失败 + 用户立刻重试」才会触发。

写幂等逻辑时,要问自己一个问题:如果这一步失败了,我这个键会不会把用户锁死?

标签 Spring Cloud微服务Seata

评论

0
加载中…
无需登录 · 同一 IP 每分钟限 3 条