尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Golang的长轮询推送方案:架构设计与生产实践

基于Golang的长轮询推送方案:架构设计与生产实践 做后端开发的同行应该都遇到过这类需求业务方过来说页面上的状态要实时刷新或者订单有变化要第一时间告诉用户。最开始能想到的就是客户端定时来拉简单是简单但浪费显而易见。后来我接手过一个通知推送项目目标用户集中在弱网环境还有不少存量系统只支持标准 HTTPWebSocket 连不上SSE 兼容性又拉胯。反复比较之后我最终用 Golang 实现了一套基于长轮询的推送方案上线跑了大半年经历过流量高峰也踩过不少暗坑。这篇文章就把整个方案的设计思路、核心代码、参数取舍和生产环境的优化经验完整写下来给还在纠结到底怎么推的同学一个可以直接抄作业的参考。长轮询Long Polling说白了就是一次普通的 HTTP 请求但服务端收到后不立刻响应而是把这个请求挂住等有消息了再返回如果一直没消息就等到超时时间到了返回一个空包。客户端拿到响应后立刻发起下一个请求。从用户体感上消息几乎是实时的而实现上完全不用任何特殊的协议升级兼容性拉满。整个过程用 Golang 来做非常顺手因为 Go 天生就是为高并发准备的goroutine 挂几万个连接不费劲标准库 net/http 也足够可靠不需要引入重型框架。这篇文章我按照从选型到落地的顺序来写先讲清楚为什么是长轮询而不是 WebSocket再拆解整体架构和核心数据结构然后给出一套完整可运行的代码接着聊生产环境怎么扛住高并发最后把我踩过的坑和排查经验一次性抖出来。适合已经会写 Go HTTP 接口、但没怎么做过服务端主动推送的开发者也适合正在选型的技术负责人参考。1. 推送方案选型长轮询凭什么还有位置1.1 短轮询、长轮询、WebSocket 到底差在哪很多刚接触推送概念的开发者第一反应都是不就让客户端循环请求吗。这个思路没错但实现方式不同效果和成本天差地别。我把几种主流方案放在一张表里对比大家感受一下维度短轮询长轮询SSEWebSocket实时性取决于轮询间隔准实时准实时实时实现复杂度最低中等中等较高兼容性最好很好差IE 完全不支持一般需要协议升级服务端压力请求量巨大连接多、请求少连接多、单向连接多、双向典型场景监控面板、低频状态检查通知推送、消息提醒行情、日志流聊天、游戏、协作短轮询最大的问题是无效请求太多。假设 1 万个客户端每 5 秒拉一次服务端每秒要处理 2000 个请求而其中绝大多数时候根本没有新数据。这种方案在小规模业务里够用但只要用户量上来第一波扛不住的就是接入层和数据库。WebSocket 是目前实时推送的主流答案但它有个隐性问题需要一次 HTTP Upgrade 握手把连接升级为全双工长连接。这个动作在标准公网环境没问题可一旦用户处在企业内网、老旧代理、移动 2G/3G 网络下很多中间设备对 Upgrade 请求不友好甚至直接拦截。另外 WebSocket 的断线重连、心跳保活、粘包处理都要自己写复杂度比很多人想象的高。SSEServer-Sent Events是在 HTTP 上做单向服务端推送的官方方案走的是 text/event-stream 流式响应代码比 WebSocket 简单很多。但它有一个硬伤IE 全系不支持部分自定义客户端实现也比较费劲。如果你的用户群体浏览器版本很新SSE 其实比长轮询更优雅。可惜我在实际项目中经常要兼容老浏览器所以最后长轮询成了最优解。1.2 长轮询的核心原理把 HTTP 请求挂起来长轮询的实现思路说起来一句话服务端不立即返回响应而是等待数据或超时后再返回。但这句话背后有几个关键点值得展开。首先一次长轮询连接在服务端占用的是一个 goroutine、一个 HTTP ResponseWriter、一个阻塞的 select。在 Go 里这几乎不消耗什么资源goroutine 初始栈只有几 KB挂一万个同时在线毫无压力。这就让挂起的成本变得极低是长轮询在 Go 里特别好用的根本原因。其次长轮询不是连接一直不断。客户端每次请求结束后都会释放 TCP 连接然后重新发起新请求。这意味着每个轮询周期都是一次完整的 HTTP 事务中间设备可以正常处理不会有连接超时被掐断的问题。这也是它穿透代理能力强的原因——它就是一台普通 web 服务器在处理普通 GET 请求。最后长轮询的准实时体验来自客户端与服务端的配合服务端一旦有数据就立刻返回客户端收到后立即发起下一次请求。这个间隙通常在几十毫秒以内用户完全感知不到。如果服务端一直没数据则按约定的超时时间返回空包客户端马上重连继续挂着等。1.3 什么场景该选长轮询什么场景该放弃根据我的实践长轮询最适合这几类场景通知类推送订单状态变更、消息提醒、审批通知消息频率不高但要求及时。兼容性要求高的内部系统用户用着老浏览器、老客户端或者网络环境受限。不想引入额外协议和复杂运维的中小型项目长轮询只依赖标准 HTTP部署和普通 Web 服务完全一样。服务端单向推送为主、偶尔需要客户端回执的场景。反过来这些场景我强烈建议放弃长轮询实时双向交互在线文档、白板协作、游戏必须上 WebSocket长轮询的双向延迟会让人抓狂。每秒几十条的高频推送K 线行情、实时日志长轮询的请求重建开销会放大SSE 或 WebSocket 更合适。纯公网场景且客户端可升级直接上 WebSocket省去很多长轮询特有的边界问题。选型这件事没有绝对的对错核心是看你的约束条件。我当时最大的约束就是必须兼容老旧网络环境所以长轮询成了唯一不妥协的选择。2. 架构设计先想清楚再动手2.1 一条消息从业务方到客户端的完整链路很多文章一上来就贴代码但我觉得先讲清楚数据流代码才有意义。长轮询推送系统的完整链路是这样业务系统产生事件 → 调用推送服务接口 → 推送服务定位目标用户 → 把消息写入该用户对应的消息通道 → HTTP 长轮询请求收到消息 → 序列化为 JSON → 返回给客户端 → 客户端处理并立即发起下一次请求。在这个链路里推送服务本身是核心它维护了一张谁在等什么的表。表的 key 是用户 ID 或客户端 IDvalue 是当前正在等待推送的连接上下文。业务方调用推送接口时服务端根据目标用户 ID 找到对应连接把消息塞进去触发该连接的 select 返回。如果要支持多实例部署链路上还要加一层消息同步。常见做法是引入 Redis Pub/Sub 或消息队列推送请求先发到 Redis 频道所有实例订阅后各自检查自己是否有目标用户的连接。这一步在单机阶段可以先不做但架构上要预留位置不然后期扩展会很痛苦。我在设计时还加了一个可选的消息持久化如果用户不在线消息是否要补发长轮询本身不带存储能力所以在线时直接推离线时可以选择丢弃也可以选择写入待推送队列等用户下次发起连接时把积压消息一次性带回去。这完全取决于业务但建议在设计消息结构时预留一个 message_id 字段方便做去重和补发。2.2 连接管理器 Hub用 map 还是用 channel整个推送服务的核心是一个叫 Hub连接管理器的东西。它维护所有活跃的等待连接提供三个基本操作注册新连接、注销过期连接、根据用户 ID 推送消息。实现 Hub 最常见的方式是 sync.RWMutex 加 map。有些 Go 新手会纠结Go 不是提倡用 channel 通信吗为什么要用锁其实这是对 Go 并发哲学的误读。Go 确实倡导不要通过共享内存来通信要通过通信来共享内存但注册中心这种高频读写、按 key 精确操作的数据结构用互斥锁保护 map 是最直观、最低心智负担的做法。channel 方案要额外管理一堆 goroutine 和消息路由复杂度反而更高。我自己写过两种版本的对比实测锁版本的吞吐和延迟都更好而且代码可读性强得多。Hub 的注册表 value 是 Client 结构体里面至少包含三个字段用户 ID、消息通道、退出信号。消息通道是带缓冲的 chan用于把推送消息投递给正在等待的 HTTP 请求退出信号用于通知旧连接你已经下岗了避免用户重复连接时老连接还占着位置。这个设计细节我后面在代码里展开说明。还有一个容易忽略的点Hub 必须在注册时处理相同用户 ID 重复连接的情况。移动端常见的现象是用户切后台再切回来或者页面重复打开如果不去重同一个用户会挂多个连接推送时会收到多份消息。我的做法是在注册新连接时找到旧连接并关闭它的退出信号让它自然退出保证每个用户 ID 只保留一个活跃连接。2.3 超时、心跳和消息缓冲的设计逻辑长轮询方案里最难拿捏的就是各种时间参数。我最早直接抄网上的例子设了 60 秒超时结果线上问题一堆后来才一点点调明白。服务端挂起超时我建议设在 25 到 40 秒之间。设太短客户端会频繁重建连接请求量大设太长一旦客户端异常断开比如断网没通知服务端连接要占很久而且中间网络设备Nginx、负载均衡器也会有空闲超时被动掐断时会打乱节奏。我用 30 秒作为默认值大部分场景都不用改。客户端超时要比服务端略大一般加 5 秒缓冲。也就是说服务端 30 秒超时返回客户端请求超时设置 35 秒。这样做的目的是让服务端先返回空包客户端正常处理重连逻辑如果客户端超时设得比服务端小就会出现客户端先断开造成一次无谓的重连和连接浪费。心跳机制是很多人会忽略的暗坑。长轮询请求挂起期间虽然没有数据传输但中间的网络设备不一定知道这个连接还活着。一些代理和负载均衡器会主动清理空闲连接导致请求被服务端完全无感知地断开。解决办法是让服务端在超时返回时返回一个标记为空的消息比如 type 为 heartbeat客户端收到后立即发起新请求。这个空包就是心跳它保证了整个链路一直在呼吸。消息缓冲的设计同样有讲究。Client 的消息通道我建议做成容量为 1 的缓冲 channel。为什么是 1 而不是更大因为长轮询是一次性的请求被消息触发后就会返回通道里的消息最多消费一条缓冲为 1 已经足够容纳推送方写入的消息。如果你把缓冲设成 100反而会在推送高峰时积压大量过期消息送达时效变差内存也会被无谓占用。如果要支持离线补发多条应该走持久化队列而不是靠加大 channel 缓冲。3. 代码实操一行一行把服务写出来3.1 项目初始化与最基本的 HTTP 长轮询先说一下环境我用的是 Go 1.21 以上版本开发机是 LinuxWindows/macOS 差异不大。初始化项目很简单mkdir long-polling-demo cd long-polling-demo go mod init long-polling-demo我先给一个去掉所有花哨功能的骨架帮助理解长轮询最核心的逻辑。这一步代码不关心用户管理只是让所有请求共享一个全局消息通道package main import ( encoding/json net/http time ) var messageCh make(chan string, 1) func handlePoll(w http.ResponseWriter, r *http.Request) { timer : time.NewTimer(30 * time.Second) defer timer.Stop() select { case -timer.C: // 超时返回空包 writeJSON(w, map[string]interface{}{ type: heartbeat, data: nil, }) case msg : -messageCh: // 收到推送消息立即返回 writeJSON(w, map[string]interface{}{ type: message, data: msg, }) } } func handlePush(w http.ResponseWriter, r *http.Request) { var body struct { Message string json:message } if err : json.NewDecoder(r.Body).Decode(body); err ! nil { w.WriteHeader(http.StatusBadRequest) return } messageCh - body.Message writeJSON(w, map[string]interface{}{result: ok}) } func writeJSON(w http.ResponseWriter, data interface{}) { w.Header().Set(Content-Type, application/json) json.NewEncoder(w).Encode(data) } func main() { http.HandleFunc(/poll, handlePoll) http.HandleFunc(/push, handlePush) http.ListenAndServe(:8080, nil) }这个骨架已经展示了长轮询的精华handlePoll 里一个 select 同时监听定时器和消息通道谁先满足就执行谁。打开终端跑一下go run .然后新开一个终端用 curl 模拟# 终端 A发起长轮询请求会挂住 curl -v http://localhost:8080/poll # 终端 B推送一条消息 curl -X POST http://localhost:8080/push \ -H Content-Type: application/json \ -d {message:hello long polling}可以看到终端 A 的请求在推送后立刻返回了消息。这个效果演示完你应该能直观理解长轮询的本质了。但全局通道的问题很明显所有客户端共享一个消息池A 用户的消息会被 B 用户收到这显然不行。下一步就要引入按用户区分的 Hub。3.2 连接管理器 Hub 的完整实现接下来是实现正式的连接管理器。这个 Hub 负责维护所有用户的活跃连接并提供注册、注销、按用户推送三个方法。我把完整代码贴出来每一段都有注释package main import ( sync time ) // Message 是推送消息的统一结构业务字段可以自行扩展 type Message struct { Type string json:type Payload interface{} json:payload Time int64 json:time } // Client 代表一个正在等待推送的客户端连接 type Client struct { ID string Ch chan *Message Done chan struct{} // 关闭它表示该连接已被顶替或注销 } // Hub 连接管理器 type Hub struct { mu sync.RWMutex clients map[string]*Client maxClients int } func NewHub(maxClients int) *Hub { return Hub{ clients: make(map[string]*Client), maxClients: maxClients, } } // Register 注册新连接如果该用户已有连接则关闭旧连接的 Done 信号 func (h *Hub) Register(c *Client) bool { h.mu.Lock() defer h.mu.Unlock() if len(h.clients) h.maxClients { return false } if old, ok : h.clients[c.ID]; ok { close(old.Done) } h.clients[c.ID] c return true } // Unregister 注销连接只有当前注册的是同一个 Client 才真正删除 func (h *Hub) Unregister(c *Client) { h.mu.Lock() defer h.mu.Unlock() if cur, ok : h.clients[c.ID]; ok cur c { delete(h.clients, c.ID) close(c.Done) } } // PushToUser 向指定用户推送一条消息返回是否推送成功 func (h *Hub) PushToUser(userID string, msg *Message) bool { h.mu.RLock() defer h.mu.RUnlock() c, ok : h.clients[userID] if !ok { return false } select { case c.Ch - msg: return true default: // 通道满了说明该连接消费能力不足视为推送失败 return false } } // OnlineCount 返回当前在线连接数 func (h *Hub) OnlineCount() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.clients) }这里有几个设计细节我要重点强调。第一Unregister 里做了只有当前注册的是同一个 Client 才删除的判断。为什么要这样因为同一个用户可能先连接 A、再连接 BA 还未超时退出时 B 注册进来了B 把 A 顶替掉。如果 A 到时退出时把 B 也删了就会出现用户明明在线却被判定离线。加了这个判断只有主人本人才有资格删除自己。第二Register 里关闭 old.Done 是关键一步。这个关闭动作会让旧连接正在监听的 select 立刻触发退出分支旧请求快速返回把位置让给新连接。如果你不做这一步旧连接会一直挂到 30 秒超时期间如果推送消息过来可能被旧连接抢走新连接反而等不到。第三PushToUser 里用了 select default 的非阻塞发送。这是为了避免一个慢客户端阻塞整个推送流程。如果某个用户的通道已经满了说明他这条消息还没消费掉可能是网络异常直接丢弃并返回失败让业务方决定是否走离线补发逻辑。3.3 长轮询接口和推送接口的实现Hub 写完之后HTTP 接口就变得很薄了。长轮询接口的核心逻辑是把当前请求的 context 和 Hub 关联起来注册一个 Client然后进入 select 等待四种情况package main import ( encoding/json net/http time ) func (h *Hub) handlePoll(w http.ResponseWriter, r *http.Request) { userID : r.URL.Query().Get(user_id) if userID { w.WriteHeader(http.StatusBadRequest) return } // 借助请求的 context客户端断开时会自动取消 ctx : r.Context() client : Client{ ID: userID, Ch: make(chan *Message, 1), Done: make(chan struct{}), } if !h.Register(client) { writeJSON(w, http.StatusTooManyRequests, map[string]string{ error: too many connections, }) return } // 请求结束时注销连接防止 goroutine 泄漏 defer h.Unregister(client) pollTimeout : 30 * time.Second timer : time.NewTimer(pollTimeout) defer timer.Stop() select { case -ctx.Done(): // 1. 客户端主动断开 return case -client.Done: // 2. 连接被新连接顶替 return case msg : -client.Ch: // 3. 收到推送消息 writeJSON(w, http.StatusOK, msg) case -timer.C: // 4. 超时返回心跳空包 writeJSON(w, http.StatusOK, Message{ Type: heartbeat, Time: time.Now().Unix(), }) } }这里我特别说一下 context 的作用。Go 的 net/http 天生支持请求 context客户端断开连接时r.Context()会被自动取消select 里的-ctx.Done()就会触发。这样我们不需要额外的心跳检测就能做到连接断开即释放资源这是用 Go 写长轮询最大的红利之一。推送接口也简单接收 JSON 请求体解析出目标用户 ID 和消息内容调用 Hub.PushToUserfunc (h *Hub) handlePush(w http.ResponseWriter, r *http.Request) { var req struct { UserID string json:user_id Type string json:type Data interface{} json:data } if err : json.NewDecoder(r.Body).Decode(req); err ! nil { w.WriteHeader(http.StatusBadRequest) return } if req.UserID { w.WriteHeader(http.StatusBadRequest) return } msg : Message{ Type: req.Type, Payload: req.Data, Time: time.Now().Unix(), } if h.PushToUser(req.UserID, msg) { writeJSON(w, http.StatusOK, map[string]string{result: ok}) } else { // 用户当前不在线业务方可决定是否走离线补发 writeJSON(w, http.StatusOK, map[string]string{result: offline}) } }3.4 完整的 main.go 和联调测试把所有文件整合到 main.go 里加上一个简单的首页输出和连接数监控接口package main import ( net/http ) func main() { hub : NewHub(100000) http.HandleFunc(/poll, hub.handlePoll) http.HandleFunc(/push, hub.handlePush) http.HandleFunc(/status, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ online: hub.OnlineCount(), }) }) http.ListenAndServe(:8080, nil) }跑起来之后按下面的流程完整联调一遍# 1. 用户 1001 发起长轮询 curl -v http://localhost:8080/poll?user_id1001 # 2. 另一个终端给 1001 推送 curl -X POST http://localhost:8080/push \ -H Content-Type: application/json \ -d {user_id:1001,type:order,data:{order_id:A123,status:paid}} # 3. 给不在线的用户 9999 推送 curl -X POST http://localhost:8080/push \ -H Content-Type: application/json \ -d {user_id:9999,type:order,data:{order_id:B456,status:paid}} # 4. 查看在线连接数 curl http://localhost:8080/status步骤 2 里poll 请求会立刻返回推送消息步骤 3 返回{result:offline}步骤 4 的 online 值会是 1。整个流程跑通你就拥有了一套可用的长轮询推送服务。把这套代码部署到服务器客户端配合好重连逻辑就能在浏览器、App、小程序里实现秒级感知的推送体验。3.5 客户端怎么配合前端与移动端要点服务端只是长轮询的一半客户端配合不好效果会大打折扣。前端这边我给出一个极简但完整的 JavaScript 实现let retryDelay 1000; // 初始重试延迟 1 秒 async function subscribe(userId) { try { const controller new AbortController(); const timeout setTimeout(() controller.abort(), 35000); const resp await fetch(/poll?user_id userId, { method: GET, signal: controller.signal, }); clearTimeout(timeout); if (resp.status 200) { const data await resp.json(); retryDelay 1000; // 成功收到响应重置重试延迟 if (data.type heartbeat) { // 心跳空包什么都不用做 } else { handleMessage(data); } } else { // 服务端返回错误进入退避重试 await backoffRetry(); } } catch (e) { // 网络异常或超时进入退避重试 await backoffRetry(); } finally { // 无论什么原因结束都立刻发起下一次长轮询 if (!document.hidden) { subscribe(userId); } } } function backoffRetry() { return new Promise((resolve) { setTimeout(resolve, retryDelay); retryDelay Math.min(retryDelay * 2, 15000); // 指数退避上限 15 秒 }); } function handleMessage(data) { console.log(收到推送, data); // 根据 data.type 分发处理业务逻辑 } // 页面可见时启动订阅页面隐藏时暂停节省资源 document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { subscribe(currentUserId); } }); subscribe(currentUserId);这段代码有几个关键点值得说。第一客户端超时用 AbortController 设为 35 秒比服务端 30 秒长保证请求一定由服务端的超时空包来终止而不是客户端主动断。第二重连用了指数退避策略。如果服务端故障或者网络抖动客户端会密集地重建连接退避机制能避免在故障期间把服务端打垮。每次成功收到响应后重置退避间隔保证恢复后立刻回到正常节奏。第三页面隐藏时暂停订阅。移动端用户在切后台时继续挂着长轮询既浪费资源又容易因为系统杀进程导致连接异常。用 visibilitychange 事件控制能让服务端的在线连接数更真实。移动端原生实现的套路和这个差不多只是把 fetch 换成 OkHttp 或 URLSession核心还是超时时间比服务端略长 收到响应立即重连 失败指数退避这三个原则。4. 生产环境优化从能跑到扛得住4.1 并发模型瓶颈与分片锁优化前一版的 Hub 用一把全局锁保护整个 map这在连接数几百、几千的时候完全没问题。但连接数上到几万推送频率又比较高时锁竞争会成为瓶颈。原因在于每一次 PushToUser 都要抢 RLock而 Register、Unregister 抢的是 Lock高并发下大量 goroutine 会在锁上排队。优化手段很经典分片锁。把整个 map 按用户 ID 哈希分成 N 个分片每个分片有自己独立的锁和 map。这样不同分片的操作互不干扰锁竞争被摊薄到原来的 1/N。我一般分 64 个分片在性能和内存开销之间比较平衡。实现思路如下type Shard struct { mu sync.RWMutex clients map[string]*Client } type ShardedHub struct { shards [64]*Shard } func (h *ShardedHub) getShard(userID string) *Shard { // 用 FNV 或 CRC32 哈希取模保证同一个用户固定落在一个分片 hash : fnv32(userID) return h.shards[hash%uint32(len(h.shards))] }Register、Unregister、PushToUser 三个操作都先通过 getShard 定位到分片然后在分片内部加锁。实测在我的机器上8 核 CPU、5 万连接、每秒 1 万次推送分片后的吞吐比单锁提升了接近 4 倍。如果你的连接数没到这个量级直接用单锁版本就好过早优化没必要。另外还有一个细化方向如果单个用户的推送特别频繁比如群消息场景可以考虑在 Client 内部再加一个独立的发送 goroutine避免 PushToUser 直接和 HTTP 写响应竞争。不过大多数场景用不上知道有这个方向即可。4.2 压测方法、内存与 goroutine 监控上线前一定要压测这是我用血泪换来的教训。压测工具我用的是hey和wrk但长轮询压测有个特殊点——不能只压 HTTP 接口的 QPS更关键的是压并发连接数和长链接稳定性。我的压测方式是写一个简单的 Go 客户端程序同时起 N 个 goroutine每个 goroutine 循环执行发起长轮询 → 等待超时或消息 → 立即重发。通过调整 N 模拟不同在线规模观察服务端的 goroutine 数、内存、响应延迟。同时用另一个脚本持续调用推送接口模拟业务方推送。压制过程中把 pprof 开起来看两个关键指标goroutine 数量和 heap 内存。连接数 3 万时goroutine 数应该稳定在 3 万上下内存里占大头的是每个 goroutine 的栈空间和 channel 缓冲。如果压测时发现 goroutine 数持续增长停不下来大概率是连接没正确注销这是长轮询服务最常见的性能杀手。一个值得注意的运维细节Go 的 net/http 默认会对每个连接创建一个 goroutine这个 goroutine 在长轮询期间会一直挂着。虽然单 goroutine 内存很小但架不住数量大。我一般会在部署时设置 GOMAXPROCS 为 CPU 核数并用环境变量 GODEBUG 控制一些运行时参数不过真正该关注的是代码层面有没有泄漏而不是去调这些运行时开关。4.3 水平扩展多实例与消息同步单实例的连接数是有上限的即使优化到撑住 10 万连接也架不住业务增长。水平扩展是早晚的事。长轮询服务和普通无状态服务有个差别一个用户的长轮询连接只落在某一个实例上业务方推送时怎么找到那个实例这就是架构设计里预留消息同步层的意义。我的做法是引入 Redis Pub/Sub。每个实例启动时订阅一个固定频道业务方调用推送接口后推送请求先发到这个频道的消息里所有实例都会收到。每个实例拿到消息后检查目标用户 ID 是否在本实例的 Hub 里在就推送不在就直接忽略。这样实例之间完全解耦加机器就能扩容。// 伪代码示意推送时先发 Redis再检查本地 func (s *Service) Push(userID string, msg *Message) { payload, _ : json.Marshal(map[string]interface{}{ user_id: userID, message: msg, }) s.redis.Publish(push:events, payload) // 本地也检查一次避免 Redis 订阅回包带来的延迟 s.hub.PushToUser(userID, msg) }这里有个优化点如果直接发 Redis 再让订阅逻辑推送会比本地直推多一步网络开销。所以我会先尝试本地推送如果发现用户不在本地再用 Redis 广播让其他实例尝试。这样大部分请求都能在本地实例直接命中只有跨实例的才走 Redis。负载均衡配置也要注意。长轮询请求会长时间占用后端连接Nginx 或云负载均衡器的空闲超时时间必须大于服务端超时时间否则请求会被网关提前掐断。我一般在 Nginx 里设置proxy_read_timeout 60s、proxy_send_timeout 60s同时保持长轮询服务端超时在 30 秒给网关留足余量。健康检查的路径也不要指向长轮询接口本身否则健康检查请求会一直挂住应该单独提供一个轻量的/healthz接口。5. 踩坑实录常见问题与排查技巧5.1 高频问题速查表长轮询系统上线后遇到的问题绝大多数是下面几类。我把它们整理成一张速查表遇到问题对号入座现象可能原因解决办法客户端收不到推送中间代理缓存了空响应响应头加 Cache-Control: no-cache, no-store服务端 30 秒超时客户端 60 秒才返回Nginx 的 proxy_read_timeout 先到掐断了连接调大 Nginx 超时或调小服务端超时goroutine 数量持续上涨连接没注销或客户端重复创建连接检查 defer Unregister做 user_id 去重推送消息被抢到旧连接上同一用户重复连接旧连接未及时退出Register 时关闭旧连接 Done 信号消息重复收到客户端超时重连后服务端又补推了同一条消息带唯一 ID客户端按 ID 去重高峰期推送延迟变高锁竞争严重或 channel 积压分片锁、确认 channel 容量是否设置合理客户端在弱网下频繁断连网关空闲超时或请求被劫持启用心跳空包、客户端加指数退避每个问题的排查思路都不太一样但有一个统一的切入点先把服务端的访问日志和 pprof 拉出来看明确问题是出在连接管理还是消息投递。连接管理的问题看 goroutine 数和在线数消息投递的问题看推送接口的耗时和返回码。别瞎猜先看数据。5.2 一次线上连接数暴涨的复盘讲一个我真实遇到过的线上事故。某个早晨业务方反馈推送大面积延迟我登录服务器一看在线连接数从平时的 3 万暴涨到了 20 万内存占用翻了 4 倍。第一反应是服务被刷了但一看 QPS 并没有异常那就不是攻击。用 pprof 抓 goroutine 栈发现大量 goroutine 阻塞在-ctx.Done()上也就是长轮询请求的等待分支。进一步排查发现罪魁祸首是客户端在一个场景下没有遵守收到响应才重连的约定——移动端在切后台再切回来时会同时发起多个并发的轮询请求而服务端当时没有做重复连接去重每个请求都注册成了独立连接旧连接还在等超时新连接又进来了数量自然爆炸。复盘下来有三个教训。第一客户端必须有全局的只有一个活跃轮询请求的控制逻辑不能简单地在回调里重连。第二服务端即使客户端写错了也要能兜底同一用户 ID 的新连接注册时必须主动顶掉旧连接这就是我在 3.2 节里强调的 Register 关闭旧 Done 的原因。第三要给 Hub 加最大连接数保护超过阈值直接拒绝新连接避免服务被拖垮。修复后我把这三条都落了地客户端加了互斥控制服务端加了重复连接顶替机制Hub 加了 maxClients 限制。至今没有再出现类似事故。5.3 几个值得养成的编码习惯长轮询服务平时看起来风平浪静出问题就是大问题。根据我的经验有几个习惯一定要养成。第一个习惯是给所有长轮询接口设置明确的响应头。至少在代码里加上这几行w.Header().Set(Cache-Control, no-cache, no-store) w.Header().Set(Connection, keep-alive) w.Header().Set(X-Accel-Buffering, no)Cache-Control防止代理缓存空响应X-Accel-Buffering: no是告诉 Nginx 不要缓冲这个响应。这两个头能解决 80% 的推送不到问题。第二个习惯是推送消息必须带 ID 和时间戳。ID 用来做客户端去重时间戳用来判断消息是否过期。有些业务场景下推送的消息是强时效的比如秒杀状态客户端拿到已经过期的消息可以直接丢弃不必再展示。没有这两个字段后面做任何补偿机制都会很痛苦。第三个习惯是记录关键的日志。长轮询服务平时没有日志很容易但出问题时没日志等于盲人摸象。我至少会记录三类日志连接注册注销、推送成功失败、超时心跳数量。日志不用太详细但要有量化数据。比如本轮心跳包占所有响应的比例这个指标能很直观地反映系统健康度——如果心跳占比长期超过 95%说明消息量不大系统在空转如果突然降到 50% 以下说明推送量上来了要关注负载变化。第四个习惯是做好优雅退出。服务发布时会有大量连接被强制断开客户端全部同时重连可能造成惊群效应。我的做法是在进程接收到 SIGTERM 信号后先让 HTTP 服务停止接收新请求等待正在处理的长轮询请求自然结束最多等几秒再退出进程。配合客户端的指数退避发布期间几乎无感知。这些经验不是从文档里抄来的都是在一次次告警和复盘里积累的。长轮询方案本身不复杂复杂的是在各种真实网络环境下把它做稳。希望这篇文章能把我的经验完整传递给你让你少走几步弯路。
返回列表