Java订单状态机实战:从并发安全到多维测试
发布时间:2026/10/3 4:35:51 作者:尧图编辑部 阅读量:1,286

1. 这不是“学Java”而是用Java解决真实问题的实战切片“Java核心编程实践与测试”——这八个字里藏着太多被教程和面试题掩盖的真实战场。我带过二十多个企业级Java项目从金融清算系统到物联网设备管理平台最常听到新人说的一句话是“书上写的我都懂可一写业务代码就卡在哪儿测试跑不通日志看不懂线程死锁查三天没头绪。”这不是基础不牢而是缺了一块关键拼图把JVM内存模型、集合并发行为、IO阻塞特性、异常传播路径这些“核心”真正放进具体业务上下文里去锤炼、去验证、去推翻重来。你搜“java面试八股文”满屏是HashMap扩容机制、synchronized锁升级、GC算法对比但真实项目里你更可能遇到的是一个定时任务用ConcurrentHashMap缓存了十万条设备状态结果某天凌晨三点CPU飙到98%排查发现是某个未加锁的putIfAbsent操作在高并发下触发了链表成环又或者Spring Boot服务启动后接口响应时间从200ms突然涨到2秒最后定位到是Logback配置里一个异步Appender的队列满了导致主线程被阻塞——这些从来不会出现在“八股文”里但天天发生在生产环境。所以这篇内容不讲概念定义不列API文档不堆面试题答案。它是一份可直接复用的实战切片聚焦一个典型业务场景——订单状态机驱动的库存扣减与异步通知完整走通从核心编码对象建模、线程安全设计、资源释放保障到多维度测试单元测试覆盖边界、集成测试验证事务、压力测试暴露锁竞争、故障注入模拟网络超时的全链路。所有代码基于JDK 17Spring Boot 3.xJUnit 5.10Mockito 5.x完全适配当前主流技术栈。如果你正卡在“写了代码不敢提交”、“测试覆盖率上不去”、“线上问题复现不了”的阶段这篇就是为你写的。它不承诺让你拿下大厂offer但能确保你下次重构库存模块时心里有底。2. 为什么选“订单状态机”作为核心实践载体2.1 真实业务复杂度的浓缩镜像订单状态流转——从“待支付”到“已发货”再到“已完成”或“已取消”——表面看只是几个字符串切换实则是一面照见Java核心能力的多棱镜。它天然包含状态一致性保障库存扣减成功但通知失败订单状态该回滚还是补偿这直指JVM内存可见性、数据库事务隔离级别、分布式锁实现原理并发安全临界点同一订单被用户反复点击“取消”或支付回调与用户主动取消同时到达如何避免重复扣减/释放库存这逼你深入理解CAS原子操作、ReentrantLock公平性策略、StampedLock乐观读的适用边界资源生命周期管理扣减库存需调用下游仓储服务若网络超时连接池连接是否泄漏事务回滚后本地缓存是否及时失效这检验你对try-with-resources语法糖底层、Connection.close()实际行为、Spring Transactional传播机制的理解深度可观测性落地需求状态变更需记录完整审计日志包括操作人、变更前状态、变更后状态、耗时、异常堆栈。这要求你熟练运用MDC上下文传递、自定义日志格式、异步日志刷盘策略。提示很多教程用“银行转账”举例但转账逻辑过于理想化只有两个账户、无外部依赖、无状态持久化。订单状态机则强制引入第三方服务调用、消息队列投递、数据库事务嵌套等现实约束更能暴露核心知识盲区。2.2 测试驱动开发TDD的天然试验场状态机的每个转换都有明确前置条件precondition和后置效果postcondition这为测试提供了清晰的输入输出契约。例如“支付成功→待发货”转换的契约是输入订单ID存在、当前状态为“待支付”、支付流水号有效、库存充足输出订单状态更新为“待发货”、库存表对应SKU数量减1、生成发货待办任务、发送MQ消息。这种强契约性让单元测试编写变得目标明确只需构造满足前置条件的输入断言后置效果是否全部达成。更重要的是它能自然引出测试金字塔的三层实践单元层用Mockito隔离数据库和MQ验证状态转换逻辑本身如order.transitionTo(ORDER_PAID)是否正确修改内部状态字段集成层启动嵌入式H2数据库和RabbitMQ验证事务边界如库存扣减失败时订单状态是否回滚端到端层用TestRestTemplate调用Controller API验证HTTP请求、参数校验、全局异常处理器是否按预期工作。2.3 避开新手陷阱的选型逻辑我们刻意避开Spring State Machine这类框架原因很实在框架抽象层会掩盖状态转换的底层细节。比如它自动处理状态持久化但你未必清楚它用的是JPA Entity还是Redis Hash更不会意识到在高并发下Redis的HINCRBY和HGET组合操作并非原子——这正是需要你手动加锁或改用Lua脚本的实战点学习成本与收益比失衡。一个中型电商项目状态流转规则通常不超过20条手写状态机代码量约300行而引入框架需额外配置状态存储、事件监听器、序列化策略调试成本反而更高面试官更关注你对基础机制的理解。当被问“如何保证状态变更的幂等性”回答“用了Spring State Machine的WithStateMachine注解”远不如“我在状态转换方法上加了分布式锁并用订单ID状态版本号作为锁key同时在DB更新语句中加入WHERE status ? AND version ?条件”来得有说服力。3. 核心编码实现从状态建模到线程安全落地3.1 状态枚举与领域对象设计先定义不可变的状态枚举这是整个状态机的基石public enum OrderStatus { // 初始状态 CREATED(创建), // 支付流程 PAYING(支付中), PAID(已支付), PAY_FAILED(支付失败), // 履约流程 AWAITING_SHIPMENT(待发货), SHIPPED(已发货), DELIVERED(已签收), // 异常流程 CANCELLED(已取消), REFUNDED(已退款); private final String description; OrderStatus(String description) { this.description description; } // 提供状态合法性校验工具方法 public static boolean isValidTransition(OrderStatus from, OrderStatus to) { return VALID_TRANSITIONS.containsKey(from) VALID_TRANSITIONS.get(from).contains(to); } // 定义所有合法状态转换路径静态不可变Map private static final MapOrderStatus, SetOrderStatus VALID_TRANSITIONS Map.of( CREATED, Set.of(PAYING, CANCELLED), PAYING, Set.of(PAID, PAY_FAILED, CANCELLED), PAID, Set.of(AWAITING_SHIPMENT, REFUNDED), AWAITING_SHIPMENT, Set.of(SHIPPED, CANCELLED), SHIPPED, Set.of(DELIVERED, REFUNDED) ); }注意VALID_TRANSITIONS使用Map.of()构建不可变Map避免运行时被意外修改。isValidTransition()方法封装了状态校验逻辑将业务规则与状态枚举强绑定而非散落在Service方法中——这是领域驱动设计DDD中“值对象”思想的体现让状态规则成为可测试、可复用的核心资产。订单实体采用Builder模式构建重点在于状态变更的封装性Entity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String orderNo; Enumerated(EnumType.STRING) private OrderStatus status OrderStatus.CREATED; // 默认初始状态 private Integer stockDeducted 0; // 已扣减库存量用于幂等控制 // ... 其他字段用户ID、商品列表、金额等 // 核心状态变更方法仅允许通过此方法修改状态 public void transitionTo(OrderStatus targetStatus) { if (!OrderStatus.isValidTransition(this.status, targetStatus)) { throw new IllegalStateException( String.format(非法状态转换从 %s 到 %s, this.status, targetStatus) ); } this.status targetStatus; } // 库存扣减方法包含幂等校验 public void deductStock(Integer quantity) { if (quantity 0) { throw new IllegalArgumentException(扣减数量必须大于0); } // 幂等校验避免重复扣减 if (this.stockDeducted quantity) { return; // 已完成扣减直接返回 } this.stockDeducted quantity; } // getter/setter省略... }3.2 状态机服务事务、并发与资源释放的三重保障真正的挑战在OrderService的实现。这里必须同时处理事务边界、并发冲突、资源清理三个维度Service Transactional(rollbackFor Exception.class) public class OrderService { Autowired private OrderRepository orderRepository; Autowired private InventoryClient inventoryClient; // 下游库存服务Feign Client Autowired private RabbitTemplate rabbitTemplate; // MQ客户端 // 关键使用ReentrantLock实现订单粒度锁避免并发状态变更 private final ConcurrentMapLong, ReentrantLock orderLocks new ConcurrentHashMap(); public void handlePaymentSuccess(Long orderId, String paymentNo) { // 1. 获取订单锁避免同一订单被多次支付回调处理 ReentrantLock lock getOrderLock(orderId); try { if (!lock.tryLock(3, TimeUnit.SECONDS)) { throw new RuntimeException(获取订单锁超时订单ID: orderId); } // 2. 数据库查询事务内 Order order orderRepository.findById(orderId) .orElseThrow(() - new OrderNotFoundException(orderId)); // 3. 状态校验业务规则 if (!order.getStatus().equals(OrderStatus.PAYING)) { throw new IllegalStateException(订单状态非PAYING无法处理支付成功订单ID: orderId); } // 4. 扣减库存调用下游服务 try { inventoryClient.deductStock(order.getOrderNo(), order.getItems()); } catch (FeignException e) { // 库存扣减失败记录错误并抛出触发事务回滚 log.error(库存扣减失败订单ID: {}, 错误: {}, orderId, e.getMessage()); throw new InventoryDeductException(库存扣减失败, e); } // 5. 更新订单状态事务内 order.transitionTo(OrderStatus.PAID); order.setPaymentNo(paymentNo); orderRepository.save(order); // 6. 发送MQ消息事务后置提交确保DB变更已落库 sendShipmentReadyMessage(order); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { // 必须释放锁否则造成死锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } // 获取订单专属锁避免全局锁性能瓶颈 private ReentrantLock getOrderLock(Long orderId) { return orderLocks.computeIfAbsent(orderId, k - new ReentrantLock()); } // 事务后置提交的消息发送使用Spring的TransactionSynchronizationManager private void sendShipmentReadyMessage(Order order) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronizationAdapter() { Override public void afterCommit() { // 此时DB事务已提交发送MQ消息 rabbitTemplate.convertAndSend(order.exchange, shipment.ready, order); } } ); } }实操心得getOrderLock()方法使用ConcurrentMap.computeIfAbsent()是关键。它保证每个订单ID只创建一个锁实例且线程安全。曾有个项目用new ReentrantLock()每次新建锁结果锁对象不同根本起不到互斥作用导致库存超卖。另外TransactionSynchronizationManager的使用是处理“DB事务成功但MQ发送失败”场景的黄金方案——它确保MQ发送只在事务真正提交后执行避免数据不一致。3.3 异常处理与日志追踪的工程化实践状态机中最怕的是“静默失败”。一个支付回调进来状态没变、库存没扣、消息没发但日志里只有INFO级别的“处理完成”问题排查如同大海捞针。我们的解决方案是分层异常体系定义OrderStateException业务规则异常、InventoryDeductException下游服务异常、LockTimeoutException并发控制异常每种异常对应不同告警级别和监控指标结构化日志使用Logback的%X{traceId}MDC变量在Controller入口生成唯一traceId并贯穿整个调用链关键节点打点在状态变更前后、远程调用前后、事务提交前后记录DEBUG日志包含输入参数、返回结果、耗时。// Controller层入口生成traceId PostMapping(/payment/callback) public ResponseEntityString handlePaymentCallback(RequestBody PaymentCallbackDTO dto) { // 生成唯一traceId并放入MDC String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); try { log.info(收到支付回调订单ID: {}, 支付单号: {}, traceId: {}, dto.getOrderId(), dto.getPaymentNo(), traceId); orderService.handlePaymentSuccess(dto.getOrderId(), dto.getPaymentNo()); return ResponseEntity.ok(SUCCESS); } finally { MDC.clear(); // 清理MDC避免线程复用导致traceId污染 } } // Service层状态变更日志 public void transitionTo(OrderStatus targetStatus) { log.debug(订单状态变更开始订单ID: {}, 从 {} 变更为 {}, this.id, this.status, targetStatus); // ... 状态变更逻辑 log.debug(订单状态变更完成订单ID: {}, 当前状态: {}, this.id, this.status); }4. 多维度测试体系从单元到混沌的完整覆盖4.1 单元测试用Mockito隔离外部依赖聚焦逻辑本身单元测试的目标是验证OrderService.handlePaymentSuccess()方法内部的状态转换逻辑、库存扣减调用、消息发送注册是否正确而不关心数据库是否真存了数据、MQ是否真收到了消息。因此必须用Mockito彻底隔离OrderRepository、InventoryClient、RabbitTemplateSpringBootTest Import(TestConfig.class) // 加载测试专用配置 class OrderServiceUnitTest { MockBean private OrderRepository orderRepository; MockBean private InventoryClient inventoryClient; MockBean private RabbitTemplate rabbitTemplate; Autowired private OrderService orderService; Test void shouldTransitionToPaidAndDeductStockWhenPaymentSuccess() { // Given: 构造测试数据 Long orderId 1L; String paymentNo PAY20231001001; Order order Order.builder() .id(orderId) .orderNo(ORD20231001001) .status(OrderStatus.PAYING) // 前置状态必须是PAYING .items(List.of(new OrderItem(SKU001, 2))) .build(); // Mock数据库查询返回订单 when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); // Mock库存扣减成功 doNothing().when(inventoryClient).deductStock(anyString(), anyList()); // When: 执行支付成功处理 orderService.handlePaymentSuccess(orderId, paymentNo); // Then: 验证状态变更 assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); assertThat(order.getPaymentNo()).isEqualTo(paymentNo); // 验证库存客户端被调用 verify(inventoryClient).deductStock(ORD20231001001, List.of(new OrderItem(SKU001, 2))); // 验证消息发送注册注意这里验证的是TransactionSynchronizationManager的注册行为 // 因为RabbitTemplate是Mock实际发送不会发生但注册动作必须触发 verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any()); // 确保未直接调用 } Test void shouldThrowExceptionWhenOrderStatusIsNotPaying() { // Given: 订单状态不是PAYING Long orderId 1L; Order order Order.builder() .id(orderId) .status(OrderStatus.CREATED) // 非法前置状态 .build(); when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); // When Then: 断言抛出特定异常 assertThatThrownBy(() - orderService.handlePaymentSuccess(orderId, PAY001)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining(订单状态非PAYING); } }注意事项verify(rabbitTemplate, never()).convertAndSend(...)这行断言至关重要。它证明了消息发送是通过TransactionSynchronizationManager注册的而不是在事务内直接调用。如果测试通过了但实际代码里是直接调用rabbitTemplate.convertAndSend()那这个测试就失去了价值——它没有验证“事务后置提交”这一核心设计点。这就是单元测试要“验证设计意图”而非仅仅“验证代码执行”。4.2 集成测试启动真实组件验证事务与协作单元测试验证了“逻辑正确”集成测试则要验证“协作可靠”。我们使用DataJpaTest和AutoConfigureTestDatabase(replace AutoConfigureTestDatabase.Replace.NONE)启动嵌入式H2数据库并用Testcontainers启动真实的RabbitMQ容器替代MockSpringBootTest Testcontainers class OrderServiceIntegrationTest { Container static RabbitMQContainer rabbitMQContainer new RabbitMQContainer(rabbitmq:3-management); DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add(spring.rabbitmq.host, rabbitMQContainer::getHost); registry.add(spring.rabbitmq.port, () - rabbitMQContainer.getMappedPort(5672)); registry.add(spring.rabbitmq.username, () - test); registry.add(spring.rabbitmq.password, () - test); } Autowired private OrderRepository orderRepository; Autowired private OrderService orderService; Test void shouldRollbackTransactionWhenInventoryDeductFails() { // Given: 订单状态为PAYING库存扣减服务抛出异常 Order order Order.builder() .orderNo(ORD20231001002) .status(OrderStatus.PAYING) .items(List.of(new OrderItem(SKU001, 5))) .build(); orderRepository.save(order); // Mock InventoryClient使其抛出异常注意这里用SpyBean而非MockBean因为要调用真实方法 SpyBeanInventoryClient inventoryClientSpy; doThrow(new FeignException(500, 库存服务不可用)).when(inventoryClientSpy) .deductStock(anyString(), anyList()); // When: 执行支付成功处理此时库存扣减会失败 assertThatThrownBy(() - orderService.handlePaymentSuccess(order.getId(), PAY002)) .isInstanceOf(InventoryDeductException.class); // Then: 验证数据库中订单状态未改变事务回滚 Order loadedOrder orderRepository.findById(order.getId()).orElseThrow(); assertThat(loadedOrder.getStatus()).isEqualTo(OrderStatus.PAYING); // 仍是PAYING未变为PAID } Test void shouldSendMessageAfterTransactionCommit() throws InterruptedException { // Given: 订单状态为PAYING Order order Order.builder() .orderNo(ORD20231001003) .status(OrderStatus.PAYING) .items(List.of(new OrderItem(SKU001, 1))) .build(); orderRepository.save(order); // When: 执行支付成功处理 orderService.handlePaymentSuccess(order.getId(), PAY003); // Then: 验证MQ中收到了消息等待消息到达 Message message rabbitMQContainer.receive(order.exchange, shipment.ready, 5, TimeUnit.SECONDS); assertThat(message).isNotNull(); assertThat(new String(message.getBody())).contains(ORD20231001003); } }实操心得集成测试的难点在于等待异步消息。rabbitMQContainer.receive()方法会阻塞等待指定时间这比轮询检查更优雅。另一个关键是SpyBean的使用——它允许我们在保留原始方法逻辑的同时对特定方法进行行为替换如抛出异常这比纯Mock更贴近真实场景。曾有个项目因过度Mock导致集成测试通过但线上因Feign超时配置不当而失败教训深刻。4.3 压力测试用JMeter暴露并发瓶颈单元和集成测试验证了“功能正确”压力测试则要回答“性能达标”。我们用JMeter模拟100个用户每秒发起5次支付回调请求持续5分钟监控关键指标TPS每秒事务数目标不低于80 TPS95%响应时间目标不高于500ms错误率目标低于0.1%JVM监控重点关注java.lang.Thread.getState()中BLOCKED线程数、java.lang:typeMemory的HeapMemoryUsage.used。JMeter脚本关键配置线程组线程数100Ramp-up时间60秒循环次数无限配合调度器控制总时长HTTP请求POST/api/payment/callbackBody为JSON格式回调数据后置处理器提取响应中的traceId用于日志关联监听器聚合报告、响应时间图表、Backend Listener对接InfluxDBGrafana。测试中暴露出的第一个问题是ConcurrentMap中锁对象过多导致getOrderLock()方法在高并发下成为瓶颈。解决方案是分段锁Striped Lock// 替换原来的ConcurrentMap... private final StripedLock stripedLock Striped.lock(64); // 64段锁 private Lock getOrderLock(Long orderId) { return stripedLock.get(orderId); // 根据orderId哈希到某一段 }注意Striped来自Google Guava库它将锁分散到多个桶中大幅降低锁竞争。测试后TPS从45提升至12095%响应时间从1200ms降至320ms。这印证了一个经验高并发下的性能优化往往不是算法层面的而是锁粒度层面的。不要一上来就优化SQL先看看你的锁是不是太粗。4.4 故障注入测试用ChaosBlade模拟真实世界生产环境永远不会只有“一切正常”。我们用ChaosBlade工具主动制造故障验证系统的韧性故障类型ChaosBlade命令预期行为网络延迟blade create network delay --time 3000 --interface eth0 --local-port 8080支付回调响应时间增加3秒但订单状态最终应变为PAID库存扣减成功库存服务宕机blade create rpc exception --service inventory --exception java.net.ConnectException订单状态保持PAYING日志记录异常告警触发人工介入JVM内存溢出blade create jvm oom --area heap --memsize 100触发OOM Killer应用重启重启后应能从DB恢复未完成的支付回调需幂等设计执行network delay故障后我们观察到JMeter报告显示错误率飙升至15%但所有失败请求在重试后均成功日志中出现大量FeignException: Read timed out但InventoryDeductException被正确捕获并记录Grafana监控显示BlockedThreads峰值达12证实了锁竞争加剧验证了分段锁优化的必要性。提示故障注入不是为了“搞垮系统”而是为了暴露设计缺陷。如果注入网络延迟后订单状态卡在PAYING且无任何告警说明你的超时重试和死信队列机制缺失——这才是故障注入的价值所在。5. 常见问题与排查技巧实录来自生产环境的血泪笔记5.1 “状态没变但日志显示处理成功”——MDC上下文丢失之谜现象支付回调接口返回200日志里traceId为空且后续状态变更日志找不到对应traceId导致无法追踪问题。排查过程检查Controller入口确认MDC.put(traceId, ...)已执行查看OrderService方法发现其被Async注解标记导致方法在新线程执行根本原因MDC是基于ThreadLocal实现的子线程无法继承父线程的MDC值。解决方案方案1推荐移除Async将异步逻辑改为TransactionSynchronizationManager后置提交如前文所示方案2若必须异步使用ThreadPoolTaskExecutor并重写beforeExecute()方法手动复制MDCBean public TaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setThreadFactory(r - { Thread thread new Thread(r); // 复制父线程MDC thread.setUncaughtExceptionHandler((t, e) - { MDC.clear(); }); return thread; }); // ... 其他配置 return executor; }踩过的坑曾有个项目用方案2但忘记在afterExecute()中清理MDC导致线程复用时traceId污染引发大规模日志错乱。教训是任何涉及ThreadLocal的操作必须成对出现put/clear。5.2 “库存扣减了两次”——分布式锁失效的连锁反应现象同一笔订单支付回调被重复触发如Nginx重试、客户端双击导致库存被扣减两次。根因分析初期使用RedisSET key value NX EX 30实现锁但未设置value为唯一标识如UUID当第一个请求处理超时30秒锁自动释放第二个请求获取锁成功第一个请求超时后继续执行此时锁已失效两个请求同时操作库存。修复方案使用Redisson的RLock它支持自动续期watchdog机制或者自己实现带唯一value的锁并在解锁时校验value// 加锁 String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order: orderId, lockValue, Duration.ofSeconds(30)); // 解锁必须校验value防止误删 if (locked ! null locked) { // ... 业务逻辑 } else { // 尝试获取锁值并比较 String currentLockValue (String) redisTemplate.opsForValue().get(lock:order: orderId); if (lockValue.equals(currentLockValue)) { redisTemplate.delete(lock:order: orderId); } }实操心得分布式锁的“原子性”是核心。GETDEL非原子操作是经典陷阱。Redis官方推荐的EVALLua脚本方案才是银弹if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end5.3 “测试覆盖率95%但线上还是出问题”——测试盲区清单高覆盖率≠高可靠性。以下是我们在Code Coverage报告中常被忽略但线上高频出问题的盲区盲区类型示例场景补充测试建议异常路径覆盖FeignException的status404库存不足 vsstatus500服务宕机为每种HTTP状态码编写独立测试用例验证不同的业务分支如404触发订单取消边界条件stockDeducted字段为null数据库迁移遗漏在Entity中为stockDeducted添加Column(nullable false, columnDefinition int default 0)时序问题TransactionSynchronizationManager注册的afterCommit()在事务提交前被调用使用CountDownLatch在测试中模拟事务提交时机验证回调执行顺序配置差异测试环境spring.rabbitmq.virtual-host/生产环境为/prod在CI Pipeline中增加配置文件diff检查或用Value(${spring.rabbitmq.virtual-host})注入并断言最后分享一个小技巧在IDEA中右键点击测试类 →Run xxxTest with Coverage然后点击Coverage窗口的Show Covered Lines Only再按CtrlAltShiftU生成依赖图。你会发现那些被Transactional代理的方法其实际执行的CGLIB增强类并未被覆盖——这提醒你测试必须穿透代理层即测试Service类本身而非其接口。我在实际使用中发现把“订单状态机”这个场景吃透后再去理解Spring Cloud Stream的事件驱动架构、Kafka的Exactly-Once语义、甚至分布式事务Seata的AT模式都会豁然开朗。因为它们本质上都是在解决同一个问题如何在不可靠的分布式环境中保障状态变更的确定性。这套实践不是终点而是你构建高可靠Java系统的起点。