Go IM 源码实战(2):WebSocket 握手 11 步,少 1 个参数连不上

你好,我是利威尔xu。

这是「项目实战」下**「Go IM 源码实战」系列第 2 篇**。上一篇我们建了整张地图,这一篇下到第一层------客户端连上来的那道门。

如果你正在照着上一篇读这个仓库,那这篇就是"合上目录、打开第一个文件"的那一次。读完你能做到三件事:

  1. 手写一条 WebSocket 连接请求,并且知道每个参数为什么不能少;
  2. 说清"握手 11 步"里每一步失败会返回什么、为什么那一步必须放在那个位置;
  3. 解释 10 万连接到底吃掉了什么资源,以及网关真正的技术含量为什么不在"建连接"上。

源码位置(全系列同一份,边读边对照):

还没看第 1 篇的建议先回去看:Go IM 源码实战(1):一条群消息 1500 次写入,那篇给的是全局视角,这篇是第一个深水区。整套系列怎么读、哪些可以跳过,写在《新粉必看:利威尔xu的技术博客学习路线与使用指南》里。

文章目录

一、先回答一个问题:服务器凭什么主动找你

打开浏览器看网页,是你点一下、服务器答一下。这个模式有三条铁打的性质:

性质 含义 对 IM 的后果
客户端先开口 服务器不能主动发数据,它只知道"有人在端口上等着" 别人发你的消息,服务器没法立刻送到你手机上
一问一答就结束 一次请求对应一次响应,响应发完这次交互就完了 每次送达都要重新发起一次交互
无状态 协议本身不记得上次是谁 每条消息都要重新带身份(token),否则服务器不知道你是谁

电话和寄信的区别,就是这个差别。你给朋友寄信,只能等他回信;他什么时候想说话,跟你无关。而打电话,两边随时都能开口。IM 要的是电话,不是信。

那怎么在没有电话线路(长连接)的年代实现"电话"?历史上试过四条路:

方案 延迟 服务器资源 双向 协议开销 现状
短轮询(每 3 秒问一次) 高,平均 = 间隔/2 中(请求极频繁) ❌ 高(头比数据大) 遗留系统
长轮询(请求挂住不答) 低 高(挂起请求占连接) ❌ 中 少量兼容场景
SSE(服务器单向流) 低 中 只有下行 低 行情、通知
WebSocket 低 高(连接常驻) ✅ 全双工 极低(帧头 2~14 字节) IM 标准方案

⚠️ 这张表最容易被读错的一点:WebSocket 那一行"服务器资源高"不是缺点描述,而是代价。一条连接 = 一个 socket 文件描述符 + 一份读缓冲 + 一个 goroutine。10 万连接就是 10 万份这些东西。

所以网关的技术含量不在"怎么建连接",在"怎么管住几十万条连接"。这句话是这一篇的主线。

面试追问:为什么不用 SSE?

SSE 只支持服务器 → 客户端单向。IM 里客户端要发消息、要发已读回执、要发"正在输入",全都要上行。SSE 要上行就得另开 HTTP 请求,等于把双向拆成两条链路,还多一次鉴权。

二、握手:一次 HTTP 请求,换一条不再属于 HTTP 的连接

WebSocket 最容易误解的地方:它不是一个"和 HTTP 并列的协议",而是借一次 HTTP 请求完成开场,然后在同一条 TCP 上切换成自己的帧格式。

text 复制代码
阶段一(还是 HTTP):
客户端 ── GET /msg_gateway?token=...  Upgrade: websocket ──→ 服务器
客户端 ←── HTTP/1.1 101 Switching Protocols ────────────── 服务器

阶段二(已经不是 HTTP 了):
客户端 ⇄──── WebSocket 帧(二进制,帧头 2~14 字节)────⇄ 服务器

为什么非要借 HTTP 起步?因为现实网络里,防火墙、代理、CDN 只放行 80/443 的 HTTP 流量。自己搞个新协议新端口,会被大量中间设备直接掐掉。伪装成 HTTP 起步,是工程上的务实选择。

客户端发出的请求头长这样(RFC 6455 规定):

http 复制代码
GET /msg_gateway?sendID=user001&token=xxx&platformID=5&isBackground=false HTTP/1.1
Host: im.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
头部 作用 人话
Upgrade: websocket 声明要升级 "我不是来取网页的"
Connection: Upgrade 告诉中间层这是个升级请求 "这连接要变性了,别缓存"
Sec-WebSocket-Key 16 字节随机数的 base64 防缓存、防误连的挑战串
Sec-WebSocket-Version: 13 协议版本 目前唯一正式版本就是 13

服务器返回 101 Switching Protocols 之后,gorilla/websocket 的 Upgrade() 干了一件很关键的事:Hijack(劫持)这条 TCP 连接。

Hijack 的意思是:从这一刻起,这条连接脱离 net/http 的管理 ,http.Server 不再管它的读写超时和生命周期。所以后面所有的读超时、写超时、心跳、关闭,都必须自己写。这就是为什么网关代码里到处都是手写的循环和超时------不是作者啰嗦,是没得选。

面试追问:WebSocket 握手失败,客户端收到的是 HTTP 错误还是 WebSocket 错误?

看失败发生在 Upgrade 之前还是之后。之前(参数错、token 无效、连接超限)只能是 HTTP 响应,因为协议还没升级;之后才是 WebSocket 的关闭帧。这个仓库里 handlerError 就是专门处理"升级前失败"的函数。

三、握手 11 步:顺序本身就是设计

主函数只有一个:internal/msggateway/ws_server.go:491 的 wsHandler。这 60 行是整个网关最关键的代码。

# 做什么 关键点
1 newContext(w, r) 建连接上下文 生成 ConnID(md5(remoteAddr+时间戳))
2 检查连接数是否超过 wsMaxConnNum 放在鉴权之前
3 ParseEssentialArgs() 解析 URL 参数 失败返回 HTTP 400 类错误
4 authClient.ParseToken() 调 auth 服务验 token 硬依赖,auth 挂了新连接全建不起来
5 validateRespWithRequest() 校验参数与 token 一致 防账号劫持
6 websocket.Upgrade() 101 + Hijack,此后与 net/http 无关
7 按需先回一包成功(isMsgResp=true) 客户端可少等一次往返
8 计算 ping 周期 只有 Web 平台才发 ping
9 new(Client) ⚠️ 没用对象池(后文说)
10 ResetClient() 初始化编码器、心跳、订阅表 SDK 类型决定 Gob / JSON
11 registerChan <- client + go readMessage() 进连接表、起读协程

真实代码(保留了每一步的判断分支,注释是我加的):

go 复制代码
func (ws *WsServer) wsHandler(w http.ResponseWriter, r *http.Request) {
	connContext := newContext(w, r)                          // 1

	if ws.onlineUserConnNum.Load() >= ws.wsMaxConnNum {      // 2 连接上限
		ws.handlerError(connContext, w, r,
			servererrs.ErrConnOverMaxNumLimit.WrapMsg("over max conn num limit"))
		return
	}

	err := connContext.ParseEssentialArgs()                  // 3 解析参数
	if err != nil {
		ws.handlerError(connContext, w, r, err)              // 升级前失败 → HTTP 响应
		return
	}

	resp, err := ws.authClient.ParseToken(connContext, connContext.GetToken()) // 4
	if err != nil {
		ws.handlerError(connContext, w, r, err)
		return
	}

	if err = ws.validateRespWithRequest(connContext, resp); err != nil {       // 5
		ws.handlerError(connContext, w, r, err)
		return
	}

	conn, err := ws.websocket.Upgrade(w, r, nil)             // 6 101 + Hijack
	if err != nil {
		log.ZWarn(connContext, "websocket upgrade failed", err)
		return
	}

	if connContext.ShouldSendResp() {                        // 7 可选的先手响应
		if err := conn.WriteMessage(websocket.TextMessage, wsSuccessResponse); err != nil {
			return
		}
	}

	var pingInterval time.Duration
	if connContext.GetPlatformID() == constant.WebPlatformID { // 8 只有 Web 发 ping
		pingInterval = pingPeriod
	}

	client := new(Client)                                    // 9
	client.ResetClient(connContext, NewWebSocketClientConn(conn, maxMessageSize, pongWait, pingInterval), ws)
	ws.registerChan <- client                                // 11
	go client.readMessage()
}

为什么"连接数检查"必须放在第一步

如果把它挪到鉴权之后,攻击者可以用大量无效 token 的连接反复打进来:每个都会被拒绝,但每次都要走一次 gRPC 鉴权,把 auth 服务打穿。放在最前面,攻击成本最低、系统代价最小。

⚠️ 但要知道它不是严格限流 :Load() 和后面的 Add(1) 不是原子的,极端并发下会超出上限几十个连接。这是"软保护",不是配额。

为什么第 5 步不是重复劳动

第 4 步只验证了"这个 token 是真的",但没验证"token 里的人,和你 URL 里写的人是同一个"。

不校验会怎样:你拿自己的合法 token (user001),URL 里写 sendID=user999。网关如果直接信任 URL 参数,就会认为"这是 user999 的连接"------你能收到 user999 的消息,还能以他的身份发消息。这是完整的账号劫持,不是理论风险。

validateRespWithRequest 就干一件事:

go 复制代码
func (ws *WsServer) validateRespWithRequest(connContext *UserConnContext, resp *auth.ParseTokenResp) error {
	if resp.UserID != connContext.GetUserID() {
		return servererrs.ErrTokenNotValid
	}
	if resp.PlatformID != connContext.GetPlatformID() {
		return servererrs.ErrTokenNotValid
	}
	return nil
}

一句话总结:token 证明"你是谁",参数一致性校验证明"你说你是这个人"。两道门缺一条,账号就是别人的。

面试追问:网关为什么调 auth 服务验签,不本地验 JWT?

本地验签需要密钥,验完就是最终结论;走 RPC 则可以把"吊销、黑名单、强制下线"这类需要查存储的逻辑集中在一处。代价很明确------auth 成为握手路径上的单点,挂了以后新连接全失败(已有连接不受影响,它们不需要重新鉴权)。这是可用性与集中化的取舍,不是一个"最优解"。

四、四个必传参数:少一个,连接就建不起来

参数解析在 internal/msggateway/context.go:93。它有两条路:

go 复制代码
func (c *UserConnContext) ParseEssentialArgs() error {
	query := c.Req.URL.Query()
	if data := query.Get("v"); data != "" {
		return c.parseByJson(data)       // 新方式:?v=<base64(json)>
	} else {
		return c.parseByQuery(query, c.Req.Header)  // 老方式:逐个 query
	}
}

新版 SDK 把参数打包成 JSON、base64 后塞进 v,好处是以后加参数 URL 格式不变(向前兼容);老方式是逐个 query,可读、易调试,但参数一多 URL 就很难看。

参数速查表(这张表建议收藏)

参数 常量名 必填 缺了会怎样
sendID WsUserID ✅ sendID is empty
token Token ✅ token is empty
operationID OperationID ✅ operationID is empty
platformID PlatformID ✅ platformID is not int / platformID is invalid
isBackground BackgroundStatus ✅ strconv.ParseBool: parsing "": invalid syntax
sdkType SDKType ❌ 默认 "go"(这是最容易踩的一脚)
sdkVersion SDKVersion ❌ 空
compression Compression ❌ 不压缩(也可从 header 取)
isMsgResp SendResponse ❌ false

为什么 isBackground 是必填,而 sdkType 有默认值

看真实的 parseByQuery:

go 复制代码
platformID, err := strconv.Atoi(query.Get(PlatformID))
if err != nil {
	return servererrs.ErrConnArgsErr.WrapMsg("platformID is not int")
}
info.PlatformID = platformID

if val := query.Get(SendResponse); val != "" {          // isMsgResp 有空判断
	ok, err := strconv.ParseBool(val)
	if err != nil {
		return servererrs.ErrConnArgsErr.WrapMsg("isMsgResp is not bool")
	}
	info.SendResponse = ok
}

background, err := strconv.ParseBool(query.Get(BackgroundStatus))  // ← 没有空判断
if err != nil {
	return err
}
info.Background = background

对比这两段:isMsgResp 有 != "" 判断,isBackground 直接就把 query 值丢给 ParseBool 。所以 URL 里不带 isBackground,ParseBool("") 立刻报错,连接在建之前就被打回。

我在这里犯过一个很典型的错:看文档写"isBackground 可选",本地用 Postman 连了半天一直报 parsing "": invalid syntax,还以为是 token 问题,最后逐行读 parseByQuery 才对上图。文档和代码不一致时,代码是唯一的真相。

sdkType 默认 go 的代价

go 复制代码
switch info.SDKType {
case "":
	info.SDKType = GoSDK          // ⚠️ 默认 go
case GoSDK, JsSDK:
default:
	return servererrs.ErrConnArgsErr.WrapMsg("sdkType is invalid")
}

为什么默认 Go 而不是 JS?因为 OpenIM 的主力用户是服务端集成方(拿 Go SDK 做机器人、做后台、做压测),默认 Go 能让他们零配置可用。

代价是:浏览器/小程序这类客户端如果忘了传 sdkType=js,服务端会用 Gob 解码它的 JSON 数据 ,直接解析失败。而 ResetClient 里就是按这个值选编码器的:

go 复制代码
if c.SDKType == GoSDK {
	c.Encoder = NewGobEncoder()
} else {
	c.Encoder = NewJsonEncoder()
}

✅ 实践建议:写客户端永远显式传 sdkType,不要依赖默认值。这一条能省你半天。

v 参数为什么必须用 RawURLEncoding

go 复制代码
reqInfo, err := base64.RawURLEncoding.DecodeString(data)

RawURLEncoding 是 URL 安全变体:用 - _ 替代 + /,并且去掉 = 填充 。因为参数要放进 URL:+ 会被解码成空格,= 会造成解析歧义。任何要塞进 URL 的 base64,都必须用 URL-safe 变体------这是个很小的点,但聊"URL 传二进制"时是标准答案。

面试追问:为什么参数不放在 header 里?

浏览器的 WebSocket API 不能自定义 header(只有子协议参数),所以鉴权信息只能走 URL 或首帧。走 URL 的代价是会被写进 Nginx 日志 ,所以生产上要关掉 query 日志或做脱敏。这个仓库同时支持 URL 参数和 v 打包,本质是在兼容老客户端和新 SDK。

五、一连接一 goroutine:读循环里的四个细节

握手成功后,每个连接起一个读协程,代码在 internal/msggateway/client.go:97:

go 复制代码
func (c *Client) readMessage() {
	defer func() {
		if r := recover(); r != nil {
			c.closedErr = ErrPanic
			log.ZPanic(c.ctx, "socket have panic err:", errs.ErrPanic(r))
		}
		c.close()
	}()

	for {
		message, returnErr := c.conn.ReadMessage()   // 阻塞读
		if returnErr != nil {
			c.closedErr = returnErr
			return
		}
		if c.closed.Load() {
			c.closedErr = ErrConnClosed
			return
		}
		if parseDataErr := c.handleMessage(message); parseDataErr != nil {
			c.closedErr = parseDataErr
			return
		}
	}
}

四个细节,每个都不是随手写的:

细节 原因 不写会怎样
defer recover() 一条坏消息不能让整个进程崩 一个畸形包 = 全员掉线
defer c.close() 读循环退出必须连带关连接 socket 和 goroutine 泄漏
阻塞读 ReadMessage() 卡住时协程被运行时挂起,不烧 CPU 换成非阻塞轮询会 100% 占核
c.closed.Load() 连接可能被别处关(被踢、心跳超时) 已经死掉的连接还在处理消息

10 万连接到底吃掉了什么

text 复制代码
100,000 × goroutine(初始栈 2KB)    ≈ 200MB
100,000 × socket fd                  ← Linux 默认 ulimit -n 1024,必须先调大
100,000 × 读缓冲(maxMessageSize)    ← 这个才是大头,按 KB 级算就是几百 MB 起

所以部署网关第一件事是 ulimit -n 1000000,否则几万连接就 too many open files。这不是代码问题,是系统配置问题------很多人在本地压测跑不出量,就是卡在这。

面试追问:一个连接一个 goroutine,10 万连接是不是就要 10 万个线程?

不是。goroutine 是用户态调度,M:N 映射到少量线程上;阻塞在 socket 读上的 goroutine 会被 runtime 挂起,不占 CPU。真正稀缺的是 fd 和内存,不是线程。这就是 Go 在这个场景比 Java 线程模型省的地方。

消息进来之后:10 个指令码,和一道防伪造关卡

读循环拿到一帧之后,handleMessage(client.go:130 起)先解压、从对象池取 Req、按 SDK 类型解码、结构体校验,然后就到了这一层最重要的三行代码:

go 复制代码
// client.go:151-152
if binaryReq.SendID != c.UserID {
	return errs.New("exception conn userID not same to req userID",
		"binaryReq", binaryReq.String())
}

这三行挡住的是水平越权(IDOR) :攻击者用自己合法账号登录、拿到合法 token、建立正常连接,然后构造一个 Req{SendID: "受害者ID"}。服务端如果信任请求里自带的 SendID,就能以受害者身份发消息。

握手里校验的是"token 属于谁",这里校验的是"消息声称的发送者,是不是这条连接的所有者"。两道关卡缺一不可------这就是面试问"IM 怎么防伪造发送者"的标准答案。

过了这道关,按 ReqIdentifier 分发。指令码全在 constant.go:

指令码 常量 客户端想干什么
1001 WSGetNewestSeq 拉会话最新 seq(登录后第一步)
1002 WSPullMsgBySeqList 按 seq 列表精确补消息
1003 WSSendMsg 发消息
1005 WSPullMsg 按 seq 区间拉历史
1006 WSGetConvMaxReadSeq 拉会话最大已读 seq
1007 WsPullConvLastMessage 拉会话最后一条消息
2001 WSPushMsg 服务端 → 客户端:有人给你发消息
2003 WsLogoutMsg 退出登录
2004 WsSetBackgroundStatus 上报 App 前后台状态
2005 WsSubUserOnlineStatus 订阅某人的在线状态
3001 WSDataError 服务端 → 客户端:数据处理出错

从这张表能读出两个设计取向:

  1. 1xxx 是"客户端主动要",2xxx 是"服务端主动给",靠号段区分方向,客户端一看号就知道走哪个分支。
  2. 推送和响应共用同一个 Resp 结构 ,只靠 ReqIdentifier 区分(2001 就是"别人发来的消息")。好处是客户端只需要一个解码入口,不用维护两套协议。

面试追问:这个 switch 有什么可改进的?

它是硬编码分支,加一个指令就要改 handleMessage,属于开闭原则的反面。换成 map[int32]HandlerFunc 注册表,新增指令只注册、不改主流程;代价是丢掉编译期的重复 case 检查。两条路各有取舍,能说出取舍就够了。

六、写消息为什么必须加锁,而读不用

一条连接会被多个协程同时写:

写来源 在哪个协程 场景
响应 读协程 你发消息,回你 SendMsgResp
推送 网关事件循环 / gRPC 处理协程 别人发消息给你
心跳 ping 心跳协程 Web 平台定时 ping
踢人通知 事件循环 你在别处登录被踢

WebSocket 协议规定:同一时刻只能有一个写操作 。不加锁会怎样?底层 net.Conn.Write 单次系统调用是原子的,但一帧可能需要多次 Write(大消息分片),交错之后就变成垃圾数据,对端直接协议错误断连。

go 复制代码
func (c *Client) writeBinaryMsg(resp Resp) error {
	if c.closed.Load() {
		return nil
	}
	encodedBuf, _ := c.Encoder.Encode(resp)   // 锁外编码

	c.w.Lock()
	defer c.w.Unlock()
	// 压缩 + conn.WriteMessage(...)
}

注意顺序:编码在锁外,写操作在锁内。序列化是耗时操作,放在临界区外能把持锁时间压到最短。这是并发代码的基本功。

⚠️ 但锁内如果 WriteMessage 永久阻塞(对端不读、TCP 窗口满),锁会一直被持有,这条连接的所有写全堵死。所以 writeWait = 10 * time.Second 这个写超时不是可选项,是防死锁的保险。

顺便看一眼心跳三个常量(internal/msggateway/constant.go):

go 复制代码
writeWait  = 10 * time.Second
pongWait   = 30 * time.Second
pingPeriod = (pongWait * 9) / 10   // 27s,必须小于 pongWait

为什么 ping 周期要小于 pongWait? 因为 pongWait 是"读超时":30 秒内没收到任何数据就判定连接死了。如果 ping 周期等于或大于 30 秒,正常连接也会被误杀。留 10% 余量是网络抖动的缓冲。

七、连接表:三层 map 与多端互踢

连接表在 internal/msggateway/user_map.go,结构是三层:

text 复制代码
userID
  └── platformID(1=iOS, 2=Android, 5=Web, ...)
        └── [conn1, conn2, ...]      ← 为什么是数组?

最底层用数组而不是单连接,因为同平台多连接是合法场景:多标签页打开同一个 Web IM、重连过渡期旧连接还没清掉。

如果设计成单连接,新连接会覆盖旧连接,旧连接变成"孤儿":既收不到消息、又占着 socket,而且连踢都踢不掉(表里已经没它了)。用数组是正解。

Get 的返回值有三个:

go 复制代码
func (m *userMap) Get(userID string, platformID int32) (clients []*Client, userExists bool, clientExists bool)

为什么要区分 userExists 和 clientExists?因为踢人逻辑要分三种情况:

情况 判断 动作
用户完全不在线 userExists=false 直接注册,不用踢
用户在线但该平台没连接 clientExists=false 不用踢
用户在线且该平台有连接 clientExists=true 按策略决定踢不踢

只返回一个 bool,调用方就分不清后两种,一定会写出"用户在 iPad 在线,却把 iPhone 踢了"的 bug。

踢人核心在 ws_server.go:333,有两个精髓:

go 复制代码
func (ws *WsServer) multiTerminalLoginChecker(clientOK bool, oldClients []*Client, newClient *Client) {
	// ① 同 token 不踢(重连过渡期,旧连接和新连接 token 一样)
	// ② 按策略算出 needKick
	// ③ 从连接表删除 + 通知客户端 + 失效旧 token
	ws.authClient.InvalidateToken(ctx, &pbAuth.InvalidateTokenReq{
		PreservedToken: newClient.token,   // ← 只保留新 token
		UserID:         newClient.UserID,
		PlatformID:     newClient.PlatformID,
	})
}

① 同 token 不踢:客户端断网重连时,短时间会同时存在新旧两条连接,token 是同一个。如果照踢不误,就会出现"刚连上就被自己踢掉"的鬼故事。

② PreservedToken 必须传 :只断连接不作废 token,被踢的客户端可以立刻拿同一个 token 重连 ,形成"踢了又连、连了又踢"的死循环。踢人必须连凭据一起废掉,这一条是很多自研 IM 的漏洞所在。

面试追问:用户 A 在手机端登录,把电脑端踢下线,网关怎么知道电脑在哪个节点?

连接表只在单进程内有效,所以跨节点要靠广播:查到目标用户不在本节点,就通过 gRPC 通知其他网关节点执行踢人。代价是广播范围 = 节点数,节点一多就要用注册中心或消息队列收敛。这也是为什么"多端互踢"看着是产品需求,做起来是分布式问题。

八、踩坑记录:三个真坑,一共花掉我 11 小时

坑一:isBackground 不传,报 parsing "": invalid syntax(约 3 小时)

现象:本地用脚本连网关,握手阶段就被拒,返回的错误是 strconv.ParseBool: parsing "": invalid syntax。第一反应是 token 过期,换 token、换账号都不行。

定位:把 parseByQuery 逐行读了一遍,发现 isMsgResp 有空判断、isBackground 没有。去掉 URL 里所有参数逐个加回来,加到 isBackground 时立刻通过。

修复:客户端每次连接都显式带上 isBackground=true/false。

教训:报错信息里出现 parsing "": invalid syntax,说明一定有个字段拿到了空字符串 。顺着 ParseBool / Atoi 反查调用点,比猜 token 快得多。

坑二:浏览器客户端没传 sdkType=js,编解码全乱(约 5 小时)

现象:握手成功了,但一发消息就断,服务端日志里是 Gob 解码失败。

定位:ResetClient 里 SDKType == GoSDK 才用 Gob,默认值恰好是 go。浏览器发的 JSON,被当 Gob 解。

修复:连接 URL 显式加 sdkType=js。

教训:接口有默认值,不代表你可以不传。 默认值是给"主用户"省事的,不是给所有客户端兜底的。

坑三:压测到 8000 连接开始 too many open files(约 3 小时)

现象:本机压测,连到 8000 左右网关不再接受新连接,日志刷 too many open files。

定位:ulimit -n 是 1024(Windows 上是另一套限制),远小于连接数。

修复:Linux 上调 ulimit -n 1000000 并写进 systemd 的 LimitNOFILE;压测机同理。

教训:长连接服务的上限经常不在代码里,在操作系统配置里。 面试聊到"你们网关撑多少连接",能说出 fd 和内存的账,比说个数字有用。

九、面试会怎么问

追问 怎么答(一句话版)
WS 握手为什么要借 HTTP? 中间设备只放行 80/443 的 HTTP,新协议新端口会被拦
连接数上限检查为什么放鉴权前? 用无效 token 打进来也要先挡,否则每次都打一次 auth 服务
只验 token 不校验参数一致会怎样? URL 写别人的 userID 就能劫持账号,属水平越权
一连接一协程能撑多少? 瓶颈在 fd 和内存(约 200MB/10 万协程栈),不在线程数
写为什么要加锁? 响应/推送/心跳/踢人四路并发写,帧分片交错会毁掉协议边界
多端互踢为什么要废 token? 不废就能拿同一个 token 立刻重连,踢不掉
网关挂了会怎样? 在线连接全断,客户端重连;新连接建不起来(auth 是握手硬依赖)

十、小结:这一层记住三个数字和一张表

  • 11 步:握手顺序本身就是安全设计,连接上限最前、参数一致性在后、Upgrade 之前全是 HTTP。
  • 9 个参数 :5 个必填 4 个可选,isBackground 和 sdkType 是两个最容易翻车的地方。
  • 2 个计数器 :onlineUserNum(用户数,给业务看)和 onlineUserConnNum(连接数,给限流看),不能合成一个。

下面这张表建议收藏,下次接任何 WS 网关、或者自己写长连接服务,照着过一遍就不会漏:

检查项 该问什么 这项目的答案
鉴权时机 在 Upgrade 前还是后? 前(升级前失败还能返回 HTTP 错误)
参数一致性 token 里的 userID 和 URL 的一致吗? 校验,否则可劫持
上限保护 连接数在哪一层挡? 鉴权之前,软保护非严格限流
跨域 CheckOrigin 怎么处理? 全放行,靠 token 兜底
心跳 ping 周期与读超时什么关系? 27s / 30s,留 10% 余量
写并发 多协程写同一连接怎么办? 连接级 mutex + 10s 写超时
连接表 同平台多连接支持吗? 支持,底层是数组
踢人 要不要废 token? 必须废,否则踢不掉
资源 fd 上限调了吗? 部署必调 ulimit -n

留个问题,评论区一句话能答: 握手阶段为什么要校验"token 里的 userID"和"URL 里的 sendID"一致,而消息处理时还要再校验一次"消息里的 sendID"和"连接的真实 userID"一致?这两道关卡分别挡住了什么?

如果这篇帮你把长连接这层打通了,点个赞、收个藏 ------这个系列后面还有 14 篇,收藏夹里放着,跟着顺序读不会断。也欢迎在评论区说说你卡在哪一步。如果你想先看点轻的,可以翻翻之前那篇讲项目骨架的《Go 项目骨架 25 个目录只用 9 个》,两篇合起来刚好是"骨架 + 门面"。

下一篇:《Go IM 源码实战(3):HTTP API 层 153 条路由,a2r.Call 一行代码怎么把 HTTP 翻译成 gRPC》------从路由注册、参数校验讲到限流,把"接入层"的另一半补完。

作者:利威尔xu | CSDN 专栏《Go IM 源码实战》

相关推荐
轻舟技术A1 小时前
Rust 重写 Pi:会话启动耗时降 91%,就绪内存少 78%
开发语言·后端·rust
傲文博一2 小时前
MySQL 不开放外网端口,Mac 怎么连接?
后端
付威20232 小时前
Rust 重写 Pi:会话启动耗时降 91%,就绪内存少 78%
后端
绝望的蛋炒饭2 小时前
长会话的模型选型:缓存命中率如何决定真实成本
人工智能·后端·agent
用户80467718009752 小时前
Spring MVC Controller 返回值类型全解
后端
用户127653018142 小时前
把客服分流、询盘清洗、多平台改写做成 3 个 HTTP 接口,纯规则引擎也能跑
后端
程序员初一2 小时前
玩了这么多年服务器,那服务器管理工具哪个好用?
后端
用户427019230122 小时前
改个模型名,两个定时任务静默死了:一次定义与引用的分离事故
后端