websocket不保证消息送达,需自行实现ack机制:服务端发送时记录msgid并启动超时重发,前端须在解析成功后立即发ack,断连时保留未确认消息供重连后重推。

WebSocket 本身不保证消息送达,conn.WriteMessage() 返回成功只代表数据进了内核缓冲区,不代表前端收到了、解析了、甚至没被中间代理(比如 Nginx)静默断掉。要做“已读”或“已送达”回执,必须自己补 ACK 机制。
为什么 onmessage 触发不等于消息已送达
前端 onmessage 回调触发,仅说明 WebSocket 帧被浏览器接收并解包成功。但以下情况仍会导致业务层面“丢失”:
- 消息体 JSON 解析失败(字段缺失、类型错),代码直接
return或抛异常,后续逻辑没执行 - 消息进入队列但未渲染(比如聊天列表还没挂载完成),
IntersectionObserver没监听到可视状态 - 用户切到其他 tab,页面被浏览器冻结,
onmessage虽触发但 DOM 更新被延迟或丢弃 - 前端收到后崩溃、刷新、关页——ACK 根本发不出去
send() 后必须立即记录待确认状态
服务端不能发完就忘。每调用一次 conn.WriteMessage(),就要同步做三件事:
- 生成唯一
msgId(推荐uuid.NewString(),避免时间戳+计数器在多协程下冲突) - 将
{msgId, conn, timestamp}存入该连接专属的未确认 map(如conn.ackQueue = make(map[string]time.Time)) - 启动一个
time.AfterFunc(timeout, func()),超时后检查ackQueue[msgId]是否已被删除;若还在,触发重发或告警
注意:不要用全局 map 存所有连接的未确认消息,容易锁竞争;每个连接维护自己的队列更轻量、更可控。
前端发 ACK 的时机必须严格卡在“解析成功后、业务处理前”
这是最容易出错的一环。错误写法是:
前端设计与 UI/UX 全方位优化专家。覆盖视觉层次、排版系统、色彩理论、响应式布局、交互体验、动画动效、无障碍访问、性能优化八大维度,帮助开发者将普通页面升级为高品质产品级界面。前端设计与 UI/UX 全方位优化专家。覆盖视觉层次、排版系统、色彩理论、响应式布局、交互体验、动画动效、无障碍访问、性能优化八大维度,帮助开发者将普通页面升级为高品质产品级界面。
websocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'chat') {
renderMessage(data); // 先渲染
sendAck(data.id); // 再发 ACK → 错!渲染失败或页面卡住,ACK 就没了
}
};
正确顺序是:
- 收到原始
e.data字符串 → 立即JSON.parse() - 提取
id字段 → 立即调用sendAck(id)(哪怕只是websocket.send(JSON.stringify({type:'ack',id}))) - 再执行
renderMessage()或其他业务逻辑
否则 ACK 和业务执行耦合,一旦渲染环节出错,服务端永远等不到回执,反复重发,甚至触发误判为“客户端离线”。
连接断开时未确认消息不能直接丢弃
当 conn.ReadMessage() 返回 *websocket.CloseError 或 io.EOF,说明连接已断。此时不能清空该连接的 ackQueue,而应:
- 标记连接为 offline 状态,并保留未确认消息列表
- 新连接建立时,要求前端带上会话 ID(如 JWT 中的
sid) - 服务端查到该
sid对应旧连接有未 ACK 消息,就主动重推(注意去重:用msgId判重,避免重复渲染)
这个兜底逻辑常被跳过,结果就是“用户重连后,上一条消息的已读状态永远卡在‘未读’”。真正难的不是发消息,是把断连那 200ms 里漏掉的状态也管住。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!










