1. 为什么系统需要实时通信
在"智能师生教育协作云平台"里,很多交互并不适合用传统 HTTP 的"请求-响应"模式:
- 师生聊天:需要低延迟、持续在线、消息及时到达。
- 课堂协作/通知:例如发布考试、提醒签到、系统广播。
- AI 对话:用户发送问题后,希望像聊天一样持续返回结果或实时反馈。
因此系统引入 WebSocket,目标是:
- 建立长连接(一次握手,多次通信)
- 服务端主动推送(不等用户刷新页面)
- 统一消息协议(可扩展为群聊、广播、通知等)
2. 总体架构:为什么选 Netty
项目里 WebSocket 采用 Netty(NIO)实现,原因主要是:
- 高并发:事件驱动,适合大量长连接
- 线程模型清晰:Boss/Worker 分工明确
- 协议栈可控:管道(Pipeline)能精细控制编解码、心跳、鉴权
整体数据流可以理解为:
前端(浏览器 WebSocket)
│ ws://host/ws
▼
Netty WebSocket Server
│ 解析消息(JSON)
▼
消息路由(绑定/单聊/群聊/心跳)
│
├─ 需要持久化:写入 MySQL(聊天记录/系统通知)
└─ 需要AI:调用 DeepSeekService(再推送给用户)
重要原则:
- WebSocket 层只做"连接与分发",不要把复杂业务逻辑堆在 Handler 里。
- AI 调用要异步化,避免阻塞 Netty IO 线程。
3. 连接生命周期:从握手到断开
3.1 连接建立
浏览器发起 WebSocket 握手,服务端升级协议成功后进入长连接状态。
你需要关注的是:
- 连接建立后并不知道用户是谁(还没有登录/绑定)
- 所以通常会设计一个
bind消息,让客户端把userId/userType发过来
3.2 用户绑定(Bind)
绑定要解决的问题:
- 让服务端知道"这个 Channel 对应哪个用户"
- 后续才能实现"点对点推送""离线策略""权限控制"
常见做法:
- 服务端维护
userKey -> channel的映射 userKey通常是userType:userId(例如teacher:12)
3.3 心跳与保活
长连接容易被 NAT/网关清理,必须有心跳:
- 客户端每隔 20~30 秒发一次
heartbeat - 服务端更新最后心跳时间,并返回
heartbeat_response - 服务端定时扫描超时连接并清理
3.4 断开与清理
断开时至少要做:
- 删除
userKey -> channel映射 - 清理会话信息(最后心跳、绑定时间)
- 从群组成员列表移除(如果有群聊)
4. 消息协议:先统一格式再谈功能
如果没有统一消息协议,功能越做越乱。建议最少包含:
- type:消息类型(bind/chat/group/heartbeat/system...)
- from:发送者信息
- to:接收者信息(单聊时必须)
- data:内容(文本/结构化 payload)
- timestamp:时间戳
一个简化示例:
json
{
"type": "chat",
"fromUserId": "12",
"fromUserType": "teacher",
"toUserId": "108",
"toUserType": "student",
"content": "今晚作业记得提交",
"timestamp": 1700000000000
}
为什么要 timestamp?
- 前端消息排序
- 离线消息补发时保持时序
- 审计和问题排查
5. 消息路由:四类消息就够支撑大多数场景
5.1 bind:绑定通道
- 校验参数是否完整
- 建立映射关系
- 返回
bind_success
5.2 chat:单聊
单聊处理建议顺序:
- 校验消息字段(to/from/content)
- 鉴权(是否允许给该对象发消息)
- 落库(可选:聊天记录/通知记录)
- 在线推送(对方在线则推送;不在线走离线策略)
5.3 group_chat:群聊
群聊比单聊多了两件事:
- 维护群组成员列表(groupId -> members)
- 广播给所有在线成员(排除发送者)
5.4 heartbeat:心跳
- 更新会话最后活跃时间
- 返回响应,保持 NAT 映射
6. 线程与性能:Netty 的坑点要避开
6.1 不要阻塞 IO 线程
WebSocket Handler 运行在 Netty 的事件循环线程上。如果你在里面直接调用:
- 数据库慢查询
- HTTP 请求(例如调用 DeepSeek)
- 大 JSON 序列化/大字符串拼接
都会导致:
- 同一线程上的其他连接也被拖慢
- 表现为"偶发卡顿/消息延迟/连接被踢"
建议:
- AI 调用用异步线程池
- 落库可用消息队列/异步写入(可选)
- 大对象序列化要控制
6.2 连接映射要并发安全
映射结构建议使用:
ConcurrentHashMap存userKey -> Channel- 清理时要小心:断开事件与心跳扫描可能并发
7. AI 对话集成:把 AI 当成一个"特殊用户"
实现 AI 对话最简单的方式之一:
- 前端把消息
toUserType设为ai - 服务端识别后走 AI 分支
- AI 返回结果后再推送到原用户 channel
**关键点:**AI 调用一定要异步,否则会卡住 IO 线程。
8. 最少代码展示(只留关键逻辑,避免堆代码)
8.1 连接映射示意(核心思想)
java
// userKey = userType + ":" + userId
private final Map<String, Channel> userChannels = new ConcurrentHashMap<>();
public void bind(String userKey, Channel channel) {
userChannels.put(userKey, channel);
}
public void unbind(Channel channel) {
userChannels.values().removeIf(c -> c == channel);
}
8.2 AI 调用建议异步化
java
CompletableFuture.supplyAsync(() -> deepSeekService.deepSeek(question), aiPool)
.thenAccept(answer -> pushToUser(userKey, answer))
.exceptionally(ex -> { pushError(userKey); return null; });
8.3 心跳超时清理(思想)
text
每隔 30s 扫描 sessions:
如果 now - lastHeartbeat > timeout:
关闭 channel
移除映射
上面三段足够表达核心,不需要把整套 Netty 启动类、前端类全部贴出来。
8.4 关键代码片段(可直接复制粘贴)
1) 通用消息协议(建议统一用一个 DTO)
java
public class WsMessage {
private String type; // bind/chat/group_chat/heartbeat
private String fromUserId;
private String fromUserType; // teacher/student/ai
private String toUserId;
private String toUserType;
private String content;
private Long timestamp;
private String groupId;
// getter/setter 省略
}
2) bind 处理(把 userKey 绑定到 Channel)
java
private final java.util.Map<String, io.netty.channel.Channel> userChannels =
new java.util.concurrent.ConcurrentHashMap<>();
private String userKey(String userType, String userId) {
return userType + ":" + userId;
}
private void handleBind(WsMessage msg, io.netty.channel.Channel channel) {
String key = userKey(msg.getFromUserType(), msg.getFromUserId());
userChannels.put(key, channel);
}
3) 单聊路由(在线就推送,不在线就记录/提示)
java
private void handleChat(WsMessage msg) {
String toKey = userKey(msg.getToUserType(), msg.getToUserId());
io.netty.channel.Channel toChannel = userChannels.get(toKey);
if (toChannel != null && toChannel.isActive()) {
toChannel.writeAndFlush(new io.netty.handler.codec.http.websocketx.TextWebSocketFrame(
com.alibaba.fastjson.JSON.toJSONString(msg)));
} else {
// 这里可以做:落库离线消息 / 未读数 +1 / 返回发送方"对方不在线"
}
}
4) 心跳超时清理(按 lastHeartbeat 扫描)
java
private final java.util.Map<String, Long> lastHeartbeat =
new java.util.concurrent.ConcurrentHashMap<>();
private void handleHeartbeat(WsMessage msg) {
String key = userKey(msg.getFromUserType(), msg.getFromUserId());
lastHeartbeat.put(key, System.currentTimeMillis());
}
// 定时任务每 30s 扫描一次
private void clearTimeout(long timeoutMs) {
long now = System.currentTimeMillis();
lastHeartbeat.forEach((key, ts) -> {
if (now - ts > timeoutMs) {
io.netty.channel.Channel ch = userChannels.remove(key);
lastHeartbeat.remove(key);
if (ch != null) {
ch.close();
}
}
});
}
9. 常见问题与排查
-
连接成功但收不到消息
- 是否完成 bind?服务端是否拿到 userKey?
- userKey 是否一致(teacher:1 vs user1 这种差异)
-
消息偶发延迟
- Handler 是否在做阻塞操作(AI/DB/大计算)
- 日志级别是否过高导致 IO 压力
-
频繁断线
- 客户端是否有心跳?间隔是否太长
- NAT/代理是否会清理长连接
10. 总结
这套 WebSocket/Netty 实时通信模块,本质上解决了三件事:
- 连接管理:让"谁在线、谁对应哪个 Channel"可追踪
- 消息协议:让消息类型可扩展、可维护
- 消息路由:把 bind/单聊/群聊/心跳分清楚,职责清晰
当你把"协议、路由、线程模型"这三块设计好,后续无论加"课堂通知、作业提醒、AI 助手、系统广播",都只是扩展 type 和路由分支,不会把系统越做越乱。