离线消息依赖服务端缓存与客户端协同,而非websocket自身:需用redis跨实例存储带msg_id和ttl的消息队列,前端localstorage暂存pending消息并支持回滚,服务端结合user+time窗口幂等校验,各环节须有降级兜底。

离线消息不是靠 WebSocket 自身实现的,而是靠“服务端主动缓存 + 客户端状态协同”完成的。WebSocket 协议本身不提供存储、确认、重试能力,强行在 WebSocketHandler 里用内存 Map 存消息,重启就丢、多实例就乱、过期要手写清理——这根本不是方案,是临时补丁。
为什么不能只依赖 WebSocket 的 onMessage/onClose 做离线逻辑
很多人一上来就在 handleTextMessage 里加 if (session.isOpen()) { send() } else { cacheInMap() },结果上线就翻车:
- 应用重启后,
ConcurrentHashMap里所有离线消息清零,用户上线再也收不到 - 集群部署时,A 实例缓存了用户 X 的消息,但用户 X 下次连到 B 实例,B 根本查不到那条缓存
-
onClose触发时机不可靠:页面刷新、网络抖动、浏览器崩溃都会触发,但你无法区分是“用户主动退出”还是“意外断开” - 没有消息幂等标识,重连后重复投递,前端展示两条一样的通知
Redis 存离线消息必须选对数据结构和 TTL
用 Redis 不是图个名字新潮,是为了解决三个硬需求:跨实例共享、自动过期、原子读写。但乱用 String 或 Hash 会埋坑:
- 推荐用
LPUSH + BRPOP或ZSET(按时间戳排序)存用户离线队列,避免单 key 膨胀;不要用String存整个消息列表,扩容和分页都困难 - 每条消息必须带唯一
msg_id和expireAt字段,Redis 写入时用EXPIREAT指定绝对过期时间,别依赖客户端轮询清理 - 序列化统一用
Jackson2JsonRedisSerializer,禁用JdkSerializationRedisSerializer——后者序列化体积大、跨语言不兼容、反序列化失败直接报ClassNotFoundException - 用户上线拉取离线消息后,必须用
LTRIM或ZREMRANGEBYRANK清空已读部分,不能只DEL整个 key,否则并发拉取会丢消息
前端 pendingQueue 必须和连接状态解耦,且支持 localStorage 回滚
用户刷新页面后,内存里的 pendingQueue = [] 就没了,正在发送但没 ACK 的消息直接人间蒸发。可靠做法是:
- 封装
sendMessage()函数,所有调用都走它;发送前先localStorage.setItem('pending_' + userId, JSON.stringify(msg)),再尝试ws.send() - 页面加载时,检查
localStorage是否有未 ACK 消息,有则重建队列并标记isFromStorage: true,避免和新输入消息混淆 - 收到服务端 ACK(比如
{msg_id: 'xxx', status: 'ok'})后,同步localStorage.removeItem()对应条目;失败时记录重试次数,超 3 次降级为 toast 提示“发送失败,请重试” - 注意
localStorage单条上限约 5MB,但实际建议单条控制在 100KB 内;超长文本提前截断或转存 CDN,只留 ID 在本地
服务端幂等校验不能只靠 msg_id,得结合用户+时间窗口
只校验 msg_id 唯一性,遇到客户端时间回拨、ID 生成算法冲突,照样重复消费。生产环境要叠加维度:
- Redis 中用
SETNX user:123:msg:abc123做首次接收标记,TTL 设为 24 小时(覆盖最大业务容忍延迟) - 同时检查该用户近 5 分钟内是否已有同类型操作(如“支付通知”),用
ZCOUNT user:123:pay_events -inf +inf防刷 - ACK 响应体必须包含服务端生成的
server_seq,前端可据此判断是否为重复响应,避免 UI 二次渲染 - 日志中固定打印
msg_id + server_seq + recv_time三元组,出问题时能快速定位是客户端重发、还是服务端误判
真正难的不是代码怎么写,而是每个环节都要回答一个问题:“如果这一步失败了,下游还能继续吗?”——比如 Redis 挂了,离线消息存不到,至少要降级写 MySQL;localStorage 写满,就退回到内存队列;服务端幂等漏判一次,前端 UI 得靠 msg_id 去重而不是傻等服务端修复。可靠性不在某一行代码,而在所有 fallback 路径都真实跑通过。











