你好,我是利威尔xu。
这是「项目实战」下**「Go IM 源码实战」系列第 2 篇**。上一篇我们建了整张地图,这一篇下到第一层------客户端连上来的那道门。
如果你正在照着上一篇读这个仓库,那这篇就是"合上目录、打开第一个文件"的那一次。读完你能做到三件事:
- 手写一条 WebSocket 连接请求,并且知道每个参数为什么不能少;
- 说清"握手 11 步"里每一步失败会返回什么、为什么那一步必须放在那个位置;
- 解释 10 万连接到底吃掉了什么资源,以及网关真正的技术含量为什么不在"建连接"上。
源码位置(全系列同一份,边读边对照):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @
07f9ff6 - 本篇涉及:
internal/msggateway/(11 个文件)、cmd/openim-msggateway/
还没看第 1 篇的建议先回去看:Go IM 源码实战(1):一条群消息 1500 次写入,那篇给的是全局视角,这篇是第一个深水区。整套系列怎么读、哪些可以跳过,写在《新粉必看:利威尔xu的技术博客学习路线与使用指南》里。
文章目录
-
- 一、先回答一个问题:服务器凭什么主动找你
- [二、握手:一次 HTTP 请求,换一条不再属于 HTTP 的连接](#二、握手:一次 HTTP 请求,换一条不再属于 HTTP 的连接)
- [三、握手 11 步:顺序本身就是设计](#三、握手 11 步:顺序本身就是设计)
-
- 为什么"连接数检查"必须放在第一步
- [为什么第 5 步不是重复劳动](#为什么第 5 步不是重复劳动)
- 四、四个必传参数:少一个,连接就建不起来
-
- 参数速查表(这张表建议收藏)
- [为什么 `isBackground` 是必填,而 `sdkType` 有默认值](#为什么
isBackground是必填,而sdkType有默认值) - [`sdkType` 默认 `go` 的代价](#
sdkType默认go的代价) - [`v` 参数为什么必须用 `RawURLEncoding`](#
v参数为什么必须用RawURLEncoding)
- [五、一连接一 goroutine:读循环里的四个细节](#五、一连接一 goroutine:读循环里的四个细节)
-
- [10 万连接到底吃掉了什么](#10 万连接到底吃掉了什么)
- [消息进来之后:10 个指令码,和一道防伪造关卡](#消息进来之后:10 个指令码,和一道防伪造关卡)
- 六、写消息为什么必须加锁,而读不用
- [七、连接表:三层 map 与多端互踢](#七、连接表:三层 map 与多端互踢)
- [八、踩坑记录:三个真坑,一共花掉我 11 小时](#八、踩坑记录:三个真坑,一共花掉我 11 小时)
- 九、面试会怎么问
- 十、小结:这一层记住三个数字和一张表
一、先回答一个问题:服务器凭什么主动找你
打开浏览器看网页,是你点一下、服务器答一下。这个模式有三条铁打的性质:
| 性质 | 含义 | 对 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 |
服务端 → 客户端:数据处理出错 |
从这张表能读出两个设计取向:
- 1xxx 是"客户端主动要",2xxx 是"服务端主动给",靠号段区分方向,客户端一看号就知道走哪个分支。
- 推送和响应共用同一个
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 源码实战》