websocket本身无离线语义,onclose无法区分断连原因;可靠离线逻辑须解耦业务状态,依赖可维护的在线字段(如last_active_at)、心跳机制及按接收方建模的user_message_inbox表,推送前查activesessions而非数据库,并在@onopen主动拉取status='pending'消息。

离线消息为什么不能只靠 WebSocket 连接判断
WebSocket 本身没有“离线”语义——onClose 触发时,你只知道连接断了,但无法区分是用户主动退出、页面刷新、网络闪断,还是手机锁屏导致心跳超时。直接丢弃未送达消息,等于把关键通知(如支付结果、验证码、系统告警)扔进黑洞。
真正可靠的离线逻辑必须和业务状态解耦:不依赖连接存续,而依赖「用户是否在线」这个可维护的状态字段。这个状态要能被多端(App/H5/小程序)统一感知,且支持快速查询。
-
UserStatusService.markOnline(userId)和markOffline(userId)必须配合心跳机制(比如每 30 秒一次 ping/pong),不能只靠@OnClose - 用户主动登出时,要显式调用
markOffline,否则断网重连期间状态会滞留为“在线” - 数据库里建议加一个
last_active_at字段,用于兜底判断:超过 5 分钟没心跳,就视为离线
消息表设计要预留“投递状态”和“接收方视角”
只存 message_content 和 sender_id 不够。离线消息推送的核心矛盾是:同一条消息对不同接收方状态不同(A 已读,B 还没上线)。所以必须按「接收方 + 消息」粒度建模,而不是全局消息池。
推荐结构(MySQL):
CREATETABLEuser_message_inbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
message_id BIGINT NOT NULL,
status ENUM('PENDING', 'DELIVERED', 'READ') DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered_at TIMESTAMP NULL,
read_at TIMESTAMP NULL,
INDEX idx_user_status (user_id, status),
UNIQUE KEY uk_user_msg (user_id, message_id)
);
-
status = 'PENDING'表示该用户尚未拉取或推送,是离线消息的判定依据 - 不要复用主消息表(
chat_messages)做状态追踪,否则多端登录时状态会互相覆盖 -
UNIQUE KEY uk_user_msg防止重复插入同一用户同条消息,避免前端多次拉取造成重复通知
推送逻辑:先查状态,再决定走 WS 还是落库
发送消息时,后端不能无脑 session.getBasicRemote().sendText()。必须先确认接收方当前是否“可投递”——即在线且连接有效。
WebSocket 8.18.2 是该协议规范的一个重要迭代版本,主要优化了连接稳定性与数据传输效率。它通过全双工通信机制,允许客户端与服务器在单一长连接上实时交换数据,大幅降低传统 HTTP 轮询的开销。该版本增强了心跳保活、自动重连及二进制帧传输能力,适用于即时通讯、在线游戏及金融行情推送等低延迟场景,为开发者提供更可靠的实时网络交互基础。
典型流程代码片段(Spring Boot):
public void sendMessageToUser(String userId, String content) {
Session session = activeSessions.get(userId);
if (session != null && session.isOpen()) {
// 在线:直接推送,并更新 inbox 表 status = 'DELIVERED'
try {
session.getBasicRemote().sendText(content);
updateInboxStatus(userId, messageId, "DELIVERED");
} catch (IOException e) {
// 推送失败(如网络中断),降级为离线存储
persistAsOffline(userId, messageId, content);
}
} else {
// 离线:只存库,不尝试推送
persistAsOffline(userId, messageId, content);
}
}
- 务必在
catch (IOException)中补存离线记录,WebSocket 的sendText()失败不会抛出运行时异常,但会静默失败 - 不要在推送前查
user_status表判断“是否在线”,而应以activeSessions实时 Map 为准——数据库状态总有延迟 - 如果使用 Redis 缓存在线状态,记得和
activeSessions保持双写一致,否则会出现“Redis 说在线,实际 session 已失效”的错觉
用户上线时怎么捞离线消息
不是等用户连上 WebSocket 后被动推送,而是要在 @OnOpen 里主动查库、推、再标记已投递。否则用户可能永远收不到第一条离线消息。
关键点:
- 查库条件必须是
WHERE user_id = ? AND status = 'PENDING',不能漏掉status条件,否则会重复推送已读消息 - 推送成功后,用
UPDATE ... SET status = 'DELIVERED' WHERE id IN (...)批量更新,避免逐条 update 性能差 - 如果离线消息过多(比如 > 50 条),前端需要分页拉取,后端接口应支持
offset/limit,别一次性全推,容易触发浏览器内存警告 - 别忘了给前端同步未读数:
SELECT COUNT(*) FROM user_message_inbox WHERE user_id = ? AND status = 'PENDING',这个值要随每次上线请求返回
最易被忽略的是:上线后查到的离线消息,必须带时间戳和唯一 ID,否则前端无法判断是否和本地缓存重复;另外,delivered_at 字段一定要在推送成功后才更新,不能在查询时就提前设值——否则网络抖动导致推送失败,消息就变成“已投递但用户没收到”。










