离线消息需服务端区分在线直推与离线暂存,并由客户端重连后主动拉取+服务端事务性确认清理。websocket推送前须查redis全局在线状态,离线消息存mysql需含status、expires_at等字段,拉取与清理须原子执行,前后端均需幂等控制与状态同步。

离线消息不是“连上了再发一遍”就能解决的,核心在于服务端必须区分「在线直推」和「离线暂存」两个路径,且客户端重连后要能主动拉取+服务端确认清理,否则必然丢消息。
WebSocket推送前必须查用户在线状态
直接遍历所有连接或硬编码广播,会导致离线用户收不到、在线用户重复收到。Spring Boot 中最轻量的做法是用 ConcurrentHashMap 缓存 userId → Session 映射:
- 在
@OnOpen时存入:activeSessions.put(userId, session) - 在
@OnClose或@OnError时移除,并调用UserStatusService.markOffline(userId) - 发送消息前先查
activeSessions.containsKey(targetUserId),为false则走数据库落库逻辑 - 注意:多实例部署时该 Map 仅本机有效,必须配合 Redis 做全局在线状态同步(如用
redis.setex("user:online:123", 300, "true"))
MySQL 存离线消息要带明确状态字段
只存 content 和 to_user 不够,无法支撑后续拉取、去重、清理。推荐表结构至少包含:
WebSocket 8.18.2 是该协议规范的一个重要迭代版本,主要优化了连接稳定性与数据传输效率。它通过全双工通信机制,允许客户端与服务器在单一长连接上实时交换数据,大幅降低传统 HTTP 轮询的开销。该版本增强了心跳保活、自动重连及二进制帧传输能力,适用于即时通讯、在线游戏及金融行情推送等低延迟场景,为开发者提供更可靠的实时网络交互基础。
ALTER TABLE chat_offline_messages ADD COLUMN (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
from_user VARCHAR(64) NOT NULL,
to_user VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
msg_type VARCHAR(32) DEFAULT 'TEXT',
status ENUM('PENDING', 'SENT', 'FAILED') DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL
);
-
status字段用于幂等控制:用户上线拉取后更新为'SENT',避免重复推送 -
expires_at建议设为 7 天,防止垃圾数据堆积(可配合定时任务清理) - 插入时加唯一索引:
UNIQUE KEY uk_to_user_created (to_user, created_at),防并发重复写 - 不要用大字段存二进制或序列化对象,MySQL 对 JSON 解析慢,且不利于分页查询
用户上线时主动拉取 + 清理需原子执行
不能先查再删,中间若发生异常或并发请求,会导致消息丢失或重复。正确做法是在事务中完成:
- 用
SELECT ... FOR UPDATE锁住目标用户的未读记录 - 查出后立即
UPDATE ... SET status = 'SENT' WHERE id IN (...) - 再把这批记录组装成 WebSocket 消息推送出去
- 示例 SQL(Spring Data JPA 可封装为 @Query):
UPDATE chat_offline_messages SET status = 'SENT' WHERE to_user = ? AND status = 'PENDING' AND expires_at > NOW() - 前端收到后应校验
msg_id防止页面刷新后重复渲染
前端重连后不能只依赖服务端补推
服务端拉取离线消息只是“被动补”,而客户端本地待发队列才是“主动保”。两者必须并存:
- 所有
ws.send()必须封装进sendMessage()函数,先 push 到pendingQueue = [] - 发送失败时(
ws.readyState !== 1),保留消息并标记retryCount: 0 - 重连成功后,按顺序重发,每条附带
msg_id和timestamp,服务端据此做幂等判断 - 高优消息(如支付确认)可额外存
localStorage.setItem('pending_msg_' + id, JSON.stringify(msg)),冷启动时恢复 - 关键点:服务端 ACK 响应必须含
msg_id,前端收到才从队列/本地存储中清除对应项
最容易被忽略的是「状态清理时机」:服务端标记 status = 'SENT' 是在用户上线拉取时,而不是消息真正送达客户端之后;而前端清理本地队列,必须等到收到服务端 ACK,二者不可混为一谈。差这一步,就可能造成消息已显示却还在本地重试、或已确认却反复拉取。










