开头先说明一下,这篇文章是我在实际项目里落了地的方案:Spring Boot 集成 Redis 的发布订阅功能,客户端用的是 Lettuce。如果你做过后端开发,多多少少听过 Redis 不只是缓存,它还有一套轻量级的发布订阅机制,适合做实时通知、分布式事件广播、在线状态同步这类场景。这次我把从依赖引入到代码实现、再到踩坑排查的完整过程整理出来,给那些想用但还没动手的人一个可以直接抄作业的参考。
文章不会只给你贴一段能跑的代码,我会把每个关键选择背后的原因讲清楚,比如为什么用 Lettuce 而不是 Jedis、为什么监听容器要单独配置、为什么一不小心消息就乱码。你跟着走一遍,差不多就能在业务里独立落地了。
1. 方案选型与核心设计思路
1.1 Redis 发布订阅到底解决什么问题
先理清一个基础问题:Redis 的发布订阅(Pub/Sub)是什么,和消息队列有什么区别。
Redis 的 Pub/Sub 模型很简单,三个角色:发布者、频道、订阅者。发布者往指定频道发送消息,所有订阅了这个频道的客户端都会收到。这个过程是异步的、即时的,发布者不需要关心谁在听,订阅者也不需要轮询拉取。
这个机制非常适合几类场景。一类是实时通知,比如用户下单后,订单服务发一条消息,库存服务、短信服务、积分服务同时收到通知去干活。一类是分布式环境下的状态广播,比如配置变更后,让所有节点立即感知并刷新本地缓存。还有一类是多人协作场景,类似聊天室里的消息推送,谁发了消息,频道里的其他人都能实时收到。
那为什么不直接上消息队列呢?因为不是所有场景都需要消息队列的持久化、重试、堆积能力。如果消息丢了可以接受、业务量不大、追求极简部署,那 Redis Pub/Sub 的轻量优势就很明显了。我这次做的模拟项目 X 就属于这种——内部服务之间的实时事件同步,消息不需要落盘,延迟要求高,Redis 本来就是现成的中间件,不需要额外引入一套消息队列来增加运维成本。
1.2 为什么客户端选 Lettuce 而不是 Jedis
Spring Boot 2.x 之后,默认的 Redis 客户端就是 Lettuce,这个选择不是随意的。我在项目里刚开始的时候也纠结过要不要换回 Jedis,后来对比完就放弃了。
Lettuce 底层基于 Netty,连接是异步的,多个线程可以共享一个连接。这意味着在高并发场景下,它不会像 Jedis 那样每个线程都要去抢连接,阻塞等待。Jedis 实例不是线程安全的,所以每个线程都得拿独立的连接,连接池耗尽之后就很容易报“Could not get a resource from the pool”。
Lettuce 是线程安全的,一个连接可以被多个线程并发复用。这个特性对发布订阅这种场景特别有意义。因为 Redis 的订阅连接和普通命令连接行为不太一样,订阅之后这个连接就处于一种特殊状态,不能随便干别的了。用 Lettuce 的话,连接管理更优雅,而且 Spring Boot 默认就是它,不需要额外适配。
一句话总结我个人的体会:如果不是对 Jedis 特别熟悉、有历史包袱,新项目直接用 Lettuce 就好,省心。
1.3 整体架构设计
在动手写代码之前,先看一下这次要实现的完整链路。
整个流程是这样的:外部请求打到某个 HTTP 接口,接口内部调用发布者组件,发布者向指定的 Redis 频道发送一条消息。与此同时,应用启动时已经注册了消息监听容器,容器里有若干个订阅者监听这个频道。消息一发布,监听容器立刻收到事件,把消息路由给对应的订阅者方法去处理。
这里容易忽略一个点:一个应用既可能是发布者,也可能是订阅者,甚至同一个应用可以同时订阅多个频道。所以代码结构上要把发布逻辑和订阅逻辑分开,不能全堆在一个类里。我采用的是发布者单独一个组件,订阅者按业务拆分成多个监听方法,这样后续每加一个频道,只需要新增监听方法和发布方法就行,改动非常小。
2. 环境准备与依赖配置
2.1 环境清单
先把版本列出来,方便你对号入座。我这次用的版本组合是经过验证的,如果你用的是更新的版本,大概率也能兼容,但最好以官方文档为准。
- JDK 1.8+(我用的 JDK 8,Spring Boot 2.x 的老项目很常见)
- Maven 3.6+
- Spring Boot 2.5.x
- Redis 6.x(Redis 5.x 以上的版本都支持,旧版本也可以跑)
- 核心依赖:spring-boot-starter-data-redis
有一点要提醒:如果你用的是 Spring Boot 3.x,那要求 JDK 17,并且 Jakarta 命名空间变了,部分代码写法要调整。我下面的示例基于 Spring Boot 2.5.x,如果你的版本不同,注意对照调整。
2.2 Maven 依赖引入
依赖其实非常简单,只要引入一个spring-boot-starter-data-redis,Lettuce 客户端会被自动带进来。完整依赖长这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>第二个依赖commons-pool2是连接池的,如果你不配连接池可以不加。但我建议加上,因为 Redis 链接被用完在实际项目中太常见了,配置了连接池能避免不少诡异问题。
spring-boot-starter-data-redis默认就会使用 Lettuce 作为客户端,这是 Spring Boot 的自动配置决定的。如果你想显式声明版本,可以加一个Lettuce的 dependencyManagement,但一般不需要。
2.3 application.yml 配置文件
配置文件是整个功能能否正常工作的关键一环,减一行可能就出问题。我建议直接复制下面这份,再按环境改地址和密码。
spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword # 没有密码就删掉这行 database: 0 timeout: 5s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 0 max-wait: 3000ms shutdown-timeout: 200mstimeout是连接超时时间,lettuce.pool是连接池配置,max-wait设置了获取连接的最大等待时间,超过这个时间直接抛异常,避免线程无限等待。
如果你用的是 Redis Cluster 或者 Sentinel,配置方式不一样,这里不展开,单机模式覆盖大多数场景了。
2.4 序列化方案选择,这一步直接决定你会不会乱码
Redis 里存的是字节,Java 对象要转成字节才能存进去。Spring Boot 默认使用的是 JDK 序列化,也就是JdkSerializationRedisSerializer,它就是消息乱码、存储体积爆炸的罪魁祸首。
为什么这么说?JDK 序列化出来的东西带一堆类描述信息,又长又难读,在命令行里看基本是一坨不可读的二进制。更重要的是,如果发布者和订阅者两边的类定义不一致,反序列化会直接报错。
所以我这次的方案是:StringRedisTemplate 管字符串消息,RedisTemplate 统一用 String 序列化 value。因为发布订阅场景里,消息体本身大部分情况就是一段 JSON 字符串。用 String 序列化,消息在 Redis 里可读,而且发布订阅两边只要约定好消息格式(比如统一是 JSON),就不会有反序列化失败的问题。
具体配置代码我放在后面的核心代码部分,在写监听容器的时候你会看到为什么这里必须单独指定序列化器。
3. 核心代码实现
3.1 消息发布者,一个极简的组件
发布者不需要继承任何基类,也不用实现接口,就是普普通通的一个 Spring 组件。核心逻辑两句话:拿到 StringRedisTemplate,调用 convertAndSend。
我先贴完整代码,然后逐段讲。
@Component public class MessagePublisher { private final StringRedisTemplate redisTemplate; public MessagePublisher(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void publish(String channel, String message) { redisTemplate.convertAndSend(channel, message); log.info("消息已发布到频道 [{}],内容:{}", channel, message); } }convertAndSend是 RedisTemplate 提供的方法,底层帮我们做了两件事:序列化消息、调用 Redis 的 PUBLISH 命令。所以发布者不需要自己去操作连接,也不需要考虑发出去之后有没有人订阅。
你也许会问:为什么不直接用 RedisConnection 的 publish 方法?因为那样要自己操心序列化,用 StringRedisTemplate 的话,发布者和订阅者只要都用同样的 String 序列化就天然对齐。
还有一点,发布者最好在业务代码中单独抽一层。不要在 Controller 里直接 new 一个发布者然后调方法,这样测试不方便,后面想改成异步发送也不灵活。我习惯的方式是:Controller 调 Service,Service 里注入发布者。
3.2 配置消息监听容器
这个类是整个过程里最容易写错的地方。Spring Data Redis 提供了RedisMessageListenerContainer,它负责把 Redis 的订阅连接管理起来,接收到消息后分发到对应的监听器。
我先给你看一份可以直接用的配置类:
@Configuration public class RedisPubSubConfig { /** * 消息监听容器,管理所有订阅者的连接和消息分发 */ @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, MessageListenerAdapter orderMessageListenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 订阅频道与监听器的映射 container.addMessageListener(orderMessageListenerAdapter, new ChannelTopic("order:channel")); return container; } /** * 消息监听器适配器,把收到的消息交给指定的处理器 */ @Bean public MessageListenerAdapter orderMessageListenerAdapter(OrderMessageListener listener) { return new MessageListenerAdapter(listener, "onMessage"); } }你可能注意到我用了MessageListenerAdapter,它的作用是把一个普通的 POJO 方法包装成 MessageListener 接口的实现。默认情况下适配器会反射调用你指定的方法名,这里指定的是onMessage。这样订阅者的代码就不用实现 Spring 的 MessageListener 接口,业务代码更干净。
ChannelTopic("order:channel")表示订阅order:channel这个频道,容器可以同时 add 多个监听器和多个 Topic。如果后面要订阅多个频道,继续 addMessageListener 就行。
这里有个比较容易踩的坑:RedisMessageListenerContainer的线程模型是单线程分发消息的。如果你有多个订阅者,消息处理耗时太长,会阻塞其他频道的消息分发。我后面在性能调优的部分专门说这个问题。
3.3 订阅者,接收消息并处理业务
订阅者就是被 MessageListenerAdapter 反射调用的那个方法所在的类。
@Component public class OrderMessageListener { /** * 收到消息后的回调方法,方法名和适配器里指定的一致 */ public void onMessage(String message, String channel) { log.info("收到频道 [{}] 的消息:{}", channel, message); // 这里写你的业务处理逻辑,比如解析 JSON、调用其他服务 } }这个方法签名有两种方式。一种是就写一个 String 参数,接收消息体;另一种可以写(String message, String channel)两个参数。如果你写两个参数,Adapter 会把频道名也传进来,这在订阅多个频道、想区分消息来源时很有用。
我实际项目里的做法是:收到消息后先判断消息体是不是 JSON,如果是就反序列化成 DTO,然后根据消息里的业务类型字段分发到不同方法。这样订阅者一个类就能处理多种消息,不用为每种消息单独建一个监听器。
有一点需要注意:订阅者方法如果抛了未捕获的异常,这个监听器的消息处理线程会被打断,虽然 Spring 的容器会尝试恢复连接,但连续异常会导致连接反复断开重建,对 Redis 服务器也是一种压力。所以订阅者内部一定要 try-catch 住,不能把异常抛出去。
3.4 配置序列化,解决乱码的核心
我在前面埋了个伏笔,说序列化方案很关键,现在给你看我推荐的配置方式。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); // key 和 value 都使用 String 序列化 template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }注意这个 RedisTemplate 必须是RedisTemplate<String, String>,不是默认的RedisTemplate<Object, Object>。如果你用了默认的,消息体里存的还是 JDK 序列化数据,发布出去之后订阅者收到的是二进制垃圾,你在 log 里看就是乱码。
消息监听容器本身也有序列化器设置,如果你直接 new 了一个无法正常序列化的 adapter,即使 RedisTemplate 对了也白搭。MessageListenerAdapter 默认使用SerializationUtils.serialize,如果你不指定,它也会尝试 JDK 序列化。所以我建议在配置 adapter 时显式指定序列化器:
@Bean public MessageListenerAdapter orderMessageListenerAdapter(OrderMessageListener listener) { MessageListenerAdapter adapter = new MessageListenerAdapter(listener, "onMessage"); adapter.setSerializer(new StringRedisSerializer()); return adapter; }setSerializer指定的是订阅时消息体反序列化使用的序列化器,不设置的话,即使你发布的时候存的是字符串,订阅者收到的还是被 JDK 序列化包装过的字节,getMessage 再转 String 也是乱码。这一条是我实测踩过的坑,当初排查了一个多小时。
3.5 写个测试接口验证全链路
代码写完了,得验证一下。我加一个 Controller,通过 HTTP 接口触发发布,然后在日志里观察订阅者是否收到消息。
@RestController @RequestMapping("/api/pubsub") public class PubSubController { private final MessagePublisher publisher; public PubSubController(MessagePublisher publisher) { this.publisher = publisher; } @PostMapping("/publish") public String publish(@RequestParam String message) { publisher.publish("order:channel", message); return "published"; } }启动应用后,调用这个接口:
curl -X POST "http://localhost:8080/api/pubsub/publish?message=hello-redis-pubsub"这时候观察应用日志,应该能看到两行日志,一行是发布者打印的,一行是订阅者打印的。如果日志里订阅者收到了内容并且可读,说明全链路正常。如果收到的是乱码或者SerializationException,那就是序列化配置没对上。
这里提醒一个小细节:如果本地同时启动了多个实例(比如端口 8080 和 8081),你调 8080 的接口,8080 和 8081 的订阅者都会收到消息。这是 Redis Pub/Sub 的广播特性,后面我详细讲。
4. 多频道与模式订阅的进阶用法
4.1 频道订阅和模式订阅,别搞混了
前面用的是ChannelTopic,精确订阅一个频道。Redis 还支持模式订阅,也就是PatternTopic,用通配符匹配一批频道。
模式订阅的命令是 PSUBSCRIBE,支持的匹配规则:
*匹配任意多个字符?匹配任意单个字符[]匹配指定范围内的字符
举个例子:如果你想订阅所有以order:开头的频道,可以配置new PatternTopic("order:*"),这样order:created、order:paid、order:cancelled这些频道的消息都能收到。
Spring 里的写法是在容器里 add 监听器时,Topic 换成 PatternTopic:
container.addMessageListener(orderMessageListenerAdapter, new PatternTopic("order:*"));它的优点是灵活,新增一个子频道不用改代码。缺点是消息量大时不够精细,所有匹配的都收,业务上得自己过滤。我的建议是:频道数量少、业务稳定就精确订阅 ChannelTopic,频道多、动态创建场景多就考虑 PatternTopic。
4.2 一个监听器处理多频道消息的写法
实际项目里会有这种需求:一个监听器要处理多个频道的消息,但是希望根据频道名走不同逻辑。前面我说过,订阅者方法可以带 channel 参数,这时候就用上了。
public void onMessage(String message, String channel) { switch (channel) { case "order:created": handleOrderCreated(message); break; case "order:paid": handleOrderPaid(message); break; default: log.warn("未处理的消息,频道:{}", channel); } }对应容器配置:
container.addMessageListener(orderMessageListenerAdapter, new ChannelTopic("order:created")); container.addMessageListener(orderMessageListenerAdapter, new ChannelTopic("order:paid"));注意这里复用同一个 adapter,适配器反射调用的方法不变,还是onMessage,但 channel 参数会帮你区分来源。这是最常见的一种多频道处理姿势。
还有一个容易忽略的坑:如果你在同一台机器上部署了多个应用实例,所有实例都会收到同一条消息。如果你只希望其中一个实例处理(比如做分布式定时任务通知),那还要额外加一把分布式锁,否则每个实例都会执行一遍业务逻辑,造成重复处理。我项目里就遇到过一次,订单创建通知一到,三个实例同时去扣库存,还好有幂等控制,不然就出大问题了。
4.3 消息内容的格式约定
发布订阅用起来方便,但消息格式如果不约定好,维护起来就像灾难。我见过一些项目,发布者直接 send 一个对象,订阅者反序列化成另一个类,两边版本一不一致就报错。
我的经验是:消息体统一用 JSON 字符串。发布之前把对象序列化成 JSON 字符串,订阅者收到后按约定反序列化成自己的 DTO。这样只要接口字段不变,两边的类定义不同也没关系。序列化工具我用的是 Jackson,Spring Boot 自带,不用额外引包。
// 发布端 OrderMessage msg = new OrderMessage(orderId, "CREATED", System.currentTimeMillis()); publisher.publish("order:created", objectMapper.writeValueAsString(msg)); // 订阅端 OrderMessage msg = objectMapper.readValue(message, OrderMessage.class);这样哪怕订阅端和发布端不在一个服务里,只要 JSON 字段对得上,就能正常通信。而且 Redis 里看到的是一条可读的字符串,排查问题的时候一眼就能看出消息内容对不对。
5. 常见问题与排查技巧实录
5.1 消息乱码,序列化器没对上
我在 3.4 部分已经说过了,这是发布订阅里出现频率最高的问题。症状很典型:订阅者收到的消息日志里能看到\xAC\xED\x00\x05t...这样的前缀,或者是SerializationException: Cannot deserialize。
排查路径我帮你整理成一张速查表:
| 检查点 | 正确配置 | 错误表现 |
|---|---|---|
| 发布端 RedisTemplate | key/value 都是 StringRedisSerializer | 消息体是二进制 |
| 监听适配器 serializer | 显式 set StringRedisSerializer | 反序列化失败 |
| 订阅者方法参数类型 | String 或与序列化器匹配 | 反射调用失败 |
只要发布端和订阅端的序列化器都是 String,中间就不会乱。如果你用的是默认配置,就要检查是不是哪里没设置到位。
5.2 消息丢失了,先看是不是没订阅上
Redis 发布订阅有个特性:消息不持久化。发布者发布消息时,如果当时没有订阅者在线,这条消息就相当于扔进了黑洞,后来再订阅也收不到。
另外一个更隐蔽的情况是:应用刚启动,订阅连接还没建立成功,业务方就发布了消息,此时也会丢。为了尽量避免这个问题,我建议:
- 确保监听容器初始化完成后再对外提供服务,必要时在监听器里加一个订阅成功的回调日志
- 在应用启动后延迟几秒再暴露接口,让订阅连接先建立起来
- 如果消息可靠性要求高,就不要用 Redis Pub/Sub 了,直接上持久化消息队列
实际的日志里看到Subscribed to channel类似的关键字,才说明订阅成功了。
5.3 多实例导致重复消费
我在 4.2 里提了一嘴,这里展开讲。Redis 发布订阅是广播模式,同一个频道有 N 个实例订阅,消息就会复制给 N 份,每份都被消费。很多人在单机测试时没遇到这个问题,一上多实例就发现业务逻辑被执行了多次。
解决方案要看业务场景。如果业务天然需要所有实例都感知(比如全局配置刷新),广播就是正确的,不用处理。如果业务只需要一个实例处理(比如某个任务执行通知),那就必须加去重逻辑,常见做法:
- 基于 Redis 的分布式锁,抢到锁的实例才执行
- 基于数据库的唯一约束,重复消息插入失败就忽略
- 在消息里带一个唯一业务 ID,处理前查询是否已处理
我项目里的做法是结合了分布式锁和消息去重表。消息里的业务 ID 作为锁的 key,处理完写入 Redis 的已处理集合,设置过期时间。这样即使广播消息到了所有实例,也只有抢到锁的那个真正干活。
5.4 消息堆积时,订阅者处理不过来的优化办法
默认情况下,RedisMessageListenerContainer 的监听线程不是无线程池的,订阅连接是阻塞式的。如果订阅者方法里有慢操作,比如调外部接口、查数据库、发邮件,消息处理速度就会严重下降,后面的消息排队等待。
我实测过的优化措施有三种。
第一种,把订阅者里的耗时操作改成异步执行,比如利用 Spring 的@Async,或者自己提交到线程池。这样监听线程可以快速返回,继续处理下一条消息。注意异步任务要控制好线程池大小,不然高并发下大量线程堆积反而拖垮应用。
第二种,适当调大监听容器的并发数。容器支持设置setTaskExecutor,给消息分发提供一个线程池,让多个消息被并行处理。但要注意:Redis 的 Pub/Sub 本身并不保证消息顺序,并行处理后顺序可能更乱。对顺序敏感的业务,这种方案不适用。
第三种,把耗时操作从订阅者里挪出去,订阅者只负责接消息,然后写入本地队列或 Redis 队列,由另外的调度任务去消费。这是一种削峰手段,也是我比较推荐的方式,因为它把“接收消息”和“处理消息”彻底解耦了。
5.5 Lettuce 连接池参数怎么调
最后说下连接池那点事。你可能会想,发布订阅不是有一个专用连接吗,连接池的参数还重要吗?
答案是重要。因为你的应用里不只有订阅功能,还有常规的缓存读写。发布订阅的监听连接是容器自己管理的专用连接,但发布消息、其他 Redis 操作仍然要走连接池。如果连接池太小,并发一高就会报RedisConnectionFailureException。
我常用的参数配置参考:
- max-active:16 到 32。如果业务并发量不大,16 足够;并发高的接口可以根据 QPS 酌情上调
- max-idle:8 左右,预留一点空闲连接
- min-idle:0 就好,启动时不占用资源
- max-wait:3 秒。超过就快速失败,别让线程死等
这些参数不是越大越好。连接太多浪费内存,连接太少又不够用。我建议先在测试环境压一下,观察连接数的使用曲线,再决定要不要调。
6. 最后分享一点我的体会
这个发布订阅功能做下来,最大的感受是:Redis 这个中间件真的太能打了,一个 Pub/Sub 就能解决不少实时通信需求,而且接入成本低到离谱——一个依赖、一份配置、三两个类,就能跑起来。但它的边界也很明显,不持久化、不保证不丢、广播语义,这些决定了它只能用在“丢了也问题不大”的消息场景里。
我自己实际用下来的体会就是:先把序列化器的事情搞清楚,再做功能测试,多实例部署前一定要想清楚重复消费的问题。这两关过了,剩下的就是业务代码的事了。
如果你想在现有基础上扩展,我建议下一步可以做三件事:一是把消息体统一封装成一个带消息 ID 和时间戳的标准结构,方便链路追踪;二是给发布者加一个失败重试机制,虽然 convertAndSend 很少失败,但网络抖动时还是要考虑;三是监控订阅者的消费耗时和积压情况,在慢处理变成故障前提前发现。这些就不展开了,等你有需要我们再聊。