从单节点到多节点:一次 WebSocket 分布式架构升级的踩坑实录
本文记录了「博思AI智能体」项目从单节点部署升级为双节点分布式架构过程中遇到的真实问题和解决方案。希望能为有类似需求的团队提供参考。
一、升级背景
1.1 原有架构

项目最初采用单节点部署:
用户 → Nginx → Spring Boot (8081)
↓
SimpleBroker (本地 WebSocket 广播)
WebSocket 使用 Spring 的 SimpleBroker 做本地消息广播,所有用户连接在同一个 JVM 内,消息投递简单直接。
1.2 升级目标
随着用户量增长,单节点成为瓶颈。目标架构:
用户 → Nginx (ip_hash) → Node 1 (8081)
→ Node 2 (8082)
↓
RabbitMQ TopicExchange
↓
跨节点消息同步
核心挑战:WebSocket 消息如何在多个节点间同步?
二、架构设计
2.1 跨节点消息总线
每个节点保留本地 SimpleBroker,同时通过 RabbitMQ TopicExchange 实现跨节点广播:
@Configuration
public class CrossNodeConfig {
public static final String EXCHANGE = "cross-node";
@Bean
public String nodeId() {
return "node-" + serverPort + "-" + UUID.randomUUID().toString().substring(0, 8);
}
@Bean
public TopicExchange crossNodeExchange() {
return new TopicExchange(EXCHANGE);
}
@Bean
public Queue crossNodeQueue(String nodeId) {
return new Queue("cross-node-" + nodeId, true, false, true);
}
@Bean
public Binding crossNodeBinding(Queue crossNodeQueue, TopicExchange crossNodeExchange) {
return BindingBuilder.bind(crossNodeQueue).to(crossNodeExchange).with("#");
}
}
2.2 统一广播入口
@Service
public class BroadcastService {
private final SimpMessagingTemplate messagingTemplate;
private final RabbitTemplate rabbitTemplate;
private final String nodeId;
public void broadcast(String destination, Object data) {
// 1. 本地广播
messagingTemplate.convertAndSend(destination, data);
// 2. 跨节点广播
Map<String, Object> payload = Map.of(
"_nodeId", nodeId,
"destination", destination,
"data", data
);
rabbitTemplate.convertAndSend(EXCHANGE, destination,
objectMapper.writeValueAsBytes(payload));
}
}
2.3 防回环机制
每条消息携带 _nodeId,接收端比对后丢弃自己发出的消息:
String sourceNode = (String) payload.get("_nodeId");
if (nodeId.equals(sourceNode)) {
return; // 丢弃自己发出的消息
}
三、踩坑实录
坑一:BroadcastService 写了但没人用
现象:6 个服务(ChatProcessor、DebateProcessor 等)都在调用 broadcastService.broadcast(),但编译报错——BroadcastService 未注入。
原因:重构时只改了调用代码,忘记添加依赖注入。
解决:逐一添加字段和构造函数参数:
private final BroadcastService broadcastService;
public ChatProcessor(..., BroadcastService broadcastService) {
this.broadcastService = broadcastService;
}
教训:重构时注入链必须同步更新,不能只改调用方。
坑二:RabbitMQ 的 Base64 编码陷阱(连踩 3 次)
现象:跨节点消息接收端反复报错:
Cannot construct instance of LinkedHashMap from String value ('eyJkZXN0...')
消息体变成了 Base64 编码的字符串。
第一轮:在 Listener 加 Base64 解码——没解决。
第二轮:改用 writeValueAsString() 发送 JSON 字符串——还是没解决。
第三轮:查阅 Spring AMQP 源码才发现,SimpleMessageConverter 对所有类型的消息(包括 String)都会做 Base64 编码。
最终方案:
// Listener 端:始终先 Base64 解码
String raw = new String(message.getBody()).trim();
String json = raw.startsWith("\"")
? objectMapper.readValue(raw, String.class)
: raw;
byte[] decoded = Base64.getDecoder().decode(json);
Map<String, Object> payload = objectMapper.readValue(decoded, Map.class);
教训:Spring AMQP 的 SimpleMessageConverter 默认行为是 Base64 编码,这是特性不是 bug。
坑三:Jackson2JsonMessageConverter 的"连带伤害"
现象:Base64 问题解决后,部署双节点,页面输入问题后所有请求都在等待,不返回结果。后端日志显示 LLM 调用成功、DB 更新成功,但前端收不到 WebSocket 消息。
原因:全局 RabbitTemplate 配置了 Jackson2JsonMessageConverter:
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
Jackson2JsonMessageConverter converter) {
RabbitTemplate rt = new RabbitTemplate(connectionFactory);
rt.setMessageConverter(converter); // 全局 JSON 转换器
return rt;
}
BroadcastService 注入的是这个全局 rabbitTemplate,发送跨节点消息时被 JSON 转换器二次序列化,导致 RabbitMQ channel 异常,连带影响了本地消息链路。
解决:BroadcastService 创建独立的 RabbitTemplate:
public BroadcastService(SimpMessagingTemplate messagingTemplate,
ConnectionFactory connectionFactory,
ObjectMapper objectMapper,
String nodeId) {
this.messagingTemplate = messagingTemplate;
this.objectMapper = objectMapper;
this.nodeId = nodeId;
// 独立的 RabbitTemplate,不受全局配置影响
RabbitTemplate rt = new RabbitTemplate(connectionFactory);
this.crossNodeRabbitTemplate = rt;
}
教训:全局 Bean 配置会影响所有注入点。特殊场景应该用独立的、最小化的组件。
坑四:WebSocket 连上了但消息收不到
现象:后端处理完全正常,但前端 WebSocket 订阅收不到消息。
排查过程:
1. SockJS 端点 /ws/chat/info 返回正常 ✓
2. 原生 WebSocket 连接成功 ✓
3. STOMP 心跳 PING/PONG 正常 ✓
4. 订阅路径前后端一致 ✓
真正的原因:Debate.jsx 的 SockJS 连接没有传 userId 参数:
// 错误
const sock = new SockJS('/ws/chat')
// 正确
const sock = new SockJS('/ws/chat?userId=' + userId)
后端 HandshakeInterceptor 从 URL 参数提取 userId 用于会话追踪,缺少 userId 导致消息投递链路断裂。
教训:WebSocket 连接能建立不等于消息能送达。要检查整个链路:连接、握手、会话注册、订阅、广播、投递。任何一个环节断了都不行。
坑五:Nginx 502 Bad Gateway
现象:部署过程中页面突然 502,所有请求失败。
原因:后端进程挂了。多节点部署时手动 kill 了旧进程,但新进程没启动成功。
解决:
cd /opt/app
nohup java -jar chat-system-project-0.0.1-SNAPSHOT.jar --server.port=8081 > app.log 2>&1 &
教训:多节点部署时,每个节点都要独立管理进程。建议用 systemd 或 supervisor 做进程守护。
四、架构最终方案
用户请求 → Nginx (ip_hash) → Node 1 (8081) / Node 2 (8082)
↓
BroadcastService
├─ 本地: messagingTemplate → SimpleBroker
└─ 跨节点: RabbitTemplate → TopicExchange("cross-node")
↓
其他节点的 Queue
↓
CrossNodeMessageListener
↓
本地 messagingTemplate → SimpleBroker
关键机制:
防回环:每条跨节点消息携带 _nodeId,接收端比对后丢弃自己发出的消息
自动扩缩容:新节点启动时自动创建独立 Queue 并绑定 Exchange,缩容时 Queue 自动清理
会话粘性:Nginx ip_hash 确保同一用户的请求路由到同一节点
五、总结
坑一:BroadcastService 未注入 → 重构不完整 → 6 个文件逐一添加依赖注入
坑二:Base64 编码(3 次)→ SimpleMessageConverter 默认行为 → Listener 端始终 Base64 解码
坑三:Jackson 转换器冲突 → 全局 Bean 污染 → 创建独立 RabbitTemplate
坑四:WebSocket 消息丢失 → SockJS 缺少 userId 参数 → URL 添加 userId 参数
坑五:502 Bad Gateway → 后端进程未启动 → 进程守护 + 启动脚本
分布式升级没有银弹,每个细节都可能成为故障点。日志是最好的朋友,逐层排查是最可靠的方法。 给张图
浙公网安备 33010602011771号