☰
订单服务与库存服务之间,我为什么选Spring Cloud Stream消息驱动
2026/10/15 16:58:45 网站建设 项目流程

一个例子:为什么订单服务和库存服务之间,我最后还是选了消息驱动

如果正在做微服务,早晚会遇到模块之间怎么通信的问题。直连调用最简单,A服务调用B服务的接口,返回结果拿到继续往下走。但真上了规模,你会发现同步调用的路子越来越难走:下游一抖动,上游就跟着超时;下游稍慢一点,整个请求链路像多米诺骨牌一样全塌。这几年做了好几个微服务相关的系统,最深的感受是,消息驱动不一定是所有场景的最优解,但它确实是解决一类典型问题的最自然的方式。

Spring Cloud Stream 在这套体系里扮演的角色很有意思。它不是一个具体的消息中间件,而是统一了消息编程模型的抽象层。你写了一套收发消息的业务代码,底层既可以跑在 RabbitMQ 上,也可以切到 Kafka,业务代码基本不用动。这期就围绕 Spring Cloud Stream 的消息驱动展开,从核心概念讲到代码实操,再把我实际部署中踩过的坑一并整理出来。适合正在学 Spring Cloud 的开发者,也适合项目里已经引入了消息队列但还没用上 Stream 的团队参考。

1. 什么时候该用消息驱动,而不是 RPC 直连

1.1 同步调用撕不开的三个死结

先说第一个死结:流量突增。你有一个订单创建接口,每下一单都要调一次库存服务。平时流量平稳,两个服务配合默契,突然遇到大促或者热点活动,瞬间的订单请求量是平时的几十倍,库存服务的数据库连接先被打满,接着接口超时,然后订单服务的线程池被阻塞,雪崩就这么来了。同步调用在这里的问题是,系统没有缓冲地带,生产者直接被消费者的处理能力绑架。

第二个死结是耦合。订单服务关心的是"下单成功"这件事,库存服务关心的是"扣减库存"这件事,支付服务关心的是"用户完成支付"这件事。如果用同步接口,订单服务就要同时知道库存、支付、积分、通知所有下游的地址和接口定义,每加一个下游,订单服务就要改一次代码。时间一长,订单服务变成一个大杂烩,什么逻辑都往里塞。

第三个死结是长任务。有些业务天生就不适合在一个请求里等结果,比如下单后发邮件、生成对账单、做数据报表。一个请求等三五个任务全部完成再返回,用户的响应时间直接飙升到秒级。这种长耗时又没有实时反馈诉求的任务,塞进同步链路里,体验极差。

消息驱动在这三类场景里的优势很明显:生产者把消息丢进队列就返回,消费者按自己的节奏慢慢处理,两边通过消息中间件解耦。流量上来时,消息在队列里排队,消费者按最大能力消费,系统不会因为瞬间流量被打垮。至于耦合,生产者只依赖消息的格式和Topic,不关心下游是谁。

1.2 我用过最值的三种典型场景

第一个场景,异步通知。比如用户注册成功后,要发送欢迎短信、初始化默认配置、推送新手任务。这些附加操作用同步接口做,注册接口会慢得不像话。把它们改成消息驱动后,注册主流程只做数据落库,其余操作全部发消息,注册接口的响应时间从800毫秒降到150毫秒,体验提升是肉眼可见的。

第二个场景,削峰填谷。业务上有过活动期间秒杀或者集中放量的需求,数据库扛不住瞬时流量。我的做法是前端请求先进消息队列,后端消费者用固定速率的线程池去处理,多出来的请求在队列里排队。用户看到的是"已受理",后台慢慢消化,而不是直接在接口层报错。

第三个场景,多系统状态同步。电商场景下,订单状态变了,需要在购物车、用户中心、报表系统、物流系统都同步状态。用同步调用,要写循环挨个通知,还要自己维护通知结果。用了消息驱动,订单服务只需往订单状态Topic发一条消息,谁关心谁订阅,天然实现了事件广播。

不过也要说句公道话,不是所有场景都适合消息驱动。需要实时返回结果的查询操作,用户等着要数据的,就别硬套消息。我有一次在需要同步返回结果的场景里用了消息驱动,结果用户点完按钮要等2秒才看到结果,后来还是换回了同步接口。消息驱动的价值是解耦和异步,牺牲的是实时性,用之前先想清楚业务是否接受这个代价。

2. Spring Cloud Stream 在消息队列之上架了一层"统一模型"

2.1 换个队列就要改代码?Stream 说不用

你直接用 RabbitMQ 写一套生产者消费者,代码里全是 ConnectionFactory、Queue、Channel 这些API。后来项目扩容,想换成 Kafka 享受更高的吞吐量,代码基本要重写,因为两家的API模型是完全不同的。更麻烦的是,消费者消费位点的管理方式、消息确认机制也各不相同。

Spring Cloud Stream 解决的就是这个问题。它定义了一套统一的编程模型:你用 MessageChannel 发消息,用 @StreamListener 注解收消息(新版本里是函数式的 Supplier 和 Consumer),业务代码里根本看不到具体的 MQ API。至于底层到底是 RabbitMQ、Kafka 还是 RocketMQ,由 Binder 来对接。

我举一个实际例子。同一个支付结果处理服务,一开始部署在 RabbitMQ 上,业务跑了大半年,后来数据量涨得厉害,架构组决定把消息链路迁到 Kafka 上。整个迁移过程,除了修改引入的 Binder 依赖和配置文件里的连接地址,一行业务代码都没动。这就是 Stream 抽象层带来的价值。

2.2 三个核心概念:Binder、Binding、MessageChannel

理解了这三个概念,Stream 基本就入门了。

Binder 是连接 Spring Cloud Stream 应用和消息中间件的桥梁。不同中间件有各自的 Binder 实现,比如 rabbit binder、kafka binder。应用启动时,Binder 负责创建队列、交换机、主题这些底层资源,并把消息中间件的事件转换成 Spring 的事件。

Binding 是绑定关系。一个 Binding 把一个目的地(destination,也就是消息队列里的 Topic 或 Exchange)和应用的通道(Channel)关联起来。这里分 Input 和 Output 两种:Input 对应消费者,消息从目的地进来;Output 对应生产者,消息从应用发出去。配置里写 destination 就是指定发到哪个 Topic / Exchange。

MessageChannel 是业务代码里直接操作的对象。发消息的时候,调用 channel.send(Message),Message 里包含 payload(业务数据,通常是 JSON)和 headers(消息属性,比如消息ID、内容类型)。

这三者的关系用一句话总结就是:Binder 连接中间件,Binding 把外部目的地和内部通道连起来,业务代码只管往通道里发消息和从通道里收消息。

2.3 分组消费和持久化订阅,这两个特性绕不开

分组消费是 Stream 里最容易被忽视但实际又最重要的概念。同一个消费者应用,如果部署了多个实例,而每个实例都监听同一个目的地,默认情况下每条消息会被每个实例都消费一遍。如果希望一条消息只被其中一个实例处理,就要给这些实例指定相同的 group。

我举个例子。订单服务有三个实例,同时监听订单事件。如果不配置 group,每下一单,三个实例都会打印日志,各自的本地缓存都会更新一遍,数据在三个实例间就不一致了。配上 group 之后,MQ 会按负载均衡策略把消息分发到其中一个实例,一条消息只被处理一次。

持久化订阅也很关键。Kafka 的消费位点保存在 broker 上,消费者重启之后还能从上次的位置继续消费。RabbitMQ 则要靠队列的持久化来保证。这里必须强调,RabbitMQ 环境下,如果消费者只设置了 destination 而没有配 group,Stream 会为每个实例创建匿名队列,消费者重启后队列可能被删除重建,消息也就丢了。所以生产环境上,group 这个配置项不是可选项,而是必选项。

3. 实操:从零搭一个 Spring Cloud Stream 消息驱动 Demo

3.1 依赖和配置怎么写,这里有点讲究

我以 RabbitMQ 版本的 Binder 为例。先加依赖:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-stream-rabbit</artifactId> </dependency>

注意选择与 Spring Cloud 版本对应的依赖版本,具体版本号可以查 Spring Cloud 的版本对应表,这里不推荐直接写最新的,稳定版本优先。如果是新版 Spring Cloud 且使用函数式编程风格,依赖一样,主要是写法上不同,后面会提到。

然后是 application.yml 的配置。我拿一个订单创建后发消息的例子来写:

spring: application: name: order-service cloud: stream: bindings: # 订单事件生产者 order-output: destination: order-event content-type: application/json # 订单事件消费者 order-input: destination: order-event group: order-group content-type: application/json # 新版函数式风格需要指定消息处理函数的绑定关系 function: definition: handleOrderEvent

这里有两个配置细节值得解释。destination 是消息要发往的目的地,在 RabbitMQ 下会对应一个 Topic 类型的 Exchange。content-type 设为 application/json,表示消息体按 JSON 处理,Stream 会完成对象和 JSON 之间的自动转换。group 参数必须写,没有写 group 的 consumer 在 RabbitMQ 下会落到匿名队列,容易出现消息丢失和重复消费,这是真正的经验之谈。

3.2 生产者:往通道里发送一条消息

老版本注解写法,先写一个发送器:

@Component @EnableBinding(Source.class) public class OrderEventSender { @Autowired private MessageChannel output; public void sendOrderEvent(Order order) { boolean sent = output.send(MessageBuilder.withPayload(order) .setHeader("eventType", "ORDER_CREATED") .build()); if (!sent) { // 发送失败的处理,可以记录日志或走补偿逻辑 log.error("订单事件发送失败:{}", order.getOrderId()); } } }

用 @EnableBinding 开启绑定,Source.class 自带一个 output 通道。MessageBuilder.withPayload 创建消息体,setHeader 设置了自定义属性。发送失败时 send 方法会返回 false,这个返回值很多老手容易忽略,我遇到过一次消息堆积排查了很久,最后发现是发送失败后没有补偿导致丢数据。

新版函数式风格更简洁,生产者的核心是一个 Supplier:

@Configuration public class OrderEventPublisher { @Bean public Supplier<Order> orderEventSupplier() { return () -> { Order order = createOrder(); log.info("发送订单事件:{}", order.getOrderId()); return order; }; } }

函数式风格下,Spring 会按配置里的 function.definition 自动把这个 Supplier 的输出绑定到 Binder 上,定时轮询发送。相比注解方式,少了 MessageChannel 的显式操作,代码更干净,但理解成本略高,新项目我更推荐这种写法。

3.3 消费者:注解方式和函数式方式的差别

注解方式消费者:

@Component @EnableBinding(Sink.class) public class OrderEventConsumer { @StreamListener(Sink.INPUT) public void onOrderEvent(Order order) { log.info("收到订单事件:{},扣减库存", order.getOrderId()); // 在这里做库存扣减、积分赠送等业务 } }

@EnableBinding(Sink.class) 开启了 input 通道,@StreamListener 监听这个通道。收到消息后,方法参数是 Order 对象,Stream 会自动完成 JSON 反序列化。这里要注意,消息体格式和 content-type 配置必须匹配,否则会报反序列化错误。

函数式风格的消费者更直观:

@Configuration public class OrderEventConsumerConfig { @Bean public Consumer<Order> handleOrderEvent() { return order -> { if (order == null || order.getOrderId() == null) { throw new IllegalArgumentException("非法订单消息"); } log.info("函数式消费订单事件:{}", order.getOrderId()); }; } }

配置里 function.definition: handleOrderEvent,Spring 会找到名字匹配的 Consumer Bean,自动绑定到 order-input 对应的目的地。这种方式的好处是函数式编程天然支持更高阶的组合,消费者里的逻辑可以拆成多个 Bean,用管道方式组合。

有基础的同学会发现,新版注解方式 @EnableBinding 已经被标记为 deprecated,但还是能用的,网上教程大部分都是注解写法。我建议新项目直接学函数式,因为官方在新版本里已经全面转向函数式模型,未来这个模型会更主流。

3.4 消息分区:让同一订单的消息永远落到同一个实例

如果消费者有多个实例,又希望同一个业务对象的消息总是由同一个消费者实例处理,就需要用到分区特性。典型场景是订单事件:同一个订单的创建、支付、关闭事件,最好由同一个消费者实例处理,这样可以避免分布式锁,让本地状态缓存更有效。

生产者配置:

spring: cloud: stream: bindings: order-output: destination: order-event producer: partitionKeyExpression: payload.orderId partitionCount: 3

消费者配置:

spring: cloud: stream: bindings: order-input: destination: order-event group: order-group consumer: partitioned: true

partitionKeyExpression 指定分区的 key,这里用的是消息体里的 orderId 字段。partitionCount 指定分区数,消费者实例数最好和分区数对应上。partitioned: true 告诉消费者它是一个分区消费者,只接收属于自己分区的消息。

我实际用下来,分区特性非常适合有状态处理的场景。如果消费者是无状态的,比如只做日志、统计,就不用启用分区,默认负载均衡就够了。

4. 为什么你的消息丢了、重复了、堆积了:排查实录

4.1 重复消费,问题不一定在 MQ 这边

我做过的项目里,重复消费是最常见的现象,而且大概率不是 MQ 造成的。很多同学一听"重复消费"就怀疑是 RabbitMQ 配置问题,其实源头往往在生产者:发送时没等确认就切下一个,消费者处理超时导致消费方重新拉取同一条消息,或者重试机制导致同一个 payload 又发了一遍。

应对思路分两层。第一层是降低重复概率,生产端确认机制要开启,RabbitMQ 里 Spring Cloud Stream 默认会对发送做确认(publisher-confirms),把确认逻辑打开,发送失败做补偿记录。第二层是消费端保证幂等,也就是同一条消息重复处理也不会产生脏数据。最简单的做法是在消费逻辑里带一个消息唯一ID,处理前先去数据库查一下这个ID是否已经存在,存在就直接跳过。稍微重一点的方案是用 Redis 的 SETNX 做消费去重,设置短TTL,可以挡掉绝大多数重复消息。

消息里加全局唯一ID的操作很简单,但很多项目没做。我踩过一次大坑:消息重试后,用户积分被加了两次,后续对账发现数据有问题。从那以后,在所有消费逻辑里,我第一件事就是查消息去重表,这个习惯一直保持到现在。

4.2 消息丢了,先检查 group 和持久化配置

消息丢失属于严重事故,但排查起来反而比重复消费容易。常见的丢失,十有八九是消费者没配 group。

我复盘过一个生产事故:周五晚上发版,消息消费者跑起来了,开始消费正常。周六早上运维重启了一轮服务,重启之后新订单的消息就丢了。排查之后发现,线上 YAML 只配了 destination,没有配 group。RabbitMQ 环境下,每个 consumer 实例会创建自己的匿名队列,消费者重启,匿名队列被销毁重建,消息自然就没了。

解决办法很简单,给所有消费者配置加上 group,并且保证 RabbitMQ 里的队列是持久化的。Spring Cloud Stream 在配置了 group 后,会自动创建持久化队列。另外,消费者监听逻辑里消息确认模式要设对,手工确认模式下处理完业务再确认,防止消费中途崩溃造成消息丢失。

4.3 消费失败自动重试,退避和死信要一起配

消费者处理消息时,网络抖动、数据库连接异常、数据格式不合法都可能导致处理失败。Spring Cloud Stream 默认会对失败消息做三次重试,三次都失败后消息会被丢弃。这个默认行为在生产上是很危险的,业务数据没处理完就丢了。

好的做法是配置重试和死信队列。RabbitMQ Binder 下配置:

spring: cloud: stream: bindings: order-input: consumer: max-attempts: 3 back-off-initial-interval: 1000 back-off-multiplier: 2.0 default-requeue-rejected: false rabbit: bindings: order-input: consumer: auto-bind-dlq: true dead-letter-queue-name: order-event-dlq

max-attempts 控制最大重试次数,back-off-initial-interval 是第一次重试前的等待时间,multiplier 是退避倍数,第二次等待2秒,第三次4秒。auto-bind-dlq 会在消息重试耗尽后把消息送到死信队列。

死信队列里的消息不能只存不处理。我常用的方案是,写一个单独的死信消费程序,定时把死信消息反序列化,人工或者通过补偿流程重新投递。没有这个配套流程,死信队列迟早变成"数据黑洞"。这个流程建议在引入 Stream 的第一时间就建好,真的等到出了线上问题再去补,代价就太大了。

4.4 消费堆积,不要盲目加消费者实例

消息堆积是典型的容量问题。现象就是队列里的消息越来越多,消费者跟不上。很多团队的直觉是加实例,但实际上要分情况。

如果消费者是纯 CPU 或 IO 处理型,加实例确实能提升吞吐,但要注意分布式锁和公共资源竞争。如果消费者背靠数据库,瓶颈在数据库负载,加实例只会把数据库压垮,堆积反而更严重。我见过一次事故,消息堆积后运维一口气把消费者扩到 20 个实例,结果数据库连接池被打爆,整个服务直接不可用。

更稳妥的做法是分步骤处理。先看消费者日志里处理单条消息的耗时,如果耗时过高(比如超过500毫秒),先优化消费者逻辑,把外部调用改成批量、或者加缓存。如果单条处理耗时正常,再看数据库负载,数据库空闲就可以加实例,数据库繁忙就先扩容数据库或者做消息合并消费。另外,堆积期间可以临时关闭不重要的下游消费者,优先保证核心链路的消息处理,这是保命优先级的取舍。

5. 我的最终体会:用 Spring Cloud Stream 最重要的三件事

如果只让我总结给后来人三条经验,大概是这些:

第一,分组配置(group)是生产环境的第一道安全底线,丢了它,直接面对丢消息和重复消费,这一条我在前面反复提,因为它是项目里真实发生过且代价最高的教训。

第二,不要忽视消息确认和重试机制。默认的重试参数只能算"能用",不是"好用",根据业务对实时性和数据可靠性的要求,重试次数、退避时间、死信队列都要提前规划好。

第三,函数式编程风格是 Spring Cloud Stream 的发展方向。注解方式资料多,学起来方便,但新项目尽量拥抱函数式模型,未来升级迁移会更顺畅。

Spring Cloud Stream 看起来像是多了一层封装,实际上这层封装帮你规避了直接操作 MQ API 最容易踩的那些坑。真正把消息驱动的思路落到系统设计里,代码层面反而干净很多。如果现在让我重新做一个微服务项目,模块间的非实时通信,我会在第一版架构设计里就引入消息驱动的骨架,这比后期业务膨胀了再回头改造,成本低太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询