上一篇文章(《@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);
}
我一开始觉得没问题,后来想想,这玩意儿在并发场景下会出问题:
超卖了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条件保证了原子性。
画个图对比一下:
从日志里可以看到:
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(购物车)
画个时间线:
为什么这么快?我总结了几个原因:
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
问题出在哪?所有商品都向下取整,累加起来会少。
画个图理解一下:
解决方案
我们改成了"最后一个吃误差"的策略:
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 ← 精确了
这样无论怎么取整,最后一个商品总能把误差吃掉。
什么情况下会炸
上面说了这么多好的设计,但这套方案不是万能的。
有几种情况下,这个方案会出问题:
1. 跨库操作
如果订单库和库存库是分开的: 这时候就需要分布式事务了,比如Seata的AT模式。
2. 调用外部服务
如果事务里要调HTTP接口:
java
@Transactional
public void generateOrder() {
orderMapper.insert(order);
// 调用第三方支付,可能超时30秒
paymentService.createPayment(order);
}
30秒的长事务,数据库连接会被占用,并发能力直线下降。
解决方案:把外部调用移出事务,用事务提交后的回调。
3. 高并发秒杀
如果1000人抢10个库存:
CAS在秒杀场景下会有大量失败重试,数据库扛不住。
解决方案:Redis预减库存 + MQ异步扣减真实库存。
4. 大批量处理
如果一次处理1000个订单:
java
@Transactional
public void batchProcess(List<Long> orderIds) {
for (Long orderId : orderIds) {
// 处理每个订单
}
}
1000个订单在一个事务里,超时是肯定的。
解决方案:分页处理,每页一个事务。
总结一下
这个订单创建方法,为什么能在生产环境稳定运行?
核心就3点:
- 失败点前置:把可能失败的校验都放在前面,写入操作放在后面
- CAS乐观锁:用WHERE条件保证库存锁定的原子性,不会超卖
- 事务要短:38ms完成,没有外部调用,写操作简单快速
外加1个细节:金额分摊要精确,最后一个商品吃掉误差。
还有1个边界:单库、简单写入、竞争不激烈的场景。
超出这个边界,就要用其他方案了。
你们在实际项目中,订单创建是怎么设计的?
有没有遇到过库存超卖、金额计算不准、事务超时的问题?
或者有更好的设计方案?
欢迎在评论区聊聊,我也想学习学习。
特别是高并发秒杀那块,我们目前的方案是Redis预减库存,但感觉还有优化空间。如果有大佬愿意指点一下,那就太好了。
如果这篇文章对你有帮助,麻烦点个赞,让更多人看到。
这篇文章从研究Mall源码、写demo、跑测试、画图、整理思路、写文章,前后花了三天时间。特别是那些mermaid图,每个都是反复调整才画出来的。
希望能帮你理解复杂事务的设计思路。
毕竟,代码能在生产环境稳定运行,一定有它的道理。