上一篇文章(《@Transactional做不到的5件事,我用这6种方法解决了》)我们聊了@Transactional的6种高级技巧,讲的是"怎么用"的问题。
这篇文章更重要,我们要聊的是"为什么不会出问题"。
因为技巧再多,如果底层设计有问题,照样会炸。
本文代码(complex分支):gitee.com/sh_wangwanb…
如果上一篇解决的是"怎么用",这一篇解决的是"为什么"。
起因是这样的
研究Mall项目的时候,看到订单创建那个方法,我当时就懵了:
@Transactional
public Long generateOrder(OrderParam orderParam) {
// 操作6张表
// 14个步骤
// 全在一个事务里
}
按常理说,步骤越多、表越多,越容易出问题。但Mall项目在GitHub上7万+星,肯定有不少公司在用,这个方法应该跑了很多订单了。
为什么没炸?
我当时想:肯定有些设计上的道道。于是照着写了个demo,跑了一遍。
先看看实际运行结果
我写了个测试脚本,模拟一次完整的下单流程:
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是怎么来的?我从日志里看到的:
2025-11-23 20:00:07.239 - 开始创建订单
2025-11-23 20:00:07.277 - 订单创建成功
我就想,这个事务到底做对了什么?
失败点前置 - 为什么前8步不会炸
我仔细看了日志,发现这14步操作,明显分成了两个阶段。
第一阶段:查询和计算
前8步全是这样的:
步骤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步开始写数据库:
步骤9:锁定库存(UPDATE)
步骤10:创建订单(INSERT)
步骤11:批量创建订单明细(INSERT)
步骤12:更新优惠券状态(UPDATE)
步骤13:扣减会员积分(UPDATE)
步骤14:删除购物车(DELETE)
这6步全是简单的写入操作:主键INSERT、主键UPDATE、批量INSERT。
后6步耗时21ms。
为什么这样设计?
我当时想了很久,后来明白了:
把失败概率高的操作前置,失败概率低的操作后置。
前8步可能失败的情况很多:
- 会员不存在
- 库存不足
- 优惠券不可用
- 积分不够
但这些校验都在事务前期完成了,一旦通过,后面的写入操作就很难失败了。
后6步可能失败的情况很少:
- 主键冲突?不太可能,订单号是唯一的
- 网络问题?那就回滚呗,数据库自己保证ACID
这就是失败点前置的精髓:让事务在写入数据前,就把该校验的都校验完。
CAS乐观锁 - 库存锁定为什么不会超卖
这块我当时被折腾惨了。
Mall的原始代码里,库存锁定是分两步的:
// 第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<br/>超卖了5件!
rect rgb(255, 200, 200)
Note over A,DB: 超卖!
end
问题出在哪?查询和更新之间有时间窗口。
改成CAS
我们改成了一条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
从日志里可以看到:
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里做了多少事:
查询操作: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接口
全是本地数据库操作。
如果有外部调用会怎样?我举个例子:
@Transactional
public void generateOrder() {
orderMapper.insert(order);
// 调MQ,假设耗时100ms
mqSender.send("order.created", orderId);
// 后续操作
}
这样的话,事务时间就会变成:38ms + 100ms = 138ms。
事务越长,锁持有时间越长,并发性能越差。
2. 写操作都很简单
我们看看写操作的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:
-- 复杂的范围锁定,慢
UPDATE oms_order
SET status = 1
WHERE member_id = ?
AND create_time >= ?
AND total_amount > ?
这种SQL会持有范围锁,影响很多行,持锁时间长。
3. 事务注解加了参数
我们的代码是这样的:
@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
如果代码里不显式指定,换数据库就可能出问题。
金额分摊的精度问题
这个坑我们也踩了。
订单明细需要分摊优惠券和积分,我们最开始是这么写的:
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
解决方案
我们改成了"最后一个吃误差"的策略:
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接口:
@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个订单:
@Transactional
public void batchProcess(List<Long> orderIds) {
for (Long orderId : orderIds) {
// 处理每个订单
}
}
1000个订单在一个事务里,超时是肯定的。
解决方案:分页处理,每页一个事务。
总结一下
这个订单创建方法,为什么能在生产环境稳定运行?
核心就3点:
- 失败点前置:把可能失败的校验都放在前面,写入操作放在后面
- CAS乐观锁:用WHERE条件保证库存锁定的原子性,不会超卖
- 事务要短:38ms完成,没有外部调用,写操作简单快速
外加1个细节:金额分摊要精确,最后一个商品吃掉误差。
还有1个边界:单库、简单写入、竞争不激烈的场景。
超出这个边界,就要用其他方案了。
你们在实际项目中,订单创建是怎么设计的?
有没有遇到过库存超卖、金额计算不准、事务超时的问题?
或者有更好的设计方案?
欢迎在评论区聊聊,我也想学习学习。
特别是高并发秒杀那块,我们目前的方案是Redis预减库存,但感觉还有优化空间。如果有大佬愿意指点一下,那就太好了。
如果这篇文章对你有帮助,麻烦点个赞,让更多人看到。
这篇文章从研究Mall源码、写demo、跑测试、画图、整理思路、写文章,前后花了三天时间。特别是那些mermaid图,每个都是反复调整才画出来的。
希望能帮你理解复杂事务的设计思路。
毕竟,代码能在生产环境稳定运行,一定有它的道理。