最近我负责的一个内部模拟项目X要从老式的 HTTP 轮询改成异步消息驱动,正好赶上 Spring Boot 4.0 的新版本落地,我顺势把 RabbitMQ 也一起接了进来。折腾了大概一周,最直观的感受是:如果还像几年前那样写一堆ConnectionFactory、Channel、消息监听容器的手动装配代码,那真是浪费了 Spring Boot 4.0 的自动装配和注解模型。标题里说的“注解魔法”一点不夸张,@EnableRabbit、@RabbitListener、@RabbitHandler组合起来之后,整个消费端的代码量少得惊人。
这篇文章就是围绕 Spring Boot 4.0 + RabbitMQ 这套组合展开的。适用读者是那些已经用过 Spring Boot,想快速把 RabbitMQ 引入项目,但对 AMQP 协议、队列交换机绑定、消息转换、手动 ACK 这些概念还比较模糊的人。我会把环境搭建、核心注解原理、发送端写法、死信和重试,还有我实测踩过的坑全部放出来,所有内容都基于我自己的真实操作经验,不是把官方文档抄一遍。
1. 为什么 Spring Boot 4.0 时代我仍然首选 RabbitMQ 这张“消息王牌”
先别急着写代码,选型这件事必须想清楚。RabbitMQ 虽然是老牌消息中间件,但我在对比过 Kafka、RocketMQ 之后,还是把它放在异步任务、订单事件、库存同步这类场景的第一位。原因很简单:这货对 AMQP 协议的实现非常完整,路由灵活,延迟低,运维找资料也容易。
1.1 从“轮询”到“事件驱动”,到底解决了什么问题
在没有消息中间件之前,我做的库存同步是定时任务每隔 30 秒扫一次数据库,把变更记录发给下游系统。这种做法有两个毛病:第一,扫描间隔导致数据延迟不可控;第二,一旦下游接口响应慢,定时任务线程池会被占满,后面全部排队。引入 RabbitMQ 之后,业务系统只要把库存变更事件扔进交换机,消费者的处理速度和业务方天然解耦,生产者不需要关心下游是否可用,下游也不用担心上游突发流量。
更重要的是,Spring Boot 4.0 对 spring-boot-starter-amqp 做了更深入的自动装配。连接工厂、管理模板、消息转换器、监听容器工厂这些组件都替你准备好了。你只需要在配置文件里写清楚host、port、username、password,再在启动类或者配置类上标注@EnableRabbit,一个可用的消息收发链路就通了。
1.2 AMQP 模型里你必须拿下的四个概念
RabbitMQ 之所以比简单的 Redis 队列难懂,是因为它多了交换机、绑定、路由键这套抽象。我用大白话解释给你:生产者不直接往队列里塞消息,而是把消息交给交换机;交换机根据路由键和绑定规则,决定消息进入哪个队列;消费者最终从队列里拿消息。
所以你必须先建立四个概念:
- 交换机(Exchange):消息入口,负责接收生产者发来的消息。常见类型有 Direct、Topic、Fanout、Headers。
- 队列(Queue):消息最终存储的地方,消费者从这里拉消息。
- 绑定(Binding):把交换机和队列关联起来,并指定路由键规则。
- 路由键(Routing Key):消息上的一个标记,交换机根据它决定投递路径。
我最早学的时候被这套东西绕晕过,后来用快递公司类比就通了:交换机是分拣中心,路由键是快递单上的地址,队列是具体的配送站点。你往分拣中心扔一件包裹,分拣中心看地址帮你扔到对应站点,消费者就是那个站点的快递员。这个类比理解透了,后面看任何配置都能对号入座。
1.3 Direct 和 Topic,我的日常选择标准
实际项目里用得最多的是DirectExchange和TopicExchange。DirectExchange 的规则是精确匹配:路由键是stock.sync,那消息的 routing key 也必须完全等于stock.sync才会进入绑定队列。适合订单支付、库存扣减这种逻辑很明确的场景。
TopicExchange 则支持通配符:*匹配一个单词,#匹配零个或多个单词。比如我监听order.#,那order.created、order.paid.event都能收到。我一般用 Topic 做领域事件分发,比如所有order.*事件都进同一个队列,再在消费端按事件类型分别处理。这样做的好处是新增一种事件类型时,不需要重新绑定队列,只要消费者里多写一个处理方法就行。
2. 依赖与容器:先把 RabbitMQ 跑起来,再说注解的事
很多人上来就写@RabbitListener,结果项目启动直接报连接拒绝,然后又一脸懵地回来查网络配置。这一步我们先解决环境和依赖,把地基打牢。
2.1 Docker 一行命令启动 RabbitMQ
本地开发我强烈推荐用 Docker,千万不要在自己电脑上手动装 Erlang 和 RabbitMQ 服务端,太折磨了。在 Docker 环境里执行:
docker run -d \ --name rabbitmq \ -p 5672:5672 \ -p 15672:15672 \ -e RABBITMQ_DEFAULT_USER=guest \ -e RABBITMQ_DEFAULT_PASS=guest \ rabbitmq:4-management这里注意两点:5672是 AMQP 协议端口,Java 客户端连接用的是它;15672是管理控制台端口,浏览器访问http://localhost:15672可以看到图形界面。镜像名后面带management才会自带管理插件,不加的话没有 UI。启动后可以用guest/guest登录管理台,但生产环境一定不要用默认账号,这个后面坑里再说。
如果你不想用 Docker,也没关系,只要能保证本机或者测试机上有一个 RabbitMQ 服务监听 5672 端口就行。
2.2 引入 spring-boot-starter-amqp 这个核心依赖
Spring Boot 4.0 的项目里,引入 RabbitMQ 比我想象中更简单。如果你是 Maven 项目,在pom.xml里加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>Spring Boot 的父 POM 已经替你把版本管理好了,不需要手动写rabbitmq-version。这个 starter 会把你需要的spring-rabbit、amqp-client全拉进来。Gradle 用户对应是:
implementation 'org.springframework.boot:spring-boot-starter-amqp'这里特别提一句:Spring Boot 4.0 里包名已经全面迁移到jakarta.*,但 Spring AMQP 的核心 API 如RabbitTemplate、MessageListenerContainer、Channel还是没有变,所以你以前写过的绝大多数 Spring AMQP 代码可以直接迁过来,只是javax开头的依赖要换成jakarta。
2.3 application.yml 里的关键配置,少一个都不行
我在application.yml里最常用的配置大概是这样的:
spring: rabbitmq: host: localhost port: 5672 username: guest password: guest virtual-host: / listener: simple: acknowledge-mode: manual prefetch: 1 retry: enabled: true max-attempts: 3 initial-interval: 2000 publisher-confirm-type: correlated publisher-returns: true template: mandatory: true逐条解释一下。acknowledge-mode: manual意思是消费者处理完后手动给 RabbitMQ 回执,这能保证消息不会因为消费端代码抛异常被自动确认丢掉。prefetch: 1表示每个消费者同一时刻最多拿一条消息,处理完再拿下一条,避免某些消息处理慢时积压太多内存。retry.enabled控制监听器内部消费失败后重试次数。
publisher-confirm-type: correlated是发送端确认,用于生产环境必须确保消息真正进入 RabbitMQ 时配置。template.mandatory: true则是路由不到任何队列时把消息退回给生产者,配合后面要讲的ReturnsCallback可以拿到不可路由的告警。
3. 注解三件套:@EnableRabbit、@RabbitListener、@RabbitHandler 的协作关系
这里是标题里“注解魔法”的核心。如果不理解这三个注解各自身份,只照着抄,出了问题很难定位。
3.1 @EnableRabbit 是总开关,它到底激活了什么
@EnableRabbit有点像是一把钥匙。它放在配置类上,用来激活 Spring 对@RabbitListener的解析和注册。源码层面的逻辑大致是:Spring 扫描到@EnableRabbit后,会注册RabbitListenerAnnotationBeanPostProcessor和RabbitListenerEndpointRegistry这两个关键组件。前者负责扫描容器里所有 bean 的方法名上是否有@RabbitListener,后者负责把这些注解描述的方法注册成真正的消息监听端点。
如果你忘了加@EnableRabbit,注解不生效时最常见的现象是:项目启动不报错,生产者消息也发得出去,但是消费者方法永远不执行,消息全部积压在队列里 unacked。我刚开始玩的时候就被这个坑磨了一个下午,后来加了注解立刻通了。
比较干净的做法是直接加在RabbitConfig配置类上:
@Configuration @EnableRabbit public class RabbitConfig { @Bean public MessageConverter jsonMessageConverter(ObjectMapper objectMapper) { Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper); converter.setCreateMessageIds(true); return converter; } }3.2 @RabbitListener 标注方法,监听从此零模板代码
@RabbitListener是我们最常用的注解。你可以把它标注在方法上,也可以标注在类上联合@RabbitHandler使用。方法上最简单,看一个我实际写的消费者:
@Component public class StockSyncListener { @RabbitListener(queues = "stock.sync.queue", concurrency = "3-6") public void onStockSync(StockSyncEvent event, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException { try { // 处理库存变更 updateStock(event.skuId(), event.delta()); // 手动确认 channel.basicAck(deliveryTag, false); } catch (Exception e) { channel.basicNack(deliveryTag, false, true); } } private void updateStock(String skuId, int delta) { // 实际数据库更新逻辑 } }这里有几个细节需要讲清楚。concurrency = "3-6"意思是监听容器最低 3 个消费者、最多 6 个消费者,Spring 会根据队列积压情况动态调整。方法参数可以直接写业务对象StockSyncEvent,前提是消息转换器是 JSON 转换器,并且消息头的__TypeId__信息能被正确识别。
手动 ACK 的参数deliveryTag是通过@Header(AmqpHeaders.DELIVERY_TAG)拿到的,这是当前消息在当前 Channel 上的唯一递增序号。basicAck(deliveryTag, false)的第二个参数false表示不批量确认,只确认当前这条。
3.3 @RabbitHandler 实现同一队列多事件类型分发
如果监听器需要处理同一队列里的不同消息类型,可以用类级@RabbitListener搭配方法级@RabbitHandler。Spring 会根据消息反序列化后的实际类型,选择匹配的方法:
@Component @RabbitListener(queues = "order.event.queue") public class OrderEventHandler { @RabbitHandler public void handleCreated(OrderCreatedEvent event) { // 处理订单创建事件 } @RabbitHandler public void handlePaid(OrderPaidEvent event) { // 处理订单支付事件 } }这种写法的好处是,一个队列对应一个业务模块,模块内不同事件类型只用不同方法处理,代码结构特别清晰。但前提是消息生产者发送时,消息头里必须包含类型信息,Jackson2JsonMessageConverter会在消息属性里写入__TypeId__,消费者端再根据这个字段找方法。如果生产端和消费端的包名不一致,反序列化会失败,后面踩坑部分会专门说。
3.4 Spring Boot 4.0 在注解驱动上的隐性增强
很多人没注意到的是,Spring Boot 4.0 里对消息监听端点的启动顺序、容器工厂配置做了更细的默认值处理。比如之前版本里如果监听容器依赖某个 Bean,必须手动配置dependsOn,现在只要 Bean 之间的依赖关系在容器中可见,监听容器会自动等待依赖就绪再启动。这意味着你在@RabbitListener方法里注入的Service如果初始化很慢,不会再出现消费者先启动然后报错的问题。
另外,Spring Boot 4.0 对虚拟线程的支持也更成熟,如果你是 JDK 21+ 环境,可以考虑在监听容器上启用虚拟线程,配置类似:
@Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( ConnectionFactory connectionFactory, MessageConverter messageConverter) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(messageConverter); factory.setTaskExecutor(Executors.newVirtualThreadPerTaskExecutor()); factory.setPrefetchCount(1); return factory; }虚拟线程的好处是每个消息都能有一个独立的轻量线程去处理,不会被传统线程池的线程数限制死。不过这个配置要不要上,得看你本机 JDK 版本,如果还在用 JDK 17,那就老老实实用默认线程池。
4. 发送端:RabbitTemplate 的序列化、confirm 回调与消息结构设计
消费端注解省了很多事,但生产端如果图省事,随便用convertAndSend发字符串、发 JSON 字符串,后面消费端解析就会越来越乱。发送端的核心任务是设计好消息结构和确认机制。
4.1 统一 JSON 消息转换器,两端不能各玩各的
我见过最麻烦的情况是,生产端用SimpleMessageConverter发 Java 对象序列化后的字节,消费端却配了Jackson2JsonMessageConverter,结果反序列化直接报ClassCastException。两端消息转换器必须对齐。生产端可以注入RabbitTemplate,然后把自定义转换器塞进去:
@Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); rabbitTemplate.setMessageConverter(messageConverter); return rabbitTemplate; }发送消息的代码就很简单了:
rabbitTemplate.convertAndSend( "stock.exchange", "stock.sync.routing.key", new StockSyncEvent(skuId, delta) );convertAndSend内部会自动调用转换器,把StockSyncEvent变成 Message 对象并设置contentType=application/json、__TypeId__等属性。我习惯把交换机、路由键做成常量,避免不同类里字符串拼写不一样导致消息进不了队列。
4.2 confirm 回调:生产环境到底怎么确认消息真的发出去了
RabbitMQ 是一个网络服务,生产者把消息交给它,中间可能因为网络闪断、交换机不存在、路由失败等各种原因丢掉。所以生产环境必须开启发送端确认。
配置我前面已经给了,publisher-confirm-type: correlated。然后在代码里设置回调:
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { if (ack) { log.info("消息确认成功: {}", correlationData.getId()); } else { log.error("消息确认失败: {}, cause: {}", correlationData.getId(), cause); } });CorrelationData是消息关联数据,发送时如果不手动设置,Spring 会生成一个 UUID。但如果你的业务需要把某条消息和数据库记录对应起来,应该在发送前就构造好CorrelationData:
CorrelationData correlationData = new CorrelationData(orderId); rabbitTemplate.convertAndSend(exchange, routingKey, message, correlationData);这样回调里的correlationData.getId()就是你的业务订单号,方便失败时做补偿。注意confirm只表示 RabbitMQ 的交换机收到了消息,不表示消息一定路由到了队列。如果路由不到,还要依赖mandatory和ReturnsCallback:
rabbitTemplate.setReturnsCallback(returned -> { log.error("消息无法路由: exchange={}, routingKey={}, replyText={}", returned.getExchange(), returned.getRoutingKey(), returned.getReplyText()); });我自己一般会在 confirm 成功且无 return 时,才把本地消息表的状态标记为“已送达”。否则就进入定时补偿逻辑,重新发送或者告警。
4.3 消息体设计:record、版本号、TraceId
消息体本身别闹心,我推荐用 Java record 表达事件。比如:
public record StockSyncEvent(String skuId, int delta, long timestamp) { }用 record 的好处是不用写一堆 getter/setter,序列化时字段名清晰,反序列化也稳定。但要注意:一旦消息结构上线,字段名尽量不要改,因为消费者的 JSON 解析对不上就会出问题。如果一定要扩展,宁可加新字段,也绝对不要删旧字段。
还有一个实用习惯:所有消息里都带上traceId或requestId,这样一条消息从生产端到消费端的完整链路可以串联起来。尤其当消费端处理失败需要查日志时,没有 traceId 基本靠猜。我在模拟项目X里所有事件都搞了一个统一父类或接口,包含eventId和occurredAt,后面排查问题省了很多时间。
5. 由浅入深:死信队列、重试与多消费者并发控制
跑通 Hello World 只是第一步,真正要上生产,你必须回答一个问题:消息处理失败怎么办?如果业务系统挂了十分钟,队列里的消息是堆着不管,还是立刻转到一个专门的地方,等系统恢复后再慢慢消化?这就引出了死信队列。
5.1 死信队列的配置方式,拒绝消息悄悄消失
RabbitMQ 的死信机制很简单:给一个队列配置x-dead-letter-exchange和x-dead-letter-routing-key,当队列中的消息被拒绝、消费超时或者队列长度超过限制时,消息会转发到指定的死信交换机,再路由到死信队列。
我在 Spring 配置里通常是声明一套完整的三件套:
@Bean public DirectExchange stockExchange() { return new DirectExchange("stock.exchange"); } @Bean public Queue stockQueue() { return QueueBuilder.durable("stock.sync.queue") .deadLetterExchange("stock.dlx") .deadLetterRoutingKey("stock.sync.dlq.routing.key") .build(); } @Bean public DirectExchange stockDlx() { return new DirectExchange("stock.dlx"); } @Bean public Queue stockDlq() { return QueueBuilder.durable("stock.sync.dlq").build(); } @Bean public Binding stockBinding() { return BindingBuilder.bind(stockQueue()).to(stockExchange()).with("stock.sync.routing.key"); } @Bean public Binding stockDlqBinding() { return BindingBuilder.bind(stockDlq()).to(stockDlx()).with("stock.sync.dlq.routing.key"); }这套 Bean 声明之后,Spring Boot 启动时会自动把交换机、队列、绑定执行到 RabbitMQ 服务端。消费者方法不需要变化,如果代码抛异常并且做了basicNack(deliveryTag, false, false),消息就会被转到死信队列。后面我单独写一个死信消费者,做人工补偿或者重新入队。注意basicNack的第三个参数如果是true,表示重新放入原队列,会形成无限循环,如果你不是故意做重试,一定不要写true。
5.2 消费重试:本地重试和死信配合,怎么选
Spring AMQP 提供了重试拦截器。配置里开启spring.rabbitmq.listener.simple.retry.enabled=true后,消费方法抛异常不会立刻 NACK,而是 Sleeper 等待后重试,达到max-attempts后才会真正把消息拒绝掉。这适合处理下游临时抖动,比如数据库连接池短暂满了、HTTP 调用刚好超时。
但本地重试有个风险:重试期间消息在消费者处理流程里,队列中的消息并没有被重新投递。如果你的消费者线程很少,一条消息重试 3 次,每次间隔 5 秒,你一个消费者可能因为这一条消息卡住 15 秒,后面 prefetch 的消息全部等在那里。所以我一般把prefetch设置为 1,让重试影响范围最小化。
更稳妥的做法是:本地重试只设置 1 到 2 次,失败之后直接basicNack进死信队列,由死信消费者决定什么时候、以什么频率重新处理。这样主队列不会被一个坏消息堵塞,补偿节奏也更可控。
5.3 并发消费者数量:不是越大越好,线程数和连接数都要盯
@RabbitListener(queues = "stock.sync.queue", concurrency = "3-6")里的并发数字,Spring 会为每个并发消费者创建一条独立的消费通道,但底层连接是共享的,由CachingConnectionFactory统一管理。并发越大,同一时刻从队列拉出来的消息就越多。你以为并发越大处理越快,但实际上如果你的下游是数据库,并发飙到 20 可能直接把数据库连接池打满。
我的经验公式是:并发消费者数不要超过下游资源池的 1/3。比如数据库连接池最大 20,那我监听并发最多配置到 6,否则一条慢 SQL 会导致整个系统雪崩。另外,如果你同时有多个@RabbitListener,每个监听器默认会共用一个连接工厂创建的连接。理论上一个 Connection 可以开多个 Channel,但如果监听器极多或者消息量极大,建议单独建一个ConnectionFactory给关键业务的监听器,避免互相影响。
5.4 幂等消费:RabbitMQ 的语义是至少一次,不是刚好一次
这一点必须单独说。RabbitMQ 消费者收到消息后,如果处理到一半进程崩溃,连接断开,消息会被重新投递。所以你的消费逻辑必须是幂等的。比如库存同步,不能简单“加上 delta”,而要去重后累加。我常用的做法是:在数据库中建一张消息去重表,主键是eventId;消费逻辑先insert ignore这个事件 ID,插入成功才处理业务,插入失败说明已经处理过,直接 ACK 跳过。
boolean firstProcess = deduplicateService.tryInsert(event.eventId()); if (!firstProcess) { channel.basicAck(deliveryTag, false); return; } try { doBusiness(event); channel.basicAck(deliveryTag, false); } catch (Exception e) { channel.basicNack(deliveryTag, false, false); }这里tryInsert返回 false 表示重复消息,直接 ACK 是最佳处理,不然它会一直进入死信队列。幂等表记得清理历史数据,保留最近 7 天就够,不然数据量涨得很快。
6. 实测中踩过的几个坑:注解不生效、序列化不一致、连接风暴
最后这部分是我最想分享的,因为代码样例谁都能抄,但这些坑不实际踩一遍真的不知道。
6.1 @RabbitListener 一直不执行,先查 @EnableRabbit 和监听容器工厂
前面提过,忘加@EnableRabbit是注解不生效的最常见原因。但还有另一种情况:你加了@EnableRabbit,消费者方法也确实注册了,可消息就是不进消费方法。这时要看是不是自定义了SimpleRabbitListenerContainerFactory,并且没有设置messageConverter。如果你创建了自定义工厂,Spring Boot 原来的自动配置工厂就不会被使用,消费者反序列化失败后可能直接报错并重试。检查顺序应该是:
- 项目中有没有
@EnableRabbit。 spring.rabbitmq.listener.simple.*配置是否生效。- 有没有自定义
RabbitListenerContainerFactory且配置完整。 - 管理控制台里看消息是
ready还是unacked。如果一直是ready但消费者不掉,大概率是监听器没注册成功或 preflight 失败。
6.2 生产端用 Java 序列化,消费端用 JSON,两个经典报错
第一个报错是ClassNotFoundException或NoClassDefFoundError,常见于生产端没改MessageConverter,默认用了SimpleMessageConverter(JDK 序列化)。消费者却配了Jackson2JsonMessageConverter。解决方案是排除所有自动配置的MessageConverter,全局指定同一个。
第二个报错更隐蔽:生产端和消费端虽然都是 JSON,但包子路径不同。比如生产端类是com.company.order.event.OrderCreatedEvent,消费端同名类在com.company.order.listener.event.OrderCreatedEvent,Jackson2JsonMessageConverter根据消息头里的__TypeId__找类时找不到。解决办法有两个:一是严格统一依赖中的类路径,二是配置AllowedList或者自定义TypeIdMapping。我在项目里为了省事,直接把生产者和消费者放到同一个依赖模块,确保类型类完全一致。
6.3 手动 ACK 忘写 basicAck 导致 unacked 堆积
如果你把acknowledge-mode设成了manual,那你的消费方法必须自己调用channel.basicAck或basicNack。我见过有人把 ACK 模式设成 manual,但方法体里没写任何 ACK 代码,结果每条消息都被消费者拉出来,标记为unacked,既不处理也不确认,RabbitMQ 默认情况下 unacked 消息不会被重新投递给其他消费者。看起来像队列积压,其实堆积在消费者内存里。
解决这事只能靠规范:只要 manual 模式,统一封装一个消费模板方法,try 里处理业务并 ACK,catch 里判断是否重试或 NACK。我后来把所有消费者都改成同一个抽象父类,把 ACK 逻辑收敛起来,减少漏写的概率。
6.4 连接风暴:CachingConnectionFactory 的缓存参数
消息连接数突然暴涨也是一个容易踩的坑。Spring 默认CachingConnectionFactory会复用连接,但 Channel 有缓存上限,默认大小是 25。如果你在高并发下创建了很多RabbitTemplate或监听容器,超过缓存上限就会反复创建新连接。再加上你配置了多个@RabbitListener且并发很高,网络层面的连接数可能快速膨胀。
我的做法是显式调大channelCacheSize,并关掉不必要的事务化:
@Bean public ConnectionFactory connectionFactory() { CachingConnectionFactory factory = new CachingConnectionFactory(); factory.setHost("localhost"); factory.setPort(5672); factory.setUsername("guest"); factory.setPassword("guest"); factory.setChannelCacheSize(50); factory.setPublisherConfirmType(CachingConnectionFactory.ConfirmType.CORRELATED); return factory; }但这也不是越大越好。Channel 不是线程完全无锁的,缓存太多会浪费内存。一般 50 足够支撑几百并发发送,除非你有极端流量,否则别往几千调。
最后再说一点我个人的使用习惯:所有关键的交换机、队列、路由键常量,我都会集中放到一个RabbitConstants类里,消费者和生产端都引用同一份,杜绝字符串拼写错误。消息结构统一用 record,事件里带eventId和occurredAt。每次改动消息结构之前,先想清楚线上有没有未消费完的旧消息,如果有,新旧结构必须兼容,否则消费者爬起来就是一堆反序列化异常。Spring Boot 4.0 和 RabbitMQ 这套组合,用好了确实是“注解魔法”,但魔法背后还是 AMQP 那套稳定的投递模型,理解模型比背注解重要。