news 2026/9/14 14:11:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Golang的长轮询推送方案:架构设计与生产实践

做后端开发的同行应该都遇到过这类需求:业务方过来说"页面上的状态要实时刷新",或者"订单有变化要第一时间告诉用户"。最开始能想到的就是客户端定时来拉,简单是简单,但浪费显而易见。后来我接手过一个通知推送项目,目标用户集中在弱网环境,还有不少存量系统只支持标准 HTTP,WebSocket 连不上,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 的断线重连、心跳保活、粘包处理都要自己写,复杂度比很多人想象的高。

SSE(Server-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 或客户端 ID,value 是当前正在等待推送的连接上下文。业务方调用推送接口时,服务端根据目标用户 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 以上版本,开发机是 Linux,Windows/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、再连接 B;A 还未超时退出时 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.PushToUser:

func (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_id=1001" # 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 监控

上线前一定要压测,这是我用血泪换来的教训。压测工具我用的是heywrk,但长轮询压测有个特殊点——不能只压 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 60sproxy_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 服务停止接收新请求,等待正在处理的长轮询请求自然结束(最多等几秒),再退出进程。配合客户端的指数退避,发布期间几乎无感知。

这些经验不是从文档里抄来的,都是在一次次告警和复盘里积累的。长轮询方案本身不复杂,复杂的是在各种真实网络环境下把它做稳。希望这篇文章能把我的经验完整传递给你,让你少走几步弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 14:10:45

SSM健身房系统:毕业设计中的MyBatis与Spring事务实战

简介&#xff1a;这是一套面向计算机专业本科生的Java毕业设计实战项目&#xff0c;基于SSM&#xff08;SpringSpringMVCMyBatis&#xff09;框架开发的健身房管理系统&#xff0c;适用于毕设选题、课程设计及Java Web全栈能力训练。系统覆盖管理员、教练、会员、访客四类角色&…

作者头像 李华
网站建设 2026/9/14 14:10:00

Lithe-IDEA:专为Java/Spring Boot设计的轻量级开源IDE

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 14:09:53

Claude Code命令行参数详解与高效开发技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 14:09:00

国内动环监控系统厂家排名2026

机房动环项目空调控制器分类、主流产品优势及行业发展趋势结合机房动环项目的现场实施场景&#xff0c;现阶段空调智能控制设备主要分为两大类。其一为精密空调原厂主控设备&#xff0c;专门配套大型IDC数据中心、A级高标准机房的专业精密空调使用&#xff0c;产品性能与适配能…

作者头像 李华
网站建设 2026/9/14 14:08:53

DirectX 12资源上传与读回:Upload/Default/Readback三类Heap实战指南

1. 这不是“拷贝粘贴”&#xff0c;而是 GPU 内存世界的通关地图如果你刚打开 Visual Studio&#xff0c;新建一个 DirectX 12 项目&#xff0c;敲下第一行ID3D12Device::CreateCommittedResource&#xff0c;然后发现纹理没显示、顶点数据全是乱码、或者Map()返回E_INVALIDARG…

作者头像 李华