做长连接业务的工程师,绕不开 WebSocket 这个话题。它把 HTTP 的"一问一答"改成了"一条常驻的双向管道",聊天、协同文档、行情推送、设备指令下发、大屏实时刷新,几乎都是这套东西在撑着。而在服务端语言里,Go 实现 WebSocket 服务以及代理这套组合,是我近两年用得最顺手的方案:goroutine 的调度开销足够低,一个连接两个协程的模型写起来直白,标准库的 net/http 又能直接把升级后的连接交出来。但真正让项目卡住的往往不是业务逻辑,而是链路中间那一层——客户端连的是域名,域名后面挂着接入层,接入层再往后才是真正处理消息的进程。这个升级请求要完整穿过去,任何一个头被改写、任何一次缓冲没刷新,表现出来的症状都是"握手 400"或者"101 之后三秒断开",日志里干干净净,排查起来极其折磨人。这篇就按我实际搭建的顺序,从协议握手的字节级细节讲到服务端的读写模型、心跳参数怎么算,再讲代理层怎么把升级请求原样转出去、四层和七层分别该怎么做,最后把线上踩过的坑整理成一份速查表。
1. 整体方案设计与技术选型思路
动手写代码之前,值得先把链路上每一环的职责划清楚。WebSocket 服务端管的是"连接的生命周期"和"消息的路由",代理层管的是"接入、分发和横切能力",这两件事混在一个进程里做,代码会迅速烂掉。我见过不少项目把鉴权、限流、协议转换全塞进业务 handler 里,结果是压测一上来,业务逻辑和连接管理互相拖累,连哪里慢都定位不出来。
1.1 协议本质:借 HTTP 的门,办 TCP 的事
WebSocket 最有意思的一点是它的"入口"和"本体"完全不同。入口是一次标准得不能再标准的 HTTP/1.1 请求,只不过带了几个特殊头部,服务端回一个 101 状态码,从这一刻起,这条 TCP 连接就不再遵循 HTTP 的请求-响应模型了,它变成了一条全双工的字节流,双方可以随时往对方发数据。
这个设计的好处很实在:它天生能复用 80/443 端口,能穿过大部分现有的网络设施,能沿用 HTTP 的鉴权体系(Cookie、Authorization 头都认)。坏处同样明显:链路上所有的中间设备——负载均衡、网关、CDN、企业出口的审计设备——都是按 HTTP 的思维写的,它们默认"一问一答、连接短命",遇到升级成常驻连接的情况,各种超时、缓冲、连接回收就开始作妖。
所以我在设计阶段会先问自己一个问题:这条链路上到底有几层会碰到这个 Upgrade 请求?每层是四层转发还是七层解析?如果某一层是七层的,它有没有把 Connection、Upgrade 这两个头原样保留?这个问题的答案决定了后面要写多少兼容代码。
1.2 服务端库的选型:三个方案的实际取舍
Go 生态里能做 WebSocket 服务端的库不少,我主要的三个选择和一个不建议的选择,用一张表说清楚更省事。
| 方案 | 特点 | 适合场景 | 我个人的顾虑 |
|---|---|---|---|
| gorilla/websocket | API 最直白,Upgrade、ReadMessage、WriteMessage三步走,社区示例最多 | 业务型长连接,团队里新人多,需要快速上手 | 内存分配比较豪放,每条消息都会新申请切片 |
| coder/websocket(原 nhooyr.io/websocket) | 全 context 驱动,取消语义清晰,API 现代 | 需要精细控制超时和取消的微服务 | 习惯 gorilla 写法的人上手要转一下弯 |
| gobwas/ws | 底层帧操作,零分配设计,性能天花板高 | 单机百万连接、消息体小而密集的推送场景 | 要自己处理帧的分片、掩码,代码量大 |
| 纯标准库手写 | 只用net/http的Hijack,自己实现握手和帧解析 | 学习协议原理,或者有极特殊的定制需求 | 生产环境不建议,帧解析的边界条件太多 |
我的默认选择是第一个。理由不是性能,而是可维护性——一年后接手这个项目的人,八成能在十分钟内看懂代码在干什么。如果业务量真的涨到单机连接数成为瓶颈,再考虑换成零分配的方案,那时候你应该已经有足够的监控数据支撑这个决策了,而不是一上来就过度设计。
1.3 代理层的定位:四层还是七层,这是个分水岭
代理层的位置决定了它能看见什么。四层代理(TCP 转发)只认 IP 和端口,它根本不知道穿过去的是 WebSocket 还是普通 HTTP,好处是性能高、协议无关,坏处是做不了按路径路由、做不了鉴权、做不了灰度。七层代理能完整解析 HTTP 头,看得见 Upgrade,可以做各种智能决策,代价是要处理升级后的连接交接,稍微写错就断。
我一般的做法是分层:最外层用四层做流量入口和初步的负载分摊,内层再用七层代理做业务路由和横切逻辑。这样即使七层代理重启,四层那一层还能撑着,不至于全站掉线。
| 对比项 | 四层代理 | 七层代理 |
|---|---|---|
| 可见信息 | IP、端口、字节数 | 完整的 HTTP 头、路径、Cookie |
| 路由粒度 | 按连接 | 按路径、按 Header、按查询参数 |
| 鉴权能力 | 基本没有 | 可以在升级前拦截 |
| 连接开销 | 低,纯字节搬运 | 中等,要解析请求 |
| 实现难度 | 一个io.Copy就够 | 要正确处理Hijack和缓冲 |
| 故障影响面 | 重启会断所有连接 | 同样会断,但可以做优雅排空 |
这张表里最后一行是我特别在意的。四层代理重启,所有长连接瞬间全断,客户端要集体重连,很容易形成惊群;七层代理至少可以在重启前给所有连接发 Close 帧,让客户端有心理准备、错峰重连。
2. 握手这一关:从 HTTP 请求到双向连接的完整细节
握手是整个链路里最脆弱的两百毫秒。它失败了,后面所有逻辑都不会被执行;它成功了但某个头被中间层改写,连接会在几百毫秒后诡异地断掉,日志里只有一句 "unexpected EOF"。所以我习惯把这段的每个字节都搞明白。
2.1 一次升级请求的字节级拆解
客户端发出来的原始请求大概长这样:
GET /ws?token=abc123&room=42 HTTP/1.1 Host: ws.internal Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://app.internal关键在于三个头:Upgrade: websocket声明意图,Connection: Upgrade告诉中间的代理这条连接要变性质了,Sec-WebSocket-Key是一个 16 字节随机数的 base64,用来防止缓存污染和历史代理的误判。服务端如果同意升级,回一个 101:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Sec-WebSocket-Accept的算法是固定的一步哈希:把客户端给的 Key 拼上一个固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1,再 base64。这个魔数常量是协议规范里写死的,任何实现都不能改。我把验证逻辑写出来,方便在自研代理里做校验:
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" func computeAccept(key string) string { h := sha1.New() io.WriteString(h, key+wsGUID) return base64.StdEncoding.EncodeToString(h.Sum(nil)) }为什么要强调这个细节?因为一旦你在代理层自己拼响应,这个值算错了,浏览器会直接报 "A server must not mask any frames",而且错误信息跟真正的原因八竿子打不着,很多人会往帧掩码的方向查半天。实际上九成的情况是 Accept 值不对。
还有一个容易被忽略的点:Connection头可能是Upgrade, keep-alive这种复合值,也可能大小写不一致。做判断的时候要用大小写不敏感的包含匹配,不能直接字符串相等。
2.2 鉴权放在升级之前,不是之后
这是我在项目里踩过的一个实实在在的坑。最初我把 token 校验放在连接建立后的第一条消息里做,逻辑上没问题,但暴露了两个毛病:第一,攻击者可以建立大量无效连接白白消耗文件描述符;第二,客户端拿到的错误是"连上了但立刻被踢",前端要做一套额外的错误提示逻辑。
正确的做法是在 handler 的最开头就校验,失败直接返回 401,此时连接还是普通 HTTP,响应能正常写出去:
func wsHandler(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") uid, err := verifyToken(token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { // Upgrade 内部已经把错误响应写出去了,这里只需要记日志 log.Printf("upgrade failed from %s: %v", r.RemoteAddr, err) return } serve(conn, uid) }这段代码里有个约定值得强调:Upgrade方法在返回 error 的时候,内部已经往 ResponseWriter 写过东西了,所以千万不要再补一个http.Error,否则会触发 "superfluous response.WriteHeader call" 的告警,日志里一片红。
Origin头的校验同理,放在升级前做,做成白名单。这里要提醒一句,CheckOrigin默认行为是"如果 Origin 存在就必须和 Host 同源",很多人在内部服务间调用时会被这个默认规则拦下来,然后图省事直接return true,这就等于把 CSRF 防线拆了。我的做法是维护一个允许的域名列表,从这个列表里查,不匹配就拒绝。
2.3 Hijack 之后,连接就归你管了
http.Server处理请求的时候,底层其实是一条 TCP 连接加一个bufio.Reader。调用Upgrade(或者直接调用Hijack)之后,这条连接的所有权就转移到了调用方,标准库不再负责它的读写、超时和关闭。这带来两个后果。
第一,你必须自己设置所有 deadline。标准库的ReadTimeout、WriteTimeout对升级后的连接完全无效,如果你不主动SetReadDeadline,一个半死不活的客户端能让你的协程永远阻塞在那里,内存泄漏就是这么来的。
第二,Hijack返回的bufio.ReadWriter里可能已经有客户端提前发过来的数据。这事儿在真实场景里很常见——浏览器拿到 101 之后会立刻开始发消息,而你还没来得及进入读循环。如果你直接对原始net.Conn调ReadMessage,那部分缓冲在bufio.Reader里的字节就被彻底丢了,症状是"第一条消息收不到"。
我的处理方式是:要么用库的Upgrade(它内部已经处理好了缓冲问题),要么在手写代理的时候,从Hijack返回的bufio.Reader里读,而不是从net.Conn里读。
conn, bufrw, err := hijacker.Hijack() if err != nil { return } // 注意:这里要从 bufrw 读,不能直接从 conn 读 // 因为客户端可能在 101 响应的同一个 TCP 段里就把第一帧数据发过来了这个坑我印象极深。当时的现象是:自研压测工具测一切正常,浏览器一连就丢第一条消息。区别就在于压测工具是"收到 101 再发消息",浏览器是"乐观发送",数据提前到了缓冲区里。
3. 服务端核心:读写分离、心跳和连接管理
握手成功之后,真正的活才开始。这一段我按"连接怎么管""心跳怎么算""消息怎么发"三块来讲,最后补一块很多人会漏掉的优雅关闭。
3.1 一连接两协程,是这个模型最舒服的地方
WebSocket 连接是双向的,但底层那条 TCP 连接的读写操作不是并发安全的——尤其是写。如果你在多个协程里同时对同一个连接调WriteMessage,帧会交错,对端直接解析失败断开。所以标准的做法是给每个连接分配一个读协程、一个写协程,所有想发消息的地方都往一个 channel 里塞,由写协程统一出队。
type Client struct { conn *websocket.Conn send chan []byte uid int64 closeOnce sync.Once onClose func(*Client) } // 读协程:负责收消息、处理控制帧、检测断开 func (c *Client) readLoop() { defer c.close() c.conn.SetReadLimit(64 << 10) // 单条消息上限 64KB,防止内存被打爆 c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { return c.conn.SetReadDeadline(time.Now().Add(pongWait)) }) for { _, payload, err := c.conn.ReadMessage() if err != nil { return } dispatch(c, payload) } } // 写协程:所有出站数据的唯一出口 func (c *Client) writeLoop() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.close() }() for { select { case payload, ok := <-c.send: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { c.conn.WriteMessage(websocket.CloseMessage, nil) return } if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { return } case <-ticker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }socket.Close用一个sync.Once包一层,保证不管是读协程先退出还是写协程先退出,关闭逻辑只执行一次,连接从 hub 里只被摘一次。这个细节不做,hub 里的计数会越跑越偏,最后监控上的"在线连接数"完全不可信。
san这个 channel 一定要有缓冲,而且要设上限。没有缓冲,广播的时候一个慢客户端就能把整个广播协程卡住;不设上限,一个死连接能把内存吃到爆。我用的是 128 到 256 的缓冲,满了就直接判定这个客户端跟不上,主动断开它。宁可断一个慢的,不能让整个房间等着。
3.2 心跳参数是怎么算出来的:一份可以抄的推导
心跳这件事,参数拍脑袋定和算出来定,线上的表现差很远。我把我的推导过程写出来。
第一步,确定链路上的"空闲回收时间"。家用路由器的 NAT 表项一般在 60 秒到 120 秒空闲后回收,企业级防火墙有的设到 300 秒,云上的负载均衡通常有个缺省的 idle timeout,常见值是 60 秒或 300 秒。你要取这条链路上所有设备的最小值,假设最严格的一台是 60 秒。
第二步,心跳间隔必须明显小于这个最小值。我一般取三分之一到二分之一,也就是 30 秒。留出这么多余量是因为网络有抖动,偶尔丢一个包不能就直接判定死亡。
第三步,读超时设成两个心跳周期加上一点余量。以 30 秒心跳为例,读超时设 75 秒。这个 2.5 倍的系数是我反复试出来的:2 倍太紧,一次网络抖动加上一次 GC 停顿就可能误杀;3 倍太松,真断了要等 90 秒才能发现,用户体验很差。
第四步,写超时单独设,一般 10 秒。写超时和心跳无关,它防的是"对端接收窗口满了,写操作一直阻塞"的情况。写不进去 10 秒,这条连接基本可以判死刑了。
| 参数 | 取值 | 推导依据 |
|---|---|---|
pingPeriod | 30s | 取链路最小空闲回收时间(60s)的一半 |
pongWait | 75s | 2.5 倍心跳间隔,容忍一次丢包加抖动 |
writeWait | 10s | 独立设置,防止写阻塞无限堆积 |
| 单条消息上限 | 64KB | 业务上最大的那条 JSON 大概 8KB,留 8 倍余量 |
| 发送队列长度 | 256 | 按峰值 20 条/秒、容忍 12 秒积压估算 |
最后一行值得展开说。发送队列的长度不是越大越好。排队意味着延迟,一个消息在队列里躺 12 秒才发出去,对聊天场景来说已经是废消息了。所以这个数字的意图不是"尽量不丢",而是"给正常抖动留够空间,超过就断"。想明白这一点,很多关于"要不要给队列加无限缓冲"的纠结就自然解开了。
3.3 消息分发:别让一把大锁拖垮整个服务
连接管理我用了两个 map:一个按用户 ID 索引,一个按房间索引。所有连接共享一把sync.RWMutex。单机一万连接以内,这个方案完全够用,锁竞争在压测里几乎看不出来。
type Hub struct { mu sync.RWMutex clients map[int64]*Client rooms map[string]map[int64]*Client } func (h *Hub) broadcast(room string, payload []byte) { h.mu.RLock() members := h.rooms[room] // 复制一份,避免持有读锁期间做发送 targets := make([]*Client, 0, len(members)) for _, c := range members { targets = append(targets, c) } h.mu.RUnlock() for _, c := range targets { select { case c.send <- payload: default: // 队列满,标记这个客户端为慢客户端 c.close() } } }这里有两个设计决定值得说清楚。一是在读锁里只做复制,不做实际发送,因为发送可能阻塞(虽然用了 select 的 default 分支理论上不阻塞,但把耗时操作挪出临界区是好习惯)。二是慢客户端的处理策略是直接关,不是丢弃单条消息——丢弃单条会让客户端状态不一致,它以为自己收到的是完整序列,实际上中间缺了一条,比直接断线更难排查。
房间数超过几千、单房间人数超过一万的时候,sync.RWMutex会开始成为热点。这时候可以按房间 ID 做哈希分片,每个分片一把锁,把竞争分散开。但这个优化一定要有数据支撑再做,我见过连接数才两千就上分片锁的项目,代码复杂度翻了三倍,性能提升在噪声范围内。
3.4 优雅关闭:滚动发布时最容易被忽略的一环
服务要发版,进程收到退出信号。如果你直接os.Exit,所有长连接瞬间断掉,几万个客户端同一秒集体重连,接入层会被打得抬不起头。我处理这套流程的顺序是这样的。
先停止接受新连接——把http.Server的Shutdown调起来,它会关闭监听套接字。然后给所有存量连接发一个 WebSocket Close 帧(状态码 1001,表示"服务端要走了"),客户端收到这个码会知道是正常下线,可以按自己的节奏重连,比如加一个 0 到 30 秒的随机退避。发完之后等一个排空窗口,我一般给 10 到 30 秒,让还在处理中的消息发完,然后强制关掉剩下的连接。
func (h *Hub) shutdown(ctx context.Context) { h.mu.Lock() for _, c := range h.clients { select { case c.send <- websocket.FormatCloseMessage(websocket.CloseGoingAway, "server restart"): default: } } h.mu.Unlock() // 等待排空,或者到期强制退出 select { case <-time.After(15 * time.Second): case <-ctx.Done(): } h.mu.Lock() for _, c := range h.clients { c.conn.Close() } h.mu.Unlock() }客户端那一侧也要配合。我要求前端在收到 1001 之后不要立刻重连,而是等 5 到 30 秒的随机时间,同时重连次数做指数退避,封顶 60 秒。这一条写在接口文档里,比什么都管用——不然服务端这边排空做得再漂亮,客户端一秒内全部回来,等于白做。
4. 代理层:把升级请求完整地转过去
现在到最麻烦的部分。服务端能跑,不代表客户端能连上,中间那一层才是真正决定成败的地方。
4.1 标准库的反向代理能不能直接用
先说结论:能。Go 从 1.12 开始,net/http/httputil里的ReverseProxy已经支持处理 101 响应了。它内部走的是handleUpgradeResponse,把客户端和后端之间的连接接管过来做双向拷贝。这意味着对于绝大多数的标准 WebSocket 场景,你不需要手写隧道。
但能用不等于随便用,有几个参数必须调对,否则会出现"握手成功但数据不通"或者"连接几分钟后莫名断开"。
func newWSProxy(target string) (*httputil.ReverseProxy, error) { u, err := url.Parse(target) if err != nil { return nil, err } rp := httputil.NewSingleHostReverseProxy(u) rp.Transport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 3 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, MaxIdleConns: 256, MaxIdleConnsPerHost: 256, IdleConnTimeout: 90 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } rp.FlushInterval = -1 rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("proxy error %s %s: %v", r.Method, r.URL.Path, err) w.WriteHeader(http.StatusBadGateway) } base := rp.Director rp.Director = func(r *http.Request) { base(r) r.Header.Set("X-Forwarded-Proto", "https") if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { r.Header.Set("X-Real-IP", ip) } } return rp, nil }逐条解释为什么要这么写。ResponseHeaderTimeout设 10 秒,是因为这个超时在握手阶段就已经生效了,它管的是"从发出请求到收到响应头"这段时间。如果设成 1 秒,后端稍微慢一点握手就会失败,报的是net/http: timeout awaiting response headers,看起来跟 WebSocket 一点关系都没有。MaxIdleConnsPerHost设成和总连接数同量级,是因为默认值 2 在高并发下会导致连接频繁新建又关闭,握手阶段的耗时直接翻倍。
The Director这一层是唯一适合做请求改写的钩子。要注意的是,如果你在 Go 1.20 以上使用,官方推荐NewSingleHostReverseProxy配合Rewrite,两者的区别是Rewrite会先清空一些由客户端带来的、可能造成头注入的头(比如X-Forwarded-For),安全性更好。旧代码里用Director的,迁移的时候要留意这一点。
还有一类问题特别隐蔽:如果你在代理层做了 gzip 压缩或者响应体改写,101 之后的连接是原始字节流,那些中间件的 Write 方法会被绕过,压缩器可能自己就崩了。所以我在代理层的中间件链里会明确判断r.Header.Get("Upgrade") != "",升级请求直接短路,不经过任何会缓冲响应体的逻辑。
4.2 手写七层隧道:什么情况下值得自己写
标准反向代理解决不了的情况主要有三类。第一,你要在代理层做协议感知的路由,比如根据连接建立后的第一条消息里的房间 ID 决定转发到哪台后端,这时候必须在升级前后都能读写字节流。第二,你需要对消息内容做审计或改写。第三,你需要实现"后端不可达时降级到备用后端",而这个切换必须在握手阶段完成,标准库的ErrorHandler拿不到足够的信息。
这几种情况下,我就自己写。核心逻辑其实不复杂,四步:校验、拨号、转发请求头、双向拷贝。
func tunnelHandler(backend string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { http.Error(w, "upgrade required", http.StatusUpgradeRequired) return } if !authorize(r) { http.Error(w, "forbidden", http.StatusForbidden) return } hj, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijack unsupported", http.StatusInternalServerError) return } dialer := &net.Dialer{Timeout: 3 * time.Second} upstream, err := dialer.DialContext(r.Context(), "tcp", backend) if err != nil { http.Error(w, "upstream unreachable", http.StatusBadGateway) return } client, bufrw, err := hj.Hijack() if err != nil { upstream.Close() return } if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { r.Header.Set("X-Real-IP", ip) } if err := r.Write(upstream); err != nil { client.Close() upstream.Close() return } done := make(chan struct{}, 2) go func() { // 注意这里拷贝的是 bufrw,不是 client // 客户端可能在 101 之前就把第一帧数据发过来了 io.Copy(upstream, bufrw) if tc, ok := upstream.(*net.TCPConn); ok { tc.CloseWrite() } done <- struct{}{} }() go func() { io.Copy(client, upstream) if tc, ok := client.(*net.TCPConn); ok { tc.CloseWrite() } done <- struct{}{} }() <-done <-done client.Close() upstream.Close() } }几个细节必须敲黑板。r.Write(upstream)这一句是把客户端的请求原样序列化后写给后端,它会自动带上Upgrade、Connection、Sec-WebSocket-Key这些头,不需要你手动拼。但前提是这些头还在r.Header里——有些前置的中间件会"清理 hop-by-hop 头",把Connection和Upgrade当成逐跳头删掉,那样转发出去的就是一个普通 GET 请求,后端的Upgrade直接返回 400。排查这种问题时,在代理的日志里打一行r.Header就清楚了。
第二个细节是那两个CloseWrite。TCP 连接支持半关闭,意思是"我这边的数据发完了,但我还在等你发"。少了这一步,一边先结束时,另一边会一直阻塞在读上,两个协程都退不出来,连接就泄漏了。这不是理论风险,我的压测里就抓到过——单纯用io.Copy双向拷贝,连接数只涨不跌。
第三个细节是超时控制。上面这段代码里我没给io.Copy设 deadline,因为长连接本来就该一直开着。但你得在代理层单独维护一个"空闲检测",否则客户端拔网线走人,代理这边的协程会挂很久。我的做法是在后端心跳的基础上,代理层不额外加检测,靠后端的pongWait自然超时,后端断开会触发代理这边的io.Copy返回。
4.3 四层转发:简单,但有它的代价
四层转发就是纯 TCP 搬运,不解析任何内容。代码短得可以贴完:
func l4Forward(listen, target string) error { ln, err := net.Listen("tcp", listen) if err != nil { return err } for { conn, err := ln.Accept() if err != nil { return err } go func(c net.Conn) { defer c.Close() up, err := net.DialTimeout("tcp", target, 3*time.Second) if err != nil { return } defer up.Close() go func() { io.Copy(up, c) up.(*net.TCPConn).CloseWrite() }() io.Copy(c, up) c.(*net.TCPConn).CloseWrite() }(conn) } }这段代码很朴素,但它有一个七层代理做不到的优势:对协议完全透明。WebSocket 之后如果还要跑别的协议(比如自定义的二进制协议直接跑在 TCP 上),它一样能转。
代价就是前面说的:看不见路径、做不了鉴权。还有一个容易被忽略的点——四层转发拿不到客户端的真实 IP,除非你用 PROXY protocol 或者透明代理。这对风控和安全审计来说是个硬伤。我的折衷方案是:四层只做入口,后面紧跟一个七层,七层做真实 IP 的注入和鉴权,业务侧只信任七层注入的X-Real-IP。
4.4 代理层该承担哪些横切能力
业务代码里不该出现的东西,我都尽量往代理层挪。列举几个我认为收益最高的。
连接数限流。按 IP、按用户、按全局三个维度做上限。全局上限用原子计数器,超过就直接拒绝升级请求。按用户的限流要注意:一个用户可能开多个标签页,上限设成 5 到 10 比较合理,设成 1 会让用户体验很糟。按 IP 的限流要小心 NAT 场景,公司出口一个 IP 后面可能是几百人。
握手耗时监控。这是我最看重的一个指标。在代理层记录"收到请求"到"收到后端 101"之间的耗时,打成分位数。P99 突然从 20 毫秒涨到 800 毫秒,说明后端要么在 GC,要么在等数据库,这时候去看业务日志往往是没用的,因为瓶颈在连接建立阶段。
连接生命周期打点。记录每条连接的建立时间、关闭时间、关闭原因(正常、心跳超时、被踢、后端异常)。这四个原因的分布能直接告诉你系统的健康状况。如果"心跳超时"占比突然升高,八成是链路中间有设备改了 idle timeout,或者某台后端的调度出了问题。
灰度路由。在代理层按用户 ID 的哈希决定转发到新版后端还是旧版,比例从 5% 慢慢放开。WebSocket 的灰度比 HTTP 麻烦,因为连接是有状态的,一旦建立就不能随意迁移,但至少新连接可以按比例分流。
func pickBackend(uid int64, stable, canary []string, ratio int) string { if ratio <= 0 { return stable[uid%int64(len(stable))] } if int(uid%100) < ratio { return canary[uid%int64(len(canary))] } return stable[uid%int64(len(stable))] }用取模而不是随机数,是为了保证同一个用户每次重连都落到同一批后端上,避免反复横跳。这个细节在灰度期间特别有用——用户反馈问题时,你能快速确定他到底在哪一版上。
5. 线上问题排查实录
这一节我把近两年遇到过的真实问题整理出来,按症状归类。不敢说覆盖全部,但至少能让你在半夜告警的时候少查半小时。
5.1 故障速查表
| 症状 | 最可能的原因 | 快速验证方式 | 处理办法 |
|---|---|---|---|
| 握手返回 400 | Sec-WebSocket-Key或Upgrade头被中间层改写 | 在代理层打印完整请求头 | 放行 hop-by-hop 头,别让中间件删 |
| 握手返回 403 | Origin不在白名单 | 看请求的 Origin 值 | 补白名单,别图省事直接放行所有 |
| 返回 101 但立刻断开 | 代理层缓冲了 101 响应没刷新 | curl -i看响应是否带完整头 | FlushInterval = -1 |
| 连接稳定但三分钟后必断 | 链路中某设备 idle timeout | 抓包看是谁发的 FIN | 缩短心跳间隔到 30 秒以内 |
| 第一条消息丢失 | 从net.Conn读而不是从bufio.Reader读 | 压测工具正常、浏览器异常 | 改从 hijack 返回的 reader 读 |
| 消息偶发错乱或解析失败 | 多协程并发写同一个连接 | 加日志看写入时间戳 | 改成单写协程 + channel |
| 连接数涨到几千后频繁超时 | 文件描述符不够或 TIME_WAIT 堆积 | ulimit -n、ss -s | 调大限制,开启端口复用 |
| 内存持续上涨不回落 | 发送队列无上限,慢客户端堆积 | 看 GC 前后堆大小 | 队列设上限,满了主动断开 |
| 广播延迟突然变大 | 广播时持有锁做发送 | pprof 看锁等待 | 锁内只复制,锁外发送 |
| 发版后大面积重连风暴 | 没有优雅关闭 | 看关闭时是否发 1001 | 加排空逻辑 + 客户端退避 |
这张表里我最想强调的是第三行和第五行,因为它们最难自己想到。第三个是"101 之后立刻断开",这个问题在自研代理上出现频率极高,本质原因是代理把后端的响应头读进缓冲区之后,没有立刻写给客户端,而客户端等不到 101 就会超时或者直接放弃。标准库的ReverseProxy已经处理了,但如果你在它前面套了一层自定义的 ResponseWriter 包装(比如为了记录响应大小),那个包装可能把Flush方法吃掉了,导致底下的刷新失效。判断方法很简单:在包装类型里实现http.Flusher接口,转发给内层。
第五个是"第一条消息丢失",前面提过一次,这里再说一遍是因为它太典型了。任何自己接管连接的地方——不管是服务端 handler 还是自研代理——都必须意识到,Hijack返回的缓冲区里可能有数据。
5.2 压测和容量估算:数字要算,不能拍
压测 WebSocket 和压测 HTTP 完全是两码事。HTTP 压测看 QPS,WebSocket 压测要看三个东西:并发连接数、消息吞吐量、连接存活时长。
连接数这块,先算文件描述符。每条连接占一个 fd,服务端占一个,代理层占一个,如果中间还有一层,那就再加一个。单机 5 万连接,光 fd 就要 5 万,而很多系统的默认软限制是 1024。改的时候要同时改/etc/security/limits.conf里的软硬限制和 systemd 服务单元的LimitNOFILE,只改一个是不生效的。
内存这块我按每条连接估算。读缓冲区 4KB,写缓冲区 4KB,两个 goroutine 的栈初始各 2KB(会随调用深度增长,实际按 8KB 算比较保险),应用层的 Client 结构体和它在 map 里的条目算 2KB。加起来大约 26KB。十万连接就是 2.6GB,加上 Go 运行时本身的开销和堆的碎片,按 3.5GB 规划比较合适。这个数字看起来很吓人,但它是线性的,好估算。
带宽这块容易被低估的是心跳。十万连接,30 秒一次 ping/pong,每秒就是 3333 个往返。每个 ping 帧在 TCP 层大概是 40 字节 IP 头加 20 字节 TCP 头加 6 字节帧头,算 70 字节,一来一回 140 字节,每秒约 470KB。这个量不算大,但它带来的是每秒 3333 次的协程唤醒和系统调用,CPU 的软中断会明显上升。所以心跳间隔不是越小越好,够用就行。
消息吞吐的计算更直接:峰值每秒消息数乘以平均消息大小,就是下行带宽。比如一个推送场景,峰值 5000 条每秒,平均每条 800 字节,那就是 4MB/s,约 32Mbps。这时候网络不是瓶颈,瓶颈在 JSON 序列化和内存分配上。我一般会在这个量级上做对象池复用,把[]byte的分配压下来。
5.3 几个只有踩过才知道的坑
第一个坑是关于SetReadLimit的。这个方法的单位是字节,设成 64KB 之后,超过这个大小的消息会让连接直接断开,而且是那种不打招呼的断开——对端看到的就是连接没了。如果你的业务里有图片或者文件的 base64 传输,一定要单独走 HTTP 上传,别塞进 WebSocket 消息里。我见过一个项目把 20MB 的文件切片塞消息里发,服务端内存直接被打穿。
第二个坑是关于SetWriteDeadline的时机。这个 deadline 必须在每次写之前重新设置,因为它是一个绝对时间点,不是时长。如果你只在连接建立时设一次,那十分钟后所有写操作都会立刻超时失败。这一点在文档里写得很清楚,但写代码时非常容易忘,尤其是在循环里连续写多条消息的时候。
第三个坑是关于连接数的监控口径。在线连接数这个指标,超时断开的连接和被正常关闭的连接,如果不在同一个地方减掉,计数就会漂移。我的做法是让所有关闭路径都走同一个close()方法,用sync.Once保护,在里面统一做减法、统一打点。这个约束用代码强制住之后,"监控数字和实际对不上"这类问题就再没出现过。
第四个坑是关于后端的 GC。Go 的 GC 是并发的,但 STW 阶段虽然短,对长连接的影响是放大式的——因为它会让所有协程同时停顿,几十万个连接的心跳在同一时刻全部延迟。表现出来的现象是"每隔一段时间有一批连接同时超时"。调GOGC到 200 或者用debug.SetMemoryLimit把堆的增长控制得更平缓,能明显缓解。这一点在连接数超过五万之后才开始显现,小规模测试根本发现不了。
6. 我在几套系统里沉淀下来的配置基线
把上面这些内容收敛成一组可以直接参考的配置,新项目起步的时候我会从这个基线开始调。
服务端这边,心跳 30 秒,读超时 75 秒,写超时 10 秒,单条消息上限 64KB,发送队列 256。MaxHeaderBytes设成 8KB 足够,WebSocket 的握手头不会太大。HTTP 服务本身的ReadHeaderTimeout设 5 秒,防止慢速握手攻击。
代理层这边,拨号超时 3 秒,ResponseHeaderTimeout10 秒,FlushInterval设为 -1,MaxIdleConnsPerHost和后端实例数保持同量级。如果用的是自研隧道,两个方向的io.Copy都要配CloseWrite。
容量规划上,单机连接数先按 3 万起步,压测验证之后再往上推。这个数字不是能力上限,而是"出问题时影响面可控"的规模。真要做到十万以上,就得在架构上做分区了,比如按用户 ID 哈希分成多个独立的连接集群,每个集群独立部署、独立扩容,任何一个集群出问题只影响一部分用户。
注意:上面所有参数都是起点,不是终点。每一条链路的空闲超时、每个业务的峰值特征都不一样,照抄参数然后指望它跑得好,跟不看说明书就调发动机没区别。上线前一定要用真实的客户端行为(包括重连、弱网、后台挂起)做一轮完整的验证。
最后再说一个我觉得最值钱的习惯:给每条连接分配一个短 ID,把它写进所有相关日志里。客户端报障的时候让他把这个 ID 发过来,你能一路追到接入层、代理层、业务层的所有日志,而不是靠时间戳去大海捞针。这个改动的成本很低,但它在排查线上问题时的价值,比多写十个监控指标都高。