
做后端这些年只要碰到“异步 事务”这两个词凑在一起十有八九要出事。不是消息丢就是数据对不上再不就是线上压测时线程池直接爆掉。Spring 的 Async 和 Transactional 单独用都很顺手一旦组合起来各种隐藏坑就全冒出来了。这篇内容就围绕 Spring 异步体系与事务一致性这条主线把我实际踩过的坑、验证过的方案、最终落地的 Outbox 模式完整整理一遍适合正在用 Spring Boot 写业务、又需要对异步消息和数据库事务保持一致性的团队参考。新手可以按顺序读老手可以直接跳到第 4 章看方案对比和第 6 章的排查清单。1. 异步与事务天然就是一对矛盾体1.1 一个订单场景引发的血案先还原一个我接手过的真实线上事故。下单接口的逻辑很直白先开一个事务写入订单表扣减库存然后向消息队列发一条“订单创建成功”的事件让下游服务去做积分发放、发送短信、更新推荐缓存之类的操作。代码大概长这样Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toEntity()); stockService.deduct(dto.getProductId(), dto.getCount()); messageSender.send(new OrderCreatedEvent(dto.getOrderId())); }表面看没问题事务包住了所有数据库操作消息也发了。但事故正好出在最后一步库存扣减抛了个异常事务回滚了订单表里没有这条订单可消息队列里的“订单创建成功”事件已经发出去了。下游积分服务收到事件给一个根本不存在的订单加了积分短信服务开始给用户发“下单成功”的短信用户点进去啥都查不到。这个单子最后是靠人工对账、逐个补数据才压下去的。问题根源很清楚数据库事务和消息发送不在同一个“原子边界”里。要么先发消息再写库消费者看到的数据可能还没提交要么先写库再发消息事务回滚后消息已经飞出去。这就是异步化和事务一致性最原始的矛盾。这里要补充一个关键认知哪怕你调整顺序把完整业务逻辑执行完之后再发消息依然有问题。因为事务提交和消息发送之间不存在任何原子性保障你没法保证“提交成功”和“发送成功”同时发生也没法保证“提交失败”和“发送失败”同时发生。只要这两件事做不到同生共死数据不一致就只是时间问题。1.2 为什么事务语义在异步线程里“不香了”很多人对 Async 的理解停留在“加个注解就完事”但真正出问题时第一道坎就是线程上下文。Spring 的实现里Transactional 开启的事务上下文是绑定在当前线程的 ThreadLocal 上的比如 TransactionSynchronizationManager 里存着当前线程的 Connection、同步器列表、事务资源。到了 Async 方法里执行线程已经切换到了线程池中的另一个线程那个线程的 ThreadLocal 是空的你期望“事务还在”的直觉立刻失效。举个实际的例子我在一个项目里见过有人这么写Async Transactional public void doAsyncWork() { orderMapper.updateStatus(orderId, PAID); }乍一看好像“异步 事务”都齐了其实这里的事务是在异步线程里新开的跟调用方那个事务没有任何关系。调用方事务如果还没提交异步线程里的 updateStatus 根本看不到调用方数据的最终版本。更糟的是如果调用方事务改了同一条记录两边还会出现锁竞争甚至死锁。异步方法里的 Transactional 不是不能用但它只管理自己这一段操作的原子性管不了外部那个大事务。另外要理解一个底层事实Async、Transactional、AOP 日志这些能力本质上都是 AOP 代理在起作用。Spring 拿到一个配置了这些注解的 Bean 后会给你生成一个代理对象外部调用走到代理上时拦截器链依次处理事务、异步、日志。可如果类内部自己调用自己的方法比如 this.doAsyncWork()走的就不是代理对象而是原生 this拦截器链整个绕过去了Async 在该失效的地方悄无声息地失效。网上经常讨论的 Spring 三级缓存原理核心关注的是循环依赖时如何提前暴露代理对象但它背后的哲学恰好点破了这套机制Spring 里几乎所有增强都建立在“代理”二字上你绕开代理等于绕开了框架给你的一切保证。2. 先把 Async 玩明白从线程池到调用链2.1 Async 的代理原理与失效陷阱既然说到代理就得先看看 Async 是怎么被激活的。通常你会在配置类上加 EnableAsyncSpring 会扫描带 Async 的方法为对应的 Bean 创建代理。调用时AsyncExecutionInterceptor 把任务丢给配置好的 TaskExecutor。这里有个很容易被忽略的点如果同一个类里方法 A 调用方法 B而 B 标了 Async这个调用是失效的。原因就是上面说的内部调用走 this不走代理。来一个典型错误Service public class OrderService { public void create(OrderDTO dto) { // some logic this.sendNotify(dto); // 不会异步 } Async public void sendNotify(OrderDTO dto) { notifyClient.send(dto); } }你以为是异步实际上还是同步执行。解决方案有两个一是把 sendNotify 拆到另一个独立 Bean比如 NotifyService里让 this 变成 spring 注入的代理对象二是自己注入 ApplicationContext用 context.getBean(OrderService.class).sendNotify(dto) 来绕开内部调用。我实际更推荐第一种职责清晰也能避免很多奇奇怪怪的问题。还有一种更隐蔽的失效是异步方法写在代理类内部、但类没有被 Spring 管理。比如你 new 了一个普通类出来哪怕类上有 Async 也不会生效。这个问题我在重构老代码时遇到过当时把一个 Component 改成了手动 new 的方式异步就变成了同步线上接口响应时间从 50ms 直接飙到 2 秒。排查了半天才发现是 Bean 管理方式变了不是注解写错了。2.2 线程池配置别再用默认的 SimpleAsyncTaskExecutorEnableAsync 之后如果你没有自定义 TaskExecutorSpring 会走默认的 SimpleAsyncTaskExecutor。这个东西的名字很有迷惑性它其实每次都会 new 一个线程来执行任务根本不复用线程。高并发场景下线程数会无节制增长直接把内存和 CPU 打爆。我一个项目就吃过这个亏接口里一个异步发信逻辑压测 200 并发瞬间创建了几百个线程GC 开始频繁 Full GC服务直接假死。正确的姿势是配置一个真正的线程池我建议直接在配置类里实现 AsyncConfigurerConfiguration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(2000); executor.setThreadNamePrefix(async-biz-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }几个参数说一下思路。核心线程数不是越大越好IO 密集型任务可以按 CPU 核数乘 2 到 4 估但更多要结合下游接口的耗时和流量来算。队列容量给一个合理的缓冲值比如 2000避免入口流量瞬间把线程打满。线程名前缀一定要设置排查问题时看线程栈一眼就能分清是哪类任务。拒绝策略我习惯用 CallerRunsPolicy它的含义是线程池满了之后任务不丢弃而是回退到提交任务的线程里同步执行。这样至少不会丢消息代价是接口响应会变慢但相比静默丢弃要好得多。还有一些细节如果你的异步任务涉及 Spring Cloud 的链路追踪记得用 TaskDecorator 把 traceId 从提交线程传递到执行线程。这个后面第 6 章再展开。2.3 异步方法里的事务怎么开才安全前面说过异步线程里的事务是独立新开的事务跟调用方无关。那么问题来了异步方法里到底要不要加 Transactional答案是看业务场景。如果你只是发个通知、调外部接口、写日志完全不需要事务但如果你要在异步线程里更新多张表并且希望这些更新要么全成要么全败那就必须加。不过直接加 Transactional 有个隐患异步线程里的事务异常不会被调用方捕获。调用方把任务提交给线程池后就返回了异步线程里的异常只能通过 Future 或者自定义的 AsyncUncaughtExceptionHandler 处理默认情况下异常会被吞掉。也就是说你的事务回滚确实发生了但没有任何人知道。所以一个比较稳的做法是在异步方法内部捕获异常按业务规则记日志或者发送告警而不是指望调用方感知。我在项目中更推荐用 TransactionTemplate 做异步方法里的手动事务控制Async public void processPayCallback(PayResult result) { try { transactionTemplate.execute(status - { paymentMapper.updateStatus(result.getOrderId(), result.getStatus()); orderMapper.markPaid(result.getOrderId()); return null; }); } catch (Exception e) { log.error(异步支付回调处理失败, e); mqTemplate.send(PAY_CALLBACK_FAILED, result); } }这样事务边界清晰哪个 SQL 抛异常会回滚外层 catch 又能把失败信息追加通知出去。比在方法上干挂一个 Transactional 更容易控制。3. TransactionalEventListener事务边界的监听器怎么用3.1 四种时机与默认行为如果你只是希望在事务成功提交后做点什么Spring 其实提供了一个比“自己判断提交状态”优雅得多的方案TransactionalEventListener。它配合 ApplicationEventPublisher 使用典型流程是在事务内发布一个事件监听器根据你指定的时机决定什么时候执行。// 事务内发布事件 Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toEntity()); applicationEventPublisher.publishEvent(new OrderCreatedEvent(dto.getOrderId())); } // 监听器 Component public class OrderEventListener { TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { // 事务提交后执行 } }这个注解有四种 phaseBEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK、AFTER_COMPLETION。顾名思义分别对应事务提交前、提交后、回滚后、一切结束后。AFTER_ROLLBACK 非常适合做补偿操作比如事务回滚后发一条告警或者记录审计日志AFTER_COMPLETION 则相当于 finally不管成功失败都执行。有几个默认行为必须知道。第一如果当前没有事务监听器默认不会执行需要设置 fallbackExecution true 才会在没有事务时也触发。第二监听器的执行默认是同步的而且跟发布事件在同一个线程所以如果监听器里做了耗时操作接口照样会被拖慢。第三如果你给监听器方法再加上 Async它就能在事务提交后异步执行这是很常见的组合。3.2 为什么 AFTER_COMMIT 还不够保险看到这里你可能觉得 AFTER_COMMIT Async 已经解决了问题事务提交后再异步执行消息不会在回滚时被发出去。确实它解决了“消息先于事务提交”的问题但它没有解决另一个问题事务提交后、异步任务真正执行前服务挂了怎么办举个例子事务提交成功事件已经发布到内存里的监听器队列里但就在异步线程还没来得及执行的时候应用宕机了。事件没了消息没发下游永远不知道发生了这件事。也就是说TransactionalEventListener 适合对可靠性要求不那么极端的场景比如发个内部通知、清理缓存、更新冗余字段不适合“这条消息丢了会出大事故”的场景。类似的问题还出现在另一种写法里事务提交后同步发送 MQ 消息到 broker发送这一步本身也可能失败。网络抖动、broker 短暂不可用、消息序列化失败任何一种情况都会造成消息丢失。AFTER_COMMIT 只是把“发送消息的时机”延后到了提交之后并没有给“发送消息这个动作”提供任何失败重试与持久化保障。真正要做可靠异步投递需要引入一个中介数据库里的待发送事件表。这就是下一章要讲的 Outbox 模式。4. 分布式事务一致性方案选型从本地消息表到 Saga4.1 本地消息表与 Outbox 模式数据库来做“保险箱”本地消息表是最经典也最容易理解的可靠异步方案它的英文名叫 Transactional Outbox核心思想就一句话业务操作和“待发送消息”写入同一个本地事务然后由一个后台任务扫描这张表把消息真正发出去发成功的标记状态失败的不断重试。为什么它能解决原子性问题因为数据库事务保证了业务表数据提交成功outbox 表里一定存在对应的待发送事件业务表回滚了outbox 表里对应的事件也会一起回滚。消息的“发送”动作从业务事务里剥离出来变成了后续的重试驱动行为业务侧不再需要关心消息是否发送成功。这个模式的优点是简单、可靠、不依赖任何中间件的新特性。缺点是最终一致性发送不会准时可能延迟几秒甚至更久而且同一条消息可能因为重试被发多次所以消费端必须做幂等。另外如果 outbox 表越来越大扫描性能会下降需要定期清理已经发送完成的记录。我一般会加一个定时清理任务保留最近 7 天的已发数据剩下的归档或者直接删。4.2 MQ 事务消息RocketMQ 的半消息机制如果你们的消息队列是 RocketMQ可以考虑它自带的事务消息能力。原理是先向 broker 发送一条“半消息”Half Message这个状态的消息消费者不可见然后执行本地事务根据本地事务的结果向 broker 二次提交 commit 或 rollback。如果本地事务执行过程中进程挂了broker 会反向回查本地事务状态决定最终投递还是丢弃。对比 Outbox事务消息把“可靠存储”从数据库转移到了 broker 内部业务代码里少了一张 outbox 表和一套扫描任务。但代价是你得依赖 RocketMQ 的特定机制而且半消息对 broker 的要求比较高回查机制也需要业务方提供一个查询接口。我在实际项目里的取舍是新项目如果已经用了 RocketMQ 并且团队熟悉它可以用事务消息普通 Spring Boot RabbitMQ 或者自建磁盘队列的项目Outbox 反而是更通用、更省事的选择。顺便说一句网上提到的“最大努力通知”本质也是一种方案发送方尽力发接收方主动拉取对账。适合对实时性要求不高、最终能靠对账兜底的场景比如支付结果通知。如果业务允许秒级甚至分钟级的一致性延迟本地消息表 定时对账通常是性价比最高的。4.3 Saga 和 TCC跨服务一致性的进阶武器如果你不止是“本地业务 消息通知”而是多个微服务之间要保证数据的一致性那 Outbox 只管得住本服务自己的事件投递管不住下游服务之间的数据协调。这时候要引入事务模式层面的方案。TCCTry-Confirm-Cancel适合强一致性要求较高的场景比如账户扣款、库存预占。核心是把每个服务自己的资源操作拆成三个动作Try 阶段检查资源并预留Confirm 阶段确认执行Cancel 阶段释放预留。它的问题是实现成本很高每一段业务逻辑都要写三个接口而且幂等、空回滚、悬挂这些都是坑。我在项目里用过一次给支付部门做余额冻结前后折腾了将近两周。Saga 则更适合长流程、可以接受最终一致性的场景比如下单后依次调用订单服务、库存服务、积分服务。它有两种编排方式一种是每个服务完成后发事件由下一个服务订阅继续另一种是中央编排器统一调用。Saga 的难点在于补偿逻辑必须可逆而且要考虑补偿时可能遇到的各种中间状态。这两者跟 Spring 异步体系的关系在于Saga 的编排消息、TCC 的状态流转通常都需要异步 消息驱动。异步让整个流程不阻塞主线程可靠性由消息投递保障但一旦某个环节失败补偿的触发又回到了“怎么可靠地发出失败事件”这个老问题上。所以你会发现不管是 Outbox 还是事务消息解决的都是同一个底座能力让业务事件可靠地被传播出去。上层用 TCC 还是 Saga底座逻辑不变。5. 实战Outbox 事件表完整落地5.1 表结构设计与事务写入拿一个贴近实际的场景来演示Spring Boot MyBatis-Plus 的下单服务要求事务提交后可靠地发送“订单创建”事件给积分服务和短信服务。首先建一张 outbox 表CREATE TABLE event_outbox ( id bigint(20) NOT NULL AUTO_INCREMENT, event_id varchar(64) NOT NULL COMMENT 业务幂等ID, event_type varchar(64) NOT NULL COMMENT 事件类型, payload text NOT NULL COMMENT 事件内容JSON, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2发送失败, retry_count int(11) NOT NULL DEFAULT 0 COMMENT 重试次数, next_retry_time datetime NOT NULL COMMENT 下次发送时间, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_time (status, next_retry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;event_id 建议用 UUID 或者业务流水号它是消费端幂等的基础同一个事件无论重发多少次event_id 必须一样。payload 里存 JSON不要存对象序列化后的 Java 特有格式否则下游如果换语言比如 Go、Python解析会很痛苦。索引 idx_status_time 是扫描任务的命脉没有这个索引表数据一多扫描就是一次全表扫。写入业务事务时订单表和 outbox 表在同一事务内写入Transactional public void createOrder(OrderDTO dto) { OrderDO order buildOrder(dto); orderMapper.insert(order); stockMapper.deduct(dto.getProductId(), dto.getCount()); outboxMapper.insert(new EventOutbox() .setEventId(order.getOrderNo()) .setEventType(ORDER_CREATED) .setPayload(JSON.toJSONString(order)) .setStatus(0) .setNextRetryTime(new Date())); }这段代码的关键在于订单逻辑成功outbox 记录一定在订单逻辑回滚outbox 记录也跟着没了。你不需要再关心“事务提交后怎么发消息”因为发送这件事已经交给了后台任务。5.2 消息扫描与投递任务实现扫描任务我用 Scheduled 实现简单够用如果团队规模大、需要分布式调度可以替换成 XXL-Job 或 ElasticJob。核心逻辑是每次捞一批 status0 且 next_retry_time 小于当前时间的记录逐条发送成功则标记 status1失败则累加重试次数并计算下次重试时间。Component public class OutboxScanner { Scheduled(fixedDelay 3000) public void scanAndSend() { ListEventOutbox events outboxMapper.selectPending(100, new Date()); for (EventOutbox event : events) { try { mqTemplate.convertAndSend(event.getEventType(), event.getPayload()); outboxMapper.markSent(event.getId(), event.getEventId()); } catch (Exception e) { log.error(outbox发送失败, eventId{}, event.getEventId(), e); int retry event.getRetryCount() 1; Date nextTime calcNextRetryTime(retry); outboxMapper.markRetry(event.getId(), retry, nextTime); } } } private Date calcNextRetryTime(int retry) { // 指数退避1分钟、5分钟、30分钟、2小时... long delaySeconds Math.min(7200, 60 * (long) Math.pow(2, retry - 1)); return new Date(System.currentTimeMillis() delaySeconds * 1000); } }扫描间隔我习惯用 3 秒业务上能接受秒级延迟。批次大小 100 到 200 比较合适不要一次捞太多否则任务执行时间长和定时调度互相追赶。重试用指数退避避免下游短暂故障时我们把消息风暴打过去但重试次数要设上限比如 20 次超过上限的转人工或者进死信队列。这里有个容易被忽略的细节markSent 必须在 MQ 发送成功之后执行。如果先标记已发送再发 MQ发送失败这条消息就永远不会被重试了。反过来MQ broker 返回成功但标记失败那这条消息会被重发一次这个重复恰恰是允许的靠消费端幂等兜底。记住一个原则宁可重复不可丢失。5.3 消费端幂等另一半防线Outbox 的重试机制决定了消息可能被消费多次消费端如果不做幂等就会出现重复加积分、重复发短信、重复扣库存的事故。幂等的手段不复杂关键是落库要有唯一约束而不是靠代码逻辑判断。最简单的做法是建一张消费记录表用 event_id 作为唯一键CREATE TABLE consume_record ( event_id varchar(64) NOT NULL, consume_time datetime NOT NULL, PRIMARY KEY (event_id) ) ENGINEInnoDB;消费逻辑在同一个事务里先尝试插入 record插入成功才执行业务操作Transactional public void handleOrderCreated(String eventId, String payload) { try { consumeRecordMapper.insert(eventId); } catch (DuplicateKeyException e) { log.warn(重复消费事件已忽略, eventId{}, eventId); return; } // 真正业务逻辑加积分、发短信等 }用数据库唯一键做幂等比“先查后插”安全得多因为查询和插入之间存在并发窗口两个线程可能同时查到不存在然后同时插入。唯一键冲突时数据库会直接拒绝其中一个从机制上保证只有一次成功。还有一种思路是利用 Redis 的 SETNX 做幂等适合业务逻辑不涉及数据库写入的场景。但 Redis 和数据库的状态一致性需要额外考虑如果 Redis 宕机或者数据被清理幂等保护就失效了。对涉及核心资金、积分、库存的操作我还是推荐数据库唯一键最土最稳。6. 常见问题与排查技巧实录6.1 典型问题速查下面这张表是我这几年来遇到频率最高的异步 事务问题整理成速查格式方便直接对着排查。现象根本原因处理方式事务还没提交消息就被消费了在事务内同步发送 MQ 消息改成 Outbox 事件表 扫描任务或使用 TransactionalEventListener(AFTER_COMMIT)事务回滚了但通知已经发出去MQ 发送和数据库事务不在一个原子边界用本地消息表保证“业务 事件”同事务写入Async 方法不生效执行是同步的类内部 this 调用或 Bean 未被 Spring 管理拆到独立 Bean或通过代理对象调用确认 Bean 是 Spring 容器管理的高并发下线程数暴涨服务假死使用了默认的 SimpleAsyncTaskExecutor配置 ThreadPoolTaskExecutor核心/最大线程数、队列、拒绝策略按流量设置异步任务失败没有任何日志异常被线程池吞掉实现 AsyncUncaughtExceptionHandler或在异步方法内部 try-catch 记录日志TransactionalEventListener 没执行当前没有事务且 fallbackExecution 默认 false确认发布事件的方法确实开了事务或设置 fallbackExecution true消费端重复处理消息网络重发、Outbox 重试导致消息重复投递消费端用 event_id 唯一键做幂等不能依赖“只消费一次”扫描任务把 MQ 打爆重试间隔过短批量过大采用指数退避重试控制批次大小和扫描频率异步线程拿不到登录用户信息用户信息存在 ThreadLocal线程切换后丢失用 TaskDecorator 传递上下文或改用显式的用户参数传递6.2 几个我踩过的大坑第一个坑是异步回调里查不到订单数据。当时我们做了一个支付回调第三方支付平台异步回调我们在回调接口里更新订单状态然后异步发消息通知仓库发货。上线后发现偶尔有订单已经支付成功但仓库迟迟没收到发货通知。查了很久发现问题出在回调事务里回调接口先更新订单状态还没等事务提交异步任务就开始查订单数据库查出来的是旧状态然后带着旧状态往下游发通知。事务提交后异步才开始执行听起来理所当然但实际代码里异步任务的触发点在事务提交之前。用 TransactionalEventListener(AFTER_COMMIT) 或者干脆用 Outbox 之后才真正解决。第二个坑是线程池队列被占满后任务静默丢失。当时的拒绝策略没配置默认是 AbortPolicy线程池满后直接抛 RejectedExecutionException。这个异常在异步场景里很容易被吞掉因为调用方早就返回了没人接到这个异常用户看到的接口是成功的但异步任务连线程池都没进就没了。后来我在自定义 AsyncConfigurer 里加了 AsyncUncaughtExceptionHandler把所有拒绝异常统一告警并且把拒绝策略改成 CallerRunsPolicy才算是把“丢任务”的问题堵住。如果你不想在提交线程里同步执行也可以改用自定义的 RejectedExecutionHandler把这些任务重新塞进一个可靠的延迟队列。第三个坑是关于链路追踪上下文丢失。我们接了 SkyWalking 之后发现异步任务对应的 trace 链总是断裂线上排查问题时根本不知道异步任务是从哪个请求触发的。解决办法是在 ThreadPoolTaskExecutor 上设置 TaskDecorator把主线程的 TraceContext 快照复制到异步线程里任务执行完再清理掉。这个坑不涉及数据一致性但会影响你排查一致性问题的效率所以我在这里特意强调一遍。没有 traceId 的情况下异步消息发错了、重试了多少次、消费链路走了哪几个服务全靠人肉分析日志效率极低。另外一个经验之谈是Outbox 扫描任务本身要盯监控。我在项目里给 outbox 表加了一个监控待发送事件数超过阈值比如持续 5 分钟超过 500 条就告警。因为正常情况下扫描任务是几秒内清空的一旦堆积说明 MQ 或者网络出了问题早发现早处理比等下游投诉过来再查要省力得多。表里的 retry_count 也要看超过 10 次的说明有系统性问题不是偶发抖动需要人工介入看 payload 里到底是什么数据导致发送一直失败。最后再说一个小技巧如果你用的是 MySQL 8.0 以上的版本可以考虑把 outbox 表的清理任务合并到扫描任务里在 markSent 之后顺手删掉状态为已发送且 create_time 超过 7 天的老记录。删除时一定要用索引字段条件去删分批次 delete limit 1000 这样别一次性 delete 全表避免产生长时间的行锁和主从延迟。做异步事务这块时间久了我最大的体会就是一句话把可靠性交给数据库把失败交给重试把重复交给幂等。数据库事务保证了业务和事件同生共死重试机制保证了偶发失败最终会被修复幂等保证了重试不会带来脏数据。三者配合整个异步消息链路才能既松耦合又不会出大乱子。这套组合不一定是最炫的但一定是最稳的。