做陪玩平台最烦的事情,就是陪玩师那边刚上线,玩家下一秒就下了单,结果陪玩师压根没收到提醒,等发现的时候订单早就被别人接了。一开始我总以为是手机通知没弹出来,后来查了日志才发现,服务端推送这块压根就没做好——玩家下单了,系统得把消息推给匹配的陪玩师,这个"推"的动作如果全靠数据库轮询或者客户端自己刷接口,那延迟和压力都顶不住。后来我把系统里这块实时功能重写了一遍,核心就用到了Redis的发布订阅机制(Pub/Sub),也就是标题里说的"redis发布与订阅",整体效果好了很多。这篇东西就把我当时的实现思路、代码结构和踩过的坑记录下来,给想搞明白陪玩系统里实时消息怎么做的朋友一个参考。
这套方案适合谁看?如果你正在写陪玩、约拍、代练、上门服务这类C2C交易平台,而且系统里已经有Redis了,不想为了"推送个通知"就单独引一套消息队列,那Redis发布订阅就是最轻量、最性价比的做法。我会把从业务场景分析、频道设计、Spring Boot里的代码实现、序列化配置到线上问题排查全串一遍,尽量做到拿着这篇东西就能自己复现出来。
1. 先从业务场景说起:陪玩系统里到底哪里需要实时推送
1.1 一个典型的陪玩下单流程,哪里需要“推送”
先把业务场景立起来。陪玩系统说白了是个双边平台:一头是陪玩师,上架自己的游戏技能和服务时间;另一头是玩家,按需下单买陪玩时长。我在系统里跑通的主流程是这样:
- 玩家在搜索页筛出符合条件的陪玩师,查看陪玩师的档期、技能标签、价格。
- 选好了,提交订单,支付成功,订单状态变成“待接单”。
- 系统要把这个“新订单”消息立即通知到陪玩师端。
- 陪玩师收到通知,选择接受,订单进入“已接单”,然后开始陪玩。
- 开黑过程中要语音开黑、共享屏幕,这其实是另一套实时音视频体系,跟本条消息推送无关。
- 结束后订单结算,双方互评。
这里面最核心的实时节点就是第3步:支付成功以后,怎么让陪玩师第一时间知道“你有个新订单了”。如果时效性差,比如隔了5秒钟陪玩师才看到通知,玩家可能已经取消退款了。之前我试过让客户端每3秒拉一次订单接口,订单量一大就撑不住,数据库压力上来接口延迟飙升,用户体验更差。
除了订单通知,还有几个地方也天然需要发布订阅:
- 大厅公屏聊天:玩家和陪玩师在一个公共房间里聊游戏心得、找队友,消息要广播给当前房间的所有人。
- 陪玩师上下线状态:玩家关注列表里要实时显示陪玩师“在线/在玩/离线”,不能每次刷新页面才变。
- 私聊新消息提醒:玩家和陪玩师谈价格、约时间,没进聊天页时要收到“你有一条新消息”的角标提醒。
- 订单状态流转:已接单、已开始、已完成这些状态变了,交易双方都要立刻知道。
这些场景有个共同特点:一次产生,多方接收,而且要求秒级触达。这种模型恰好就是发布订阅最舒服的适用区。
1.2 为什么不选轮询、长连接、消息队列,偏偏用Redis发布订阅
其实我当时纠结过四类方案:客户端轮询、WebSocket长连接、独立消息队列(RabbitMQ/Kafka)、Redis发布订阅。一个个说下我当时的取舍逻辑。
- 客户端轮询:最简单,但是最笨。客户端每隔几秒请求一次接口,查“有没有新订单”,服务端被无效请求占满。陪玩师刷一下、玩家刷一下,并发一高接口全白热化。而且实时性永远受轮询间隔限制,你说3秒轮询一次,那最坏延迟就是3秒,这个延迟在“抢订单”场景里不可接受。
- WebSocket长连接:实时性确实好,能做到真正的服务端主动推送。但它的问题在于连接管理成本高:握手、心跳、断线重连、分布式环境下的会话路由,一套做下来没个一周搞不定,而且很多场景下客户端用的不是WebSocket而是小程序或纯HTTP环境,兼容性也麻烦。
- 独立消息队列:RabbitMQ、Kafka都是成熟的消息中间件,自带消息持久化、消费确认、重试机制,可靠性和功能完整性都碾压Redis的发布订阅。但问题也很现实——对于一个中小体量的陪玩系统,专门搭一套消息队列集群,运维成本和资源占用都高,尤其Redis本来就在跑,为了“通知”这个功能再引一套重型中间件,性价比很低。
- Redis发布订阅:轻量、毫秒级延迟、支持一对多广播,写法也简单,几行代码就能把消息从服务端推给所有订阅了某个频道的客户端。缺点是不持久化、没有ACK机制,但这个缺点在某些场景下其实没那么致命,后面我会单独讲。
当时我拍板选Redis发布订阅,还有一个很现实的原因:Redis本身已经是系统基础设施了,缓存、分布式锁、排行榜都靠它,不需要额外部署任何东西,只需要多用两个命令,发布订阅的运维成本几乎为零。
2. Redis发布订阅的原理与核心概念
2.1 三个角色:发布者、频道、订阅者
Redis发布订阅的模型特别容易理解,我用一个生活例子来类比。
你把Redis想象成一家广播电台,频道(Channel)就是一个个不同的广播频率。订阅者(Subscriber)就是听众,拿着收音机调到某个频率上;发布者(Publisher)就是主持人,对着麦克风说话。主持人说的话,所有在这个频率上收听的人都能同时听到,不在这个频率上的人完全感受不到。而且主持人根本不需要知道听众是谁、有多少人,他只需要对着麦克风讲就完了。
对应回技术实现:
- 发布者调用
PUBLISH channel message命令,往指定频道发一条消息。 - 订阅者调用
SUBSCRIBE channel命令,订阅一个或多个频道。 - Redis把消息推送给所有订阅了这个频道的客户端。
- 没有订阅者的频道,消息发出去就没了,不会存下来等以后有人订。
另外Redis还有一个模式订阅(Pattern Subscribe),用通配符匹配频道。比如订阅order:*,那order:create、order:accept、order:finish这些频道的消息都能收到。这个特性在陪玩系统里非常有用,一个监听器就能处理所有订单相关的消息。
2.2 发布订阅的生命周期、消息形态和三个特殊表现
这里有几个绕不开的特性,理解不透后面就是各种莫名其妙的问题。
第一,Redis发布订阅是“即发即弃”(fire-and-forget)模式。消息不会写入Redis的任何数据结构里,频道本身也不存储历史消息。你在频道上发一条消息,如果当前没有任何订阅者,这条消息就直接丢了,之后也不会补发。这一点和消息队列有本质区别,Kafka里消息会在分区里保留一段时间,但Redis的Pub/Sub不行。
第二,消息推送是即时的,延迟在毫秒级别。因为Redis是单线程处理命令的,PUBLISH命令往频道上一发布,订阅这个频道的每个客户端连接都会立刻收到这条消息,中间没有任何批处理或者积压缓冲。
第三,Redis发布订阅是一对多广播。一条消息发出来,所有订阅者都会收到同一份,不是竞争消费。打个比方:订单通知发给10个陪玩师,10个陪玩师都能收到这条“新订单”推送,而不是只有1个人收到。所以它天然适合做广播场景,但不适合做任务分发/负载均衡——如果我要把订单平均分给10个陪玩师,每人只处理属于自己的那部分,就需要消息队列里那种“消费组”的概念,而发布订阅做不到。
第四,特别容易忽略的一点,订阅行为是有状态的。客户端一旦执行SUBSCRIBE命令,这个连接就进入了“订阅模式”,在这个连接上只能执行订阅相关的命令(SUBSCRIBE/UNSUBSCRIBE/PING等),不能再执行GET、SET这些普通命令,否则会报错。这一点在使用连接池的时候尤其容易踩坑,后面代码部分会专门强调。
3. 陪玩系统里Redis发布订阅的落地设计
3.1 频道命名规范与消息协议设计
动手写代码之前,先把频道和消息格式定下来。这两个是后续所有功能的地基,一开始没设计好,改起来特别痛。
频道命名方面,我按“业务域:事件类型:版本号”的格式来命名。之所以加版本号,是因为Redis的频道结构是扁平的,改协议没有升级机制,只能靠频道名区分。我系统里实际用到的几个频道如下:
order:notify:v1—— 订单通知频道,推送新订单、订单状态变更等消息。lobby:chat:v1—— 大厅公屏聊天频道。user:status:v1—— 陪玩师上下线状态频道。im:message:v1—— 私聊新消息提醒频道。
消息内容统一用JSON格式,而且内部再包一层“信封”,方便扩展。我定义的消息结构大概是这样的:第一层是消息类型type、数据data、时间戳ts,其中data里再放具体的业务字段。比如新订单通知的消息体:
{ "type": "new_order", "data": { "orderId": "CK20250612001", "playerId": 10086, "playerName": "小明", "gameName": "王者荣耀", "duration": 60, "price": 120.00 }, "ts": 1749720000000 }这样做的好处有几个:
- 所有消息长一个样,监听端只要反序列化一次信封,再根据type字段分发到不同的业务处理器,代码结构统一。
- 调试方便,用Redis客户端直接往频道里发一条JSON就能模拟消息,不用跑完整业务流程。
- ts时间戳可以用来排查消息延迟,曾经有一次我发现订单通知晚了2秒,就是靠ts定位到是网络传输问题。
3.2 工程结构怎么划分,监听器要拆几个
在Spring Boot工程里,我按“配置类-发布服务-监听器-业务处理器”四层来组织代码。
redis-pubsub/ ├── config/ │ └── RedisPubSubConfig.java # 监听器容器、序列化配置 ├── publisher/ │ ├── OrderMessagePublisher.java # 订单相关消息发布 │ └── LobbyMessagePublisher.java # 公屏消息发布 ├── listener/ │ ├── OrderNotifyListener.java # 订单频道监听 │ ├── LobbyChatListener.java # 公屏聊天监听 │ └── UserStatusListener.java # 用户状态监听 ├── handler/ │ ├── MessageDispatcher.java # 消息分发器,按type分发 │ └── OrderMessageHandler.java # 具体处理订单消息这里有个设计心得:监听器不要和业务逻辑混在一起。监听器的职责是“从Redis拿到原始消息,反序列化,然后交给业务处理器”,它本身不应该处理订单状态流转、不应该写数据库、不应该调第三方接口。把监听器和业务处理器拆开以后,一个是方便单测,另一个是方便以后切消息队列——如果平台发展大了要换Kafka,只需要重写监听器,业务处理器完全不动。
监听器拆几个,这个看业务量。最开始我图省事只写了一个全局监听器,订阅所有频道,结果每个频道都要去做一层白名单判断,代码混乱,出了问题也不知道是哪个频道的消息触发的。后来我改成按“业务域”拆监听器:订单一个、公屏一个、用户状态一个。每个监听器只关心自己领域的频道。
3.3 三个典型场景的频道与推送策略
把三个核心场景串起来看一下实际的消息流转。
场景一:新订单通知。玩家支付成功以后,订单服务调用发布服务,往order:notify:v1频道发一条type为new_order的消息。所有订阅了这个频道的在线陪玩师连接都会收到,客户端拿到消息后弹出浮窗“您有一个新订单”。这里有个点要注意:不该把订单的全部敏感数据都推进这条消息里,推orderId就够了,真正的订单详情由客户端根据orderId再拉接口。这样能减少消息体积,也避免价格字段在推送链路里被篡改或者泄露。
场景二:大厅公屏聊天。这个和订单通知完全不同,公屏消息量很大,而且没有精确的接收者,所有在大厅里的人都要看到。我按照房间ID来设计频道粒度,每个大厅聊天室对应一个频道,比如lobby:chat:v1,然后聊天室ID拼在后面作为区分。用户发言时,服务端把消息发布到对应频道的推送带上,所有在线订阅者就能收到。比轮询舒服太多了。
场景三:陪玩师上下线。这个场景我做得比较轻,陪玩师登录成功后自动订阅user:status:v1,下线时退订,同时发布一条上下线状态消息。关注了这位陪玩师的玩家端,通过服务端关联关系,也会收到对应提醒。因为状态变更不像聊天那么频繁,这个频道不会撑爆Redis。
4. 配置与核心代码实现
4.1 引入依赖与配置文件
我是在Spring Boot 2.7.x + spring-data-redis环境下做的实现,Maven里需要引入spring-boot-starter-data-redis。如果用的是Spring Boot 3.x,依赖名不变,只是切换到了Jakarta命名空间,代码整体差异不大。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置文件里主要是连接信息,注意要配置连接池,因为监听器容器会占用连接。下面是application.yml里的关键配置:
spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3000ms这里有个关键点:Redis发布订阅监听器会独占一个连接,持续阻塞等待消息。如果项目里还有其他操作Redis的逻辑,连接池必须开大一点,否则监听器把连接占完,业务操作Redis就会排队等着拿连接,拖慢接口响应。
4.2 RedisMessageListenerContainer的配置
这是整个发布订阅的“心脏”。RedisMessageListenerContainer负责管理订阅连接、把收到的消息分发给对应的监听器。我先看config包里的配置类:
@Configuration public class RedisPubSubConfig { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, OrderNotifyListener orderNotifyListener, LobbyChatListener lobbyChatListener, UserStatusListener userStatusListener) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 关键:容器默认不会做序列化,需要用Topic + 消息监听适配器来指定消息转换器 container.addMessageListener(orderNotifyListener, new PatternTopic("order:notify:v1")); container.addMessageListener(lobbyChatListener, new PatternTopic("lobby:chat:v1")); container.addMessageListener(userStatusListener, new PatternTopic("user:status:v1")); return container; } }PatternTopic支持通配符,我用的都是精确频道名,但如果你订阅order:*,那后续扩展订单状态变更频道时就不需要改代码。通配符模式有个注意点,PatternTopic匹配的消息里第一个元素是原始频道名,监听器处理时要按这个来区分消息来源。
然后定义监听器容器要用的消息转换器。默认的情况下,RedisMessageListenerContainer处理消息是把消息的body直接按字节数组交付给监听器,所以要设置消息转换器,让容器帮忙把字节转成字符串或者对象。这里我推荐直接用StringRedisSerializer,消息体统一是JSON字符串,收到后在监听器里再用Jackson反序列化成对象。这样最省心,不要再去做乱七八糟的序列化。
@Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.setMessageListener(new MessageListenerAdapter(new OrderNotifyListener(), new Jackson2JsonRedisSerializer<>(Object.class))); // 具体监听器注册方式根据需求定 container.addMessageListener(new MessageListenerAdapter(orderNotifyListener), new PatternTopic("order:notify:v1")); return container; }4.3 发布消息的核心代码
发布消息非常简单,核心就是redisTemplate.convertAndSend(channel, message)。我用单独的Publisher类封装了一下,方便统一处理消息序列化和日志记录。
@Component public class OrderMessagePublisher { private static final Logger log = LoggerFactory.getLogger(OrderMessagePublisher.class); private final StringRedisTemplate redisTemplate; public OrderMessagePublisher(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void publishNewOrder(NewOrderNotifyMessage message) { String channel = "order:notify:v1"; String payload = JSON.toJSONString(message); // Fastjson或Jackson都可以 redisTemplate.convertAndSend(channel, payload); log.info("发布新订单通知, channel={}, orderId={}", channel, message.getOrderId()); } }这里有一个特别实操的点:发布用StringRedisTemplate,不要用RedisTemplate。原因很简单,StringRedisTemplate默认的序列化器就是StringRedisSerializer,发布出去的字符串到Redis是纯文本,用redis-cli的SUBSCRIBE命令肉眼直接能看到消息内容。如果用RedisTemplate默认的JdkSerializationRedisSerializer,那发出去的是Java序列化后的二进制对象,redis-cli里看到的是一堆乱码,调试非常痛苦。我一直是用StringRedisTemplate做推送,序列化交给JSON工具。
4.4 监听器的代码实现与消息分发
监听器要实现MessageListener接口,重写onMessage方法。注意onMessage方法签名有两个参数:message是整个消息对象,包含body、channel两个部分;pattern是模式订阅时匹配到的pattern,精确订阅时为null。我实际代码里是这样写的:
@Component public class OrderNotifyListener implements MessageListener { private final MessageDispatcher dispatcher; public OrderNotifyListener(MessageDispatcher dispatcher) { this.dispatcher = dispatcher; } @Override public void onMessage(Message message, byte[] pattern) { String channel = new String(message.getChannel(), StandardCharsets.UTF_8); String body = new String(message.getBody(), StandardCharsets.UTF_8); log.info("收到订单频道消息, channel={}, body={}", channel, body); dispatcher.dispatch(channel, body); } }Channel在Message对象里就是byte[]类型,需要用UTF-8转成字符串。body也是byte[],用String构造方法转成字符串。千万注意字符集,默认平台编码在Windows上可能是GBK,转出来中文全乱码,所以写代码时一定要显式传StandardCharsets.UTF_8。
收到消息后,我进入一个统一的消息分发器,按之前的type字段分发给具体的业务处理方法:
@Component public class MessageDispatcher { private final OrderMessageHandler orderMessageHandler; // ... 其他handler public void dispatch(String channel, String messageBody) { MessageEnvelope envelope = JSON.parseObject(messageBody, MessageEnvelope.class); String type = envelope.getType(); switch (type) { case "new_order": orderMessageHandler.handleNewOrder(envelope.getData()); break; case "order_status_change": orderMessageHandler.handleOrderStatusChange(envelope.getData()); break; // ... 其他消息类型 default: log.warn("未知消息类型, type={}", type); } } }这个模式的好处是,以后每增加一种消息类型,只需要在Dispatcher里加一个case分支,然后在对应的Handler里面写业务逻辑,监听器完全不用动。
4.5 消息体反序列化的坑,Java泛型丢失问题
这里必须单独拎出来讲,因为这是我实际开发中花了大半天才搞定的问题。
如果需要把data转换成一个具体的Java对象,直接用JSON.parseObject(jsonString, XXX.class)是没有问题的。但如果我是想先解析成MessageEnvelope,再从envelope里取data转成具体对象,就一定会碰到泛型问题。比如data字段在MessageEnvelope里定义为Object类型,从JSON里解析出来以后,它实际上是一个JSONObject类型,直接强转成NewOrderNotifyMessage会报ClassCastException。
解决办法是把data也设计成JSON字符串,或者解析的时候用TypeReference指定泛型。我当时是这么处理的,信封设计里data本身就是个JSONObject,然后在dispatch里面直接通过envelope.getData().toJavaObject(NewOrderNotifyMessage.class)来做转换,这样最干净。如果你用的是Jackson,可以这么写:
JsonNode root = objectMapper.readTree(messageBody); String type = root.get("type").asText(); JsonNode dataNode = root.get("data"); NewOrderNotifyMessage msg = objectMapper.treeToValue(dataNode, NewOrderNotifyMessage.class);不要偷懒直接拿整个messageBody去反序列化成NewOrderNotifyMessage,因为信封外面还包着type和ts两个字段,反序列化的时候会抛UnrecognizedPropertyException。
4.6 Redis Desktop Manager里怎么验证发布订阅功能
代码写完以后怎么快速验证?最直接的方式就是用客户端工具连接Redis,然后手动执行SUBSCRIBE和PUBLISH命令验证。我用的是Redis Desktop Manager,新版本里也有命令行工具,连接上以后可以直接操作。
在Redis Desktop Manager的Console里执行:
SUBSCRIBE order:notify:v1回车之后会进入等待消息的状态,界面会显示订阅成功。然后再开另一个连接(或者同一个客户端里再开一个tab,也可以直接用redis-cli):
PUBLISH order:notify:v1 "{\"type\":\"new_order\",\"data\":{\"orderId\":\"TEST001\",\"playerId\":1}}"如果配置没问题,订阅的那个连接会马上收到这条消息,屏幕上有返回。这样就能验证频道连通性,不需要启动整个系统去扣完完整整的订单流程。
用可视化客户端验证有个技巧:Redis Desktop Manager的Console不支持多条命令的并发操作,如果在一个tab里先SUBSCRIBE,就没办法在同一tab再敲PUBLISH命令。所以一定要开两个连接窗口,一个订阅一个发布。另外,如果你在Redis Desktop Manager里设置过多个数据库(database:0、database:1),注意订阅和发布必须在同一个数据库下,否则也收不到。
5. 线上问题排查与避坑实录
5.1 连接被占满,订阅和读写互相影响
第一次把发布订阅部署到测试环境,跑了一下午,突然发现业务接口大量超时。排查了半天,最后是通过Redis的CLIENT LIST命令看到,所有连接都处于subscribed状态,业务读写的连接全被订阅连接占满了。
原因是RedisMessageListenerContainer默认需要为每个订阅者单独建立连接,如果项目里同时注册了很多监听器,每个监听器都占一条长连接,连接池不够用,业务操作就抢不到连接了。解决方法是:配置连接池时调大max-active,同时把监听器合并——不要给每个业务场景都单独注册一个Listener,可以把一组频道合并到一个监听器里。
5.2 序列化格式不一致,收到消息全是乱码
这个坑特别经典。我一开始图省事,发布端用了StringRedisTemplate,监听端却给监听器配了Jackson序列化器,结果onMessage里收到的原始body还能正常解析,但日志里打印出来全是乱码,这不是数据丢了,是字节流被错误解析。
我的最终方案:发布和订阅两端统一用StringRedisTemplate + JSON字符串,不配置任何自定义消息转换器,让消息在Redis里以纯文本形式传送。监听器从message.getBody()拿到byte[]以后,一律new String(body, StandardCharsets.UTF_8)转字符串,再去用Jackson反序列化。这个方案的优点是链路清晰、日志可读、调试方便,缺点是反序列化需要自己写几行代码,但这点成本换来的稳定性非常值。
5.3 订阅消息丢失,服务重启期间发的消息没了
这是发布订阅最容易被诟病的地方,也是它的本质特性。Redis的Pub/Sub是即发即弃的,如果服务端没有订阅者在线,这期间发生的所有消息都会丢失。对于订单通知这种“必须送达”的消息,光靠发布订阅是不够的。
我的处理方式是“发布订阅+落库兜底”双通道。发布订阅负责实时推送,但同一条消息也会写入MySQL的message_log表。如果客户端长时间没收到推送,比如断网重连以后,客户端主动调一次“拉取未读消息”的接口,把落库的消息拉回来。这样实时性由Redis保证,可靠性由MySQL兜底,两边互补。这也是我要给所有要用发布订阅做核心业务的人的真心话:发布订阅不是消息队列,不要试图用它来承载绝对不丢失的可靠性消息。
5.4 多实例部署时消息重复处理,幂等性必须做
陪玩系统一般不会单机部署,至少也是两台应用服务器。这时候如果两台服务器都订阅了同一个频道,一条消息发出来,两台都会收到,于是订单通知的处理逻辑会被执行两次,可能会导致重复推送、重复发短信、甚至重复扣费。
解决这个问题的核心思路是幂等。我在订单消息处理里加了一个基于Redis的分布式锁,同一个orderId在处理前先尝试加锁,如果加锁失败说明已经有其他实例在处理了,当前实例直接丢弃这条消息。加锁的key可以用处理过的业务主键来做,锁的过期时间不要太长,2-3秒即可,因为订单通知的处理逻辑本身很快。
// 伪代码:消息处理器中的幂等控制 String lockKey = "lock:order:notify:" + orderId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(3)); if (!locked) { log.info("重复订单通知消息, orderId={}, 忽略处理", orderId); return; } // 业务处理逻辑...5.5 频道数量膨胀与内存问题
当客户端比较多、频道又按房间维度拆分时,频道数量会膨胀得非常快。大厅聊天室按房间ID建频道,房间一多,Redis内存里保存的是“频道与订阅者的映射关系”,虽然单个订阅关系占不了多少内存,但架不住量大。
我的处理经验是:最大限度复用频道,不搞太细的频道粒度。大厅公屏聊天虽然按房间拆开更精确,但实际用下来发现,如果房间ID非常多,这些频道的调度对Redis连接的管理也是个负担。后来我把公屏聊天改成按业务域建一个频道,消息里带上roomId字段,客户端收到消息后用本地逻辑过滤一遍,只显示自己房间的消息。这个方案在消息量不大的情况下性能损失可以忽略,但对连接管理带来的简化是非常明显的。如果消息量真的大到要按房间拆分频道,那建议直接上专业的IM组件或消息队列,别再硬扛Redis了。
6. 发布订阅之外的另一种选择:Stream与消息队列对比
6.1 Redis Pub/Sub vs Redis Stream vs MQTT vs 消息队列
很多人在热词里会搜到Redis Stream或者MQTT,也会纠结到底该用哪套。我把四个方面放在一张表里,看起来最直观:
| 对比项 | Redis Pub/Sub | Redis Stream | MQTT | RabbitMQ/Kafka |
|---|---|---|---|---|
| 消息持久化 | 无,离线即丢 | 有,可回溯消费 | 有遗嘱/保留消息,取决于Broker | 有,按策略保留 |
| 消费确认ACK | 无 | 有 | 有QoS机制 | 有 |
| 重复消费 | 一定会重复广播 | 可控制 | 取决于QoS | 可控制 |
| 实现复杂度 | 极低,几行代码 | 中等,需要管理消费组 | 高,需要部署Broker | 高,需要部署集群 |
| 延迟 | 毫秒级 | 毫秒级 | 毫秒级 | 毫秒级 |
| 运维成本 | 零,复用Redis | 零,复用Redis | 中,要部署EMQX等 | 高,要维护集群 |
MQTT其实跟Redis的定位不同,MQTT是物联网领域的轻量级消息协议,有Broker做消息的路由和保留,支持遗嘱消息,适合网络波动大的IoT设备。如果你要做的不是陪玩系统而是硬件上报数据,MQTT是更专业的选择。但放在Web服务器场景里,为了用MQTT还要单独部署一个Broker,就有点杀鸡用牛刀了。
Kafka/RabbitMQ在可靠性方面碾压Redis,但如果只是订单通知、公屏聊天这种可以容忍偶发丢失的实时推送,部署一套Kafka集群的成本比Redis发布订阅高了一个量级,而且只有一两台机器的时候,Kafka的性能压根跑不出来。
6.2 我的选型建议:什么阶段用什么方案
如果你的系统还在起步阶段,Redis本身就是标配,那直接上Redis发布订阅是最合理的,开发成本低、迭代快。
如果业务量上来了,对消息的可靠性要求越来越高(比如支付通知、订单状态通知不能丢),但又不是海量消息,那就在发布订阅外面加落库兜底,不换技术栈。
如果消息量非常大、客户端连接数多,或者需要消息回溯、消费确认,这时候才真正需要换消息队列。我给的建议是:先确认瓶颈到底在哪里,而不是看到别人用Kafka就跟着用。我用发布订阅跑到一万多在线用户的时候,Redis的CPU占用率也不过5%左右,离需要换技术栈还有很大距离。
7. 关于这套方案,我的最终实践心得
写了这么多,最后说点掏心窝子的体会。Redis发布订阅这套方案,在陪玩、约拍、代练这种C2C实时交易场景里,确实是目前性价比最高的实时推送实现方式。一句话总结它的定位:它适合做广播通知,不适合做可靠消息投递。你把它的边界搞清楚,用在对的地方,就非常好用;用错了地方,硬拿它当消息队列使,就会踩到消息丢失、重复消费这些让人头疼的坑。
我在这个项目里学到最重要的经验,不是怎么写RedisMessageListenerContainer,而是“实时推送”这个需求本身要分两层看:第一层是消息的即时触达,这部分交给Redis发布订阅;第二层是消息的可靠兜底,这部分必须靠落库和客户端主动拉取来保证。两层配合才能既快又稳,缺一个都不行。刚开始写这套功能的时候,我也天真地以为“推送出去就完事了”,直到线上真的丢了几个订单通知、被运营追着问罪,才老老实实把落库兜底补上。技术选型的本质,不是选一个完美的工具,而是搞清楚你手里的工具到底适合解决什么问题,然后再去构建一套能把短板补上的完整方案。
最后再分享一个小技巧,我在排查发布订阅问题时最常用的三条指令:PUBSUB CHANNELS(查看当前活动的频道)、PUBSUB NUMSUB(查看频道的订阅者数量)、CLIENT LIST(查看客户端连接状态)。这三个命令能帮你快速判断消息到底发到了哪个频道、有多少人在听、连接是不是还活着。建议把这几个命令贴在工位旁边,排查问题的时候翻出来用,比啥都管用。