6张表、14步业务逻辑,Mall订单事务凭什么比你的3步事务还稳?

上一篇文章(《@Transactional做不到的5件事,我用这6种方法解决了》)我们聊了@Transactional的6种高级技巧,讲的是"怎么用"的问题。

这篇文章更重要,我们要聊的是"为什么不会出问题"。

因为技巧再多,如果底层设计有问题,照样会炸。


本文代码(complex分支):gitee.com/sh_wangwanb...

如果上一篇解决的是"怎么用",这一篇解决的是"为什么"。

起因是这样的

研究Mall项目的时候,看到订单创建那个方法,我当时就懵了:

java 复制代码
@Transactional
public Long generateOrder(OrderParam orderParam) {
    // 操作6张表
    // 14个步骤
    // 全在一个事务里
}

按常理说,步骤越多、表越多,越容易出问题。但Mall项目在GitHub上7万+星,肯定有不少公司在用,这个方法应该跑了很多订单了。

为什么没炸?

我当时想:肯定有些设计上的道道。于是照着写了个demo,跑了一遍。

先看看实际运行结果

我写了个测试脚本,模拟一次完整的下单流程:

bash 复制代码
bash test-order-api.sh

结果是这样的:

复制代码
【初始状态】
会员积分:1000
购物车:iPhone 15 Pro(7999元)+ AirPods Pro 2(1899元 x2)
可用优惠券:1张(满5000减500)
库存:iPhone 100件、AirPods 100件

【创建订单】
订单号:20251123200007273163
订单总金额:11797.00
实付金额:11296.00(用了500元券 + 100积分)

【最终状态】
会员积分:1000 → 900(扣了100)
购物车:清空
优惠券:已使用
库存:iPhone锁定1件、AirPods锁定2件

数据完全一致,38ms完成。

这个38ms是怎么来的?我从日志里看到的:

yaml 复制代码
2025-11-23 20:00:07.239 - 开始创建订单
2025-11-23 20:00:07.277 - 订单创建成功

我就想,这个事务到底做对了什么?

失败点前置 - 为什么前8步不会炸

我仔细看了日志,发现这14步操作,明显分成了两个阶段。

第一阶段:查询和计算

前8步全是这样的:

sql 复制代码
步骤1:查询会员信息(SELECT)
步骤2:查询购物车(SELECT)
步骤3:查询库存,校验是否充足(SELECT)
步骤4:计算订单总金额(内存计算)
步骤5:查询可用优惠券(SELECT)
步骤6:计算积分抵扣(内存计算)
步骤7:计算实付金额(内存计算)
步骤8:生成订单号(内存计算)

注意,这8步全是SELECT和内存计算,没有任何写操作。

用表格看得更清楚:

阶段 步骤 操作类型 如果失败 副作用
第一阶段 步骤1:查询会员 SELECT 直接返回
步骤2:查询购物车 SELECT 直接返回
步骤3:校验库存 SELECT 直接返回
步骤4:计算总金额 内存计算 -
步骤5:查询优惠券 SELECT 直接返回
步骤6:计算积分 内存计算 直接返回
步骤7:计算实付金额 内存计算 -
步骤8:生成订单号 内存计算 -
第二阶段 步骤9:锁定库存 UPDATE 事务回滚 有回滚
步骤10:创建订单 INSERT 事务回滚 有回滚
步骤11:创建明细 INSERT 事务回滚 有回滚
步骤12:更新优惠券 UPDATE 事务回滚 有回滚
步骤13:扣减积分 UPDATE 事务回滚 有回滚
步骤14:删除购物车 DELETE 事务回滚 有回滚

关键点:前8步任何一步失败,数据库都是干净的,没改任何东西。

这个设计的好处在哪?

如果前8步任何一步失败了,没有副作用。

  • 会员不存在?直接返回,数据库一个字符都没改
  • 库存不足?直接返回,数据库一个字符都没改
  • 优惠券不可用?直接返回,数据库一个字符都没改

从日志里看,前8步耗时17ms,全是查询和计算。

第二阶段:数据库写入

后6步开始写数据库:

sql 复制代码
步骤9:锁定库存(UPDATE)
步骤10:创建订单(INSERT)
步骤11:批量创建订单明细(INSERT)
步骤12:更新优惠券状态(UPDATE)
步骤13:扣减会员积分(UPDATE)
步骤14:删除购物车(DELETE)

这6步全是简单的写入操作:主键INSERT、主键UPDATE、批量INSERT。

后6步耗时21ms。

为什么这样设计?

我当时想了很久,后来明白了:

把失败概率高的操作前置,失败概率低的操作后置。

前8步可能失败的情况很多:

  • 会员不存在
  • 库存不足
  • 优惠券不可用
  • 积分不够

但这些校验都在事务前期完成了,一旦通过,后面的写入操作就很难失败了。

后6步可能失败的情况很少:

  • 主键冲突?不太可能,订单号是唯一的
  • 网络问题?那就回滚呗,数据库自己保证ACID

这就是失败点前置 的精髓:让事务在写入数据前,就把该校验的都校验完。

CAS乐观锁 - 库存锁定为什么不会超卖

这块我当时被折腾惨了。

Mall的原始代码里,库存锁定是分两步的:

java 复制代码
// 第1步:查询库存
PmsSkuStock skuStock = skuStockMapper.selectByProductId(productId);
if (skuStock.getStock() - skuStock.getLockStock() >= quantity) {
    // 第2步:锁定库存
    skuStockMapper.lockStock(productId, quantity);
}

我一开始觉得没问题,后来想想,这玩意儿在并发场景下会出问题:

sequenceDiagram participant A as 线程A participant B as 线程B participant DB as 数据库 Note over DB: 初始库存100,锁定0 A->>DB: SELECT库存 DB-->>A: 库存100,锁定0,可用100 B->>DB: SELECT库存 DB-->>B: 库存100,锁定0,可用100 Note over A: 判断:可用100 >= 10,OK A->>DB: UPDATE锁定+10 Note over DB: 锁定变成10 Note over B: 判断:可用100 >= 95,OK B->>DB: UPDATE锁定+95 Note over DB: 锁定变成105 Note over DB: 问题:库存100,锁定了105
超卖了5件! rect rgb(255, 200, 200) Note over A,DB: 超卖! end

问题出在哪?查询和更新之间有时间窗口。

改成CAS

我们改成了一条SQL:

sql 复制代码
UPDATE pms_sku_stock
SET lock_stock = lock_stock + #{quantity}
WHERE product_id = #{productId}
  AND (stock - lock_stock) >= #{quantity}

这条SQL妙在哪?WHERE条件保证了原子性。

画个图对比一下:

sequenceDiagram participant A as 线程A participant B as 线程B participant DB as 数据库(CAS) Note over DB: 初始库存100,锁定0 A->>DB: UPDATE ... WHERE 可用>=10 Note over DB: 检查:100-0=100 >= 10,通过 DB->>DB: 原子操作:lockStock+10 DB-->>A: 返回:影响1行,成功 Note over DB: 锁定变成10 B->>DB: UPDATE ... WHERE 可用>=95 Note over DB: 检查:100-10=90 < 95,不通过 DB-->>B: 返回:影响0行,失败 Note over B: 影响0行,说明库存不足 B->>B: 抛异常,事务回滚 rect rgb(200, 255, 200) Note over A,DB: 不会超卖! end

从日志里可以看到:

yaml 复制代码
2025-11-23 20:00:07.265 DEBUG - ==> Parameters: 1(Integer), 1001(Long), 1(Integer)
2025-11-23 20:00:07.267 DEBUG - <== Updates: 1

2025-11-23 20:00:07.268 DEBUG - ==> Parameters: 2(Integer), 1002(Long), 2(Integer)
2025-11-23 20:00:07.270 DEBUG - <== Updates: 1

Updates: 1 就表示锁定成功。如果返回0,就说明库存不足,事务会回滚。

CAS vs 悲观锁

有人可能会问,为什么不用SELECT FOR UPDATE

我们对比一下:

悲观锁 :线程B必须等线程A释放锁,串行执行。
CAS:线程B直接失败返回,不等待。

在电商场景下,库存竞争其实不算激烈(不是秒杀那种),CAS的性能更好。

当然,如果是高并发秒杀,CAS会有大量失败重试,那就得用Redis预减库存了。

事务要短 - 38ms完成14步操作

这个38ms我当时很惊讶。

我们来算算,38ms里做了多少事:

diff 复制代码
查询操作:6次SELECT
- 会员信息
- 购物车商品(2条)
- 库存信息(2次查询)
- 优惠券列表

写入操作:
- 2次UPDATE(锁库存)
- 1次INSERT(订单)
- 1次批量INSERT(订单明细,2条)
- 1次UPDATE(优惠券)
- 1次UPDATE(积分)
- 1次DELETE(购物车)

画个时间线:

gantt title 订单创建事务时间线(38ms) dateFormat SSS axisFormat %L section 第一阶段 查询会员 :a1, 000, 2ms 查询购物车 :a2, after a1, 3ms 查询库存 :a3, after a2, 4ms 查询优惠券 :a4, after a3, 2ms 计算金额 :a5, after a4, 6ms section 第二阶段 锁定库存 :b1, after a5, 4ms 创建订单 :b2, after b1, 3ms 创建明细 :b3, after b2, 7ms 更新优惠券 :b4, after b3, 2ms 更新积分 :b5, after b4, 3ms 删除购物车 :b6, after b5, 2ms

为什么这么快?我总结了几个原因:

1. 没有外部调用

这个事务里,没有任何外部调用

  • 没有调MQ
  • 没有调Redis
  • 没有调HTTP接口

全是本地数据库操作。

如果有外部调用会怎样?我举个例子:

java 复制代码
@Transactional
public void generateOrder() {
    orderMapper.insert(order);
    
    // 调MQ,假设耗时100ms
    mqSender.send("order.created", orderId);
    
    // 后续操作
}

这样的话,事务时间就会变成:38ms + 100ms = 138ms。

事务越长,锁持有时间越长,并发性能越差。

2. 写操作都很简单

我们看看写操作的SQL:

sql 复制代码
-- 主键UPDATE,走主键索引,很快
UPDATE ums_member SET integration = ? WHERE id = ?

-- 主键INSERT,主键自增,很快
INSERT INTO oms_order (...) VALUES (...)

-- 批量INSERT,一次完成,很快
INSERT INTO oms_order_item (...) VALUES (...), (...)

没有复杂的WHERE条件,没有JOIN,没有子查询。

全是最简单的主键操作。

对比一下,如果是这样的SQL:

sql 复制代码
-- 复杂的范围锁定,慢
UPDATE oms_order 
SET status = 1 
WHERE member_id = ? 
  AND create_time >= ?
  AND total_amount > ?

这种SQL会持有范围锁,影响很多行,持锁时间长。

3. 事务注解加了参数

我们的代码是这样的:

java 复制代码
@Transactional(
    rollbackFor = Exception.class,
    isolation = Isolation.REPEATABLE_READ,
    timeout = 30
)
public Map<String, Object> generateOrder(OrderParam orderParam) {
    // ...
}

这几个参数很重要:

  • rollbackFor = Exception.class:所有异常都回滚,避免遗漏
  • isolation = REPEATABLE_READ:明确隔离级别,不依赖数据库默认值
  • timeout = 30:30秒超时,避免长事务

我之前写代码,从来不加这些参数,后来踩了坑才知道重要性。

比如有次数据库从MySQL换成PostgreSQL,突然出现了幻读问题。原因是:

  • MySQL默认 REPEATABLE_READ
  • PostgreSQL默认 READ_COMMITTED

如果代码里不显式指定,换数据库就可能出问题。

金额分摊的精度问题

这个坑我们也踩了。

订单明细需要分摊优惠券和积分,我们最开始是这么写的:

java 复制代码
for (OmsCartItem cartItem : cartItems) {
    // 按比例分摊,向下取整
    BigDecimal itemCouponAmount = couponAmount
        .multiply(itemTotalAmount)
        .divide(totalAmount, 2, RoundingMode.DOWN);
}

看起来没问题吧?实际跑起来发现:

复制代码
优惠券总额:500.00
商品1分摊:339.02
商品2分摊:160.97
合计:499.99  ← 少了0.01

问题出在哪?所有商品都向下取整,累加起来会少。

画个图理解一下:

graph LR A[优惠券500元] --> B[商品1: 7999元] A --> C[商品2: 3798元] B --> D[按比例: 7999/11797 * 500 = 339.024...] D --> E[向下取整: 339.02] C --> F[按比例: 3798/11797 * 500 = 160.975...] F --> G[向下取整: 160.97] E --> H[合计: 339.02 + 160.97 = 499.99] G --> H H --> I[丢失: 0.01元] style I fill:#ffcccc

解决方案

我们改成了"最后一个吃误差"的策略:

java 复制代码
BigDecimal allocatedCouponAmount = BigDecimal.ZERO;

for (int i = 0; i < cartItems.size(); i++) {
    BigDecimal itemCouponAmount;
    
    if (i == cartItems.size() - 1) {
        // 最后一个商品:总额 - 已分摊
        itemCouponAmount = couponAmount.subtract(allocatedCouponAmount);
    } else {
        // 按比例分摊
        itemCouponAmount = couponAmount
            .multiply(itemTotalAmount)
            .divide(totalAmount, 2, RoundingMode.DOWN);
        allocatedCouponAmount = allocatedCouponAmount.add(itemCouponAmount);
    }
}

改完后再看日志:

复制代码
商品1优惠券分摊:339.02
商品2优惠券分摊:160.98  ← 这里变成了160.98
合计:500.00  ← 精确了
graph LR A[优惠券500元] --> B[商品1: 前N-1个] A --> C[商品2: 最后一个] B --> D[按比例分摊: 339.02] D --> E[累加已分配: 339.02] C --> F[总额-已分配: 500.00-339.02] F --> G[最终分摊: 160.98] E --> H[合计: 339.02 + 160.98 = 500.00] G --> H H --> I[误差: 0] style I fill:#ccffcc

这样无论怎么取整,最后一个商品总能把误差吃掉。

什么情况下会炸

上面说了这么多好的设计,但这套方案不是万能的。

有几种情况下,这个方案会出问题:

1. 跨库操作

如果订单库和库存库是分开的: 这时候就需要分布式事务了,比如Seata的AT模式。

2. 调用外部服务

如果事务里要调HTTP接口:

java 复制代码
@Transactional
public void generateOrder() {
    orderMapper.insert(order);
    
    // 调用第三方支付,可能超时30秒
    paymentService.createPayment(order);
}

30秒的长事务,数据库连接会被占用,并发能力直线下降。

解决方案:把外部调用移出事务,用事务提交后的回调。

3. 高并发秒杀

如果1000人抢10个库存:

graph TD A[1000个请求] --> B[CAS更新库存] B --> C[990个失败] B --> D[10个成功] C --> E[大量重试] E --> F[数据库压力大] style F fill:#ffcccc

CAS在秒杀场景下会有大量失败重试,数据库扛不住。

解决方案:Redis预减库存 + MQ异步扣减真实库存。

4. 大批量处理

如果一次处理1000个订单:

java 复制代码
@Transactional
public void batchProcess(List<Long> orderIds) {
    for (Long orderId : orderIds) {
        // 处理每个订单
    }
}

1000个订单在一个事务里,超时是肯定的。

解决方案:分页处理,每页一个事务。

总结一下

这个订单创建方法,为什么能在生产环境稳定运行?

核心就3点:

  1. 失败点前置:把可能失败的校验都放在前面,写入操作放在后面
  2. CAS乐观锁:用WHERE条件保证库存锁定的原子性,不会超卖
  3. 事务要短:38ms完成,没有外部调用,写操作简单快速

外加1个细节:金额分摊要精确,最后一个商品吃掉误差。

还有1个边界:单库、简单写入、竞争不激烈的场景。

超出这个边界,就要用其他方案了。


你们在实际项目中,订单创建是怎么设计的?

有没有遇到过库存超卖、金额计算不准、事务超时的问题?

或者有更好的设计方案?

欢迎在评论区聊聊,我也想学习学习。

特别是高并发秒杀那块,我们目前的方案是Redis预减库存,但感觉还有优化空间。如果有大佬愿意指点一下,那就太好了。


如果这篇文章对你有帮助,麻烦点个赞,让更多人看到。

这篇文章从研究Mall源码、写demo、跑测试、画图、整理思路、写文章,前后花了三天时间。特别是那些mermaid图,每个都是反复调整才画出来的。

希望能帮你理解复杂事务的设计思路。

毕竟,代码能在生产环境稳定运行,一定有它的道理。

相关推荐
架构师专栏16 分钟前
Spring Boot 4 概述与重大变化
spring boot·后端
解道Jdon44 分钟前
IntelliJ IDEA 2025.3 全面对接 Spring7
spring boot·intellij idea
曾帅1681 小时前
idea springboot开发编译所见即所得应用不需要重启
java·spring boot·intellij-idea
q***01771 小时前
Spring Boot 热部署
java·spring boot·后端
百***68821 小时前
开源模型应用落地-工具使用篇-Spring AI-Function Call(八)
人工智能·spring·开源
g***72702 小时前
springBoot发布https服务及调用
spring boot·后端·https
想不明白的过度思考者2 小时前
基于 Spring Boot 的 Web 三大核心交互案例精讲
前端·spring boot·后端·交互·javaee
程序员三明治2 小时前
【Spring进阶】Spring IOC实现原理是什么?容器创建和对象创建的时机是什么?
java·后端·spring·ioc·bean生命周期
程序员西西3 小时前
SpringCloudGateway入门实战
java·spring boot·计算机·程序员·编程