news 2026/9/26 5:21:11

WebSocket集群消息不丢:Spring Boot集成RabbitMQ广播实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WebSocket集群消息不丢:Spring Boot集成RabbitMQ广播实践

1. 从单体到集群:WebSocket推送为什么会"丢消息"

先说一个我实际踩过的坑:项目早期是一个单体Spring Boot应用,用的spring-boot-starter-websocket加STOMP,前端连上来就完事,服务端要推消息直接SimpMessagingTemplate.convertAndSend("/topic/xxx"),一切岁月静好。后来业务量上来,单机扛不住,我们把服务扩成三节点,前面挂Nginx做负载均衡——噩梦就从这里开始了。

为什么会丢消息?核心原因在于WebSocket是一个长连接,它天生"粘人"。客户端一旦完成STOMP握手,连接就固定在某个具体的服务实例上,这个实例的会话表里保存了这条Session。当你往/topic/xxx发消息时,消息只会经过你连接的那个Broker转发给本地会话——如果客户端A连的是实例1,而实例2上的某个业务逻辑触发了推送,消息发出去就是"石沉大海",因为实例2的会话表里根本没有客户端A的Session。

这就像你在一个小卖部里喊人,老板认识所有常客,嗓门一大人人都听得见。但当你把生意做到三个分店,总店喊一嗓子,分店的客人自然听不到。单体架构下Spring的简单消息代理(Simple Broker)是进程内存级别的,它不知道、也管不到其他进程的会话。

解决思路一般有三条:

  1. 共享Session存储 + 自研路由:把Session信息放到Redis,推送时找到Session所在实例,再发起内部调用。听起来合理,但实现起来要处理跨节点通信、Session失效同步、粘性路由失败重试,复杂度不低。
  2. 粘性会话(Sticky Session):让同一个客户端永远打到同一个实例。这在短连接场景没问题,但WebSocket是长连接,一旦某个实例重启、发版滚动更新,粘在上面的连接全部断线,客户端必须重连,集群的"高可用"打了折扣。
  3. 外部消息代理 + 广播订阅:用RabbitMQ或ActiveMQ作为STOMP Broker,所有实例订阅同一个Topic,谁收到消息,谁负责把消息转给本地会话。客户端任意连到哪个实例都能收到推送。

我最终选的是第三条。理由很直接:它在不改动客户端连接方式的前提下解决了一致性问题,而且Spring Boot对RabbitMQ STOMP的支持已经非常成熟,不需要自己造轮子。下面我把整条链路拆开讲。

注意:这个方案不是"客户端到客户端"的聊天室方案,它解决的是服务端主动单向推送的消息可达性问题——比如订单状态变更、系统通知、告警触发这类场景。如果是实时双向通信,方案还得调整。

2. 集群下消息路由的原子性:为什么"每个实例都收到==不丢消息"

我们先澄清一个容易混淆的概念。很多人听到"广播"就担心:同一个消息发给所有实例,每个实例都往客户端推一遍,客户端会不会收到多条重复消息?

答案是:不会。因为每个实例只转发属于它本地会话的消息。STOMP的Topic模型在服务端看来,订阅者是"会话(Session)",而不是"客户端"。实例1收到一条推送到/topic/alerts的消息,它遍历自己的Session表,发现有3个会话订阅了这个Topic,就推给这3个连接;实例2上也订阅了同样的Topic,但它本地只有2个会话,就推给这2个。这5个会话对应5个不同的客户端,消息各推一次,互不重复。

这里的关键在于"消息的原子性"——消息必须被集群中的每一个实例都接收到,再有各自的本地会话去"消化"。只要有一个实例没收到消息,它上面的会话就会漏掉这条推送。所以集群推送的核心不是"发给谁",而是**"如何保证所有实例都收到"**。

这时候就需要一个外部消息代理来承担"扇出(Fan Out)"的职责。以RabbitMQ为例,它的Topic Exchange(主题交换机)天然支持通配符路由——你定义一个队列绑定关系,让每个Spring Boot实例都创建一个同名队列并绑定到同一个Exchange上,发送端只要往这个Exchange发一条消息,RabbitMQ会把它复制到所有绑定的队列中,每个实例的监听器自然都能消费到。

用生活化的比喻:RabbitMQ就是那个"群发邮件"的邮局,每个服务实例是邮局分店,客户是收件人。邮局只要把同一封信投递到每家分店的邮箱里,分店再把信送到各自片区的客户手中,就能保证所有客户都收到信。

所以这一阶段的架构变成这样:

  • 三层结构:客户端 → Spring Boot实例(含STOMP端点 + 本地Broker转发) → RabbitMQ Broker
  • Spring Boot实例既是STOMP服务端,也是RabbitMQ的客户端
  • 发送端不再直接往本地Broker推消息,而是通过RabbitMQ的Topic Exchange发布
  • 每个实例监听同一个队列,拿到消息后用本地的SimpMessagingTemplate往本地会话推送

这里有一个重要的点:Spring Boot的STOMP配置里,你可以不再依赖本地的Simple Broker,而是指定用RabbitMQ作为Broker。也就是说客户端发来的/topic订阅请求,会由RabbitMQ去管理订阅关系,而不是实例本地管理。这样一来,客户端A连的是实例1,但它订阅/topic/alerts这个动作会被同步到RabbitMQ——实例2推送消息时,RabbitMQ知道实例1上有客户端A的订阅,就会把消息投递给实例1,实例1再转给客户端A。本质上,你连订阅关系都被"集群化"了,消息丢失的可能性进一步降低。

不过这里要注意,全走RabbitMQ Broker会引入额外的网络跳数和延迟,对于单向通知类业务场景(如告警、站内信),本地Simple Broker + 外部MQ广播的"混合模式"反而更轻量、更容易排查问题。我这次方案选的就是混合模式:客户端STOMP端点由Spring Boot提供,订阅关系由本地的Simple Broker管理,跨实例的广播走RabbitMQ的Topic Exchange。

这个思路的好处是:客户端不需要感知RabbitMQ的存在,服务端推送链路边界清晰,出问题时只需看"Producer是否把消息发到了Exchange"以及"Consumer是否从Queue里拿到了消息"这两段即可,排查成本低。

3. 手写Token握手校验:为什么不能用Spring Security的默认过滤器

STOMP握手本质是一次HTTP请求,但握手成功后,连接就升级成WebSocket长连接,后续帧不再走HTTP协议。这意味着——你没法用Spring MVC里的HandlerInterceptor或Filter去拦截STOMP帧来做认证,因为那些都是HTTP层的东西。

我在最初做Token认证时踩过一个坑:用了Spring Security,在SecurityFilterChain里配置了/ws/**放行,又在WebSocket握手拦截器里做Token校验。看起来逻辑通顺,但等到联调时发现,某些客户端的Token是在握手后的第一条CONNECT帧里带过来的,握手阶段根本拿不到。这样一来,握手阶段拦截器校验失败,连接直接被拒,前端一脸懵。

标准做法是:不要依赖握手的HandshakeInterceptor做核心认证,而是用ChannelInterceptor拦截CONNECT帧,在该帧里校验Token。为什么?因为STOMP协议规定,CONNECT帧是客户端在WebSocket建立之后、正式建立STOMP会话之前发送的第一帧,里面可以携带Authorization头(如果你用的是自定义Header,也可以放在nativeHeaders里)。这个时机正好是认证的"黄金窗口"——连接已经建立,但尚未进入业务消息收发阶段,此时拒绝连接是干净且无副作用的。

认证链路大致是:

  1. 客户端在WebSocket URL上带Token参数(如ws://host/ws?token=xxx)——方便握手阶段做快速预检
  2. 握手拦截器拿到Token后只做"格式预检"(是否为空、是否过期),不查库,避免握手链路过重
  3. 客户端发送CONNECT帧,携带Authorization: Bearer <token>
  4. ChannelInterceptor实现类拦截CONNECT帧,从StompHeaderAccessor里取出Auth头,解析Token,校验签名和有效期,加载用户信息
  5. 认证通过后,把用户信息写入StompHeaderAccessor的sessionAttributes或user属性,后续业务消息里直接取用
  6. 认证失败则返回ERROR帧并关闭连接

这里有个细节要强调:ChannelInterceptor拦截的是整个STOMP通道上的所有帧,不仅仅是CONNECT。所以你在实现时要判断StompCommand.CONNECT,只在这个命令上做认证逻辑,否则后续每一条消息都走一遍Token校验,浪费不说,还可能误伤订阅和发送。

写完拦截器后,还需要在WebSocket配置里把它注册为clientInboundChannel的拦截器:

@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final TokenChannelInterceptor tokenChannelInterceptor; public WebSocketConfig(TokenChannelInterceptor tokenChannelInterceptor) { this.tokenChannelInterceptor = tokenChannelInterceptor; } @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(tokenChannelInterceptor); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端订阅前缀 registry.enableSimpleBroker("/topic", "/queue"); // 服务端消息前缀 registry.setApplicationDestinationPrefixes("/app"); } }

这个配置里有个值得注意的设计:enableSimpleBroker("/topic", "/queue")——/topic用于广播推送,/queue用于点对点推送。在集群场景下,/queue一定要小心用,因为Simple Broker的"点对点"也是进程内存级别的,客户端A在实例1上订阅了一个/queue/xxx,实例2往/queue/xxx推消息,实例1是收不到的。如果需要跨实例点对点,应该改用RabbitMQ的Queue模式,或者把点对点也设计成广播(每个实例只关心自己是否有匹配的本地会话,其实配合Session级用户映射反而更简单)。

4. RabbitMQ的接入与自动配置:一套配置解决"订阅关系"和"消息扇出"

我选择RabbitMQ作为外部Broker,有一个很实际的考量:Spring Boot的spring-boot-starter-amqp对RabbitMQ的封装足够成熟,而且Spring官方的spring-messaging模块里,SimpleMessageBroker和RabbitMessageBroker的切换成本非常低。

但要提醒的是:直接用RabbitMQ作为STOMP Broker和混合模式下的"消息扇出中间件"是两回事。前者需要启用RabbitMQ的STOMP插件,客户端直接把STOMP帧发给RabbitMQ的61613端口;后者客户端仍然把STOMP帧发给Spring Boot服务,Spring Boot再作为生产者和消费者与RabbitMQ打交道。

我做的是后者,理由之前说过:客户端只需要知道一个入口地址(Nginx代理后的WebSocket端点),不需要关心背后有几台RabbitMQ、哪个队列在哪,灵活性和可维护性都好很多。

具体实现时,需要在项目里引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>

然后配置文件里加上RabbitMQ的连接信息:

spring: rabbitmq: host: 10.0.0.12 port: 5672 username: admin password: admin123 listener: simple: acknowledge-mode: manual concurrency: 3 max-concurrency: 8

这里我把acknowledge-mode设成了manual——手动确认。为什么?因为WebSocket推送是异步的,消息到达监听器后,推送给客户端的过程可能很快,但你不能假设它一定成功。如果监听器方法抛出异常,自动确认模式下消息会被丢弃,推送就丢了;手动确认模式下,你可以先尝试推送,推送失败就执行basicNack,让消息重回队列,由另一个线程重试。

RabbitMQ的消息拓扑我这样设计的:

  • 交换机:push.exchange,类型是topic
  • 队列:push.queue.v1,绑定键是push.*
  • 发送端:convertAndSend("push.exchange", "push.notice", payload)
  • 每个Spring Boot实例都声明同一个队列push.queue.v1,绑定同一个交换机

为什么要"同一个队列"而不是"每个实例一个队列"?这涉及到RabbitMQ的一个特性:同一个队列被多个消费者监听时,消息是负载均衡分发的(Work Queue模式),而不是广播。在推送场景下我们不希望这样——我们希望每个实例都能收到消息,所以必须每个实例声明一个独立队列,或者让每个实例声明同一个主题队列的副本。

正确做法是:每个实例声明一个带唯一后缀的队列,比如push.queue.instance1、push.queue.instance2,都绑定到push.exchange上,绑定键都用push.*。这样发送端发一条消息,RabbitMQ会根据绑定关系复制到每个实例的队列里,实现广播效果。

@Bean public Queue pushQueue() { // queueName 是每个实例启动时自己生成的前缀,例如 "push.queue." + UUID.randomUUID() return new Queue(queueName, true, false, true, Map.of("x-ha-policy", "all")); } @Bean public TopicExchange pushExchange() { return new TopicExchange("push.exchange", true, false); } @Bean public Binding pushBinding() { return BindingBuilder.bind(pushQueue()).to(pushExchange()).with("push.*"); }

消费者这一侧,用@RabbitListener监听唯一队列,收到消息后调用SimpMessagingTemplate.convertAndSend把消息推给本地订阅者:

@RabbitListener(queues = "#{pushQueue.name}") public void onPushMessage(Message message, Channel channel) throws Exception { try { PushPayload payload = objectMapper.readValue(message.getBody(), PushPayload.class); // 推给订阅了对应主题的所有本地会话 messagingTemplate.convertAndSend("/topic/" + payload.getTopic(), payload.getData()); // 手动确认 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { log.error("push message error", e); channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); } }

注意这里messagingTemplate.convertAndSend的目标是/topic/...,这个主题是客户端直接订阅的主题;而RabbitMQ那边的绑定键push.*只是内部路由用的。层与层之间是解耦的,消息在进入Spring Boot之后,由本地Broker决定往哪些会话推送。

5. 集群环境下的负载均衡与粘性会话:该配的还是要配

有人会问:既然广播机制已经保证了消息不丢,那Nginx还需要粘性会话吗?

答案是:建议配置,但不能依赖。原因有两个:

第一个原因是WebSocket连接升级的流程。客户端先发起GET /ws的HTTP握手请求,Nginx需要把这条请求转发给某个后端实例,握手成功后升级成WebSocket长连接。如果Nginx没有配置Upgrade和Connection头,这个请求会被当成普通HTTP请求处理,WebSocket握手直接失败。这是最低配,必须做。

第二个原因和握手阶段带Token有关。如果Token是放在URL参数里的,那么每次重连都是无状态的,路由到哪个实例都一样,不需要粘性会话。但如果你在CONNECT帧里带Token,而Nginx做的是HTTP层面的负载均衡,它根本看不到STOMP帧里的内容——这和分析TCP payload一样不现实。所以粘性会话更多是为了减少WebSocket频繁重连带来的握手开销,而不是解决消息丢失问题。

Nginx的配置片段:

upstream ws_backend { server 10.0.0.1:8080; server 10.0.0.2:8080; server 10.0.0.3:8080; ip_hash; } server { listen 80; location /ws { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }

ip_hash在这里承担了粘性会话的职责——同一个客户端IP的请求总是转发到同一个后端实例。但要注意:如果客户端走的是移动网络,IP可能会频繁变化,或者多个用户共享同一个出口IP,ip_hash就会不够精准。更高级的做法是sticky模块基于Cookie做会话保持,不过对于WebSocket场景,Cookie方案略显鸡肋——毕竟握手之后连接是长连接,只要不重启,一直挂在那个实例上就够了。真正需要处理的是实例重启导致的连接断开。

游戏规则是这样的:你无法保证实例不重启,所以客户端必须实现自动重连机制。前端在onclose事件里做指数退避重连,重连时会带上同一个Token重新握手。这时粘性会话的作用就体现出来了——如果重连的请求被路由到了另一个实例,消息推送也不受影响(因为广播机制保证每个实例都能消费到RabbitMQ里的消息),只是握手成本高了一点点。所以严格来说:有粘性会话,性能好一点;没有粘性会话,功能也不会坏。这就是我说的"建议配置,但不能依赖"。

6. Token认证与clientInboundChannel:别再走HTTP Filter的老路

这一节我想展开讲讲Token认证的实现细节,因为这是容易出"看似对,实则错"的地方。

先纠正一个常见误解:有人会在WebSocketConfig里通过addInterceptors注册HandshakeInterceptor,然后在beforeHandshake方法里做Token解析。这个方法是能拿到Token的,但它有个致命问题——它只处理握手阶段的HTTP请求参数。如果你约定Token必须放在Authorization头里,那么常规的JavaScript WebSocket API并不能自定义Header,你只能放在URL参数或protocol里。前端代码稍不注意,就把这个环节绕过去了。

更稳妥的方式是"三阶段校验":

  1. 握手阶段(可选但推荐):从URL参数里取token,只检查是否为空、格式是否合法,做一个低成本的快速预检。
  2. CONNECT帧阶段(必须):从StompHeaderAccessor的nativeHeaders里取Authorization头,做完整校验(签名+有效期+用户信息加载)。
  3. 订阅/发送阶段(按需):针对SUBSCRIBE或SEND命令做细粒度的权限控制。

第二阶段的实现大致是这样:

public class TokenChannelInterceptor implements ChannelInterceptor { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { String token = extractToken(accessor); if (token == null || !tokenProvider.validateToken(token)) { throw new AuthenticationCredentialsNotFoundException("invalid token"); } String username = tokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 关键是这一行:把用户信息放进 accessor 中,后续可以通过 accessor.getUser() 获取 accessor.setUser(userDetails); } return message; } private String extractToken(StompHeaderAccessor accessor) { List<String> authHeaders = accessor.getNativeHeader("Authorization"); if (authHeaders == null || authHeaders.isEmpty()) { return null; } String header = authHeaders.get(0); if (header.startsWith("Bearer ")) { return header.substring(7); } return null; } }

这里的关键点是accessor.setUser(userDetails)。设置之后,后续的业务代码里通过SimpMessagingTemplate.convertAndSendToUser(username, "/queue/private", payload)可以实现点对点推送——它的底层原理是把用户名和Session ID关联起来,在Simple Broker的本地会话表里做映射。单节点下一切正常;集群下,convertAndSendToUser是否能跨节点送达,取决于用户是否恰好连接在本实例上。所以如果需要跨节点的点对点推送,要么用RabbitMQ作为Broker,要么把点对点改成"广播给所有实例,每个实例根据本地Session表判断是否推送"。我在实际项目中更倾向于后者,逻辑更直接。

另外值得注意的一点:Token过期后连接并不会自动断开。因为STOMP长连接一旦建立,后续帧不再做HTTP层面的认证,只要你不实现"定期校验Token"的逻辑,过期的Token依然可以继续收发消息。我在生产环境里加了一个心跳机制:客户端每30秒发送一个/app/heartbeat消息,服务端在ChannelInterceptor里拦截这个命令并检查Token是否过期,过期就返回ERROR帧强制断开连接。这个机制对那些长期挂机不关闭页面的用户特别重要。

7. 实例重启与连接补偿:状态恢复思路

集群滚动发布或者实例崩溃时,长连接一定会断开,这是WebSocket的天然宿命。我们能做的不是避免断线,而是保证断线恢复后的消息不丢。

这里有个矛盾:如果消息在实例重启期间推送,那一刻没有实例持有客户端的Session,消息即使被RabbitMQ广播到所有实例,也找不到接收者。简单粗暴的解决方式是"宁可多不可少"——推送方在发出消息时,把消息持久化到Redis里,带上一个全局唯一的消息ID;客户端重连成功后,上报自己最后收到的消息ID,服务端把差值补发。

这个机制我称之为"时间窗口补偿"。实现思路不复杂:

  1. 推送方发送消息前,先写入Redis Stream,key按业务维度区分,value包含消息体、发送时间、消息ID。
  2. RabbitMQ负责把消息广播到各实例,各实例推送给本地会话。
  3. 客户端在每次推送消息里带上msgId,客户端存储最近收到的msgId,重连后的第一条消息带上它。
  4. 服务端提供一个/app/pullMissing?msgId=xxx的端点,根据客户端上报的消息ID,从Redis Stream里读取之后的消息,通过convertAndSendToUser补发。

这个方案的复杂度确实上去了,但对"可靠推送"有硬性要求的业务(比如交易结果通知、工单状态更新),这一步是绕不过去的。如果只是普通站内信、状态提醒,你可以接受极端情况下的少量丢失,那把这个机制做成可选的补偿开关就好,不必常态启用。

我个人的实现经验是:先把基础推送链路做稳(RabbitMQ广播 + 手动ACK),再把补偿机制作为第二优先级。因为补偿机制本身依赖"消息已经持久化"这个前提,而如果广播链路本身就丢消息,补偿机制反而会掩盖问题,让你排查起来更费劲。递进式的架构演进,比一步到位更稳。

8. 生产环境实测与踩坑总结:几个让我排查到凌晨的细节

最后分享几个在生产环境里踩过的坑。每一个都让我和团队吃过苦头,写出来帮大家提前绕开。

第一个坑:RabbitMQ广播变成工作队列。前面提到每个实例要声明独立队列,但如果你的实例是在@PostConstruct里动态创建队列的,并且队列名写死了(比如push.queue),那么三个实例声明的是同一个队列名,RabbitMQ会认为这是同一个队列的三个消费者——消息只会被其中一个消费者收到。表面上看消息好像"没丢",但实际推送覆盖率急剧下降。排查这个问题时,我先看了RabbitMQ管理台,发现三个实例的Connection都连着同一个队列,才恍然大悟。解决办法就是让队列名带上实例唯一标识。

第二个坑:手动ACK与自动ACK混用导致消息重复消费。如果你在application.yml里配了acknowledge-mode: manual,但监听器方法里用了channel.basicAck,同时某些分支代码里忘了确认,RabbitMQ会一直重发,导致客户端收到重复推送。我的建议是:在监听器最外层套一个try-catch-finally,final里确保每条消息只ack一次,never只nack重试超过3次的消息。否则一旦某条消息一直处理失败,它会卡住后续所有消息,形成积压。

第三个坑:Nginx的proxy_read_timeout太短导致连接被掐断。WebSocket是长连接,默认Nginx的proxy_read_timeout是60秒,也就是说60秒内后端没返回任何数据,Nginx就会主动断开连接。对一个"偶尔推一条告警"的场景来说,这个默认值完全不够。需要调到3600秒以上,同时前端要做心跳保活(每30秒发一个PING帧或业务心跳),否则仍可能被中间网络设备掐断。

第四个坑:心率和Token校验的冲突。我在第6节提到心跳消息走/app/heartbeat,但/app前缀的消息会先经过clientInboundChannel,如果你的Token校验逻辑对每个非CONNECT命令都做一次完整JWT解析,高并发心跳下CPU吃掉不少。优化办法是:在ChannelInterceptor里对心跳命令做轻量校验,比如只检查Redis里的session是否有效,不再解析JWT签名。生产环境实测,这个优化能让单实例的Tps从两千提升到近万,还是很可观的。

第五个坑:SockJS与原生WebSocket的兼容差异。如果你的前端用的是withSockJS(),有些场景(比如IE浏览器)会走XHR轮询兜底,这时推送延迟会明显变大,而且Nginx的配置要额外处理/ws/**下的多个请求路径。我对新项目一律建议原生WebSocket,只有确实需要兼容老浏览器才开SockJS兜底。

现在回头看看整条链路:客户端通过Token完成两段式认证,连接任意一个Spring Boot实例,STOMP订阅关系落到本地Simple Broker;发送方消息进入RabbitMQ Topic交换机,广播到每一个实例的独立队列;每个实例消费消息后转给本地的SimpMessagingTemplate,最终推送到持锁的客户端会话;期间任何一步失败都有重试和补偿机制兜底。这套架构虽然没有"银弹"那么玄乎,但至少能让我们在业务半夜告警时少接几个"消息没收到"的工单。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 5:20:00

LogViewPro中文版:超大文本文件秒开与日志排查实战指南

简介&#xff1a;LogViewPro中文版是一款专为超大文本文件场景设计的日志查看与分析工具&#xff0c;面向系统管理员、运维工程师和开发人员&#xff0c;解决普通编辑器打开大日志卡顿、搜索缓慢的常见痛点。它优化了大文件读取机制&#xff0c;即使面对几GB乃至更大的文本也能…

作者头像 李华
网站建设 2026/9/26 5:19:29

DeepSeek V4.1 Flash 接入实战:API、本地部署与代码助手配置

1. 从一次真实的接入翻车说起上周帮一个朋友调试他的代码助手工作流&#xff0c;他信誓旦旦跟我说“DeepSeek V4.1 Flash 我已经接好了&#xff0c;API 也能通”&#xff0c;结果我打开他的 VS Code 一看&#xff0c;Continue 插件里报了一长串cc switch local proxy failed wh…

作者头像 李华
网站建设 2026/9/26 5:19:29

Docker容器化部署实战:从镜像管理到场景化运维指南

1. 容器到底是什么&#xff1a;先拆掉认知门槛搞 Docker 的人经常遇到一种尴尬&#xff1a;跟同事说“用容器跑一下”&#xff0c;对方第一反应是“哦&#xff0c;虚拟机吧”。这是最大的误区。容器不是虚拟机&#xff0c;它是一个运行在宿主操作系统之上的隔离进程&#xff0c…

作者头像 李华
网站建设 2026/9/26 5:19:25

大数据开发能力图谱:从考试题库反向构建工程能力

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 5:18:12

MCP Java Client从零开发:核心抽象、工具调用与避坑指南

说实话&#xff0c;MCP 这个概念从 2024 年底火到现在&#xff0c;绝大多数人的关注点其实都停在“连个 server 用用”的层面——比如往 Cursor、Codex 里塞个 Figma MCP、Playwright MCP&#xff0c;能跑就行。但真正到了要自己动手开发 mcp client 的时候&#xff0c;很多人就…

作者头像 李华