实战③:核心业务实现(事务、库存与幂等)

bee2026-10-0885 分钟0 次阅读
下单主流程完整落地:库存校验与扣减、订单落库、事务边界划分、幂等键与乐观锁防超卖、支付回调与超时关单——BeeOrder 最关键的一段代码。
1 / 170
小节
〇、30 秒看懂
2 / 170

前面 44 篇把零件全交到你手上了:容器、代理、事务、缓存、消息、定时任务。这一篇要干的事只有一件——把它们按对的位置装进「下单」这条链路,让它在 100 个人同时抢 5 件货时依然不超卖、不漏单、不发假通知。所谓核心业务实现,难点从来不是「代码写不出来」,而是「边界放错位置」:哪些动作必须捆在一起成功或失败?哪个数字绝对不能被缓存?哪条消息绝对不能提前发?这篇逐条给你答案,并且每一条都能亲手跑出来。

3 / 170

先把五个词一句话解释(全文反复出现):

4 / 170
  • 事务(transaction):一组数据库操作「要么全部生效、要么全部撤销」的打包方式,靠 @Transactional 这个注解声明
  • 幂等键(idempotency key):客户端为「一次下单意图」生成的唯一编号,同一个键重复提交十次也只产生一张订单
  • 乐观锁 / 条件更新:不加显式锁,而是在 UPDATE 的 WHERE 里写上「库存还够」这个前提;前提不成立,这条语句就一行都改不动
  • 行锁:数据库为了保护同一行数据而临时挂上的牌子,同一行的写操作只能排队,牌摘掉(事务结束)才轮到下一个人
  • 超卖:库存只有 5 件却卖出 6 件——本质是「读到的数」和「真正扣的数」之间隔了一段时间,这段时间里数被别人改了
5 / 170
类比

演唱会抢票。 一个座位不能被两个人锁住,靠的不是「我先看了一眼还剩这张」,而是「我把这张票当场按进系统里」——票务系统处理这两件事之间不能有时间差,否则你看到的余票永远是旧的。BeeOrder 防超卖的整段设计就是这一句话:把「判断还有货」和「把这货扣下」合并成一条 SQL(UPDATE ... WHERE available >= n),中间不给任何人插队的缝隙。乐观锁是「检票闸机」(一次只过一个,谁先抢到算谁的),悲观锁 FOR UPDATE 是「把整个包厢钥匙拿走」(我看完之前别人只能在门外站着)。

6 / 170
类比

旋转餐厅的玻璃墙。 事务开着的时候,你占着一张桌子和一段连接(数据库连接池里的一个连接);里面还没结账(没 commit),外面排队的客人就只能看着。所以「在事务里调远程接口」等于把外卖小哥也拉进来坐在你的位子上等他取餐——桌子被占的时间从 5ms 变成 200ms,高峰期整座餐厅(连接池)直接停摆。这就是第三节所有取舍的来源。

7 / 170
架构图
图 · 核心业务用例地图
图 · 核心业务用例地图
8 / 170

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

9 / 170
原理动画
动图 · 下单六步:提交之前不发通知
动图 · 下单六步:提交之前不发通知
10 / 170

这条时间线是本篇最重要的一张图,后面每一节都在拆它的某一步。先记住一个结论:前三步在事务门外,中间三步在同一次提交里,最后一步一定在提交之后。

11 / 170

学完这一篇,你应该能回答三个问题:

12 / 170
  1. 为什么「查一下还有没有货,再决定扣不扣」这种写法一定会超卖,而把它写成一条 SQL 就不会?
  2. 为什么 @Transactional 有时看起来完全没生效——既不报错也不回滚?它失效的三个常见姿势是什么?
  3. 「下单成功」的通知应该在哪一刻发出?早一步会怎样,晚一步又会怎样?
13 / 170
小节
一、分层落地总览:谁负责什么
14 / 170

前两篇定好了表与契约,这一篇开始写真正跑得动的代码。BeeOrder 的分层纪律只有一句话:Controller 只做协议转换,Service 承载业务与事务,Mapper 只碰 SQL。三者职责一旦混淆,事务就会失控——这也是后面所有坑的根源。

15 / 170
对照表
层包职责绝对不做的事
Controllercom.beeorder.order.controller取参数、调 Service、包 Result不写业务分支、不碰 Mapper、不开事务
Servicecom.beeorder.order.service业务编排、事务边界、幂等与状态流转不拼 SQL、不返回 Entity 给上层
Mappercom.beeorder.order.mapper单条 SQL,条件更新与唯一约束在此兜底不做业务判断、不做多次查询编排
16 / 170

本次交付落在这些类里:order.controller.OrderController、order.service.OrderService、order.mapper.OrderMapper / OrderItemMapper、inventory.service.InventoryService、inventory.mapper.InventoryMapper、payment.service.PaymentService,跨模块调用遵循「order 调 inventory,单向依赖」的边界——这正是上一篇定下的 available / locked / version 字段要被用上的地方。

17 / 170
内核实验
TeaVMService 互相依赖时会发生什么未启动
订单服务与库存服务互相注入——正是循环依赖现场
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
18 / 170

上图的沙盘演示了一个真实的风险:OrderService 需要 InventoryService 扣库存,而如果 InventoryService 又反过来注入 OrderService(比如为了回查订单),构造器注入会在启动时直接报循环依赖。边界纪律不是纸上谈兵,它决定了你的项目能不能正常启动。

19 / 170
小节
二、下单主流程:从请求到订单号
20 / 170

下单是整个项目最关键、最容易出错的链路。先看全景,再逐段啃代码:

21 / 170
架构图
图 1 · 下单主流程
图 1 · 下单主流程
22 / 170
小节
2.1 Controller:幂等键从 Header 取
23 / 170

幂等键是「一次下单意图」的身份证,按上一篇的契约,它放在 Header 里而不是 Body 里——这样即便请求体被重放,键也不会被篡改,且便于网关统一处理:

24 / 170
java
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));    }}
25 / 170
小节
2.2 OrderService.create():核心编排
26 / 170

这是全篇最该逐行读懂的一段。它把「校验 → 计算 → 落库 → 扣库存 → 写流水」串成一次事务:

27 / 170
java
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);    }}
28 / 170

分步解读:

29 / 170
  • 第 1 步幂等:先查 (user_id, idempotent_key),命中说明这单已经下过,直接返回首次结果,避免后面的插入冲突——这是快路径,真正的兜底在唯一索引。
  • 第 2 步校验与计算:selectOnShelf 只返回上架商品;金额用 BigDecimal.multiply 逐项累加,一处 double 都不能有。
  • 第 3 步落库:订单先落库拿到 id,订单项再带上 order_id 批量插入;OrderItem.snapshotOf 冻结商品名与单价快照。
  • 第 4 步扣库存:交给 InventoryService.lock,在里面用条件更新与行数判断决定成败。
30 / 170
要点

注意主流程里没有看到 Redis、没有看到消息发送。这些都是「事务不能容忍」的操作,第五节会讲清楚它们为什么必须挪出去。

31 / 170
小节
三、事务边界:哪些操作必须在同一个事务里
32 / 170

「创建订单 + 扣库存 + 写流水」是 一组原子事实:订单建了库存没扣,会超卖;库存扣了订单没建,会凭空少货。所以它们必须在同一个事务里,要么全成,要么全回滚。

33 / 170
java
@Transactional(        rollbackFor = Exception.class,   // ② 连受检异常也回滚        timeout = 3,                     // ③ 超时 3 秒,防止慢查询长时间持锁        isolation = Isolation.READ_COMMITTED)public OrderVO create(Long userId, CreateOrderRequest req) { ... }
34 / 170

为什么写 rollbackFor = Exception.class?Spring 默认只在遇到 RuntimeException 和 Error 时回滚。如果代码里抛了一个受检异常(比如某个查询包装后的 IOException),不加这一项,事务会照常提交——订单建了、库存却没扣。显式写成 Exception.class 是一道不写就等着踩的保险。

35 / 170

事务粒度也要有边界,判断标准是「这个操作失败时,是否必须抹掉前面所有动作」:

36 / 170
对照表
放在事务内移出事务(事务提交后)
插入订单、订单项发送下单成功通知
扣减库存、写库存流水写操作日志、埋点上报
更新订单状态调用远程服务、发 MQ 消息
37 / 170

为什么远程调用与消息发送必须移出? 它们不是数据库操作,却会长时间占用数据库连接与行锁。一个 200ms 的短信接口卡在事务里,就意味着这一行的库存锁多持 200ms,高并发下直接演变成锁等待与连接池耗尽。正确做法是:事务内只落库,事务提交后再发消息。

38 / 170
内核实验
TeaVM下单主流程的事务实验未启动
切到「外层回滚」,看订单与流水的最终状态
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
39 / 170

上面这个沙盘把「事务提交」与「事务回滚」两种结局摆在一起:切到回滚,你会看到订单行与库存流水一起消失——这就是原子性的直观含义。要发通知,更稳妥的是用事件机制:在事务内发布一个事件,监听器用 @TransactionalEventListener(phase = AFTER_COMMIT) 只在提交后才执行(#41 讲过),既保证「不早发」,又保证「不占锁」。

40 / 170
坑

@Transactional 挂在 private 方法、或方法内 this.xxx() 自调用时完全失效——因为 Spring 靠代理拦截,自调用不走代理。第 4 节里 inventoryService.lock 是跨 Bean 调用才生效;如果你把扣库存写成 this.lockStock(),事务注解会被静默忽略,扣库存就不会随订单回滚。

41 / 170
小节
四、库存扣减与防超卖(三种方案)
42 / 170

库存是整个项目里唯一会被高并发争抢的数据。三个请求同时买最后一件,处理不当就会卖出三件——这就是超卖。三种主流方案,先看对比:

43 / 170
对照表
方案原理优点缺点适用量级
A 悲观锁SELECT ... FOR UPDATE 锁行后再更新实现直观、强一致持锁时间长、并发差、易死锁低并发、强一致场景
B 乐观锁(条件更新)UPDATE ... WHERE available >= n 原子更新无显式锁、并发好、实现简单高冲突时失败率上升绝大多数电商(推荐)
C Redis 预扣Lua 原子扣减内存库存,异步落库吞吐最高、挡住流量一致性弱、宕机丢数据、要补偿秒杀级流量
44 / 170
小节
4.1 方案 A:悲观锁
45 / 170
sql
-- 必须在事务内,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};
46 / 170

锁范围一旦没走索引(WHERE 字段无索引)就会升级成锁全表,是最容易引发死锁的写法。

47 / 170
小节
4.2 方案 B:乐观锁条件更新(BeeOrder 采用)
48 / 170

核心思想是把「判断」和「扣减」合并成一条原子 SQL,让数据库在加行锁的一瞬间完成校验:

49 / 170
xml
<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>
50 / 170
java
Long productId, int quantity; // 调用方传入int rows = inventoryMapper.lockStock(productId, quantity);if (rows == 0) {                                  // 影响行数为 0 = 条件未命中    throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);   // 1001 库存不足}
51 / 170

为什么 available >= #{quantity} 就是防超卖的答案? 因为这条 UPDATE 在数据库里对目标行加排他锁、读取、判断、写入是一气呵成的:库存 5、三个请求各买 2,只有一个能成功,其余两个执行时 available 已不足,WHERE 不成立 → 影响行数 0 → 抛 1001。判断与扣减之间没有任何时间窗口,超卖从物理上被杜绝。

52 / 170

那 version 列还要不要?要,但它解决的是另一类问题:当你必须先 SELECT 出来、在应用层算完再写回时(比如「库存不足则触发补货逻辑」),需要 AND version = #{v} 来检测期间有没有别人改过。纯粹的自减场景,available >= n 已经足够。

53 / 170
小节
4.3 方案 C:Redis 预扣
54 / 170

把库存放到 Redis,用 Lua 脚本保证「判断 + 扣减」原子:

55 / 170
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])  -- 返回剩余库存
56 / 170

它的价值是把流量挡在数据库之外,代价是引入第二套事实源:Redis 扣成功、异步落库失败怎么办?Redis 宕机重启、预扣数据丢失怎么办?这些都得靠对账与补偿兜底——只有在秒杀那种「数据库绝对扛不住」的流量下才值得。

57 / 170
原理动画
动图 · 并发下单如何不超卖
动图 · 并发下单如何不超卖
58 / 170
要点

乐观锁的失效场景只有一个——同一行的并发冲突率极高(比如 1000 QPS 抢同一件库存)。这时大量请求会拿到影响行数 0,重试也没用(真没货了)。此时该做的是限流或排队,而不是换 Redis——因为瓶颈根本不在锁,而在库存这个稀缺资源本身。

59 / 170
小节
五、幂等落地:唯一索引兜底
60 / 170

第 2 节的主流程里有一次「幂等前置检查」,但那次查询不能作为唯一防线:并发下两个请求可能同时查不到、同时插入。真正的兜底是上一篇设计的那条唯一索引 uk_order_idem (user_id, idempotent_key)。

61 / 170
java
try {    orderMapper.insert(order);} catch (DuplicateKeyException e) {    // 唯一键冲突 = 并发重复提交:查出首次结果返回,不再继续扣库存    Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey());    log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo());    return assembleVO(exist);}
62 / 170

捕获冲突后不是报错,而是返回首次结果——对用户来说,两次点击得到同一个订单,这就是幂等。三个幂等点的字段沿用上一篇的设计,一个字都没改:

63 / 170
对照表
幂等场景幂等键约束方式
下单order.idempotent_key(客户端 UUID)唯一键 (user_id, idempotent_key)
支付回调payment.trade_no(网关流水号)唯一键 uk_payment_trade_no + 状态校验
库存扣减inventory_log 的 (ref_id, type)唯一键 uk_invlog_ref_type
64 / 170
坑

在 @Transactional 方法里捕获 DuplicateKeyException 要小心——有些数据库驱动/配置下,唯一键冲突会把当前事务标记为 rollback-only,之后即使你 return 了首次结果,提交阶段也会抛 UnexpectedRollbackException。稳妥做法是把「插入订单」抽到独立事务方法里(REQUIRES_NEW 或单独的 Service),在它之外捕获冲突,避免污染外层事务。

65 / 170

上面这段代码在并发下的真实走位,值得用一段动画补齐:两个请求就算同时越过前置检查,也只有一个能真正插入成功——另一个被数据库的唯一索引当场拦下,再被代码捡回来,返回首次结果:

66 / 170
原理动画
动图 · 双击提交为什么只会产生一张订单
动图 · 双击提交为什么只会产生一张订单
67 / 170
小节
六、支付回调:验签、幂等、状态机
68 / 170

支付回调是唯一对外开放的写接口,也是攻击面最大的地方。它不校验 JWT,但必须做三件事:验签、幂等、状态机校验。

69 / 170
java
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);                // ③ 固定成功应答,让网关别再重发    }}
70 / 170

PaymentService.handleCallback 就是上一篇给出的两道防线落地:

71 / 170
java
@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());   // 锁定库存转实扣}
72 / 170

三处校验的位置很关键:验签在 Controller(挡掉伪造请求)、幂等在 Service 开头(挡掉重复回调)、状态机在条件更新里(挡掉「已关闭订单又支付成功」)。三者缺一,脏数据就会从不同方向钻进来。

73 / 170
提示

验证这段逻辑最有效的不是读代码,而是写一个「重复回调」集成测试——用同一个 trade_no 连发两次 callback,断言订单只变一次 PAID、库存只扣一次、第二次返回成功码 2001。

74 / 170
小节
七、超时关单:定时任务与多实例防重
75 / 170

「创建后 30 分钟未支付自动关单并释放库存」是系统自身的行为,靠定时任务实现:

76 / 170
java
@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");        }    }}
77 / 170
java
@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);  // 锁定 → 可售    }}
78 / 170

两点必须讲清:第一,关单不是「先查再改」,而是用条件更新 WHERE status = 'CREATED';这样即便「支付回调」和「关单任务」同时到达同一个订单,也只有一个能改成功,另一个影响行数为 0,避免「关了单却又收到支付」。第二,多实例必须防重:@Scheduled 在每个实例都会跑,用 Redis 分布式锁(#40 的 setIfAbsent + 过期时间)保证同一时刻只有一个实例在扫,否则两个实例会把同一批订单各关一次、各回滚一次库存。

79 / 170
说明

release 里同样要写库存流水(type = 'RELEASE'),并靠 uk_invlog_ref_type 唯一键保证「同一订单只释放一次」——关单任务重试或重复执行都不会重复回补库存。

80 / 170

「每次改状态都必须带来源条件」这条纪律,最直观的呈现方式是把订单的一生画成一张状态机——每条出边都写着自己独有的准入条件。点下面每个状态看它的出路,注意 CREATED 那两条出边:支付回调与关单任务争夺的,正是这个订单唯一一次「离开 CREATED」的资格:

81 / 170
交互图解
回路订单状态机:每次改状态都要带「从哪来」1 / 6
顺序点过六个状态,重点看 CREATED 的两条出边——它们共用同一个裁决依据:WHERE status = 'CREATED' 的条件更新
→
→
→
→
→
↻
CREATED 已创建
下单成功、等待支付;expire_time 已写入,30 分钟没动静就会被关单任务盯上,两条出边注定只有一条能走通。
全部看懂了条件更新不只用在扣库存——订单的每一次状态跳转,写入条件里都必须带「从哪个状态来」。
82 / 170
小节
八、订单查询:分页与快照读取
83 / 170

列表接口用条件分页(OrderQuery → PageResult),SQL 走 idx_order_user_status_time 联合索引;详情接口则直接读订单项的快照字段,不去 join product——这正是第一篇讲「快照」的兑现:

84 / 170
代码对照
代码xml
<!-- 我的订单列表:条件分页,参数全部走索引最左前缀 --><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 &gt;= #{beginTime}</if>     <if test="endTime != null">   AND o.create_time &lt;  #{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 就能看光他人的订单——这是最典型也最致命的越权漏洞。

85 / 170
小节
九、缓存策略:商品缓存与库存的边界
86 / 170

缓存的取舍原则只有一句:读多写少、且容忍短暂不一致的数据才配缓存。商品详情完美符合——几乎只读、偶尔编辑:

87 / 170
java
@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);    }}
88 / 170

库存恰恰相反,不能这样缓存。 它是写热点,缓存库存等于给自己制造「缓存与数据库不一致」的难题:扣减走的是数据库条件更新,缓存里的数字随时滞后。BeeOrder 的边界是:

89 / 170
对照表
数据能否缓存原因
商品详情(名称、价格)能,TTL 10 分钟读多写少,短暂陈旧可接受
库存数量不缓存(或仅 Redis 预扣场景)写热点,缓存必然与条件更新打架
订单列表/详情不缓存强一致、按用户隔离,命中率低
90 / 170
坑

用 @Cacheable 缓存列表或带敏感字段的对象时,别忘了 key 要包含所有影响结果的参数(如 userId、status、page),否则不同用户会读到彼此的缓存——这是缓存引起的越权,比没缓存危险得多。

91 / 170
小节
十、关键测试:并发下单不超卖
92 / 170

再好的设计,也要用并发测试证明。核心是 CountDownLatch 让 100 个线程同时起跑,最大化冲突:

93 / 170
代码对照
代码java
@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 内存库),否则行锁与隔离级别行为对不上

要点:并发测试的价值在于「把偶发问题变成必现」。单次跑可能侥幸通过,务必重复执行(或调大线程数)直到稳定——这正是下一篇交付篇要展开的「可重复的集成测试」。

94 / 170
小节
十一、三个必须避开的坑
95 / 170
对照表
现象根因解决
扣库存了、订单却回滚了@Transactional 自调用/private 方法失效,代理未拦截扣库存走跨 Bean 调用;或注入自身代理;事务方法必须 public
库存出现负数 / 超卖扣减 SQL 漏了 WHERE available >= n 条件条件更新是唯一防线,必须写;无符号列再加一层兜底
并发下大量死锁多行库存按不同顺序加锁,锁范围过大批量扣减按 product_id 升序加锁;WHERE 走唯一索引避免锁表
96 / 170

第三个坑值得展开:一个订单含商品 A、B,另一个订单含 B、A,若都按购物车顺序加锁,就会互相等待对方的行锁 → 死锁。统一按 product_id 升序加锁即可打破环路。

97 / 170
小节
十二、两个实验:把「分层」和「一个请求的完整旅程」摊开看
98 / 170

第 1 节讲的分层不是画图好看,而是决定对象在每一层换不换脸、事务边界落在哪一层。下面这个实验是「一次自上而下的下单调用」——切到不同参数,你能分别看到三件事:Bean 之间的依赖方向能不能反着来、同一个数据在 Controller / Service / Mapper 三层分别是什么形状(DTO、Entity、VO)、以及事务边界到底压在哪一层的哪个方法上。

99 / 170
内核实验
TeaVM一次下单自上而下:谁调谁、事务落在哪未启动
先看「一次下单自上而下」确认调用只能向下;再切「对象在各层的形态」看清 DTO→Entity→VO 的三次换装;最后切「事务边界落在哪」——它必须落在 Service 的公开方法上,这正是第三节的前提
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
100 / 170

选「越层调用代价」那一档还会演示一件很具体的坏事:Controller 绕过 Service 直接调 Mapper,于是那条 SQL 跑在事务外,扣完库存不会随订单回滚——你在第十一节表格里看到的第一个坑,在这里能亲眼复现。

101 / 170

接下来把视角从「一次方法调用」放大到「一个 HTTP 请求穿过全站」。#22 的 DispatcherServlet、#25 的参数校验与异常解析、#26 的过滤器与拦截器、#28 的连接池、本篇的事务,其实是同一条流水线上的五道工序。下面这个实验把它们串成一次真实请求:

102 / 170
内核实验
TeaVM一个下单请求穿过全站未启动
先跑「成功链路」;再依次切「参数校验失败」「业务异常」「数据库故障」「慢请求与超时」——注意四条失败路径分别在哪一道工序被拦下,以及它们各自对应第十五节速查表里的哪一种报错文案
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
103 / 170

这两个实验合起来给出本篇第一条主线:结构上只能自上而下(layer),运行时是一条固定流水线(apiflow)。所有后面会踩的坑,本质都是「在这条线的某一站做了不该做的事」——比如在事务里等远程接口,或者在提交前把消息发出去。

104 / 170

换一种学法:把这一篇的结论拆成几条命令,自己在内核控制台里敲一遍——看回滚怎么发生、唯一索引怎么兜底、限流器怎么选型,比读两遍正文记得牢:

105 / 170
内核控制台
106 / 170
小节
十三、库存争抢的第三种方案,以及「该限流还是该换方案」
107 / 170

第 4 节列了三种防超卖方案,并说明 BeeOrder 采用 B(乐观条件更新)。这一节补上 C(Redis 预扣)的具体机制,因为它是唯一「把判断和扣减搬到数据库之外」的做法:用一段 Lua 脚本(一整段在 Redis 里原子执行的代码)把「读余量 → 判断够不够 → 扣减」合成一次不可打断的操作。

108 / 170

它的价值在于客户端根本拿不到中间态:没有「我刚才读到的数」这个东西,所以物理上不可能超卖。代价是这份「余量」从此活在内存里而不是活在数据库里——这就是第四节末尾说的「第二套事实源」:宕机、异步落库失败、多个 key 之间不原子,全要靠对账与补偿兜回来。

109 / 170

那么并发真上来时,先动哪个旋钮?下面这个沙盘把「并发量」和「是否启用 Redis 预扣」放在一起,同时盯住三个读数:库存一致性、P99、数据库活跃连接数。

110 / 170
沙盘
沙盘超卖沙盘:并发冲上来时该动哪个旋钮
运行结果
成功下单:5 单,其余 295 单返回 1001
剩余库存:0(依然没有超卖)
P99:1876ms ↑↑
DB 活跃连接:10/10 —— Connection is not available, request timed out after 3000ms
# 一致性没问题,问题在排队:300 个请求抢同一行行锁,而连接只有 10 条
最容易被误诊的一幕:有人看到没超卖就以为一切正常,其实连接池已经打满。此时该做的是限流或排队削峰,而不是换锁方案——瓶颈从来不在锁,而在稀缺库存和有限连接。
111 / 170
提示

沙盘的结论可以直接背——没有超卖不等于没问题。判断依据始终是那三个数字:成功数是否等于库存数、P99 是否失控、连接池是否被打满。只要有一个不对,就该开始限流,而不是急着把 MySQL 换成 Redis。

112 / 170
小节
十四、事务传播与「提交之后才发消息」:三个实验收尾
113 / 170

第三节把「远程调用与消息发送必须移出事务」讲成了纪律,但纪律需要一个能亲手验证的地方。这件事由两个组件配合完成:Spring 事件机制(#41:事务内 publishEvent,监听器与发布者在同一线程里同步执行)+ @TransactionalEventListener(phase = AFTER_COMMIT)(阶段监听器——只有事务真的提交了才会被叫起来干活的那种监听器)。

114 / 170
代码对照
代码java
// ① 事务内:只发布,不做任何外部动作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 之前就被发出。用户收到「下单成功」的短信、点进详情页却是「订单不存在」——这条竞态窗口通常只有几毫秒,但在测试环境里几乎必然出现,在生产日志里又几乎必然抓不到。第九节的缓存回填也排在同一条时间线上,别把它挪到提交前。

115 / 170

下面三个实验分别把这三处不确定性摊开。第一个专治「我的 @Transactional 到底生效没有」——七种传播行为(REQUIRED / REQUIRES_NEW / NESTED / SUPPORTS / NOT_SUPPORTED / NEVER / MANDATORY)摆在一起,外加回滚规则那一档:

116 / 170
内核实验
TeaVM七种传播行为:这笔操作算不算同一笔账未启动
先看 REQUIRED 默认档(下单与扣库存共用一笔事务);再切 REQUIRES_NEW 看「操作日志独立提交」为什么不会随订单回滚;最后切回滚规则,确认受检异常默认不回滚这件事——这正是第三节要求显式写 rollbackFor = Exception.class 的原因
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
117 / 170

第二个专门演示「提交之前把消息发出去」的后果,以及换成阶段监听器之后的差别:

118 / 170
内核实验
TeaVM事件时序:谁在提交之前说话未启动
依次切「同步发布阻塞谁」「AFTER_COMMIT 时机」「换成消息队列」三档,对照上面那段代码看清楚:事件本身是同步的,只有加了 phase 才会等到提交之后再执行
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
119 / 170

第三个回到第九节的缓存边界——为什么商品详情敢缓存、库存不敢:

120 / 170
内核实验
TeaVM缓存三坑:穿透、击穿、雪崩未启动
切「未命中回填」看回填发生在哪一步;再依次切「缓存穿透」(不存在的 id 反复打到库)与「缓存击穿与雪崩」(热点 key 过期瞬间全量回源),对照第九节那张「能否缓存」表——库存之所以不进缓存,就是因为它扛不住这套时间线
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
121 / 170
架构图
图 · 缓存回填与「提交后发消息」
图 · 缓存回填与「提交后发消息」
122 / 170

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

123 / 170
架构图
图 · 三种防超卖怎么选
图 · 三种防超卖怎么选
124 / 170

最后用这张对照图收住第四节的分歧:悲观锁与 Redis 预扣各解决了什么、又各自新引入了什么麻烦,右边是本项目采用的乐观条件更新。真正要看的是底部那句——判断和扣减之间有没有时间窗口,这一个差别就决定了会不会超卖。

125 / 170

到这里,本篇的机制已经全部出场。考你一下:每一种机制到底挡住了哪种坏事?先点左边的机制,再点右边它负责封死的故障——这些配对背下来,线上出问题时的第一反应就会很不一样:

126 / 170
配对闯关
闯关机制配故障:这几件武器各自挡住什么已配对 0/6 · 配错 0
左边是你在本篇代码里真正写下的六种机制,右边是它们各自负责封死的那种脏数据
先点左边一个
127 / 170
小节
十五、常见报错速查
128 / 170

下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在核心业务里最常撞的墙,都在这里。

129 / 170
对照表
报错原文(片段)真实原因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 与缓存
130 / 170
提示

这八条里有三条(幂等冲突、AFTER_COMMIT 缺失、缓存越权)根本不以红字异常出现——程序跑得挺欢,只是数据错了。所以核心业务的验收不能只看「有没有报错」,必须像第十节那样跑并发测试,再用第十七节的练习逐条核对。

131 / 170

表格第 7 行那种「收尾才炸」的异常,值得单独留一个现场:它是全篇唯一一个「业务日志看起来一切正常」的故障——外层明明 catch 了异常、还打了日志,最后却在提交阶段翻车。先别看答案,点出你认为的凶手帧:

132 / 170
报错急救
报错急救UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
内层异常被吞了,提交还是失败

下单偶发 500,数据看着却「正常」:订单没建、库存也没扣。日志里外层明明 catch 了异常,还打印了「重复下单,返回首次结果」——可返回给客户端的却是 500。

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:803)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:752)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:689)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:131)
at com.beeorder.order.service.OrderService.create(OrderService.java:57)
at com.beeorder.order.controller.OrderController.create(OrderController.java:41)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
133 / 170
小节
十六、随堂自测
134 / 170

先来一道热身题,考的是第四节那条「唯一防线」:

135 / 170
随堂自测
随堂自测库存只有 1 件,两个线程同时下单。下面哪种写法一定能防住超卖?
先自己选一个,选中立刻告诉你对不对
136 / 170

再来一道综合题,把第三、九、十四节串起来:

137 / 170
随堂自测
随堂自测create() 标注了 @Transactional(rollbackFor = Exception.class),方法里依次做四件事:插入订单;调用 InventoryService.lock() 扣库存;publishEvent(...) 发布下单事件;this.notifySms() 调用同类里一个标了 @Transactional(propagation = NOT_SUPPORTED) 的方法。关于这次调用,下列哪项正确?
先自己选一个,选中立刻告诉你对不对
138 / 170
小节
十七、动手练习
139 / 170
小节
第一档 · 照做:用 10 个线程抢 3 件库存,亲眼看到「恰好 3 单成功」
140 / 170

目标:跑通一个最小但完整的并发验证——条件更新如何把超卖关死,以及影响行数怎么变成业务错误码。全程只需要 JDK 与 Maven,数据库用一个免费的 MySQL 8 镜像。

141 / 170

第一步,起一个真 MySQL(不要用 H2,行锁行为对不上):

142 / 170
bash
docker run -d --name bee-mysql -p 3306:3306 \  -e MYSQL_ROOT_PASSWORD=bee -e MYSQL_DATABASE=beeorder mysql:8.0
143 / 170

第二步,建一张最小的库存表并塞进 3 件货(sql/step1.sql):

144 / 170
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);
145 / 170

第三步之前,先把这份 pom.xml 生成出来——勾选这一档真正用到的依赖,每一项都会告诉你为什么存在;等做到第三档要补 Redis 防重锁时,再回来把 Redis 勾上:

146 / 170
生成器
生成器把第一档实验的依赖一次配齐pom.xml3 / 9
先只留「JDBC + MySQL 驱动 + 测试」看最小骨架;再叠上 Lombok 省样板代码;最后勾 Redis 与 MyBatis——对应第三档「幂等 + 流水 + 提交后通知」要补的能力
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
JDBC只要模板类和 HikariCP,不想被 ORM 绑住时的最小选择。
MySQL 驱动scope=runtime:编译期不需要它,运行期靠 SPI 反射装载,别写成 compile。
Testscope=test;@SpringBootTest、MockMvc、AssertJ 都在里面,漏了就找不到 @Test。
147 / 170

第三步,写主类(pom.xml 只需 spring-boot-starter-jdbc 与 mysql-connector-j):

148 / 170
java
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();    }}
149 / 170

第四步,编译运行:

150 / 170
bash
mvn -q compile exec:java -Dexec.mainClass=com.example.stock.StockApp
151 / 170

预期输出(「库存不足」那几行的顺序和条数会交错,但最后一行统计必须稳定):

152 / 170
text
库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单库存不足,拒绝下单成功=3 剩余库存=0
153 / 170

最后一行必须是 成功=3 剩余库存=0。哪怕你把线程数改成 200、再重复跑十次,这个结论也不许变——一旦变了,就说明你的 SQL 里少了 AND available >= 1。

154 / 170

验收清单:① 说清 ready 和 gun 两个闩各自挡在哪一步;② 手动删掉 AND available >= 1 重跑,观察报错或负数(UNSIGNED 列会先把负数顶回去,那就是第二道保险在工作);③ 解释为什么这里根本不需要先 SELECT 一次库存。

155 / 170
小节
第二档 · 变体:只改一处,结论就翻面
156 / 170
  1. 把上面那条 UPDATE 拆成「先 queryForObject 读 available,Java 里判断后再 update」,其余不动。你会观察到:成功= 常常大于 3,库存变成负数或被 UNSIGNED 拒绝——这就是「检查与行动之间的时间窗口」,也是第四节方案 A/B 的本质区别。要修好它你得补 FOR UPDATE 并把整段包进事务,于是顺路复现了第十三节沙盘里连接被长时间占用的场景。
  2. 把扣减挪进一个标了 @Transactional 的 Service 方法,却在同一个类里用 this.deduct(...) 调用它,并在扣减之后 throw new RuntimeException()。你会观察到:订单没插进去、库存却被扣走了——自调用绕过了代理,注解形同虚设。把调用改成注入另一个 Bean 再调,一致性立刻恢复。
  3. 在事务方法里加一句 Thread.sleep(200) 模拟远程调用,同时把 spring.datasource.hikari.maximum-pool-size 设为 4,然后用 20 个线程压。你会观察到:日志出现 Connection is not available, request timed out after 30000ms,而数据库几乎没什么负载——瓶颈在连接被事务占住,不在 SQL。
  4. 把监听器从 @TransactionalEventListener(phase = AFTER_COMMIT) 改成 @EventListener,并在监听器里用同一个 JdbcTemplate 反查这张订单。你会观察到:偶发甚至高频查到 null,因为监听器跑在 COMMIT 之前。
157 / 170

提示:做完第 2 条再回头看第十五节表格第 5 行,两边讲的是同一件事。

158 / 170
小节
第三档 · 造一个:给下单链路装上「幂等 + 流水 + 提交后通知」三件套
159 / 170

需求:在第一档的工程上补齐一个能交付的最小闭环。

160 / 170
  • 订单表加 idempotent_key,与 user_id 组成唯一索引;插入冲突时捕获 DuplicateKeyException,返回首次创建的订单号(不许把错误抛给客户端)
  • 每次扣减都往 inventory_log 写一条流水,(ref_id, type) 建唯一键,type 取 LOCK / RELEASE / COMMIT 三种之一
  • 关单逻辑写成条件更新 WHERE status = 'CREATED',并用一个 @Scheduled 任务每分钟扫一次过期订单;用 Redis setIfAbsent + 过期时间做多实例防重
  • 「下单成功」的通知走 @TransactionalEventListener(phase = AFTER_COMMIT),监听器里只做打印,模拟发消息
  • 提供一条命令:mvn -q test 跑完三类测试(单元 / 切片 / 并发集成),全绿才算完成
161 / 170

验收清单:① 用同一个幂等键连发 20 次请求,订单表只有 1 行、流水只有 1 条;② 100 线程抢 5 件库存,成功数恰好 5 且剩余库存为 0;③ 人为让通知监听器抛异常,确认订单仍是 PAID(通知失败不能回滚订单);④ 把 AFTER_COMMIT 改回 @EventListener,并发测试应出现可复现的「查到 null」——这条反向验证要写进 README,作为团队的下意识红线。

162 / 170
小节
十八、要点自查:这一篇的五条硬结论
163 / 170
自检

不看上文,说出「判断 + 扣减」为什么必须写在同一条 SQL 里,以及影响行数为 0 时应用层应该做什么。

164 / 170
自检

@Transactional 静默失效的三个常见姿势分别是什么?为什么它们都不报错?

165 / 170
自检

哪些操作绝对不能放进事务?给出理由时请用「占住了什么资源」来回答,而不是「不规范」。

166 / 170
自检

为什么下单通知必须挂在 @TransactionalEventListener(phase = AFTER_COMMIT) 上?如果这个下游动作失败,订单要不要回滚?

167 / 170
自检

商品详情可以缓存、库存不可以——差别究竟落在哪一个维度上?如果未来非要缓存库存,你必须额外准备什么?

168 / 170
口诀

扣减要一条语句写完,事务里不干网络的事,消息等提交之后再发,缓存只放不怕旧的数。

169 / 170
决策
决策BeeOrder 的防超卖,选数据库乐观锁还是 Redis 预扣?
170 / 170
总结

这一篇把「合同」变成了真正能跑的代码。核心是四件事:用 @Transactional(rollbackFor = Exception.class) 把订单、库存、流水绑成一个原子操作;用 UPDATE ... WHERE available >= n 的条件更新从物理上杜绝超卖;用唯一索引 + 状态机把下单与支付的幂等做在数据库层;用 CountDownLatch 并发测试证明「100 个请求抢 5 件库存,最后恰好 5 单成功」。另外别忘了三条纪律:远程调用与消息发送移出事务、定时任务用 Redis 锁防多实例重跑、@Transactional 自调用会静默失效。至此 BeeOrder 的主链路已经闭合——下一篇,我们给它配上单测、集成测试、CI/CD 与容器化部署,让它真正上线。