实战③:核心业务实现(事务、库存与幂等)
前面 44 篇把零件全交到你手上了:容器、代理、事务、缓存、消息、定时任务。这一篇要干的事只有一件——把它们按对的位置装进「下单」这条链路,让它在 100 个人同时抢 5 件货时依然不超卖、不漏单、不发假通知。所谓核心业务实现,难点从来不是「代码写不出来」,而是「边界放错位置」:哪些动作必须捆在一起成功或失败?哪个数字绝对不能被缓存?哪条消息绝对不能提前发?这篇逐条给你答案,并且每一条都能亲手跑出来。
先把五个词一句话解释(全文反复出现):
- 事务(transaction):一组数据库操作「要么全部生效、要么全部撤销」的打包方式,靠
@Transactional这个注解声明 - 幂等键(idempotency key):客户端为「一次下单意图」生成的唯一编号,同一个键重复提交十次也只产生一张订单
- 乐观锁 / 条件更新:不加显式锁,而是在
UPDATE的WHERE里写上「库存还够」这个前提;前提不成立,这条语句就一行都改不动 - 行锁:数据库为了保护同一行数据而临时挂上的牌子,同一行的写操作只能排队,牌摘掉(事务结束)才轮到下一个人
- 超卖:库存只有 5 件却卖出 6 件——本质是「读到的数」和「真正扣的数」之间隔了一段时间,这段时间里数被别人改了
演唱会抢票。 一个座位不能被两个人锁住,靠的不是「我先看了一眼还剩这张」,而是「我把这张票当场按进系统里」——票务系统处理这两件事之间不能有时间差,否则你看到的余票永远是旧的。BeeOrder 防超卖的整段设计就是这一句话:把「判断还有货」和「把这货扣下」合并成一条 SQL(UPDATE ... WHERE available >= n),中间不给任何人插队的缝隙。乐观锁是「检票闸机」(一次只过一个,谁先抢到算谁的),悲观锁 FOR UPDATE 是「把整个包厢钥匙拿走」(我看完之前别人只能在门外站着)。
旋转餐厅的玻璃墙。 事务开着的时候,你占着一张桌子和一段连接(数据库连接池里的一个连接);里面还没结账(没 commit),外面排队的客人就只能看着。所以「在事务里调远程接口」等于把外卖小哥也拉进来坐在你的位子上等他取餐——桌子被占的时间从 5ms 变成 200ms,高峰期整座餐厅(连接池)直接停摆。这就是第三节所有取舍的来源。

上图六个分支正好对应本篇的六节主线:下单主流程、事务边界、并发与库存、幂等三处、异步与补偿、缓存取舍。任何一支出错都能在各自的章节里找到独立的验证方法——这也是为什么这篇是全站的「收口篇」:它不引入新组件,只考验你把已有组件放对位置的能力。

这条时间线是本篇最重要的一张图,后面每一节都在拆它的某一步。先记住一个结论:前三步在事务门外,中间三步在同一次提交里,最后一步一定在提交之后。
学完这一篇,你应该能回答三个问题:
- 为什么「查一下还有没有货,再决定扣不扣」这种写法一定会超卖,而把它写成一条 SQL 就不会?
- 为什么
@Transactional有时看起来完全没生效——既不报错也不回滚?它失效的三个常见姿势是什么? - 「下单成功」的通知应该在哪一刻发出?早一步会怎样,晚一步又会怎样?
前两篇定好了表与契约,这一篇开始写真正跑得动的代码。BeeOrder 的分层纪律只有一句话:Controller 只做协议转换,Service 承载业务与事务,Mapper 只碰 SQL。三者职责一旦混淆,事务就会失控——这也是后面所有坑的根源。
| 层 | 包 | 职责 | 绝对不做的事 |
|---|---|---|---|
| Controller | com.beeorder.order.controller | 取参数、调 Service、包 Result | 不写业务分支、不碰 Mapper、不开事务 |
| Service | com.beeorder.order.service | 业务编排、事务边界、幂等与状态流转 | 不拼 SQL、不返回 Entity 给上层 |
| Mapper | com.beeorder.order.mapper | 单条 SQL,条件更新与唯一约束在此兜底 | 不做业务判断、不做多次查询编排 |
本次交付落在这些类里:order.controller.OrderController、order.service.OrderService、order.mapper.OrderMapper / OrderItemMapper、inventory.service.InventoryService、inventory.mapper.InventoryMapper、payment.service.PaymentService,跨模块调用遵循「order 调 inventory,单向依赖」的边界——这正是上一篇定下的 available / locked / version 字段要被用上的地方。
上图的沙盘演示了一个真实的风险:OrderService 需要 InventoryService 扣库存,而如果 InventoryService 又反过来注入 OrderService(比如为了回查订单),构造器注入会在启动时直接报循环依赖。边界纪律不是纸上谈兵,它决定了你的项目能不能正常启动。
下单是整个项目最关键、最容易出错的链路。先看全景,再逐段啃代码:

幂等键是「一次下单意图」的身份证,按上一篇的契约,它放在 Header 里而不是 Body 里——这样即便请求体被重放,键也不会被篡改,且便于网关统一处理:
package com.beeorder.order.controller;@RestController@RequestMapping("/api/v1/orders")public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; // 构造器注入,依赖不可变 } @PostMapping public Result<OrderVO> create(@RequestHeader("X-Idempotency-Key") String idempotentKey, @Valid @RequestBody CreateOrderRequest req) { Long userId = JwtUtil.currentUserId(); // 从 JWT 取,绝不信任前端传入的 userId req.setIdempotentKey(idempotentKey); // 幂等键统一由 Header 提供 return Result.ok(orderService.create(userId, req)); }}这是全篇最该逐行读懂的一段。它把「校验 → 计算 → 落库 → 扣库存 → 写流水」串成一次事务:
package com.beeorder.order.service;@Servicepublic class OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final ProductMapper productMapper; private final InventoryService inventoryService; public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper, ProductMapper productMapper, InventoryService inventoryService) { this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; this.productMapper = productMapper; this.inventoryService = inventoryService; } @Transactional(rollbackFor = Exception.class) // 见第三节:任何异常都要回滚 public OrderVO create(Long userId, CreateOrderRequest req) { // 1) 幂等前置检查:快路径,命中直接返回首次结果 Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); if (exist != null) { return assembleVO(exist); } // 2) 校验商品 + 计算金额(BigDecimal,逐项累加) BigDecimal total = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (CreateOrderRequest.Item i : req.getItems()) { Product p = productMapper.selectOnShelf(i.getProductId()); if (p == null) { throw new BizException(ErrorCode.ORDER_STATUS_ILLEGAL, "商品不存在或已下架"); } BigDecimal amount = p.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())); total = total.add(amount); items.add(OrderItem.snapshotOf(p, i.getQuantity(), amount)); // 价格快照 } // 3) 落库订单(状态 CREATED)+ 订单项 Order order = Order.created(userId, req, total); orderMapper.insert(order); // 唯一键 uk_order_idem 兜底防重 items.forEach(it -> it.setOrderId(order.getId())); orderItemMapper.batchInsert(items); // 4) 扣减库存(同事务内,条件更新防超卖)+ 写流水 for (OrderItem it : items) { inventoryService.lock(it.getProductId(), it.getQuantity(), order.getId()); } return assembleVO(order); }}分步解读:
- 第 1 步幂等:先查
(user_id, idempotent_key),命中说明这单已经下过,直接返回首次结果,避免后面的插入冲突——这是快路径,真正的兜底在唯一索引。 - 第 2 步校验与计算:
selectOnShelf只返回上架商品;金额用BigDecimal.multiply逐项累加,一处 double 都不能有。 - 第 3 步落库:订单先落库拿到
id,订单项再带上order_id批量插入;OrderItem.snapshotOf冻结商品名与单价快照。 - 第 4 步扣库存:交给
InventoryService.lock,在里面用条件更新与行数判断决定成败。
注意主流程里没有看到 Redis、没有看到消息发送。这些都是「事务不能容忍」的操作,第五节会讲清楚它们为什么必须挪出去。
「创建订单 + 扣库存 + 写流水」是 一组原子事实:订单建了库存没扣,会超卖;库存扣了订单没建,会凭空少货。所以它们必须在同一个事务里,要么全成,要么全回滚。
@Transactional( rollbackFor = Exception.class, // ② 连受检异常也回滚 timeout = 3, // ③ 超时 3 秒,防止慢查询长时间持锁 isolation = Isolation.READ_COMMITTED)public OrderVO create(Long userId, CreateOrderRequest req) { ... }为什么写 rollbackFor = Exception.class?Spring 默认只在遇到 RuntimeException 和 Error 时回滚。如果代码里抛了一个受检异常(比如某个查询包装后的 IOException),不加这一项,事务会照常提交——订单建了、库存却没扣。显式写成 Exception.class 是一道不写就等着踩的保险。
事务粒度也要有边界,判断标准是「这个操作失败时,是否必须抹掉前面所有动作」:
| 放在事务内 | 移出事务(事务提交后) |
|---|---|
| 插入订单、订单项 | 发送下单成功通知 |
| 扣减库存、写库存流水 | 写操作日志、埋点上报 |
| 更新订单状态 | 调用远程服务、发 MQ 消息 |
为什么远程调用与消息发送必须移出? 它们不是数据库操作,却会长时间占用数据库连接与行锁。一个 200ms 的短信接口卡在事务里,就意味着这一行的库存锁多持 200ms,高并发下直接演变成锁等待与连接池耗尽。正确做法是:事务内只落库,事务提交后再发消息。
上面这个沙盘把「事务提交」与「事务回滚」两种结局摆在一起:切到回滚,你会看到订单行与库存流水一起消失——这就是原子性的直观含义。要发通知,更稳妥的是用事件机制:在事务内发布一个事件,监听器用 @TransactionalEventListener(phase = AFTER_COMMIT) 只在提交后才执行(#41 讲过),既保证「不早发」,又保证「不占锁」。
@Transactional 挂在 private 方法、或方法内 this.xxx() 自调用时完全失效——因为 Spring 靠代理拦截,自调用不走代理。第 4 节里 inventoryService.lock 是跨 Bean 调用才生效;如果你把扣库存写成 this.lockStock(),事务注解会被静默忽略,扣库存就不会随订单回滚。
库存是整个项目里唯一会被高并发争抢的数据。三个请求同时买最后一件,处理不当就会卖出三件——这就是超卖。三种主流方案,先看对比:
| 方案 | 原理 | 优点 | 缺点 | 适用量级 |
|---|---|---|---|---|
| A 悲观锁 | SELECT ... FOR UPDATE 锁行后再更新 | 实现直观、强一致 | 持锁时间长、并发差、易死锁 | 低并发、强一致场景 |
| B 乐观锁(条件更新) | UPDATE ... WHERE available >= n 原子更新 | 无显式锁、并发好、实现简单 | 高冲突时失败率上升 | 绝大多数电商(推荐) |
| C Redis 预扣 | Lua 原子扣减内存库存,异步落库 | 吞吐最高、挡住流量 | 一致性弱、宕机丢数据、要补偿 | 秒杀级流量 |
-- 必须在事务内,FOR UPDATE 会持有行锁直到事务结束SELECT available, locked FROM inventory WHERE product_id = #{productId} FOR UPDATE;-- 应用层判断 available >= n,再执行 UPDATEUPDATE inventory SET available = available - #{n}, locked = locked + #{n} WHERE product_id = #{productId};锁范围一旦没走索引(WHERE 字段无索引)就会升级成锁全表,是最容易引发死锁的写法。
核心思想是把「判断」和「扣减」合并成一条原子 SQL,让数据库在加行锁的一瞬间完成校验:
<update id="lockStock"> UPDATE inventory SET available = available - #{quantity}, <!-- 可售减少 --> locked = locked + #{quantity}, <!-- 锁定增加 --> version = version + 1, update_time = NOW(3) WHERE product_id = #{productId} AND available >= #{quantity} <!-- 防超卖的关键条件 --></update>Long productId, int quantity; // 调用方传入int rows = inventoryMapper.lockStock(productId, quantity);if (rows == 0) { // 影响行数为 0 = 条件未命中 throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); // 1001 库存不足}为什么 available >= #{quantity} 就是防超卖的答案? 因为这条 UPDATE 在数据库里对目标行加排他锁、读取、判断、写入是一气呵成的:库存 5、三个请求各买 2,只有一个能成功,其余两个执行时 available 已不足,WHERE 不成立 → 影响行数 0 → 抛 1001。判断与扣减之间没有任何时间窗口,超卖从物理上被杜绝。
那 version 列还要不要?要,但它解决的是另一类问题:当你必须先 SELECT 出来、在应用层算完再写回时(比如「库存不足则触发补货逻辑」),需要 AND version = #{v} 来检测期间有没有别人改过。纯粹的自减场景,available >= n 已经足够。
把库存放到 Redis,用 Lua 脚本保证「判断 + 扣减」原子:
-- KEYS[1] = stock:{productId},ARGV[1] = 购买数量local stock = tonumber(redis.call('GET', KEYS[1]))if stock == nil or stock < tonumber(ARGV[1]) then return -1 -- 库存不足endredis.call('DECRBY', KEYS[1], tonumber(ARGV[1]))return stock - tonumber(ARGV[1]) -- 返回剩余库存它的价值是把流量挡在数据库之外,代价是引入第二套事实源:Redis 扣成功、异步落库失败怎么办?Redis 宕机重启、预扣数据丢失怎么办?这些都得靠对账与补偿兜底——只有在秒杀那种「数据库绝对扛不住」的流量下才值得。

乐观锁的失效场景只有一个——同一行的并发冲突率极高(比如 1000 QPS 抢同一件库存)。这时大量请求会拿到影响行数 0,重试也没用(真没货了)。此时该做的是限流或排队,而不是换 Redis——因为瓶颈根本不在锁,而在库存这个稀缺资源本身。
第 2 节的主流程里有一次「幂等前置检查」,但那次查询不能作为唯一防线:并发下两个请求可能同时查不到、同时插入。真正的兜底是上一篇设计的那条唯一索引 uk_order_idem (user_id, idempotent_key)。
try { orderMapper.insert(order);} catch (DuplicateKeyException e) { // 唯一键冲突 = 并发重复提交:查出首次结果返回,不再继续扣库存 Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo()); return assembleVO(exist);}捕获冲突后不是报错,而是返回首次结果——对用户来说,两次点击得到同一个订单,这就是幂等。三个幂等点的字段沿用上一篇的设计,一个字都没改:
| 幂等场景 | 幂等键 | 约束方式 |
|---|---|---|
| 下单 | order.idempotent_key(客户端 UUID) | 唯一键 (user_id, idempotent_key) |
| 支付回调 | payment.trade_no(网关流水号) | 唯一键 uk_payment_trade_no + 状态校验 |
| 库存扣减 | inventory_log 的 (ref_id, type) | 唯一键 uk_invlog_ref_type |
在 @Transactional 方法里捕获 DuplicateKeyException 要小心——有些数据库驱动/配置下,唯一键冲突会把当前事务标记为 rollback-only,之后即使你 return 了首次结果,提交阶段也会抛 UnexpectedRollbackException。稳妥做法是把「插入订单」抽到独立事务方法里(REQUIRES_NEW 或单独的 Service),在它之外捕获冲突,避免污染外层事务。
上面这段代码在并发下的真实走位,值得用一段动画补齐:两个请求就算同时越过前置检查,也只有一个能真正插入成功——另一个被数据库的唯一索引当场拦下,再被代码捡回来,返回首次结果:

支付回调是唯一对外开放的写接口,也是攻击面最大的地方。它不校验 JWT,但必须做三件事:验签、幂等、状态机校验。
package com.beeorder.payment.controller;@RestController@RequestMapping("/api/v1/payments")public class PaymentController { private final PaymentService paymentService; public PaymentController(PaymentService paymentService) { this.paymentService = paymentService; } /** 网关异步回调:不验 JWT,但要验签 + 幂等 */ @PostMapping("/callback") public Result<Void> callback(@RequestBody PayCallbackDTO dto) { paymentService.verifySign(dto); // ① 验签:签名或金额不符直接抛 2002 paymentService.handleCallback(dto); // ② 幂等 + 状态机 return Result.ok(null); // ③ 固定成功应答,让网关别再重发 }}PaymentService.handleCallback 就是上一篇给出的两道防线落地:
@Transactional(rollbackFor = Exception.class)public void handleCallback(PayCallbackDTO dto) { Payment pay = paymentMapper.selectByTradeNo(dto.getTradeNo()); if (pay != null && PayStatus.SUCCESS.name().equals(pay.getStatus())) { log.info("重复支付回调,已忽略 tradeNo={}", dto.getTradeNo()); return; // 第一道:已成功,幂等返回 } // 第二道:条件更新,只有 CREATED 才能转 PAID int rows = orderMapper.updateStatus(dto.getOrderId(), OrderStatus.CREATED, OrderStatus.PAID); if (rows == 0) { log.warn("订单状态不允许支付,忽略 orderId={}", dto.getOrderId()); return; // 已关闭或已支付,忽略 } paymentMapper.markSuccess(dto.getTradeNo()); // trade_no 唯一键兜底 inventoryService.commitLocked(dto.getOrderId()); // 锁定库存转实扣}三处校验的位置很关键:验签在 Controller(挡掉伪造请求)、幂等在 Service 开头(挡掉重复回调)、状态机在条件更新里(挡掉「已关闭订单又支付成功」)。三者缺一,脏数据就会从不同方向钻进来。
验证这段逻辑最有效的不是读代码,而是写一个「重复回调」集成测试——用同一个 trade_no 连发两次 callback,断言订单只变一次 PAID、库存只扣一次、第二次返回成功码 2001。
「创建后 30 分钟未支付自动关单并释放库存」是系统自身的行为,靠定时任务实现:
@Componentpublic class OrderCloseJob { @Scheduled(fixedDelay = 60_000) // 每分钟扫一次 public void closeExpired() { if (!redisLock.tryLock("beeorder:job:close-order", 55, TimeUnit.SECONDS)) { return; // 多实例只有一个抢到锁 } try { // 扫描状态为 CREATED 且已过 expire_time 的订单(走 idx_order_status_expire) List<Long> ids = orderMapper.selectExpiredIds( OrderStatus.CREATED, LocalDateTime.now(ZoneOffset.UTC), 200); ids.forEach(orderService::close); } finally { redisLock.unlock("beeorder:job:close-order"); } }}@Transactional(rollbackFor = Exception.class)public void close(Long orderId) { // 条件更新:只有 CREATED 才能关单,天然防止与支付回调并发重复处理 int rows = orderMapper.updateStatus(orderId, OrderStatus.CREATED, OrderStatus.CLOSED); if (rows == 0) { return; // 已被支付或已关闭,跳过 } for (OrderItem it : orderItemMapper.selectByOrderId(orderId)) { inventoryService.release(it.getProductId(), it.getQuantity(), orderId); // 锁定 → 可售 }}两点必须讲清:第一,关单不是「先查再改」,而是用条件更新 WHERE status = 'CREATED';这样即便「支付回调」和「关单任务」同时到达同一个订单,也只有一个能改成功,另一个影响行数为 0,避免「关了单却又收到支付」。第二,多实例必须防重:@Scheduled 在每个实例都会跑,用 Redis 分布式锁(#40 的 setIfAbsent + 过期时间)保证同一时刻只有一个实例在扫,否则两个实例会把同一批订单各关一次、各回滚一次库存。
release 里同样要写库存流水(type = 'RELEASE'),并靠 uk_invlog_ref_type 唯一键保证「同一订单只释放一次」——关单任务重试或重复执行都不会重复回补库存。
「每次改状态都必须带来源条件」这条纪律,最直观的呈现方式是把订单的一生画成一张状态机——每条出边都写着自己独有的准入条件。点下面每个状态看它的出路,注意 CREATED 那两条出边:支付回调与关单任务争夺的,正是这个订单唯一一次「离开 CREATED」的资格:
列表接口用条件分页(OrderQuery → PageResult),SQL 走 idx_order_user_status_time 联合索引;详情接口则直接读订单项的快照字段,不去 join product——这正是第一篇讲「快照」的兑现:
<!-- 我的订单列表:条件分页,参数全部走索引最左前缀 --><select id="pageByUser" resultType="com.beeorder.order.vo.OrderVO"> SELECT o.order_no, o.status, o.total_amount, o.create_time, o.pay_time FROM `order` o WHERE o.user_id = #{userId} AND o.deleted = 0 <if test="status != null"> AND o.status = #{status} </if> <if test="beginTime != null"> AND o.create_time >= #{beginTime}</if> <if test="endTime != null"> AND o.create_time < #{endTime} </if> ORDER BY o.create_time DESC LIMIT #{size} OFFSET #{offset}</select><!-- 订单详情:订单项直接读快照,商品改名改价不影响历史订单 --><select id="selectItemsByOrderId" resultType="com.beeorder.order.vo.OrderItemVO"> SELECT id, product_id AS productId, product_name AS productName, <!-- 下单时的名字 --> product_price AS productPrice, <!-- 下单时的价格 --> quantity, amount FROM order_item WHERE order_id = #{orderId}</select>- 列表 SQL 的第一个条件是
user_id,正好命中联合索引最左列,状态与时间继续往右用 - 详情 SQL 没有
JOIN product——商品名与价格是快照,直接读order_item - 记得给
order加反引号:它是 SQL 关键字,漏了就是 1064 语法错误
提示:user_id 必须来自 JWT(JwtUtil.currentUserId()),而不是从请求参数取。否则一个 ?userId=别人的id 就能看光他人的订单——这是最典型也最致命的越权漏洞。
缓存的取舍原则只有一句:读多写少、且容忍短暂不一致的数据才配缓存。商品详情完美符合——几乎只读、偶尔编辑:
@Servicepublic class ProductService { /** 商品详情:读多写少,缓存 10 分钟;缓存的是 VO,不是 Entity */ @Cacheable(cacheNames = "product:detail", key = "#id", sync = true) public ProductVO detail(Long id) { Product p = productMapper.selectOnShelf(id); return p == null ? null : ProductConverter.toVO(p); } /** 商品编辑:先写库,再主动删缓存,避免脏读 */ @CacheEvict(cacheNames = "product:detail", key = "#id") public void update(Long id, ProductUpdateDTO dto) { productMapper.update(id, dto); }}库存恰恰相反,不能这样缓存。 它是写热点,缓存库存等于给自己制造「缓存与数据库不一致」的难题:扣减走的是数据库条件更新,缓存里的数字随时滞后。BeeOrder 的边界是:
| 数据 | 能否缓存 | 原因 |
|---|---|---|
| 商品详情(名称、价格) | 能,TTL 10 分钟 | 读多写少,短暂陈旧可接受 |
| 库存数量 | 不缓存(或仅 Redis 预扣场景) | 写热点,缓存必然与条件更新打架 |
| 订单列表/详情 | 不缓存 | 强一致、按用户隔离,命中率低 |
用 @Cacheable 缓存列表或带敏感字段的对象时,别忘了 key 要包含所有影响结果的参数(如 userId、status、page),否则不同用户会读到彼此的缓存——这是缓存引起的越权,比没缓存危险得多。
再好的设计,也要用并发测试证明。核心是 CountDownLatch 让 100 个线程同时起跑,最大化冲突:
@Testvoid concurrentCheckoutShouldNotOversell() throws Exception { int stock = 5, threads = 100; inventoryMapper.seed(productId, stock); // 初始化库存 = 5 CountDownLatch ready = new CountDownLatch(threads); CountDownLatch start = new CountDownLatch(1); AtomicInteger success = new AtomicInteger(); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { final int seq = i; pool.submit(() -> { ready.countDown(); start.await(); // 等发令枪 try { orderService.create(userId, request(seq, 1)); // 每个请求独立幂等键 success.incrementAndGet(); } catch (BizException e) { // 1001 库存不足,属预期 assertEquals(ErrorCode.STOCK_NOT_ENOUGH.getCode(), e.getCode()); } return null; }); } ready.await(); // 100 个线程就绪 start.countDown(); // 发令:同时冲 pool.shutdown(); assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); assertEquals(stock, success.get()); // 恰好 5 单成功 assertEquals(0, inventoryMapper.selectAvailable(productId)); // 库存精确归零 assertEquals(stock, orderMapper.countByUser(userId)); // 订单数 = 库存数}- 每个线程用独立的
idempotentKey,测的是超卖而不是幂等;这两件事各有各的测试 - 断言三条:成功数 = 库存、剩余库存 = 0、订单数 = 库存数——任何一条不成立就说明超卖或漏扣
- 这类测试要在真实数据库上跑(不是 H2 内存库),否则行锁与隔离级别行为对不上
要点:并发测试的价值在于「把偶发问题变成必现」。单次跑可能侥幸通过,务必重复执行(或调大线程数)直到稳定——这正是下一篇交付篇要展开的「可重复的集成测试」。
| 现象 | 根因 | 解决 |
|---|---|---|
| 扣库存了、订单却回滚了 | @Transactional 自调用/private 方法失效,代理未拦截 | 扣库存走跨 Bean 调用;或注入自身代理;事务方法必须 public |
| 库存出现负数 / 超卖 | 扣减 SQL 漏了 WHERE available >= n 条件 | 条件更新是唯一防线,必须写;无符号列再加一层兜底 |
| 并发下大量死锁 | 多行库存按不同顺序加锁,锁范围过大 | 批量扣减按 product_id 升序加锁;WHERE 走唯一索引避免锁表 |
第三个坑值得展开:一个订单含商品 A、B,另一个订单含 B、A,若都按购物车顺序加锁,就会互相等待对方的行锁 → 死锁。统一按 product_id 升序加锁即可打破环路。
第 1 节讲的分层不是画图好看,而是决定对象在每一层换不换脸、事务边界落在哪一层。下面这个实验是「一次自上而下的下单调用」——切到不同参数,你能分别看到三件事:Bean 之间的依赖方向能不能反着来、同一个数据在 Controller / Service / Mapper 三层分别是什么形状(DTO、Entity、VO)、以及事务边界到底压在哪一层的哪个方法上。
选「越层调用代价」那一档还会演示一件很具体的坏事:Controller 绕过 Service 直接调 Mapper,于是那条 SQL 跑在事务外,扣完库存不会随订单回滚——你在第十一节表格里看到的第一个坑,在这里能亲眼复现。
接下来把视角从「一次方法调用」放大到「一个 HTTP 请求穿过全站」。#22 的 DispatcherServlet、#25 的参数校验与异常解析、#26 的过滤器与拦截器、#28 的连接池、本篇的事务,其实是同一条流水线上的五道工序。下面这个实验把它们串成一次真实请求:
这两个实验合起来给出本篇第一条主线:结构上只能自上而下(layer),运行时是一条固定流水线(apiflow)。所有后面会踩的坑,本质都是「在这条线的某一站做了不该做的事」——比如在事务里等远程接口,或者在提交前把消息发出去。
换一种学法:把这一篇的结论拆成几条命令,自己在内核控制台里敲一遍——看回滚怎么发生、唯一索引怎么兜底、限流器怎么选型,比读两遍正文记得牢:
第 4 节列了三种防超卖方案,并说明 BeeOrder 采用 B(乐观条件更新)。这一节补上 C(Redis 预扣)的具体机制,因为它是唯一「把判断和扣减搬到数据库之外」的做法:用一段 Lua 脚本(一整段在 Redis 里原子执行的代码)把「读余量 → 判断够不够 → 扣减」合成一次不可打断的操作。
它的价值在于客户端根本拿不到中间态:没有「我刚才读到的数」这个东西,所以物理上不可能超卖。代价是这份「余量」从此活在内存里而不是活在数据库里——这就是第四节末尾说的「第二套事实源」:宕机、异步落库失败、多个 key 之间不原子,全要靠对账与补偿兜回来。
那么并发真上来时,先动哪个旋钮?下面这个沙盘把「并发量」和「是否启用 Redis 预扣」放在一起,同时盯住三个读数:库存一致性、P99、数据库活跃连接数。
成功下单:5 单,其余 295 单返回 1001剩余库存:0(依然没有超卖)P99:1876ms ↑↑DB 活跃连接:10/10 —— Connection is not available, request timed out after 3000ms# 一致性没问题,问题在排队:300 个请求抢同一行行锁,而连接只有 10 条
沙盘的结论可以直接背——没有超卖不等于没问题。判断依据始终是那三个数字:成功数是否等于库存数、P99 是否失控、连接池是否被打满。只要有一个不对,就该开始限流,而不是急着把 MySQL 换成 Redis。
第三节把「远程调用与消息发送必须移出事务」讲成了纪律,但纪律需要一个能亲手验证的地方。这件事由两个组件配合完成:Spring 事件机制(#41:事务内 publishEvent,监听器与发布者在同一线程里同步执行)+ @TransactionalEventListener(phase = AFTER_COMMIT)(阶段监听器——只有事务真的提交了才会被叫起来干活的那种监听器)。
// ① 事务内:只发布,不做任何外部动作applicationEventPublisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getOrderNo()));// ② 提交后:监听器才被叫醒,此刻消息带出去,消费者一定查得到这张订单@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)public void onOrderCreated(OrderCreatedEvent e) { mqProducer.send("order.created", e.orderNo()); // 放 MQ / 发短信 / 加积分}坑:漏写 @TransactionalEventListener 而直接用 @EventListener,通知会在 commit 之前就被发出。用户收到「下单成功」的短信、点进详情页却是「订单不存在」——这条竞态窗口通常只有几毫秒,但在测试环境里几乎必然出现,在生产日志里又几乎必然抓不到。第九节的缓存回填也排在同一条时间线上,别把它挪到提交前。
下面三个实验分别把这三处不确定性摊开。第一个专治「我的 @Transactional 到底生效没有」——七种传播行为(REQUIRED / REQUIRES_NEW / NESTED / SUPPORTS / NOT_SUPPORTED / NEVER / MANDATORY)摆在一起,外加回滚规则那一档:
第二个专门演示「提交之前把消息发出去」的后果,以及换成阶段监听器之后的差别:
第三个回到第九节的缓存边界——为什么商品详情敢缓存、库存不敢:

上图把两条最容易排错的时间线合并画在了一起:缓存的正确顺序是「先写库、再删缓存」,消息的正确顺序是「先提交、再发」。两者共用同一句心法——凡是让外面看得见的动作(缓存里的值、MQ 里的消息、用户手机上的短信),都必须排在 COMMIT 之后。

最后用这张对照图收住第四节的分歧:悲观锁与 Redis 预扣各解决了什么、又各自新引入了什么麻烦,右边是本项目采用的乐观条件更新。真正要看的是底部那句——判断和扣减之间有没有时间窗口,这一个差别就决定了会不会超卖。
到这里,本篇的机制已经全部出场。考你一下:每一种机制到底挡住了哪种坏事?先点左边的机制,再点右边它负责封死的故障——这些配对背下来,线上出问题时的第一反应就会很不一样:
下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在核心业务里最常撞的墙,都在这里。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
Duplicate entry '1001-7f3a...' for key 'order.uk_order_idem' | 幂等键重复:客户端重放、用户连点两次、或重试逻辑复用了同一个键 | 这不是 Bug,正是唯一索引在兜底:捕获 DuplicateKeyException 后查出首次结果原样返回,别抛 500 | 本篇第五节 · #44 唯一键设计 |
Transaction timed out: deadline was ...(伴随 @Transactional(timeout = 3)) | 长事务:事务里夹了远程调用、短信、HTTP 请求,或一次扫了上万行 | 顺着日志找耗时最长的那一步;把非 DB 动作挪到 AFTER_COMMIT 监听器里,事务只留 SQL | 本篇第三节 · #31 事务内核 |
org.springframework.dao.CannotAcquireLockException / Deadlock found when trying to get lock | 两个事务里的多行库存按不同顺序加锁,互相等待对方的行锁 | 批量扣减统一按 product_id 升序;确认 WHERE 走唯一索引而不是全表扫描 | 本篇第十一节 |
Connection is not available, request timed out after 30000ms(HikariPool-1) | 连接被长事务占住:默认 maximumPoolSize=10,十个慢请求就能让第 11 个开始排队 | 先查是不是事务里调了外部接口;再看 spring.datasource.hikari.maximum-pool-size 是否照搬了本地默认值 | 第十三节沙盘 · #28 连接池 |
加了 @Transactional 的方法抛了异常,数据却照样落库(一行都没回滚) | 自调用失效:this.lockStock() 没经过代理;或注解标在 private / final 方法上;或你抛的是受检异常而没写 rollbackFor | 三查:方法是不是 public、调用是不是跨 Bean、异常是不是 RuntimeException。必要时刻注入自身代理(@Lazy 自注入) | 本篇第三节 · #14 AOP 内幕 |
| 用户收到「下单成功」短信,紧接着查订单返回「不存在」 | 消息在事务提交前发出(用了 @EventListener 而不是 @TransactionalEventListener(phase = AFTER_COMMIT)),消费者比 COMMIT 跑得更快 | 把监听器改成阶段监听器;若必须立刻发,改用「本地消息表 + 定时投递」保证至少一次 | 第十四节 · #41 事件与 MQ |
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only | 内层 @Transactional 抛异常并把事务标记为回滚,外层把异常 catch 掉想继续提交——但标记已经擦不掉 | 别让内层复用外层事务:改成 REQUIRES_NEW,或把这段抽到独立 Service 方法里单独开事务 | 第十四节 txprop 实验 · #31 |
| 改完商品价格列表页一直显示旧价;或直接读到别人用户的缓存 | 缓存与库不一致:只写了库没 @CacheEvict;或缓存 key 漏掉了 userId 这类影响结果的参数 | key 必须包含所有影响输出的参数;写路径一律「先写库、再删缓存」;怀疑脏了就把 TTL 临时调到 30s 观察 | 本篇第九节 · #32 Redis 与缓存 |
这八条里有三条(幂等冲突、AFTER_COMMIT 缺失、缓存越权)根本不以红字异常出现——程序跑得挺欢,只是数据错了。所以核心业务的验收不能只看「有没有报错」,必须像第十节那样跑并发测试,再用第十七节的练习逐条核对。
表格第 7 行那种「收尾才炸」的异常,值得单独留一个现场:它是全篇唯一一个「业务日志看起来一切正常」的故障——外层明明 catch 了异常、还打了日志,最后却在提交阶段翻车。先别看答案,点出你认为的凶手帧:
下单偶发 500,数据看着却「正常」:订单没建、库存也没扣。日志里外层明明 catch 了异常,还打印了「重复下单,返回首次结果」——可返回给客户端的却是 500。
先来一道热身题,考的是第四节那条「唯一防线」:
再来一道综合题,把第三、九、十四节串起来:
目标:跑通一个最小但完整的并发验证——条件更新如何把超卖关死,以及影响行数怎么变成业务错误码。全程只需要 JDK 与 Maven,数据库用一个免费的 MySQL 8 镜像。
第一步,起一个真 MySQL(不要用 H2,行锁行为对不上):
docker run -d --name bee-mysql -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=bee -e MYSQL_DATABASE=beeorder mysql:8.0第二步,建一张最小的库存表并塞进 3 件货(sql/step1.sql):
CREATE TABLE inventory ( product_id BIGINT NOT NULL PRIMARY KEY, available INT UNSIGNED NOT NULL, -- UNSIGNED 让负数成为第二道保险 version INT NOT NULL DEFAULT 0);INSERT INTO inventory (product_id, available) VALUES (2001, 3);第三步之前,先把这份 pom.xml 生成出来——勾选这一档真正用到的依赖,每一项都会告诉你为什么存在;等做到第三档要补 Redis 防重锁时,再回来把 Redis 勾上:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>第三步,写主类(pom.xml 只需 spring-boot-starter-jdbc 与 mysql-connector-j):
package com.example.stock;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.jdbc.core.JdbcTemplate;import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicInteger;@SpringBootApplicationpublic class StockApp { public static void main(String[] args) throws Exception { var ctx = SpringApplication.run(StockApp.class, args); JdbcTemplate jdbc = ctx.getBean(JdbcTemplate.class); int threads = 10; // 10 个并发请求抢 3 件货 var ready = new CountDownLatch(threads); var gun = new CountDownLatch(1); // 发令枪:真正把并发压到同一瞬间 var ok = new AtomicInteger(); var pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { ready.countDown(); gun.await(); // 关键:判断与扣减写在同一条 SQL 里 int rows = jdbc.update( "UPDATE inventory SET available = available - 1, version = version + 1 " + " WHERE product_id = 2001 AND available >= 1"); if (rows == 1) ok.incrementAndGet(); // 抢到了 else System.out.println("库存不足,拒绝下单"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } ready.await(); gun.countDown(); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); Integer remain = jdbc.queryForObject( "SELECT available FROM inventory WHERE product_id = 2001", Integer.class); System.out.println("成功=" + ok.get() + " 剩余库存=" + remain); ctx.close(); }}第四步,编译运行:
mvn -q compile exec:java -Dexec.mainClass=com.example.stock.StockApp预期输出(「库存不足」那几行的顺序和条数会交错,但最后一行统计必须稳定):
库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单成功=3 剩余库存=0最后一行必须是 成功=3 剩余库存=0。哪怕你把线程数改成 200、再重复跑十次,这个结论也不许变——一旦变了,就说明你的 SQL 里少了 AND available >= 1。
验收清单:① 说清 ready 和 gun 两个闩各自挡在哪一步;② 手动删掉 AND available >= 1 重跑,观察报错或负数(UNSIGNED 列会先把负数顶回去,那就是第二道保险在工作);③ 解释为什么这里根本不需要先 SELECT 一次库存。
- 把上面那条
UPDATE拆成「先queryForObject读 available,Java 里判断后再update」,其余不动。你会观察到:成功=常常大于 3,库存变成负数或被UNSIGNED拒绝——这就是「检查与行动之间的时间窗口」,也是第四节方案 A/B 的本质区别。要修好它你得补FOR UPDATE并把整段包进事务,于是顺路复现了第十三节沙盘里连接被长时间占用的场景。 - 把扣减挪进一个标了
@Transactional的 Service 方法,却在同一个类里用this.deduct(...)调用它,并在扣减之后throw new RuntimeException()。你会观察到:订单没插进去、库存却被扣走了——自调用绕过了代理,注解形同虚设。把调用改成注入另一个 Bean 再调,一致性立刻恢复。 - 在事务方法里加一句
Thread.sleep(200)模拟远程调用,同时把spring.datasource.hikari.maximum-pool-size设为 4,然后用 20 个线程压。你会观察到:日志出现Connection is not available, request timed out after 30000ms,而数据库几乎没什么负载——瓶颈在连接被事务占住,不在 SQL。 - 把监听器从
@TransactionalEventListener(phase = AFTER_COMMIT)改成@EventListener,并在监听器里用同一个JdbcTemplate反查这张订单。你会观察到:偶发甚至高频查到null,因为监听器跑在 COMMIT 之前。
提示:做完第 2 条再回头看第十五节表格第 5 行,两边讲的是同一件事。
需求:在第一档的工程上补齐一个能交付的最小闭环。
- 订单表加
idempotent_key,与user_id组成唯一索引;插入冲突时捕获DuplicateKeyException,返回首次创建的订单号(不许把错误抛给客户端) - 每次扣减都往
inventory_log写一条流水,(ref_id, type)建唯一键,type取LOCK/RELEASE/COMMIT三种之一 - 关单逻辑写成条件更新
WHERE status = 'CREATED',并用一个@Scheduled任务每分钟扫一次过期订单;用 RedissetIfAbsent+ 过期时间做多实例防重 - 「下单成功」的通知走
@TransactionalEventListener(phase = AFTER_COMMIT),监听器里只做打印,模拟发消息 - 提供一条命令:
mvn -q test跑完三类测试(单元 / 切片 / 并发集成),全绿才算完成
验收清单:① 用同一个幂等键连发 20 次请求,订单表只有 1 行、流水只有 1 条;② 100 线程抢 5 件库存,成功数恰好 5 且剩余库存为 0;③ 人为让通知监听器抛异常,确认订单仍是 PAID(通知失败不能回滚订单);④ 把 AFTER_COMMIT 改回 @EventListener,并发测试应出现可复现的「查到 null」——这条反向验证要写进 README,作为团队的下意识红线。
不看上文,说出「判断 + 扣减」为什么必须写在同一条 SQL 里,以及影响行数为 0 时应用层应该做什么。
@Transactional 静默失效的三个常见姿势分别是什么?为什么它们都不报错?
哪些操作绝对不能放进事务?给出理由时请用「占住了什么资源」来回答,而不是「不规范」。
为什么下单通知必须挂在 @TransactionalEventListener(phase = AFTER_COMMIT) 上?如果这个下游动作失败,订单要不要回滚?
商品详情可以缓存、库存不可以——差别究竟落在哪一个维度上?如果未来非要缓存库存,你必须额外准备什么?
扣减要一条语句写完,事务里不干网络的事,消息等提交之后再发,缓存只放不怕旧的数。
这一篇把「合同」变成了真正能跑的代码。核心是四件事:用 @Transactional(rollbackFor = Exception.class) 把订单、库存、流水绑成一个原子操作;用 UPDATE ... WHERE available >= n 的条件更新从物理上杜绝超卖;用唯一索引 + 状态机把下单与支付的幂等做在数据库层;用 CountDownLatch 并发测试证明「100 个请求抢 5 件库存,最后恰好 5 单成功」。另外别忘了三条纪律:远程调用与消息发送移出事务、定时任务用 Redis 锁防多实例重跑、@Transactional 自调用会静默失效。至此 BeeOrder 的主链路已经闭合——下一篇,我们给它配上单测、集成测试、CI/CD 与容器化部署,让它真正上线。