第20章 · 即时通讯 IM 服务(正式版)

2023-04-14T14:11:02+08:00 | 75分钟阅读 | 更新于 2023-04-14T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 20 章。第 19 章你啃下了 Feed 流,本章是全书最后一块硬骨头业务——即时通讯(IM):评论、点赞、Feed 都是用户主动刷新才看得到,IM 不一样——你发消息,对方屏幕上要立刻弹出来。

一句话预告本章的核心:「服务器主动把消息推给客户端」这件事,请求/响应模型天生做不到——你需要一条反过来由服务端随时开口说话的通道(WebSocket 长连接),再解决「消息跨节点找到人」(分布式转发)和「不丢不重有序离线可达」(可靠投递)三件事。IM 之难,难就难在这三座山。

这一版做了三件事:

  1. 每个概念先补课再使用。轮询、全双工、帧、半开连接、NAT、指数退避、抖动、ACK、消费组、网关……全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。拨号电话 vs 一直在线的对讲机、营业厅传话、快递柜离线包裹——IM 的每个机制都有生活原型。
  3. 结尾有 12 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 20 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

先看一个真实到不能再真实的事故现场,感受一下这一章在解决什么问题:

事故现场:社区上线了「私信」功能,第一版偷懒用 HTTP 轮询实现:前端每 2 秒调一次 /api/messages?after=最新一条id,问服务端「有没有新消息」。上线当晚运营搞了场活动,同时在线冲到 5 万人——网关 QPS 直接飙到 2.5 万,其中 99% 的请求的答案是「没有新消息」。CPU 全部耗在「回答一个注定是『没有』的问题」上;消息高峰一来服务端变慢 → 前端等不及把轮询间隔偷偷改成 1 秒 → 请求量再翻倍 → 服务端更慢——雪崩。

复盘结论:轮询只是在「硬凑」实时性——它就像每 2 分钟开一次信箱,一天开 720 次,多数时候是空的。真正的即时通讯需要服务端具备主动找客户端说话的能力,这要求两端之间维持一条「长连接」。而长连接一开,两座大山就立在面前:百万条长连接怎么维护(内存、文件描述符、心跳、断线重连),以及消息怎么跨节点找到人(A 连在节点 1、B 连在节点 2)。

本章的主线就是翻这两座山:先搞清 IM 为什么难(3.1)→ 选协议(3.2)→ 学 WebSocket 原理并上手(3.3 / 3.4)→ 单节点广播(3.5)→ 分布式转发的两条思路(3.6)→ 用 Kafka 把群聊广播跑通(3.7)→ 可靠投递四件套:不丢、不重、有序、离线可达(3.8)→ 心跳与重连(3.9)→ 最后站在开源方案 OpenIM 的肩膀上看看生产全貌(3.10)。「跟着写」会搭一个完整的 im-service 微服务——WebSocket 8600 端口、JWT 校验、Redis 在线路由、Kafka 双 topic、离线消息、优雅停机,一个不少。

学完这一章,你就具备了搭建分布式 IM 的能力,前面 19 章攒的积木(Kafka、Redis、JWT、ETCD 注册、优雅停机、Canal)会在这一章全部再次登场。下一章(最后一章)做全课程总结与进阶路线。

开读前先看一眼全局:IM 的全部难点就一句话——“消息不能丢、不能重、要按序到达”。下面这条链路是全章的主干:消息从长连接进来,落库拿到序号,经 Kafka 交给投递服务,在线就推、离线就存,最后靠 ACK + 重发保证可靠。前 19 章的积木(Kafka、Redis、JWT、etcd、Canal)都会在这条链路上再次出现。

flowchart LR
    A["用户 A 发消息"] --> B["WebSocket 长连接网关"]
    B --> C["消息落库 + 生成 seq 序号"]
    C --> D[("Kafka")]
    D --> E["投递服务"]
    E --> F{"接收方在线?"}
    F -->|"在线"| G["长连接实时推送"]
    F -->|"离线"| H["写离线消息
上线后拉取"] G --> I["ACK 确认
超时重发保证不丢"] H --> I E -.->|"实例增减动态感知"| J["etcd 注册发现"]

这张图怎么读:用户 A 发消息先经「WebSocket 长连接网关」进入「消息落库 + 生成 seq 序号」——先落库才可能不丢,再经 Kafka 交给「投递服务」;投递服务按「接收方在线?」分流:在线走「长连接实时推送」,离线走「写离线消息、上线后拉取」,两条路最后都汇到「ACK 确认、超时重发」兜底。下方虚线是投递服务与「etcd 注册发现」的互动:实例扩缩容时靠它动态感知,投递服务不用重启。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 gorilla/websocket 起一个 WebSocket 服务:升级连接、读循环、唯一的写 goroutine、Ping 心跳与读超时续期。
  2. 写出单节点连接管理器 ConnManager:多端登录(map[uid]map[*Conn])、串行写、慢消费者保护(非阻塞投递,队列满就断开)。
  3. 搭出 im-service 完整链路:JWT 校验 → Redis 在线路由(im:route:<uid>,TTL 靠心跳续期)→ Kafka 双 topic(im-message 业务处理 / im-forward 定向投递)→ 离线暂存 + 上线补拉。
  4. 会写优雅停机:停 HTTP → 广播「请重连」系统消息 → 冲刷发送队列 → 关连接并清理路由。

理解(能讲清原理和取舍,不要求背代码)

  1. IM 的两座大山:长连接维护(连接常驻、占内存占文件描述符)和分布式消息投递(跨节点找人)。
  2. 轮询为什么不行:流量放大 + 雪崩恶性循环。
  3. WebSocket 原理:HTTP 101 协议升级、帧结构、全双工;「长连接」的两层含义(TCP Keep-Alive vs 应用层心跳)。
  4. 注册机制 vs 广播机制各自的瓶颈;网关和后端服务为什么必须分离。
  5. 群聊广播的关键细节:每个网关节点必须是独立消费组,共用一个组消息会被「分摊」掉。
  6. 可靠投递 = ACK + 先落库再推 + 幂等去重:「至少一次」+ 幂等 ≈ 效果上的恰好一次;顺序性只保证「会话内有序」。
  7. 心跳间隔与超时的权衡(30s60s 心跳 + 23 倍判定超时);重连风暴与「指数退避 + 随机抖动」。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. XMPP(XML 老协议、跨系统互通)和 WebRTC(浏览器间音视频)各自的主场。
  2. OpenIM:开源 IM 方案,分层架构和我们的设计同构;业务接入推荐 Canal 监听 binlog 异步同步用户。
  3. sync.Map 与 ekit 的 syncx.Map(泛型封装)。
  4. gorilla/websocket 官方 chat 示例:channel 扮演 Kafka 角色的单节点方案。
  5. CSWSH(跨站 WebSocket 劫持):CheckOrigin 直接 return true 的安全隐患。

前置依赖与补课清单

需要你已掌握在哪章学过本章还会补补在哪一节
Kafka topic / 消费组 / offset / 至少一次第 07 章❌ 不补(本章是它的第二次实战)—
Redis 基本命令与 TTL第 05 / 06 章❌ 不补—
JWT 生成与校验第 02 / 03 章❌ 不补(直接复用)—
ETCD 服务注册、优雅停机第 12 章❌ 不补(跟着写直接复用)—
Canal 监听 binlog第 18 章❌ 不补(3.10 接入 OpenIM 用到)—
goroutine / channel / sync.Mutex第 01 章❌ 不补—
幂等(重复消费的后果与对策)第 07 / 18 章❌ 不补—
什么是轮询 / 长连接 / 全双工未讲过✅ 重点补3.1 / 3.3
WebSocket 握手与帧结构未讲过✅ 重点补3.3
什么是网关第 13 章只提了负载均衡✅ 补3.6
ACK / 半开连接 / NAT / 指数退避未讲过✅ 重点补3.8 / 3.9

不需要你提前懂的:XMPP / WebRTC 的协议细节(知道定位即可)、WebSocket 帧的位级编码(gorilla 库替你处理)、OpenIM 全部 REST API(用到哪查哪)。

预计时长与学习成果

  • 预计时长:5 小时(概念精读 2 小时 + 跟着写 im-service 2.5 小时 + OpenIM 试用 0.5 小时);本章新概念密度高(长连接、跨节点转发、可靠投递三座山),建议分两次学:第一次读完 3.1–3.5 把「长连接 + 单节点」跑通,第二次学 3.6–3.10 并做「跟着写」。
  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    im-service 端到端跑通。用户 1、用户 2 用 wscat 连上 ws://localhost:8600/ws?token=...:用户 1 发一条私聊消息,自己秒收 message_ack、用户 2 秒级收到;用户 2 断开后用户 1 连发两条,redis-cli LLEN im:offline:2 返回 2,用户 2 重连按序补拉、List 清空;conv_type=group 的群消息所有在线成员各收一份(发送者除外);Ctrl-C 停机时客户端先收到「请重连」系统提示再断开,im:route:* 全部清空。

本章的学习路线(先懂为什么难 → 学协议 → 单机跑通 → 分布式 → 可靠性 → 开源全貌):

flowchart LR
    A["3.1 IM 为什么难
两座大山"] --> B["3.2-3.4 WebSocket
原理·上手"] B --> C["3.5 单节点广播
Hub"] C --> D["3.6-3.7 分布式
注册vs广播·Kafka群聊"] D --> E["3.8-3.9 可靠投递
ACK·心跳重连"] E --> F["3.10 OpenIM
开源全貌"] F --> G["第四节 跟着写
im-service 落地"] G --> H["第七节 面试问答"]

这张图怎么读:从左到右的单向路线:3.1 先立起「两座大山」(长连接维护、分布式投递)看清 IM 难在哪,3.2-3.4 选定 WebSocket 并吃透 101 握手与帧结构,3.5 用 Hub 把单节点广播跑通,3.6-3.7 翻第二座山(注册 vs 广播两条路线、Kafka 独立消费组群聊),3.8-3.9 补齐可靠投递四件套(ACK/去重/会话内有序/离线补拉)与心跳重连,3.10 看 OpenIM 开源全貌,最后第四节跟着写 im-service,第七节面试问答收尾。

即时通讯 IM 服务两座大山长连接维护·分布式投递协议选型XMPP·WebSocket·WebRTCWebSocket 原理101 握手·帧结构·心跳分布式转发注册 vs 广播·网关+Kafka可靠投递·OpenIMACK·去重·离线·开源方案

三、正文

3.1 IM 为什么难:从「消息秒到」说起

先抛问题。IM(Instant Message,即时通讯)是一种通过网络实时传递文本、多媒体和文件的通信方式——关键词是即时:你发了消息,对方屏幕上要立刻弹出来。评论、点赞、Feed 我们都做过了,它们有个共同点:用户主动刷新才看得到(拉模式),服务端从不主动找客户端。而 IM 的本质要求恰恰相反:服务端必须能主动把消息推到客户端。这和 HTTP 的请求/响应模型是根本冲突的——HTTP 里永远是客户端先开口,服务端只能「答」,不能「主动说」。不解决这个冲突,就会出现导读里那个 5 万人轮询把网关打挂的事故。

补课胶囊 · 轮询:轮询 = 前端每隔固定时间发一次请求,问服务端「有没有新东西」→ 类比:每 2 分钟开一次信箱看有没有新信,一天开 720 次,绝大多数时候是空的 → 回到主线:轮询是用「高频问」硬凑「实时」,代价全在无效请求上。

给直觉。轮询的两个缺点,放在 IM 场景下都会被放大:

  1. 流量放大:每个在线用户每 2 秒一次请求,5 万人在线 = 2.5 万 QPS,其中 99% 的答案是「没有新消息」——纯浪费。
  2. 雪崩风险:服务端压力大 → 响应变慢 → 前端以为「数据还没到」轮询得更勤 → 请求量再涨 → 服务端更慢——恶性循环,和第 14 章讲过的雪崩是同一族问题。
flowchart TD
    subgraph 轮询模型
        C1[客户端] -->|"每 2 秒问一次:有新消息吗?"| S1[服务端]
        S1 -->|"99% 的回答:没有"| C1
    end
    subgraph 长连接模型
        C2[客户端] <-->|"连着,随时双向说话"| S2[服务端]
        S2 -->|"有新消息,立刻推"| C2
    end
复习提示:这张图在讲:轮询是「客户端不停问」,长连接是「两端一直连着、服务端随时推」。IM 要的实时性,只有右边这种模型能低成本地给。

长连接是答案,但答案自带两座大山。把「每 2 秒问一次」换成「一直连着」,新问题立刻出现:

  • 大山一:长连接维护。一个 HTTP 请求处理完连接就释放,所以普通 Web 服务扛得住高 QPS;但一个 WebSocket 连接是常驻的——占内存、占文件描述符。几千个连接还好,几十万上百万就对单机是巨大考验。连接断了怎么发现(心跳)、断了怎么回来(重连)、服务器发布时怎么把连接迁走(连接迁移),全是日常工程问题。
  • 大山二:分布式投递。IM 后端是分布式系统,A 连在节点 1、B 连在节点 2,A 给 B 发消息,节点 1 怎么知道 B 在哪?3.6 节专门翻这座山。

IM 的完整需求是一座冰山。我们聚焦的「实时聊天」只是露出水面的一角——完整的 IM(如微信)还要解决:

  • 实时聊天(文本、图片、多媒体消息)
  • 群组功能(创建群、邀请、群管理)
  • 权限控制(能否加群、能否看资源)
  • 用户模块(注册、登录、资料管理)
  • 用户关系(加好友、拉黑、屏蔽)
  • 消息记录 / 历史消息
  • 内容审核(国内合规要求)
  • 搜索(文件、聊天记录、群、用户)
  • 多端信息同步
flowchart TD
    RT[实时聊天
本课重点] --> G[群组/权限] RT --> U[用户/关系] RT --> H[历史/审核] RT --> S[搜索/多端同步]
复习提示:这张图在讲:IM 的「冰山」——露在水面的是实时聊天,水面下是群组、关系、历史、审核等一大堆系统工程。本课只抠最顶上的实时聊天;其余本质是增删改查,但组合在一起做成 IM,难度不亚于做一个微博或小红书。

顺带认几个常见 IM 工具,感受产品形态差异(面试聊开放性问题时用得上):

工具特点
WhatsApp端到端加密、全球覆盖、轻量
微信多功能平台,融合社交 / 支付 / 小程序
Telegram开放 API、隐私保护、支持机器人
Slack团队协作、频道、第三方集成
Microsoft Teams与 Office 365 深度集成

常见坑:新手以为 IM 的难点是「存消息」——存消息就是一张表的事。真正的难点是让消息秒到、且对方在哪一目了然(长连接 + 分布式投递),以及后面的「不丢不重」(3.8 节)。

小收口:这一节确立了 IM = 服务端主动推送 + 两座大山(长连接维护、分布式投递)。要主动推送,先得选一条「能双向说话」的通道——下一节看三种协议怎么选。

3.2 协议选型:XMPP、WebSocket、WebRTC 三选一

先抛问题。「服务端主动推」的通道不止一种技术方案,业界主流的有三个:XMPP、WebSocket、WebRTC。选哪个?——先看它们各自的主场。

补课胶囊 · XMPP:XMPP(Extensible Messaging and Presence Protocol)= 基于 XML 的老牌开放通信协议,专为 IM 设计,主打「在线状态 + 多系统互通」→ 类比:老式电报局统一的电报格式,任何电报局都认识,但每句话都要包一层啰嗦的格式头 → 回到主线:如今影响力下降,主要在「需要和其他 IM 互通」的场景使用。

补课胶囊 · WebRTC:WebRTC(Web Real-Time Communication)= 浏览器之间直接进行实时音视频通信的技术,点对点、端到端加密 → 类比:两人直接打电话,不经过(或很少经过)总机 → 回到主线:它管的是音视频通话,不负责普通文本消息的传递。

WebSocket(本章主角)则是一种在单个 TCP 连接上进行全双工通信的协议——「全双工」就是双方可以同时收发,像打电话,谁都能随时开口(普通 HTTP 是半双工的 walkie-talkie:你说完我才能说)。它的五个核心特点:

  • 双向通信:全双工,服务器可主动推送——IM 最需要的能力。
  • 低延迟:不用每次请求都重新建连(省掉 TCP 握手 + HTTP 头),实时性高。
  • 轻量级:数据以「帧」为单位传输,头部极小(HTTP 每个请求都带一整套头部)。
  • 持久连接:一条连接一直复用,直到任意一方主动关闭。
  • 跨平台:浏览器原生支持(手机 App、桌面端也都有成熟库)。

三者对比一张表:

维度XMPPWebSocketWebRTC
通信类型文本消息 + 在线状态全双工通用通信实时音视频点对点
灵活性高(开放标准,可扩展)中(简洁但不够灵活)低(专用)
实时性较高高(全双工低延迟)高(取决于网络)
安全性TLS/SSL加密通信(wss)端到端加密
典型场景跨系统互通普通消息 / 实时推送音视频通话
复习提示:选型结论:要和别的 IM 系统互通 → XMPP;普通消息收发 → WebSocket(当前主流 IM 基本都构建在它之上);音视频通话 → WebRTC。三者不互斥——微信的文本消息走类 WebSocket 的长连接,视频通话走另一套 RTC 协议。本课程主角是 WebSocket。

常见坑:把 WebRTC 当成「更好的 WebSocket」。两者解决的问题不同——WebRTC 是为音视频实时流设计的(延迟优先、可容忍丢帧),传普通文本消息反而笨重;文本消息要的是「必达、有序」,这正是 WebSocket 的主场。

小收口:协议选了 WebSocket。它凭什么能「先握手一次、之后双向随时说话」?下一节拆它的原理——握手升级和帧结构。

3.3 WebSocket 原理:一次握手,永久双向(核心概念一)

先抛问题。WebSocket 明明是基于 TCP 的,为什么不直接用 TCP,非要「从 HTTP 升级」?——因为现实约束:浏览器和 App 只会先发 HTTP 请求,中间的代理、网关、防火墙也都只认识 HTTP。WebSocket 的聪明之处在于借用 HTTP 的壳完成第一句对话,然后立刻翻脸换成自己的协议——这样它能穿透一切只为 HTTP 放行的网络环境。

给直觉。整个生命周期四步:

  1. 客户端发一个特殊的 HTTP 请求,询问「能不能升级成 WebSocket?」
  2. 服务端同意,回一个特殊状态码,协议从此切换。
  3. 两端用 WebSocket 的「帧」持续双向通信。
  4. 任意一端发起关闭,连接结束。
sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: GET /ws + Upgrade: websocket(HTTP 壳)
    S-->>C: HTTP/1.1 101 Switching Protocols
    Note over C,S: 之后脱离 HTTP,走 WebSocket 全双工帧
    C->>S: 文本/二进制帧(随时发)
    S-->>C: 主动推送帧(随时发)

握手的具体报文(面试可能让你现场写关键头部)。客户端的升级请求:

GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==    # 随机串,用于校验握手合法性
Sec-WebSocket-Protocol: chat                    # 子协议(可选)
Sec-WebSocket-Version: 13                       # 协议版本
Origin: https://example.com                     # 来源(跨域控制)

服务端的响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=   # 由 Key 计算得出

逐行讲解:Upgrade: websocket + Connection: Upgrade 是「我要换协议」的暗号;Sec-WebSocket-Key 是客户端生成的随机串,服务端用它按固定算法算出 Sec-WebSocket-Accept 回来——客户端验证算得对,才认定对面是真 WebSocket 服务(防止普通 HTTP 服务器「假装同意」);101 Switching Protocols 这个状态码一辈子几乎只在这一处出现,看到它 = 协议切换成功。

升级之后:帧(Frame)。WebSocket 传输的最小单位叫帧,不用背位级细节(gorilla 库会替你处理),但要知道结构里有哪些角色:

字段长度含义
FIN1 bit是否消息的最后一个片段(大消息可拆多帧)
RSV3 bits保留字段
Opcode4 bits操作码:文本帧 / 二进制帧 / Ping / Pong 控制帧 / 关闭帧
Mask1 bit负载是否经过掩码处理(客户端→服务端必须掩码)
Payload Length7 / 7+16 / 7+64 bits负载长度(按需扩展)
Masking Key4 bytes仅 Mask=1 时存在
Payload Data任意长度实际消息内容
复习提示:只需要记住两点:① 帧是 WebSocket 的最小传输单位,Opcode 区分「这是文本、二进制、还是心跳控制帧」;② Ping / Pong 是协议自带的控制帧——这就是 WebSocket 原生的心跳机制,3.9 节会直接用它。

「长连接」其实有两层含义(面试爱抠这个概念):

  1. TCP 层长连接:TCP 协议栈自带的 Keep-Alive 机制,定时发保活探测包,发现对端无响应就关闭连接。问题是它默认约 2 小时才探测一次——对 IM 来说太迟钝了,用户手机切了 WiFi 你要两小时后才知道。
  2. 应用层长连接:应用自己在连接上约定心跳(比如 RPC 的心跳请求、WebSocket 的 Ping/Pong 控制帧)。注意一个易混点:HTTP 的 Keep-Alive 只是「连接复用」——同一 TCP 连接多传几个请求、省去反复握手,它不探测对端是否还活着,不是心跳,别混为一谈。

WebSocket 的长连接兼具两层:底层靠 TCP 维持,应用层用 Ping/Pong 心跳确认「对面还活着」。为什么应用层必须自己再来一套?——3.9 节的「半开连接」问题会给出完整答案。

常见坑:新手以为「升级成功后还是 HTTP」。101 之后这条连接上跑的就不再是 HTTP 了——没有请求头、没有 URL、没有状态码,只有帧。所以你不能再往回写 HTTP 401——这个特性直接决定了跟着写第 3 步「JWT 必须在升级之前校验」。

小收口:原理清楚了——HTTP 壳握手(101)→ 帧通信 → 双层保活。下一节动手:用 Go 的 gorilla/websocket 真正起一个 WebSocket 服务,并避开两个新手必踩的坑。

3.4 上手:最简 WebSocket 服务与两个必踩的坑

先抛问题。原理看得再明白,不如亲手跑起来。Go 生态里事实标准库是 github.com/gorilla/websocket——先装依赖:

go get github.com/gorilla/websocket

最小可运行示例。第一块:定义「升级器」(把 HTTP 连接升级为 WebSocket 的工具):

// ws_demo/main.go(第一块:升级器)
package main

import (
    "fmt"
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

// Upgrader 把 HTTP 连接升级为 WebSocket
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024, // 读缓冲区大小
    WriteBufferSize: 1024, // 写缓冲区大小
    // CheckOrigin 校验请求来源。演示放行所有来源,生产必须校验白名单!
    CheckOrigin: func(r *http.Request) bool { return true },
}

第二块:升级 + 一个「服务端每秒主动推送时间戳」的读循环:

// ws_demo/main.go(第二块:处理器与入口)
func handleWS(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil) // ① HTTP → WebSocket
    if err != nil {
        return
    }
    defer conn.Close()

    go func() { // ② 独立 goroutine:每秒主动推一条(体会"服务端主动说")
        for {
            if conn.WriteMessage(websocket.TextMessage,
                []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().Unix()))) != nil {
                return
            }
            time.Sleep(time.Second)
        }
    }()

    for { // ③ 读循环:收客户端发来的消息
        _, msg, err := conn.ReadMessage()
        if err != nil {
            return
        }
        fmt.Printf("received: %s\n", msg)
    }
}

func main() {
    http.HandleFunc("/ws", handleWS)
    http.ListenAndServe(":8600", nil)
}

跑起来验证:

go run .
# 另开一个终端,安装 wscat(WebSocket 版的 curl)
npm install -g wscat
wscat -c ws://localhost:8600/ws
# 预期:每秒打印一条 {"ts":1712345678}——这就是服务端主动推送;
# 你手动敲 "hello" 回车,服务端终端打印 received: hello

逐行讲解:upgrader.Upgrade 就是 3.3 节那场 101 握手的代码化身——它完成协议切换并把连接包装成 *websocket.Conn;之后 conn.WriteMessage(写帧)和 conn.ReadMessage(读帧)就是全双工通信的全部 API。注意②和③分别在两个 goroutine 里——这不是巧合,是纪律,马上讲。

常见坑一:CheckOrigin 直接 return true。跨站 WebSocket 劫持(CSWSH:Cross-Site WebSocket Hijacking)——恶意网页里的 JavaScript 可以向你的 ws:// 地址发起连接(浏览器会自动带上受害者的 Cookie)。如果服务端不校验 Origin 头,攻击者就能冒用用户身份建立连接、收发消息。生产代码必须校验 Origin 白名单:

CheckOrigin: func(r *http.Request) bool {
    return r.Header.Get("Origin") == "https://your-site.com"
}

常见坑二:并发写同一个连接。gorilla/websocket 的规则:一个连接同时最多允许一个读者和一个写者。多个 goroutine 同时 WriteMessage 同一个 conn 会触发 panic / 数据错乱。正确姿势是把所有写(业务消息、Ping)都交给唯一的写 goroutine,别人只能往它的队列里投——跟着写第 2 步会把这个模式完整实现,先记住口诀:一个连接一个写 goroutine,其余人只准投队列。

顺手学一个优化:BufferPool。每次新建连接都分配一块写缓冲太浪费,用对象池复用(和第 09 章热榜里「池化」的思想一致):

var upgrader = websocket.Upgrader{
    WriteBufferPool: &sync.Pool{
        New: func() any { return make([]byte, 1024) },
    },
}

小收口:单个客户端连上了,服务端也能主动推了。但 IM 是「A 发消息给 B」——服务端得知道都有谁连着,这就需要连接管理器(Hub),下一节。

3.5 单节点广播:Hub 与连接管理

先抛问题。最简单的 IM 场景:A、B、C 三个用户都连着同一个服务进程。A 发了一条消息,服务端要转发给 B 和 C——前提是服务端得先「记住」都有谁连着、各自的连接是什么。这个「花名册」就叫 Hub。

给直觉。

flowchart TD
    A[用户 A 上线] --> R[Hub.Register A]
    B[用户 B 上线] --> R2[Hub.Register B]
    A -- 发消息 --> BC[Hub.Broadcast]
    BC --> W[写 B 的连接]
    BC -. 跳过 A 自己 .-> X[不写 A]
复习提示:这张图在讲:Hub 用 map[uid]conn 记下所有在线连接,广播就是「遍历 map 挨个写」,并跳过发送者自己。

最小可运行示例。第一块:结构体与「上线注册 / 下线注销」:

// ws_demo/hub.go(第一块:结构体与上下线)
package ws

import (
    "sync"

    "github.com/gorilla/websocket"
)

// Hub 维护所有在线连接,实现广播
type Hub struct {
    mu    sync.RWMutex          // Go 内置 map 非线程安全,读写都要加锁
    conns map[int64]*websocket.Conn // uid -> conn
}

func NewHub() *Hub {
    return &Hub{conns: make(map[int64]*websocket.Conn)}
}

// Register 用户上线时注册连接
func (h *Hub) Register(uid int64, conn *websocket.Conn) {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.conns[uid] = conn
}

// Unregister 用户下线时移除
func (h *Hub) Unregister(uid int64) {
    h.mu.Lock()
    defer h.mu.Unlock()
    delete(h.conns, uid)
}

第二块:广播:

// ws_demo/hub.go(第二块:广播)
// Broadcast 广播消息,skipUid 用于不转发给发送者自己
func (h *Hub) Broadcast(msg []byte, skipUid int64) {
    h.mu.RLock() // 只读遍历,用读锁——多个广播可并发
    defer h.mu.RUnlock()
    for uid, conn := range h.conns {
        if uid == skipUid {
            continue // 不转发给自己
        }
        // 注意:生产中写入要异步 + 超时控制,见下面的坑
        _ = conn.WriteMessage(websocket.TextMessage, msg)
    }
}

逐行讲解:map[int64]*websocket.Conn 是花名册本体——key 是用户 ID、value 是他的连接;sync.RWMutex 是因为 Go 内置 map 不是线程安全的(并发读写直接 panic),上线/下线是写操作用 Lock,广播遍历是读操作用 RLock(读锁可多个并发,广播频繁时更划算);skipUid 体现一个产品细节——群聊里自己发的消息通常不回推给自己(自己的界面已经显示出来了)。

常见坑:慢消费者拖垮整个 Hub。Broadcast 里直接同步 WriteMessage——如果 B 的网络很烂(手机进电梯),写 B 这一步会阻塞在这里直到超时,后面的 C、D、E 全都跟着等。一个慢客户端拖垮整个网关,这是 IM 生产的经典事故。正确姿势(跟着写第 2 步完整实现):每个连接配一个带缓冲的发送队列,广播只是非阻塞地投队列,队列满了就认定是慢消费者、直接断开它——「宁可牺牲一个,不能拖垮全体」。

顺带补课:sync.Map 与 syncx.Map。Go 标准库的 sync.Map 是「读多写少」场景的线程安全 map(Load / Store / LoadOrStore / CompareAndSwap),不用自己加锁。但它没有泛型、取值要断言类型,所以课程里常用 ekit 库的 syncx.Map——对 sync.Map 的泛型封装,类型更安全。Hub 这种「上线一次、广播千万次」的场景正好是它的主场。

小收口:单节点 IM 齐活了——Hub 记花名册、Broadcast 挨家挨户发。但这套只在一个进程里有效:生产环境 IM 后端是多个节点,A 连节点 1、B 连节点 2 时,节点 1 的 Hub 里根本没有 B。下一节翻第二座大山。

3.6 分布式转发:注册机制 vs 广播机制(核心概念二)

先抛问题。A 连着节点 1,B 连着节点 2,A 给 B 发消息——节点 1 怎么知道 B 连在哪个节点上?群聊场景更复杂:500 人的群散落在 20 个节点上,一条消息要送达所有人。

补课胶囊 · 网关:网关(Gateway)= 系统里专门负责「接住客户端连接」的那一层,只管连接的建立、维持、收发,不做业务逻辑 → 类比:大楼前台,只负责登记来访者和传话,不管你上楼办什么事 → 回到主线:IM 里维护 WebSocket 连接的进程就叫「网关节点」。

给直觉:营业厅类比——A 在 1 号营业厅办业务,B 在 2 号厅。A 说「帮我告诉 B 一声」,1 号厅有两条路:① 查登记簿(B 在 2 号厅),直接打内线电话给 2 号厅;② 拿喇叭对全部分店广播「有 B 的口信,B 在的厅请转达」。

方案一:注册机制(精准找人)。

A → 节点1 → 注册中心(查 B 在哪) → 节点2 → B

B 连上节点时,把自己的信息(uid + 节点地址)注册到注册中心(Redis / ETCD 都行);节点 1 收到 A 发给 B 的消息后,查注册中心找到 B 在节点 2,定向转发过去。

缺点:注册中心容易成为瓶颈——每个用户上线、下线、心跳都要写它,多端登录时(手机 + 电脑 + 平板同时在线)压力更大。而且它一挂,整个 IM 的「找人」能力全灭——单点。

方案二:广播机制(广撒网)。

节点 1 不知道 B 在哪,索性给所有节点广播这条消息。节点 2 收到后发现 B 连着自己,转发给 B;节点 3 发现没有 B,丢弃。

广播的实现方式有三种:

  • RPC 广播调用:节点 1 逐个 RPC 调用节点 2、3、4……节点多的时候调用扇出很大;
  • 消息队列(生产主流):所有节点订阅同一个 Kafka topic——节点 1 发一条,所有节点都收到;
  • Redis 发布订阅:和消息队列同理,但 Redis pubsub 不保证可靠(订阅者掉线期间的消息直接丢),只适合「丢了无所谓」的场景。

缺点:消息队列 / Redis 这类中间件容易成为瓶颈;每条消息要在每个节点过一遍(哪怕 90% 的节点上根本没有目标用户),浪费是常态。

flowchart TD
    subgraph 注册机制
        A1[A→节点1] --> REG[(注册中心)]
        REG --> N2[节点2→B]
    end
    subgraph 广播机制
        A2[A→节点1] --> B1[广播到所有节点]
        B1 --> N3[节点2 命中 B,转发]
        B1 --> N4[节点3 无 B,丢弃]
    end
复习提示:这张图在讲:注册机制「精准找人但压注册中心」,广播机制「广撒网但压消息中间件」。两者各有瓶颈——分布式 IM 没有免费午餐,只有把瓶颈挪到可控的地方。

生产方案的第三层思考:网关与后端分离。真实的 IM 架构通常分两层:

  • 网关(Gateway):只负责维持 WebSocket 连接。特点:稳定、少变更、轻量——正因为简单,才容易横向扩展扛住海量连接。
  • 后端服务:处理业务逻辑(存消息、查群成员、审核、推送决策)。微服务架构下这里是一大堆服务,迭代频繁。

为什么要分开?后端服务天天发版,网关一年难得动一次——如果把连接维护和业务逻辑写在一个进程里,每次发版所有用户连接全部断开重连(重连风暴,3.9 节)。分离之后:后端随便发版,网关稳如泰山,用户的连接一点不动。

常见坑:新手设计分布式 IM 第一反应是「注册中心方案更精准,肯定更好」。但注册中心的所有读写(上线/下线/心跳续期)都是每用户每几秒一次的高频操作——几百万人在线时,这个量级足以把 ETCD / Redis 打出毛病。广播机制把「找人」的随机读变成了「全量流」的顺序消费,反而更容易水平扩展。两者取舍的本质是:精准查找的代价是热点,全量广播的代价是流量。

小收口:两条路都通了——注册机制精准但压注册中心,广播机制省心但压中间件。工程上的主流答案是「广播机制 + Kafka」,下一节把它落成代码。

3.7 Kafka 群聊:广播机制的落地

先抛问题。广播机制里「所有节点都收到每条消息」这个效果,用 Kafka 怎么实现?——直觉上 Kafka 是「分摊」消息的(一个消费组内大家分着消费),怎么让它变成「人手一份」?

给直觉。整体架构:

flowchart LR
    A[客户端 A] -->|ws| GW1[网关1]
    GW1 -->|发到 Kafka| K[(Kafka topic=msg)]
    K --> GW1
    K --> GW2[网关2]
    K --> GW3[网关3]
    GW1 --> B[用户 B]
    GW2 --> C[用户 C]
    GW3 --> D[用户 D]
复习提示:这张图在讲:所有网关都往同一个 Kafka topic 发消息、也都消费这个 topic。任意网关收到的消息会被「广播」到所有网关,再由各网关下发给连在自己身上的用户。Kafka 在这里充当广播总线。

第一块积木:Message 格式。先定义消息长什么样:

// im-demo/message.go
package im

// Message IM 消息格式(教学最简版)
type Message struct {
    Seq     int64  `json:"seq"`     // 前端生成的序列号,本次连接内唯一即可
    Type    string `json:"type"`    // 消息类型:text/image/system...
    Content string `json:"content"` // 具体内容,根据 Type 解析
    Cid     int64  `json:"cid"`     // channel id:聊天 ID(单聊/群聊/系统通知)
}

逐行讲解:Seq 由前端生成,用于前后端关联(收到 ACK 知道是哪条、失败按它重试)——注意后端落库时还会有自己的服务端 ID,两者不冲突;Type 标识消息类型(多媒体、系统消息都靠它扩展);Cid 是「聊天 ID」——单聊、群聊、系统通知在数据结构上是同一种东西,都是一个 channel。

第二块积木:网关接收消息并转发。网关的读循环收到客户端消息后,转交给后端处理,并回 ACK:

// im-demo/gateway.go(第一块:读循环与转发)
package im

import (
    "context"
    "encoding/json"

    "github.com/gorilla/websocket"
)

// Gateway WebSocket 网关
type Gateway struct {
    conn    *websocket.Conn
    uid     int64
    backend BackendService // 转发到后端服务
}

// ReceiveAndForward 接收客户端消息并转发到后端
func (g *Gateway) ReceiveAndForward() error {
    for {
        _, data, err := g.conn.ReadMessage() // ① 读
        if err != nil {
            return err
        }
        var msg Message
        if err := json.Unmarshal(data, &msg); err != nil {
            g.sendError(0, "invalid message format") // ② 格式错,通知前端
            continue
        }
        if err := g.backend.SendMessage(context.Background(), g.uid, msg); err != nil {
            g.sendError(msg.Seq, "send failed") // ③ 转发失败,通知前端重试
            continue
        }
        g.sendAck(msg.Seq) // ④ 成功,回 ACK(3.8 节展开)
    }
}
// im-demo/gateway.go(第二块:ACK 与错误帧)
func (g *Gateway) sendAck(seq int64) {
    ack, _ := json.Marshal(map[string]any{"type": "ack", "seq": seq})
    _ = g.conn.WriteMessage(websocket.TextMessage, ack)
}

func (g *Gateway) sendError(seq int64, reason string) {
    e, _ := json.Marshal(map[string]any{"type": "error", "seq": seq, "reason": reason})
    _ = g.conn.WriteMessage(websocket.TextMessage, e)
}

第三块积木:后端找成员、投 Kafka。

// im-demo/backend.go
package im

import "context"

// BackendService 后端服务接口
type BackendService interface {
    SendMessage(ctx context.Context, sender int64, msg Message) error
}

// KafkaBackend 基于 Kafka 的后端实现
type KafkaBackend struct {
    groupSvc GroupService  // 查询群成员
    producer KafkaProducer // Kafka 生产者
}

func (b *KafkaBackend) SendMessage(ctx context.Context, sender int64, msg Message) error {
    // ① 找到当前聊天的所有成员
    members, err := b.groupSvc.GetMembers(ctx, msg.Cid)
    if err != nil {
        return err
    }
    // ② 投递到 Kafka,每个网关节点都会消费到
    //    生产中这里还会做:存储消息记录、内容审核、同步搜索等
    return b.producer.Send("msg", encodeMsg(sender, msg, members))
}

第四块积木:网关消费 Kafka 并下发。回到开头的问题——怎么让「分摊」变「人手一份」?

// im-demo/consume.go
package im

import "context"

// ConsumeLoop 每个网关节点都作为独立消费组,消费全部消息
func (g *Gateway) ConsumeLoop(ctx context.Context) error {
    // 关键:以「节点 ID」为消费组名 → 每个网关都是独立消费组,各拿全量消息
    consumer := g.kafka.Consumer("msg-group-" + g.nodeID)
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case msg := <-consumer.Messages():
            var event MsgEvent
            json.Unmarshal(msg.Value, &event)
            // 只把连在自己节点上的目标用户真正下发
            for _, uid := range event.TargetUids {
                if conn, ok := g.hub.Get(uid); ok {
                    _ = conn.WriteMessage(websocket.TextMessage, event.Payload)
                }
            }
        }
    }
}

逐行讲解:核心就一行——消费组名用 nodeID 区分。回忆第 07 章:Kafka 把一个 topic 的消息在一个消费组内分摊(每条消息只给组内一个消费者);而不同消费组各自独立消费全量。所以每个网关节点起一个自己专属的消费组(组名带 nodeID),人人都能拿到全量消息,再各自判断「这条消息的目标用户在不在我这」——广播完成。

复习提示:⚠️ 新手必踩的坑:群聊消费组不能共享。如果所有网关共用一个消费组名,Kafka 会把消息「分摊」给组内成员——一条消息只会被其中一个网关消费,连在别的网关上的用户就收不到。所以每个网关节点必须用不同的消费组(用 nodeID 区分),各自拿到全量消息再本地过滤下发。想验证很简单:起两个节点故意共用消费组,观察「有用户收不到消息」;改成独立消费组,现象消失(跟着写和自测题都会再碰到它)。

延伸阅读:gorilla/websocket 官方 chat 例子。gorilla 仓库自带一个 chat 示例,和本章的群聊高度同构——精髓在 Hub.run 方法:用 Go channel 做消息分发,channel 扮演了我们这里 Kafka 的角色(注册 / 注销 / 广播全走 channel)。这个例子对初学者不太友好(满屏 channel 操作),但学完本章再回头看,你会发现它就是把「Kafka 广播总线」换成了进程内的 channel——一个单节点方案。适合当作学完本章的自检阅读材料。

小收口:跨节点广播跑通了——Kafka 当总线、每节点独立消费组、本地过滤下发。但到目前为止我们只保证了「消息大概能到」,微信级别的 IM 要的是「不丢、不重、有序、离线也可达」——下一节把这四件事一次讲透。

3.8 可靠投递:不丢、不重、有序、离线可达(核心概念三)

先抛问题。用户对 IM 的信任底线是「发出去的消息对方一定能收到,且只收到一次」。翻车一次(表白消息丢了 / 同一条红包消息弹了两次),用户就卸载你。但分布式环境下「不丢不重」天然矛盾:为了不丢就要重试,重试就可能重——这一节就是解开这对矛盾。

补课胶囊 · ACK:ACK(Acknowledgement)= 确认应答,接收方收到消息后回一个「我收到了」的小回执 → 类比:快递签收短信——没收到签收短信你就查物流、找客服 → 回到主线:IM 里 ACK 是「消息被服务端接收成功」的回执,客户端没收到 ACK 就重发。

第一件事:不丢——先落库,再 ACK。可靠投递的标准姿势:

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: 发消息 seq=1
    S->>S: 持久化落库
    S-->>C: ACK(seq=1)
    Note over C: 超时未收到 ACK,按 seq 重发
复习提示:这张图在讲:服务端「先落库、再 ACK」——只有持久化成功的消息才配回「收到了」;客户端「没收到 ACK 就重发」。两边配合,消息不可能丢(除非磁盘物理损坏)。

ACK 与重试的配合还要抠三个细节:

  • 客户端发消息时先本地标记「发送中」,启动超时计时(如 5 秒);
  • 超时未收到 ACK → 重发(设次数上限,比如 3 次),期间 UI 一直转圈;
  • 重试用尽仍未 ACK → 标记「发送失败」,交给用户手动点重发,而不是无限重试刷爆服务端。

第二件事:不重——幂等去重。重试天然带来重复:消息其实到了,只是 ACK 在路上丢了,客户端会再发一次。去重两条路:

  • 服务端幂等:以 (发送者 uid, 客户端 seq) 或服务端消息 ID 为键做唯一约束(DB 唯一索引 / Redis SETNX)。重发的消息插不进去,直接返回成功——重试 + 幂等 = 效果上的恰好一次(这句话面试直接用)。
  • 客户端去重:接收方按「会话内序号」检查,发现比已处理的序号旧或相等就丢弃,避免界面出现重复气泡。
复习提示:⚠️ 新手必踩的坑:把「至少一次」当「恰好一次」。任何重试机制都只能给你「至少一次」(at-least-once)——消息可能重复到达;只有配上服务端幂等去重,用户才感知不到重复。面试里把「ACK 重试 + 幂等去重」连起来说,才算答完整——这和第 07 / 18 章 Kafka 消费者是同一个道理。

第三件事:有序——会话内有序,不求全局有序。

为什么顺序会乱:分布式后端是多节点并发的——A 连发两条消息,可能经不同网关节点、不同 Kafka 分区转发;再叠加重试(旧消息重发插到新消息后面)和网络乱序,到达接收方时顺序就乱了。

解法的关键是保证「会话内有序」而不是「全局有序」——全局有序代价极高(所有消息串行过一台机器)且没必要(张三和李四的聊天顺序,和王五赵六的有什么关系?):

  • 服务端分配会话内单调递增 ID:单聊可以由 DB 自增主键天然保证;群聊则需要集中的 ID 分配器——比如对会话 ID 用 Redis INCR,或者把同一个群的消息路由到同一个 Kafka 分区(第 18 章按主键 hash 分区的老朋友),由该分区串行分配。
  • 客户端排序缓冲:接收方收到消息后如果发现序号跳号(收到 3 却还没收到 2),先放进缓冲区等一小会儿,补齐或超时后再渲染。
复习提示:注意语义差别:顺序性保证的是「发送顺序 = 展示顺序」。如果业务还要求「已读回执也按序上报」,那是另一条链路的顺序问题。面试时先问清楚面试官说的是哪个环节的顺序,再作答——这本身就是加分动作。

第四件事:离线可达——在线就推,离线先存。

flowchart TD
    M[消息到达] --> P[持久化 DB]
    P --> ON{用户在线?}
    ON -- 是 --> D[实时下发]
    ON -- 否 --> OFF[存离线]
    OFF --> U[用户上线拉取未读]
    U --> R[标记已读]
复习提示:这张图在讲:离线消息的本质就是「在线就推、不在线先存」,上线再补拉。类比:快递柜——你不在家,快递先放柜子里,你回来再取。

三个工程细节:① 用户离线时消息持久化到 DB(或 Redis List,跟着写第 5 步实现);② 上线后拉取未读消息,拉取后标记已读;③ 离线消息存储一般有时效(7 天 / 30 天),超期的只保留在 DB 归档里。

第五件事(顺带):已读未读。客户端上报已读位置(lastReadSeq:我读到了会话内第几条);服务端比对「最新消息序号 - 已读位置」算出未读数。群聊更复杂——要为每个成员维护独立的已读位置(我读过的你未必读过),微信群的「xx 人已读」就是这么来的。

常见坑:新手把「可靠投递」寄托在 TCP 身上——「TCP 不是可靠传输吗?」TCP 的「可靠」只覆盖单条连接内的字节流;消息一旦经过 Kafka、跨了节点、断线重连换了一条连接,TCP 的保证就鞭长莫及。端到端的可靠性必须由应用层自己构建(ACK + 幂等 + 序号),这和第 07 章「Kafka 的 at-least-once 要靠消费端幂等兜底」是同一个思想。

小收口:四件套全部到位——ACK 保不丢、幂等保不重、会话内 ID 保有序、离线暂存保可达。到这里 IM 的「消息逻辑」就完整了。但还有一件日常到不能再日常的事没讲:连接本身会断——心跳、重连、迁移,下一节。

3.9 长连接的日常维护:心跳、重连、连接迁移

先抛问题。3.1 节大山一的三个子问题在这里逐一解决:连接怎么发现「对面已经死了」(心跳)、断了怎么回来(重连)、服务器要发版怎么把连接迁走(连接迁移)。

① 心跳的必要性——半开连接问题。

补课胶囊 · 半开连接:半开连接 = 一端已经断了(进程被杀 / 断网),另一端却还以为连接活着 → 类比:对方已经挂了电话走人了,你还捧着话筒滔滔不绝 → 回到主线:手机切 WiFi、NAT 映射超时、进程被杀……这些情况下对端不会有任何报文通知你「我断了」,服务端这边连接看起来还活着(conn 还在 map 里),实际写过去的数据石沉大海。

补课胶囊 · NAT:NAT(Network Address Translation)= 让多台设备共享一个公网 IP 的网络地址转换机制,路由器靠一张「映射表」记住「哪个内网设备在和哪个外面的人说话」→ 类比:小区前台代收快递,靠登记簿记住每户的门牌 → 回到主线:这张表有条目有效期(常见几分钟),长时间没流量就删掉条目——此后外面来的消息全被前台丢弃,连接事实上已死,但你毫无感知。

心跳就是用「定期小报文」主动探测连接是否还通:一段时间没收到对方心跳,就认定连接已死,清理资源。3.3 节说过 WebSocket 协议自带心跳——Ping / Pong 控制帧。服务端典型写法:

// 心跳:读超时 + Pong 续期 + 定时 Ping(三件套)
// 步骤 1:设置读超时。超过 readDeadline 还没收到任何东西(含 Pong),
// ReadMessage 会返回超时错误 → 走断开清理逻辑
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error {
    // 收到 Pong,读超时"续命"
    conn.SetReadDeadline(time.Now().Add(90 * time.Second))
    return nil
})

// 步骤 2:另起 goroutine 定时发 Ping(30s 一次)
go func() {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
            return // 写失败,说明连接已死
        }
    }
}()

逐行讲解:这套机制的精髓是「读超时当死活判定」——服务端不需要真的解析 Pong 的内容,只要「读超时时间内收到过任何东西(Pong 也算)」就续期;Ping 只是促使客户端回 Pong 的探针(浏览器会自动回 Pong,不用前端写代码)。心跳间隔(30s)必须明显小于判定超时(90s),给网络抖动留余量。

复习提示:⚠️ 心跳间隔不是越小越好。太短,移动端费电费流量、服务端 CPU 被无意义的 Ping 刷屏;太长又赶不上 NAT 超时(常见 NAT 表项几分钟就过期)。工程上常见 30s60s 心跳 + 23 倍的判定超时。另外 TCP 自带的 Keep-Alive 默认约 2 小时才探测一次,对 IM 来说太迟钝——这就是为什么必须在应用层自己做心跳(3.3 节留的问题在这里闭环)。

② 重连的姿势——指数退避 + 随机抖动。断线后客户端如果立刻重连,而服务端恰好整体过载,几十万客户端同时重连会把它彻底打死(重连风暴——服务刚恢复就又被压死,循环往复)。标准解法:

  • 指数退避:第 1 次等 1s、第 2 次等 2s、然后 4s、8s……设上限(如 60s)——重试越失败越保守;
  • 随机抖动(jitter):在退避时间上加一个随机偏移,把各客户端的重连时刻错开——别让几十万人同一毫秒一起冲;
  • 重连成功后先补拉离线消息,再恢复正常收发(和 3.8 节离线消息衔接——掉线期间的消息一条不能少)。

③ 连接迁移。网关节点要发布新版本 / 缩容时不能直接 kill——几十万连接瞬间断开 + 全员重连 = 自己制造重连风暴。正确姿势分三步:

  1. 先从负载均衡摘除本节点(不再接新连接);
  2. 给存量连接一个优雅关闭窗口:发一条「服务器重启,请重连」的系统消息,客户端收到后主动断开、按退避策略连到别的节点;
  3. 等窗口结束(队列冲刷完)再退出进程。

用户感知就是「闪了一下,消息没丢」。跟着写第 6 步会把这套优雅停机完整实现。

常见坑:只做心跳不做重连(连接死了客户端干等)、只做重连不做退避(风暴)。心跳、退避、迁移是三件独立的工程,缺一不可。

小收口:大山一的三个子问题(发现死连接、断线回来、发版迁移)全部解决。正文最后一步:看看生产级的开源 IM 长什么样——OpenIM。

3.10 OpenIM:站在开源的肩膀上

先抛问题。前面九节教你造一个 IM;这一节教你用一个 IM——真实工作里,多数公司的「站内私聊」需求根本不需要从零造轮子,接一个成熟开源方案(OpenIM)就够了。知道什么时候造、什么时候用,比只会其中一样值钱。

OpenIM 是什么。一款开源 IM 解决方案,提供完整的工具和库来构建实时通讯应用:

  • 开源:开放源代码,活跃社区支持,出问题可以自己翻代码;
  • 灵活可定制:分层架构,支持自定义消息类型;
  • 跨平台:Web / iOS / Android 多端通用,提供各端 SDK。

整体架构——你会发现它和我们自己设计的一一同构:

flowchart TD
    SDK[SDK 层
多语言/多平台接入] --> ACC[access layer
访问层,少量业务逻辑] ACC --> SVC[service layer
push/auth/user/msg/friend/group] SVC --> MQ[消息队列 → msgtransfer → 本地缓存] MQ --> STORE[存储层
数据存储 + 消息存储]
复习提示:这张图在讲:OpenIM 的分层和本章的设计完全一致——接入层管连接(我们的「网关」)、service 层管业务(我们的「后端服务」)、底下是消息总线(我们的 Kafka)与存储。原理学透了,看任何开源方案都是换皮的自己的设计。

Docker Compose 部署:

# 克隆部署仓库
git clone https://github.com/openimsdk/openim-docker openim-docker
cd openim-docker
make init  # 初始化部署参数

# 配置 OPENIM_IP(用环境变量)
export OPENIM_IP=<你的服务器 IP>

# 启动(建议先清理 Docker 中同名容器,避免端口冲突)
docker-compose up -d
复习提示:注意:启动过程缓慢,拉镜像需要科学上网;最好先清理 Docker 里之前部署的同名容器,防止端口冲突。试用时用 OPENIM_IP 访问(不要用 localhost,小部分功能会有问题);Web 端用任意手机号注册(演示环境验证码统一为 666666);管理后台在 http://OPENIM_IP:11002,默认账号密码都是 chatAdmin。加好友需要对方同意后才能开始对话。

监控:OpenIM 自带监控接入——后台管理左侧菜单 → Dashboard → Grafana(默认账号密码都是 admin),数据源用 Prometheus(部署在 Docker 内部,用 Docker 服务名作为域名),导入官方提供的 Grafana Dashboard JSON 即可。关键监控指标就两个:message/s(发送消息频率)和 failures/s(失败频率)——IM 的健康度一屏看清。

在业务系统中接入 OpenIM。典型场景:你的内容社区需要「作者和读者私聊」,不需要完整 IM。要点两步:

  1. 前端:接入 OpenIM SDK,在网站里嵌入私聊窗口;
  2. 后端:业务系统的用户要同步到 OpenIM(用户在内容社区注册,也得在 OpenIM 里有账号才能收发消息)。

用户数据同步推荐异步——用第 18 章的 Canal 监听 binlog,业务服务零侵入:

用户注册 → 业务 DB → Canal 监听 binlog → 消费者 → 调 OpenIM 注册接口

Go 代码(用 ekit 的 httpx):

// internal/openim/syncer.go(第一块:结构体与请求构造)
package openim

import (
    "context"
    "net/http"

    "github.com/gotomicro/ekit/httpx"
)

// UserSyncer 把业务系统用户同步到 OpenIM
type UserSyncer struct {
    secret    string // 部署 OpenIM 时配置在 config/config.yaml 的 secret
    apiBase   string // 例如 http://OPENIM_IP:10002
    transport http.RoundTripper
}

// SyncUser 注册用户到 OpenIM
func (s *UserSyncer) SyncUser(ctx context.Context, uid int64, nickname, phone string) error {
    reqBody := map[string]any{
        "secret": s.secret,
        "users": []map[string]any{{
            "userID":   uid,
            "nickname": nickname,
            "phone":    phone,
        }},
    }
    req, err := httpx.NewRequest(ctx, http.MethodPost, s.apiBase+"/user/user_register", reqBody)
    if err != nil {
        return err
    }
    // operationID 标识本次请求,方便链路追踪——推荐用 OTel 的 trace ID
    req.Header.Set("operationID", traceIDFromContext(ctx))
    return s.do(req)
}
// internal/openim/syncer.go(第二块:发送与校验)
func (s *UserSyncer) do(req *http.Request) error {
    var resp struct {
        ErrCode int    `json:"errCode"`
        ErrMsg  string `json:"errMsg"`
    }
    if err := httpx.DoRequest(s.transport, req, &resp); err != nil {
        return err
    }
    if resp.ErrCode != 0 { // OpenIM 的约定:ErrCode 不为 0 即失败
        return fmt.Errorf("openim register failed: %s", resp.ErrMsg)
    }
    return nil
}

关键点:请求要携带 secret(部署时配置);要传 operationID 头部(推荐用 OTel 的 trace ID,出问题能顺着链路查);返回的 ErrCode != 0 即为失败。完整的 REST API 文档在 OpenIM 官网(doc.rentsoft.cn),大部分时候只需要接「用户注册同步」这一个接口,其他用到时照文档写即可。上面是「单用户同步」;如果以后引入批量注册功能,记得改成批量接口,性能才扛得住。前端 SDK 是接入 OpenIM 最「五花八门」的部分(Web / iOS / Android / Flutter / Unity 都有),具体 API 参考官方文档。

常见坑:同步用户写成「注册接口里同步调 OpenIM」——业务注册接口从此依赖 OpenIM 的可用性(OpenIM 挂了用户就注册不了)。走 Canal 异步,业务只管写自己的 DB,同步慢一点没关系——又是「解耦」这个老朋友。

小收口:正文到此收官。回顾全章:两座大山(长连接、分布式投递)→ WebSocket(协议选型 + 原理)→ Hub(单节点)→ 注册 vs 广播(分布式)→ Kafka 群聊(广播落地)→ 可靠投递四件套 → 心跳重连迁移 → OpenIM。接下来进「跟着写」,把这套蓝图落成一个能跑的 im-service。


四、跟着写:分步实现

上一章结束时,Feed 流收件箱已能稳定写入,但「刷新才看得到」终究不够快。这一节把正文写好的「积木」(upgrader、Hub、读循环骨架、Kafka 广播)组装成能跑的 im-service 微服务(WebSocket :8600,ETCD 注册同第 12 章)。和 Feed 章一样,老规矩不变:viper 读配置、zap 打日志、复用 pkg/kafka 和 pkg/registry。

相比正文的教学版,这次落地要升级四点(每一点都对应正文里的一个坑):

  1. 协议升级:所有 WebSocket 报文统一装进 Frame 外壳——业务消息和信令(心跳、ACK、错误)共用一条连接;
  2. 多端登录:一个用户可能手机 + 电脑同时在线,连接管理从 map[uid]conn 升级为 map[uid]map[*Conn];
  3. 串行写:正文 3.4 / 3.5 节的「并发写」坑,用「一个连接一个写 goroutine + 发送队列」根治;
  4. 双 topic:正文 3.7 节的单 topic 全量广播升级为「im-message 业务处理 + im-forward 定向投递」,消息不必在每个节点空跑一遍。

步骤总览:

  1. 消息协议——Message + Frame 统一 JSON 编解码;
  2. ConnManager 连接管理器(多端登录 + 串行写 + Ping,核心);
  3. WebSocket 接入——JWT 校验 + Redis 在线路由 + 心跳(核心);
  4. Kafka 双 topic 转发——im-message 消费 + im-forward 定向投递(核心);
  5. 离线消息——Redis List 暂存 + 上线补拉;
  6. main 组装、优雅停机与端到端验证。

第 1 步:消息协议升级

正文 3.7 节的 Message 是教学最简版;生产规格要多三样东西——幂等 ID(msg_id,重发去重靠它)、会话类型(单聊/群聊分开处理)、状态位(sending/sent/read/failed 给前端展示)。并且所有报文统一装进 Frame 外壳——业务消息和信令共用一条 WebSocket:

// im-service/internal/domain/message.go
package domain

import "encoding/json"

// Message 消息体:幂等 ID + 会话类型 + 状态位的生产规格
type Message struct {
    MsgID      string `json:"msg_id"`      // 消息唯一 ID(客户端生成的 UUID),服务端按它幂等去重
    MsgType    string `json:"msg_type"`    // text / image / file / system
    ConvType   string `json:"conv_type"`   // single 私聊 / group 群聊
    SenderID   int64  `json:"sender_id"`
    ReceiverID int64  `json:"receiver_id"` // 私聊=对方用户 ID;群聊=群 ID
    Content    string `json:"content"`     // 按 MsgType 解释:文本/图片URL/文件URL…
    Timestamp  int64  `json:"timestamp"`   // Unix 秒
    Status     string `json:"status"`      // sending / sent / read / failed
}
// im-service/internal/domain/frame.go
package domain

// Frame.Type 常量:信令只做传输控制,不落库;message_read / sync_request 等留作练习
const (
    FrameMessage    = "message"     // 业务消息
    FrameConnectAck = "connect_ack" // 连接建立确认
    FrameHeartbeat  = "heartbeat"   // 应用层心跳(协议层 Ping/Pong 之外的兜底)
    FrameMessageAck = "message_ack" // 服务端已接收 / 已送达
    FrameError      = "error"       // 服务端错误提示
)

// Frame 传输帧:所有 WebSocket 报文的统一外壳
type Frame struct {
    Type    string          `json:"type"`              // 取上面的常量
    Payload json.RawMessage `json:"payload,omitempty"` // 按 Type 再解析
}

// ForwardEvent im-forward topic 的载荷:跨节点转发的定向包裹
type ForwardEvent struct {
    GatewayAddr string  `json:"gateway_addr"` // 接收者所在网关地址(查 im:route 得到)
    TargetUID   int64   `json:"target_uid"`   // 实际推送对象(群聊时 != Message.ReceiverID)
    Message     Message `json:"message"`
}

再写两个 5 行以内的小包装:NewFrame(typ, payload) 打包帧并序列化、ParseFrame(data) 反序列化帧——都是 json.Marshal / Unmarshal 的一层薄封装,自己动手写。

验证:go build ./... 通过;用 NewFrame 编一条消息打印出来,肉眼看 JSON 字段和上面结构体注释一一对应。

第 2 步:ConnManager 连接管理器(核心)

正文 3.5 节的 Hub 只支持「一个用户一条连接」,也没解决写并发。生产要求多端登录(map[uid][]conn)、sync.RWMutex 保护、慢消费者隔离。这里升级成 ConnManager,并把 3.9 节的心跳收编进来——Ping 的写也必须走唯一的写 goroutine(原因见下方坑)。

先看常量和单条连接 Conn——注意 writeLoop 是本步的灵魂:

// im-service/internal/gateway/conn_manager.go(第一块:常量与 Conn)
package gateway

import (
    "errors"
    "sync"
    "time"

    "github.com/gorilla/websocket"
)

// 心跳参数:30s 一次 Ping,90s 收不到报文判死连接(3.9 节的工程约定)
const (
    pingInterval = 30 * time.Second
    readTimeout  = 90 * time.Second
    writeTimeout = 10 * time.Second // 单条写超时:慢消费者保护
    sendQueueLen = 256
)

var ErrSlowConsumer = errors.New("send queue full: slow consumer")

// Conn 单条连接:所有写(业务消息 + Ping)都投给 send 队列,
// 由唯一的 writeLoop 串行写出——gorilla/websocket 不允许并发写同一连接
type Conn struct {
    ws     *websocket.Conn
    send   chan []byte
    once   sync.Once
    closed chan struct{}
}

func newConn(ws *websocket.Conn) *Conn {
    c := &Conn{ws: ws, send: make(chan []byte, sendQueueLen), closed: make(chan struct{})}
    go c.writeLoop() // 启动唯一的写 goroutine
    return c
}

writeLoop 消费 send 队列 + 定时 Ping,两种写天然串行:

// im-service/internal/gateway/conn_manager.go(第二块:writeLoop)
// writeLoop 唯一的写 goroutine:业务写和 Ping 在这里汇合成串行流
func (c *Conn) writeLoop() {
    ticker := time.NewTicker(pingInterval)
    defer ticker.Stop()
    write := func(mt int, data []byte) bool { // 统一设写超时;写失败=连接已死
        _ = c.ws.SetWriteDeadline(time.Now().Add(writeTimeout))
        return c.ws.WriteMessage(mt, data) == nil
    }
    for {
        select {
        case msg := <-c.send:
            if !write(websocket.TextMessage, msg) {
                c.Close()
                return
            }
        case <-ticker.C:
            if !write(websocket.PingMessage, nil) {
                c.Close()
                return
            }
        case <-c.closed:
            _ = c.ws.Close()
            return
        }
    }
}

// TrySend 非阻塞投递:队列满=慢消费者,交给上层断开,绝不阻塞调用方
func (c *Conn) TrySend(msg []byte) error {
    select {
    case c.send <- msg:
        return nil
    default:
        return ErrSlowConsumer // 3.5 节「慢消费者拖垮 Hub」的根治方案
    }
}

// Close 幂等关闭(sync.Once 保证多个 goroutine 同时调也只关一次)
func (c *Conn) Close() { c.once.Do(func() { close(c.closed); _ = c.ws.Close() }) }

最后是管理器本体——值用集合(map[*Conn]struct{}),天然支持一个用户多条连接(多端登录):

// im-service/internal/gateway/conn_manager.go(第三块:ConnManager)
// ConnManager 连接管理器。BroadcastAll(全量广播)/ CloseAll(关全部连接
// 并返回 uid,停机清路由用)同理:遍历 uid 逐个 Send/Close——先复制 uid
// 再放锁,持锁回调会死锁,留给你写
type ConnManager struct {
    mu    sync.RWMutex
    conns map[int64]map[*Conn]struct{} // uid -> 该用户的全部连接(多端)
}

func NewConnManager() *ConnManager {
    return &ConnManager{conns: make(map[int64]map[*Conn]struct{})}
}

func (m *ConnManager) Add(uid int64, c *Conn) { // 上线注册(第二次 Add 即多端登录)
    m.mu.Lock()
    defer m.mu.Unlock()
    if m.conns[uid] == nil {
        m.conns[uid] = make(map[*Conn]struct{})
    }
    m.conns[uid][c] = struct{}{}
}

// Remove 断开移除,最后一条没了删整个 entry;返回该用户是否还有连接
// (第 3 步据此决定要不要清 Redis 路由——多端在线时不能误清)
func (m *ConnManager) Remove(uid int64, c *Conn) bool {
    m.mu.Lock()
    defer m.mu.Unlock()
    delete(m.conns[uid], c)
    if len(m.conns[uid]) == 0 {
        delete(m.conns, uid)
        return false
    }
    return true
}

// Send 推给该用户全部连接(多端同步);群聊批量下发就是外层循环调它
func (m *ConnManager) Send(uid int64, msg []byte) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    for c := range m.conns[uid] {
        _ = c.TrySend(msg) // 非阻塞投递:慢消费者只牺牲自己
    }
}
复习提示:⚠️ 新手必踩的坑:Ping 也会并发写。读循环推业务消息是一个 goroutine,3.9 节的 Ping 定时器又是一个——两个都是「写」,照样踩正文 3.4 节说的并发写。所以上面把业务写和 Ping 全收进唯一的 writeLoop,对外只暴露 TrySend(投队列)。口诀:一个连接一个写 goroutine,其余人只准投队列。

验证:go build ./... 编译通过(连接管理器依赖真实连接,行为验证放到第 3 步端到端做)。

第 3 步:WebSocket 接入——JWT 校验 + 在线路由 + 心跳(核心)

连接建立流程:ws://host:8600/ws?token=<jwt> → 校验 token → 升级并注册连接管理器 → Redis 记录路由(im:route:<uid> = 本节点地址,TTL 60s)→ 回 connect_ack → 心跳续期。

两个前置说明:parseToken 与第 02 章的 JWT 中间件同款(ParseWithClaims 校验后从 Subject 取 uid),直接搬来;NewHandler 就是把依赖逐字段赋值,自己写。先看接口定义与 Handler 结构:

// im-service/internal/gateway/ws_handler.go(第一块:依赖接口与结构体)
package gateway

import (
    "context"

    "github.com/redis/go-redis/v9"
    "github.com/yourname/content-community/im-service/internal/domain"
    "go.uber.org/zap"
)

// Producer 发 Kafka(第 4 步实现);Offline 离线存取(第 5 步实现)
type Producer interface {
    ToMessage(ctx context.Context, msg domain.Message) error     // → im-message(key=sender_id)
    ToForward(ctx context.Context, ev domain.ForwardEvent) error // → im-forward(key=接收者,保序)
}

type Offline interface {
    Push(ctx context.Context, uid int64, msg domain.Message) error
    Drain(ctx context.Context, uid int64, send func(domain.Message) error) error
}

// Handler WebSocket 接入层
type Handler struct {
    manager  *ConnManager
    rdb      *redis.Client
    producer Producer
    offline  Offline
    nodeAddr string // 本节点地址:写进 Redis 路由,多实例部署时每实例不同
    jwtKey   []byte // JWT 密钥,与 user-service 共享(同第02章)
    logger   *zap.Logger
}

// upgrader 参数说明见正文 3.4 节;生产环境 CheckOrigin 务必校验白名单
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}

接入的前半段——升级、注册、路由:

// im-service/internal/gateway/ws_handler.go(第二块:接入前半段)
// HandleWS 处理 ws://host:8600/ws?token=<jwt>
func (h *Handler) HandleWS(w http.ResponseWriter, r *http.Request) {
    // ① 升级前校验 JWT——升级成 WebSocket 后就没法回 HTTP 401 了(正文 3.3 节)
    uid, err := h.parseToken(r.URL.Query().Get("token"))
    if err != nil {
        http.Error(w, "invalid token", http.StatusUnauthorized)
        return
    }
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }
    conn := newConn(ws)
    h.manager.Add(uid, conn)
    routeKey := "im:route:" + strconv.FormatInt(uid, 10)
    defer func() { // 断开清理:Remove 返回 false = 该用户最后一条连接,才清路由
        if !h.manager.Remove(uid, conn) {
            _ = h.rdb.Del(context.Background(), routeKey).Err()
        }
    }()
    // ② 注册在线路由 im:route:<uid> = 本节点地址,TTL 60s(靠心跳续期)
    _ = h.rdb.Set(context.Background(), routeKey, h.nodeAddr, 60*time.Second).Err()
    // ③ 回 connect_ack;上线补拉离线消息在第 5 步接上
    ack, _ := domain.NewFrame(domain.FrameConnectAck, map[string]any{"uid": uid})
    _ = conn.TrySend(ack)
    h.serveLoop(uid, conn, ws, routeKey) // ④ 心跳 + 读循环,见第三块
}

后半段——心跳与读循环:

// im-service/internal/gateway/ws_handler.go(第三块:心跳与读循环)
func (h *Handler) serveLoop(uid int64, conn *Conn, ws *websocket.Conn, routeKey string) {
    // 心跳落地(原理见正文 3.9 节):Ping 由第 2 步的 writeLoop 发,
    // 这里只管读超时和续期
    ws.SetReadDeadline(time.Now().Add(readTimeout))
    ws.SetPongHandler(func(string) error {
        ws.SetReadDeadline(time.Now().Add(readTimeout)) // 收到 Pong,读超时续命
        _ = h.rdb.Set(context.Background(), routeKey, h.nodeAddr, 60*time.Second).Err() // 顺手续路由 TTL
        return nil
    })
    for { // 读循环:心跳直接回帧;业务消息进 im-message
        _, data, err := ws.ReadMessage()
        if err != nil {
            return // 读失败=连接关闭/超时,走 defer 清理
        }
        frame, err := domain.ParseFrame(data)
        if err != nil {
            continue // 解不开的帧直接丢弃
        }
        switch frame.Type {
        case domain.FrameHeartbeat:
            pong, _ := domain.NewFrame(domain.FrameHeartbeat, map[string]any{"ts": time.Now().Unix()})
            _ = conn.TrySend(pong)
        case domain.FrameMessage:
            var msg domain.Message
            if json.Unmarshal(frame.Payload, &msg) != nil {
                continue
            }
            msg.SenderID = uid     // 发送者以 token 为准,防止伪造 sender_id
            msg.Timestamp = time.Now().Unix()
            if err := h.producer.ToMessage(context.Background(), msg); err != nil {
                continue // 发送失败:回 error 帧让客户端重试(打包同上,略)
            }
            msg.Status = "sent" // message_ack:服务端已接收,客户端停止转圈/重试
            ackMsg, _ := domain.NewFrame(domain.FrameMessageAck, msg)
            _ = conn.TrySend(ackMsg)
        }
    }
}
复习提示:⚠️ 新手必踩的坑:路由 TTL 不续期,在线用户被「判离线」。路由 key 的 TTL 是 60s,如果只在上线时写一次,60 秒后 key 过期——用户明明还在线,投递逻辑却查不到路由,消息全进了离线箱。解法就是上面 PongHandler 里的那句 Set:每个心跳周期(30s)续一次,TTL 永远够不到 0。凡是「带 TTL 的在线状态」都要先想清楚谁来续期。

验证:先 go build ./...(main 第 6 步才组装,此时可临时写个只挂 /ws 路由的极简 main 起服务)。服务起来后:

wscat -c "ws://localhost:8600/ws?token=<jwt>"  # token 从 user-service 登录接口拿
# 预期:连上立刻收到 {"type":"connect_ack",...};发 {"type":"heartbeat"} 预期收到带 ts 的回帧
redis-cli TTL im:route:1  # 在 1~60 之间跳动,每隔 30s 被 Pong 续回 60——不会归零

第 4 步:Kafka 双 topic 转发(核心)

正文 3.7 节的方案是「单 topic 全量广播」——每个网关消费所有消息、本地过滤,简单但所有消息要在每个节点过一遍。生产规格是双 topic 各司其职:im-message(网关 → 业务处理:落库、判离线)+ im-forward(业务处理 → 目标网关定向投递,按 receiver_id 分区保序)。

先给生产者实现——NewKafkaProducer 就是第 07 章的 sarama 同步生产者(WaitForAll + Return.Successes),不再重复:

// im-service/internal/repository/producer.go
package repository

import (
    "encoding/json"
    "strconv"

    "github.com/IBM/sarama"
    "github.com/yourname/content-community/im-service/internal/domain"
)

// 双 topic 常量
const (
    TopicIMMessage = "im-message" // 网关 → 业务处理
    TopicIMForward = "im-forward" // 业务处理 → 目标网关(key=接收者,同用户保序)
)

// KafkaProducer 实现 gateway.Producer
type KafkaProducer struct{ p sarama.SyncProducer }

func (k *KafkaProducer) send(topic, key string, v any) error {
    data, err := json.Marshal(v)
    if err != nil {
        return err
    }
    // key 参与分区选择:同 key 进同分区、分区内有序——
    // 「按 receiver_id 分区保证顺序」就是这一行(正文 3.8 节的会话内有序)
    _, _, err = k.p.SendMessage(&sarama.ProducerMessage{
        Topic: topic, Key: sarama.StringEncoder(key), Value: sarama.ByteEncoder(data),
    })
    return err
}

func (k *KafkaProducer) ToMessage(ctx context.Context, msg domain.Message) error {
    return k.send(TopicIMMessage, strconv.FormatInt(msg.SenderID, 10), msg)
}

func (k *KafkaProducer) ToForward(ctx context.Context, ev domain.ForwardEvent) error {
    return k.send(TopicIMForward, strconv.FormatInt(ev.TargetUID, 10), ev)
}

再写业务处理侧:消费 im-message,完成投递流程(落库 → 查路由 → 在线转发 / 离线暂存):

// im-service/internal/service/message_service.go(第一块:结构与消费入口)
package service

import (
    "context"
    "encoding/json"

    "github.com/redis/go-redis/v9"
    "github.com/yourname/content-community/im-service/internal/domain"
    "go.uber.org/zap"
)

// Forwarder service 只需要转发能力(与 gateway.Producer 是同一个实现的两个面)
type Forwarder interface {
    ToForward(ctx context.Context, ev domain.ForwardEvent) error
}

// 消息存储与群成员查询(表结构同前几章风格,CRUD 自己写,第 6 步先用内存桩)
type MessageRepo interface{ Save(ctx context.Context, msg domain.Message) error }
type GroupRepo interface{ Members(ctx context.Context, groupID int64) ([]int64, error) }

// MessageService 消费 im-message:先落库,再决定「在线转发 / 离线暂存」。
// NewMessageService 就是逐依赖赋值,自己写
type MessageService struct {
    repo    MessageRepo
    groups  GroupRepo
    rdb     *redis.Client
    forward Forwarder
    offline Offline
    logger  *zap.Logger
}

// Consume 一条 im-message 的处理入口。返回 error → 不提交 offset →
// Kafka 重投(至少一次);重复消息靠 msg_id 幂等
func (s *MessageService) Consume(data []byte) error {
    var msg domain.Message
    if err := json.Unmarshal(data, &msg); err != nil {
        s.logger.Error("poison message dropped", zap.Error(err)) // 毒消息重试也没用,记日志丢弃
        return nil
    }
    ctx := context.Background()
    // ① 先落库再转发:持久化成功才算"真的发出去了"(正文 3.8 节的先落库再 ACK)
    if err := s.repo.Save(ctx, msg); err != nil {
        return err
    }
    // ② 群聊从简:查成员逐人投递(发送者不回推,他已有 message_ack)
    if msg.ConvType == "group" {
        members, _ := s.groups.Members(ctx, msg.ReceiverID) // 生产要处理 err 与 >1000 人的异步批量
        for _, uid := range members {
            if uid != msg.SenderID {
                _ = s.deliver(ctx, msg, uid)
            }
        }
        return nil
    }
    return s.deliver(ctx, msg, msg.ReceiverID) // ③ 私聊:直接投给接收者
}

投递决策——查在线路由,在线发 im-forward,离线存 Redis List:

// im-service/internal/service/message_service.go(第二块:投递决策)
// deliver 投递决策:查在线路由——在线发 im-forward,离线存 Redis List
func (s *MessageService) deliver(ctx context.Context, msg domain.Message, uid int64) error {
    route, err := s.rdb.Get(ctx, "im:route:"+strconv.FormatInt(uid, 10)).Result()
    if err != nil && err != redis.Nil {
        return err // Redis 故障:交给 Kafka 重试,宁慢勿丢
    }
    if route != "" { // 在线:定向转发给持有连接的网关
        return s.forward.ToForward(ctx, domain.ForwardEvent{
            GatewayAddr: route, TargetUID: uid, Message: msg,
        })
    }
    return s.offline.Push(ctx, uid, msg) // 离线:暂存(第 5 步实现)
}

最后是网关侧:消费 im-forward,只处理「投给本节点」的消息:

// im-service/internal/gateway/forward_consumer.go
package gateway

import (
    "encoding/json"

    "github.com/yourname/content-community/im-service/internal/domain"
)

// ConsumeForward 消费 im-forward 并下发给本节点的目标用户。
// 与正文 3.7 节同理:每个网关节点用「独立消费组」拿全量,
// 再按 gateway_addr 过滤——若共用一个消费组,消息会被随机某个
// 网关抢走,目标用户反而收不到(见下方坑)
func (h *Handler) ConsumeForward(data []byte) error {
    var ev domain.ForwardEvent
    if json.Unmarshal(data, &ev) != nil {
        return nil // 毒消息丢弃
    }
    if ev.GatewayAddr != h.nodeAddr {
        return nil // 不是投给本节点的,忽略
    }
    raw, err := domain.NewFrame(domain.FrameMessage, ev.Message)
    if err != nil {
        return err
    }
    h.manager.Send(ev.TargetUID, raw) // 推给该用户在本节点的全部连接(多端同步)
    return nil                        // 接收方的回执链路留作练习
}
复习提示:⚠️ 新手必踩的坑:定向转发也别共用消费组。正文 3.7 节说过广播要独立消费组,有人会想:im-forward 是定向消息,共用一个组「分摊」岂不更省?不行——Kafka 只保证「组内一个消费者拿到消息」,拿到的那台网关未必持有目标用户的连接,消息被它消费掉又过滤丢弃,就静默丢失了。所以 im-forward 依然是每节点独立消费组 + gateway_addr 过滤:人人都能看到,只有该处理的那台动手。

验证:go build ./... 通过后(消费组循环见第 6 步),开两个终端分别用用户 1、用户 2 的 token 连接,用户 1 发:

{"type":"message","payload":{"msg_id":"u-001","msg_type":"text","conv_type":"single","receiver_id":2,"content":"你好"}}
# 预期:用户1 立刻收到 {"type":"message_ack",...,"status":"sent"};
#       用户2 秒级收到 {"type":"message","payload":{...,"sender_id":1,"content":"你好",...}}
# 多端同步:用户2 再开第三个 wscat 连接,用户1 再发一条,用户2 的两个连接都收到

第 5 步:离线消息——Redis List 暂存 + 上线补拉

第 3、4 步留了 Offline 接口,现在补上实现:离线消息压进 im:offline:<uid>(Redis List,整体保留 7 天),上线时逐条弹出补拉。service 包把第 3 步 gateway 包的 Offline 接口原样复制一份即可。

先写 Push:

// im-service/internal/service/offline.go(第一块:Push)
package service

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/yourname/content-community/im-service/internal/domain"
)

// OfflineRedis 离线消息实现:Redis List,key=im:offline:<uid>
type OfflineRedis struct{ rdb *redis.Client }

func NewOfflineRedis(rdb *redis.Client) *OfflineRedis { return &OfflineRedis{rdb: rdb} }

// Push 从右侧压入一条,并给整个 List 续 7 天过期(超期只剩 DB 里的)
func (o *OfflineRedis) Push(ctx context.Context, uid int64, msg domain.Message) error {
    data, err := json.Marshal(msg)
    if err != nil {
        return err
    }
    key := fmt.Sprintf("im:offline:%d", uid)
    pipe := o.rdb.Pipeline()
    pipe.RPush(ctx, key, data)
    pipe.Expire(ctx, key, 7*24*time.Hour)
    _, err = pipe.Exec(ctx)
    return err
}

再写 Drain(上线补拉):

// im-service/internal/service/offline.go(第二块:Drain)
// Drain 上线补拉:从左侧逐条弹出,推一条删一条;
// 中途断线,没弹出的还留在 List 里
func (o *OfflineRedis) Drain(ctx context.Context, uid int64, send func(domain.Message) error) error {
    key := fmt.Sprintf("im:offline:%d", uid)
    for {
        data, err := o.rdb.LPop(ctx, key).Bytes()
        if err == redis.Nil {
            return nil // 队列空,补拉完成
        }
        if err != nil {
            return err
        }
        var msg domain.Message
        if json.Unmarshal(data, &msg) != nil {
            continue // 脏数据跳过
        }
        if err := send(msg); err != nil { // 推送失败:塞回队首,下次上线再试
            o.rdb.LPush(ctx, key, data)
            return err
        }
    }
}

gateway 侧的接入点就在第 3 步的 ③——连上后 go h.offline.Drain(...) 逐条 TrySend(把这一行补上)。

复习提示:⚠️ 取舍要知道:LPop 存在「弹出后、送达前」的丢失窗口。LPop 弹出即删除,如果进程恰好在这两步之间崩溃,这条消息就丢了。教学版取其简单;生产上要么「先推、确认送达后按值 LREM 删」,要么上 BRPOPLPUSH 双 List 可靠队列。面试被追问离线消息可靠性时,能说出这个窗口和两种补法,就是加分项。

验证:

# 用户2 断开(wscat 里 Ctrl-C);用户1 连发两条消息
redis-cli LLEN im:offline:2   # 2——离线暂存成功
# 用户2 重新连接:立刻按发送顺序收到两条离线消息
redis-cli LLEN im:offline:2   # 0——补拉后清空

第 6 步:main 组装、优雅停机与端到端验证

配置遵循全局约定(viper 读 configs/):

# im-service/configs/config.yaml
im:
  ws_addr: ":8600"             # WebSocket 端口
  node_addr: "127.0.0.1:8600"  # 写进 Redis 路由的本节点地址;多实例部署时每实例不同
  jwt_secret: "dev-secret"     # 与 user-service 共享
kafka:
  brokers: [localhost:9092]
redis:
  addr: localhost:6379

main 的前半段——依赖组装与双消费组:

// im-service/main.go(第一块:组装与消费组)
package main

import (
    "github.com/redis/go-redis/v9"
    "github.com/spf13/viper"
    "go.uber.org/zap"

    "github.com/yourname/content-community/im-service/internal/gateway"
    "github.com/yourname/content-community/im-service/internal/repository"
    "github.com/yourname/content-community/im-service/internal/service"
)

func main() {
    // 全局约定:viper 读 configs/ + zap 结构化日志;ETCD 注册同第12章,此处省略
    viper.AddConfigPath("configs")
    _ = viper.ReadInConfig()
    logger, _ := zap.NewProduction()
    rdb := redis.NewClient(&redis.Options{Addr: viper.GetString("redis.addr")})
    producer, err := repository.NewKafkaProducer(viper.GetStringSlice("kafka.brokers"))
    if err != nil {
        logger.Fatal("kafka producer failed", zap.Error(err))
    }
    offline := service.NewOfflineRedis(rdb)
    // 组装:同一个 producer/offline 实例,同时满足 gateway 与 service 两侧接口。
    // 两个 repo 参数教学版用内存桩(Save 存 slice、Members 查 map,各 3 行,自己写),
    // 换成前几章的 GORM 实现即是生产版
    manager := gateway.NewConnManager()
    handler := gateway.NewHandler(manager, rdb, producer, offline,
        viper.GetString("im.node_addr"), viper.GetString("im.jwt_secret"), logger)
    msgSvc := service.NewMessageService(&memMessageRepo{},
        &memGroupRepo{members: map[int64][]int64{1: {1, 2, 3}}}, rdb, producer, offline, logger)
    // 消费组一:im-message,全网一个组(任一 im-service 实例处理一次即可)
    go repository.MustConsume(viper.GetStringSlice("kafka.brokers"), "im-message-group",
        repository.TopicIMMessage, msgSvc.Consume, logger)
    // 消费组二:im-forward,每个网关独立消费组(组名带 node_addr,原因见第 4 步的坑)
    go repository.MustConsume(viper.GetStringSlice("kafka.brokers"),
        "im-forward-"+viper.GetString("im.node_addr"),
        repository.TopicIMForward, handler.ConsumeForward, logger)
    serve(manager, handler, logger, rdb) // HTTP 服务 + 优雅停机,见第二块
}

后半段——HTTP 服务与优雅停机(正文 3.9 节连接迁移的落地):

// im-service/main.go(第二块:HTTP 服务与优雅停机)
func serve(manager *gateway.ConnManager, handler *gateway.Handler,
    logger *zap.Logger, rdb *redis.Client) {
    mux := http.NewServeMux()
    mux.HandleFunc("/ws", handler.HandleWS)
    srv := &http.Server{Addr: viper.GetString("im.ws_addr"), Handler: mux}
    go func() { _ = srv.ListenAndServe() }()
    logger.Info("im-service started", zap.String("addr", srv.Addr))
    // 优雅停机:广播提示 → 等 5s 冲刷发送队列 → 断开并清路由
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx) // 不再接受新连接
    closeFrame, _ := domain.NewFrame("system", domain.Message{
        MsgType: "system", Content: "server restarting, please reconnect",
        Timestamp: time.Now().Unix(),
    })
    manager.BroadcastAll(closeFrame) // 第 2 步练习:遍历 uid 逐个 Send
    time.Sleep(5 * time.Second)      // 给 writeLoop 时间把队列里的消息冲完
    for _, uid := range manager.CloseAll() { // 第 2 步练习:关全部连接并返回 uid
        rdb.Del(ctx, "im:route:"+strconv.FormatInt(uid, 10))
    }
    logger.Info("im-service stopped gracefully")
}

消费循环 MustConsume 不再整段贴出:把第 07 章的消费组三件套(Setup/Cleanup/ConsumeClaim)原样搬来,唯一改动是把处理逻辑换成传入的回调 fn——fn 返回 error 就不提交 offset 直接 return(外层重入后从上次提交位重投,至少一次;重复投递靠 msg_id 幂等兜底),成功才 MarkMessage。

端到端验证(这是本章的验收标准,覆盖五类场景):

go run .
# 终端1 用户1、终端2 用户2 分别 wscat 连接
# ① 在线私聊:用户1 发 receiver_id=2 → 用户1 收 message_ack,用户2 秒级收到
# ② 离线暂存:用户2 断开,用户1 连发两条 → redis-cli LLEN im:offline:2 返回 2
# ③ 离线补拉:用户2 重连 → 按序收到两条,LLEN 归零
# ④ 群聊:conv_type=group、receiver_id=1 → 在线成员各收一份,发送者不重复收
# ⑤ 优雅停机:Ctrl-C → 客户端先收 system 提示再断开;redis-cli KEYS "im:route:*" 为空

本章完成后的项目结构

content-community/                          # 本章改动一览(其余省略)
├── im-service/                             # 新增微服务(WebSocket :8600)
│   ├── configs/config.yaml                 # viper 配置:im / kafka / redis 段
│   ├── main.go                             # 组装 + 双消费组 + 优雅停机
│   └── internal/
│       ├── domain/
│       │   └── message.go                  # Message / Frame / ForwardEvent 协议(JSON 编解码)
│       ├── gateway/
│       │   ├── conn_manager.go             # Conn + ConnManager:多端连接管理、串行写、Ping(核心)
│       │   ├── ws_handler.go               # JWT 校验 + 路由注册 + 心跳 + 读循环(核心)
│       │   └── forward_consumer.go         # 消费 im-forward,按 gateway_addr 过滤下发
│       ├── service/
│       │   ├── message_service.go          # 消费 im-message:落库→查路由→转发/离线(核心)
│       │   └── offline.go                  # 离线消息 Redis List:Push + Drain
│       └── repository/
│           ├── producer.go                 # KafkaProducer:im-message / im-forward 双 topic
│           └── consumer.go                 # MustConsume:第07章消费组模板回调化
└── pkg/kafka/                              # 第07章已有,本章复用不改动

验证清单

  • 错误 token 连接被拒(HTTP 401),合法 token 连上立刻收到 connect_ack
  • redis-cli GET im:route:<uid> 返回本节点地址,TTL 随心跳续期、不会归零
  • 私聊消息:发送者收到 message_ack(status=sent),在线接收者秒级收到
  • 多端同步:同一用户开两个连接,两边都收到消息
  • 离线消息:全部断开后消息进 im:offline:<uid>;重连按序补拉、List 清空
  • 群消息:conv_type=group 时全体在线成员各收一份(发送者除外)
  • Ctrl-C 优雅停机:客户端先收 system 提示再断开,im:route:* 全部清理
  • 停掉 Kafka 再发消息:offset 不提交,Kafka 恢复后消息补投(至少一次,落库靠 msg_id 幂等)

五、工程实践要点

1. JWT 必须在握手升级之前校验

upgrader.Upgrade 返回 101 的那一刻,这条连接就不再是 HTTP 了——没有状态码、没有响应头可写。之后再发现 token 无效,你连 HTTP 401 都发不出去,只能升级后主动断开(客户端体验差、还白占一次握手)。正确姿势是跟着写第 3 步那样:先查 token、再 Upgrade。顺序写反,是新手第一个翻车点。

2. 一个连接一个写 goroutine,其余人只准投队列

gorilla/websocket 的硬性规则:同一连接同时最多一个读者、一个写者。业务转发、心跳 Ping、系统广播……只要有两个 goroutine 同时 WriteMessage 同一个连接,等待你的就是 panic 或帧错乱。本章 ConnManager 的解法值得抄走复用:每个连接出生时自带一个写 goroutine + 带缓冲的 channel,所有写需求统一投队列——顺便获得了慢消费者保护(队列满就断开,一个卡死的客户端拖不垮整个网关)。

3. CheckOrigin 别图省事直接 return true

跨站 WebSocket 劫持(CSWSH)的攻击路径:恶意网页的 JavaScript 向你的 ws:// 发起连接,浏览器自动带上受害者的 Cookie——如果不校验 Origin 头,攻击者等于拿着别人的身份收发消息。演示代码里 return true 没事,生产代码必须校验 Origin 白名单。WebSocket 绕过了浏览器的同源策略保护,安全责任就全在你服务端自己肩上。

4. 心跳三件套,间隔按「3060s + 23 倍超时」定

读超时当死活判定 + Pong 续期 + 定时 Ping,三件缺一不可。间隔不是越小越好:太短移动端费电、服务端 CPU 被 Ping 刷屏;太长赶不上 NAT 表项过期。工程惯例:心跳 30s60s、判定超时取 23 倍(给网络抖动留余量)。另外记住 TCP 自带 Keep-Alive 默认 2 小时才探测一次——对 IM 毫无用处,应用层心跳是唯一选择。

5. 重连永远带指数退避 + 随机抖动

客户端断线后「立刻重连」是最朴素的写法,也是最危险的写法:服务端一旦整体重启,几十万客户端同一毫秒冲回来——服务刚恢复就再次被打死,循环往复。指数退避(1s→2s→4s…封顶 60s)让重试越失败越保守,随机抖动把各客户端的重连时刻错开。两行代码的成本,换掉一整类生产事故。

6. 网关发版按三步走,别直接 kill

直接 kill 网关 = 几十万连接瞬间断开 + 全员重连风暴。正确姿势:先从负载均衡摘除(不接新连接)→ 广播「服务器重启请重连」系统消息给一个宽限期 → 冲刷发送队列、清理路由 key 后再退出。这套流程的前提是网关与后端服务分离——网关稳定少变更、业务天天发版,两者写在一个进程里,每次发版都是一次自残。

7. 把「至少一次 + 幂等 = 恰好一次」刻进肌肉记忆

所有重试机制(客户端超时重发、Kafka offset 重投)给你的都只是至少一次——消息可能重复到达。用户感知不到重复,靠的是服务端幂等((sender, seq) 唯一键 / Redis SETNX)把重复吞掉。这个结论在课程里出现了三次:第 07 章 Kafka 消费者、第 18 章 Canal 同步、本章 IM——它是分布式可靠性的第一性原理,面试直接引用。

8. 站内私聊先评估 OpenIM,别急着造轮子

这一章教你造 IM,是为了让你懂原理;真实工作里,「给产品加个私聊」这种需求,用 OpenIM 这类开源方案半天就能上线。决策标准:IM 是不是产品的核心竞争力?内容社区的私聊只是辅助功能 → 接 OpenIM(用户同步走 Canal 监听 binlog,业务零侵入);做的是 IM 产品本身 → 自研。知道什么时候造、什么时候用,比只会造值钱。


六、自测题与动手练习

自测题(合上书能答出来才算懂;答案都在正文对应小节里):

  1. IM 的两座大山是什么?为什么说 HTTP 的请求/响应模型天生做不到「服务端主动推送」?(3.1 节)
  2. 轮询的两大缺点是什么?为什么它会引发雪崩式的恶性循环?(3.1 节)
  3. XMPP / WebSocket / WebRTC 各自主场是什么?微信为什么三者都用得上?(3.2 节)
  4. WebSocket 握手到底发生了什么?101 之后还能给客户端回 HTTP 401 吗?为什么?(3.3 节)
  5. gorilla/websocket 的两个必踩坑是什么?分别怎么解决?(3.4 节)
  6. 注册机制和广播机制各自的瓶颈在哪?为什么生产主流选「Kafka 广播」?(3.6 节)
  7. Kafka 天生是「一个消费组内分摊消费」的,怎么让它变成「每个节点人手一份」?消费组名为什么要带上节点地址?(3.7 节)
  8. 可靠投递四件套是什么?TCP 本身就是可靠传输,为什么还必须应用层再搞一套 ACK + 幂等?(3.8 节)
  9. 什么是半开连接?NAT 怎么悄无声息地杀死你的长连接?为什么 TCP Keep-Alive 救不了场?(3.9 节)
  10. OpenIM 的分层架构和本章的设计怎么对应?用户数据同步为什么推荐 Canal 异步而不是注册接口里同步调?(3.10 节)

动手练习(建议真做一遍,难度递增):

  1. 加「对方正在输入」提示(对应第 3 步,体会协议扩展):给 Frame 加 type=typing;网关收到后查出接收者路由,直接投 im-forward 转发,不进 im-message、不落库、不存离线。做到什么程度算完成:两个 wscat 窗口一端敲字另一端秒级出提示;能说清为什么 typing 事件不需要可靠投递四件套(丢了无所谓 → 没有落库和 ACK 的必要,还能反推「哪些消息类型必须可靠」)。
  2. 思考题:离线消息从 List 换成 ZSET 会更好吗(对应第 5 步):Redis List 只能从头弹出、无法按会话拉取、无法「标记已读后只拉未读」。设想用 ZSET im:offline:<uid>(score=消息时间戳)重写 Push/Drain。做到什么程度算完成:能说清 List 方案「LPop 出来发送失败要 LPush 回去」的竞态窗口,以及 ZSET 方案怎么用 ZRANGEBYSCORE + ZREMRANGEBYSCORE 实现原子补拉。
  3. 压测长连接(对应 3.1 节大山一,亲眼看资源消耗):写一个 Go 模拟器,用 goroutine 开 1000 → 5000 → 10000 个 WebSocket 连接挂着不动,观察服务端进程 RSS 和 lsof | wc -l(文件描述符数量);然后让每个连接每 10 秒发一条消息,观察 CPU。做到什么程度算完成:能报出「单机 1 万空闲连接占多少内存、多少 fd」,并且知道 ulimit -n 要调大、调大前后各压一次做对比。
  4. 接入 OpenIM(课程原版作业,选做,对应 3.10 节):Docker Compose 部署 OpenIM,写一个 Canal 消费者把内容社区的新注册用户同步进 OpenIM,前端嵌 SDK 让两个用户互发一条消息。做到什么程度算完成:能画出「用户注册 → binlog → Canal → 消费者 → OpenIM 注册接口」的完整链路图,并回答「OpenIM 挂了会不会影响业务注册」。

七、模拟面试问答

以下 12 题按「基础 → 进阶 → 追问」三档排列,覆盖本章全部核心考点。每题给出一句话结论(先声夺人)、展开(怎么说满 1~2 分钟)、面试官想听的关键词和常见失分点。建议先遮住答案自己说一遍,再对照。

面试官
【基础】 为什么 IM 不能用 HTTP 轮询?长连接解决了什么?
候选人

一句话结论:轮询用「高频地问」硬凑实时性,代价是流量放大和雪崩风险;长连接把「服务端被动答」变成「服务端主动推」,实时性从「轮询间隔级」变成「毫秒级」,且空闲时几乎零成本。

展开:轮询的两大缺点在 IM 场景都被放大——① 流量放大:5 万人在线、每 2 秒问一次 = 2.5 万 QPS,其中 99% 的回答是「没有新消息」,CPU 全耗在回答注定为「没有」的问题上;② 雪崩:服务端变慢 → 前端以为数据没到、缩短轮询间隔 → 请求量再涨 → 更慢——恶性循环。长连接(WebSocket)一次握手后连接常驻,服务端有消息立刻推、没消息一个字节都不用发。但要注意长连接不是免费的:它带来了两座新大山——长连接维护(占内存占 fd、心跳、重连)和分布式投递(跨节点找人)。所以答案的后半句应该主动交代「长连接是实时性的答案,但它自带两个新问题」,展示你知道自己在做什么取舍。

面试官想听的关键词:流量放大、99% 无效请求、恶性循环 / 雪崩、服务端主动推送、两座大山。

常见失分点:只说「轮询浪费资源」就停——说不出「无效请求占比」和「雪崩闭环」这两个具体机制;或者反过来把长连接夸成完美方案,暴露没意识到长连接的维护成本。

面试官
【基础】 WebSocket 是什么?它和 HTTP 是什么关系?
候选人

一句话结论:WebSocket 是在单个 TCP 连接上进行全双工通信的协议;它和 HTTP 的关系是「生于 HTTP」——用一个 HTTP 101 请求完成握手升级,之后这条连接就彻底脱离 HTTP,只剩 WebSocket 自己的帧。

展开:为什么不能直接用 TCP?因为浏览器只会先发 HTTP,中间的代理、网关、防火墙也都只认识 HTTP——WebSocket 借 HTTP 的壳完成第一句对话(客户端发 Upgrade 请求、服务端回 101 Switching Protocols),然后立刻切换成自己的协议,这样能穿透一切只为 HTTP 放行的网络。升级成功后:没有请求头、没有 URL、没有状态码,两端以「帧」为单位全双工通信——帧头极小(对比 HTTP 每次请求一整套头部)、谁都能随时开口。一个容易被追问的细节:101 之后这条连接不再是 HTTP,所以不能再回 HTTP 401——鉴权必须放在握手升级之前做。保活是两层:底层 TCP 维持连接,应用层用协议自带的 Ping/Pong 控制帧确认「对面还活着」。

面试官想听的关键词:全双工、HTTP 101 协议升级、帧、借 HTTP 的壳、101 之后不是 HTTP、双层保活。

常见失分点:说「WebSocket 是 HTTP 的一种」或「基于 HTTP 实现的」——握手之后就没有 HTTP 了,关系只存在于那一次 101;答不出「为什么非要从 HTTP 升级而不是直接连 TCP」这个设计动机。

面试官
【基础】 为什么长连接需要心跳?心跳间隔怎么定?
候选人

一句话结论:心跳是为了解决「半开连接」——一端已经死了(断网 / 进程被杀 / NAT 表项过期),另一端却毫无感知;间隔按「心跳 3060 秒、判定超时取 23 倍」定。

展开:TCP 连接断开在很多场景下不会有任何报文通知对端:手机切 WiFi、NAT 映射超时被删、进程被杀——服务端这边连接看起来还活着(还在 map 里),写过去的数据石沉大海,白白占着内存和 fd。心跳就是用「定期小报文」主动探测:服务端设置读超时(如 90 秒),只要超时窗口内收到过任何东西(Pong 也算)就续期,超时未收到就判定死亡、清理资源;Ping 只是促使客户端回 Pong 的探针(浏览器自动回,前端零代码)。间隔权衡:太短移动端费电费流量、服务端 CPU 被 Ping 刷屏;太长赶不上 NAT 表项过期(常见几分钟)。还要能接住追问「TCP 不是自带 Keep-Alive 吗」——默认约 2 小时才探测一次,对 IM 太迟钝,必须在应用层自己心跳。

面试官想听的关键词:半开连接、NAT 表项过期、读超时当死活判定、Pong 续期、30s60s + 23 倍、TCP Keep-Alive 太迟钝。

常见失分点:只答「检测连接是否存活」却说不清为什么连接死了你不知道(缺半开连接 / NAT 的机制解释);心跳间隔拍脑袋说「越短越好」。

面试官
【基础】 离线消息怎么实现?
候选人

一句话结论:核心就八个字——「在线就推,离线先存」:消息到达先持久化,接收者在线走实时下发,离线就暂存(Redis List / DB),等用户上线后按序补拉、拉完标记。

展开:链路拆开是四步:① 消息到达先落库(持久化是可靠投递的地基,离线消息只是它的一个分支);② 查在线状态:在线 → 走投递链路实时下发;离线 → 暂存到该用户的离线队列(教学版用 Redis List 的 RPUSH,key 形如 im:offline:<uid>);③ 用户上线 / 重连成功后,第一件事就是补拉:把队列里的消息按序 LPOP 弹出下发(消费失败要 LPUSH 回去,防止丢);④ 拉取完标记已读,超期消息(7 天 / 30 天)只在 DB 归档。可以类比快递柜:你不在家,快递先放柜子里,回来再取。加分点:主动补一句「重连成功后先补拉离线再恢复收发,掉线期间的消息一条不能少」——把离线消息和断线重连两条线缝起来,说明你理解的是完整系统而不是孤立组件。

面试官想听的关键词:先落库、在线推 / 离线存、Redis List 暂存、上线补拉、时效过期、消费失败回滚。

常见失分点:只说「存到数据库,上线查一下」——说不清「先落库再决定推不推」的顺序,以及补拉时「弹出失败要塞回去」的细节;漏掉时效设计。

面试官
【进阶】 分布式场景下,用户连在不同节点上,消息怎么送达?
候选人

一句话结论:两条路线——注册机制(查注册中心精准找人,代价是注册中心成为高频读写的热点和单点)与广播机制(消息发到所有节点、各自本地过滤,代价是中间件流量和无效消费);生产主流是「Kafka 当广播总线 + 每节点独立消费组」。

展开:A 连在节点 1、B 连在节点 2,节点 1 必须回答「B 在哪」。方案一:B 连上时把 uid + 节点地址注册到 Redis / ETCD,节点 1 查到后定向转发——精准,但上线、下线、心跳续期都是每用户每几秒一次的写操作,几百万人在线足以把注册中心打出毛病,而且它一挂「找人」能力全灭。方案二:节点 1 把消息广播给所有节点,节点 2 发现 B 连着自己就下发、节点 3 没有就丢弃——把「随机读」变成「全量流的顺序消费」,天然水平扩展。广播的落地三种方式:RPC 逐个调用(扇出大)、Kafka(生产主流)、Redis pubsub(不保证可靠,掉线期间消息直接丢,只适合丢了无所谓的场景)。最后要补上架构层结论:网关与后端服务分离——网关只管连接、稳定少变更才扛得住海量长连接,业务逻辑放后端随便发版。

面试官想听的关键词:注册机制 vs 广播机制、热点 / 单点、Kafka 广播总线、本地过滤、网关与后端分离。

常见失分点:直接说「注册机制更精准所以更好」——没算过注册中心的高频读写账;不知道广播机制的存在,或答不出「Kafka 怎么实现广播」(下一题的引子)。

面试官
【进阶】 怎么保证消息不丢、不重?
候选人

一句话结论:不丢靠「先落库再 ACK + 客户端超时重发」,不重靠「服务端幂等去重」;两句合起来是分布式可靠性的第一性原理——至少一次投递 + 幂等消费 = 效果上的恰好一次。

展开:先说不丢。服务端持久化成功之后才回 ACK(「收到」这两个字只有落了盘才配说);客户端发消息先标记「发送中」,超时(如 5 秒)没收到 ACK 就重发,重试用尽标记失败交给用户手动点——两边配合,消息不可能丢。再说不重:重试天然带来重复——消息其实到了、只是 ACK 在路上丢了,客户端会再发一次。去重两条路:服务端以 (发送者 uid, 客户端 seq) 或消息 ID 做唯一约束(DB 唯一索引 / Redis SETNX),重发的插不进去、直接返回成功;接收端客户端按会话内序号检查,旧的丢弃,避免界面弹重复气泡。顺势带出有序性:只保证会话内有序(DB 自增主键 / Redis INCR / 同群消息路由到同一 Kafka 分区),不做全局有序——全局有序要所有消息串行过一台机器,代价极高且没必要。四件套合体:ACK 保不丢、幂等保不重、会话内 ID 保有序、离线暂存保可达。

面试官想听的关键词:先落库再 ACK、超时重发、至少一次、幂等 / 唯一约束、会话内有序、四件套。

常见失分点:把重试机制直接说成「保证不丢不重」——重试只给「至少一次」,重复必须靠幂等兜底,少说后半句等于没答完整;顺序性上来就吹「全局有序」。

面试官
【进阶】 群聊广播怎么用 Kafka 实现?消费组有什么讲究?
候选人

一句话结论:让 Kafka 当「广播总线」——所有网关往同一个 topic 发、也都消费这个 topic;关键细节是每个网关节点必须用独立的消费组(组名带节点地址),共用一个组消息会被组内成员「分摊」掉,广播就废了。

展开:Kafka 的默认语义是「一个消费组内各成员分摊分区」——正好和广播(人手一份)相反。解法就是每个网关一个独立消费组:同一条消息,Kafka 会给每个组都投一份,组内只有一个成员所以不会被分摊。节点收到消息后本地过滤:连在我身上的用户就下发,没连就丢弃——90% 的节点收到的是「与自己无关」的消息,这个浪费是广播机制天生的代价,换来的是彻底的水平扩展。我们的完整实现是双 topic 分工:im-message(网关 → 业务处理,全网共用一个消费组做落库和路由决策)+ im-forward(业务处理 → 目标网关定向投递,每节点独立消费组按 gateway_addr 过滤)——前者是「业务总线」,后者是「投递总线」。这个设计的祖师爷是 gorilla/websocket 官方 chat 示例——单节点方案里 channel 扮演了 Kafka 的角色。

面试官想听的关键词:广播总线、独立消费组、组名带节点地址、本地过滤、双 topic、浪费换扩展。

常见失分点:说「所有节点加同一个消费组消费」——直接把广播做成了分摊,消息只到部分节点;能说出独立消费组但讲不清「为什么」,等于背结论没懂原理。

面试官
【进阶】 WebSocket 连接为什么不能并发写?你的连接管理器怎么设计的?
候选人

一句话结论:gorilla/websocket 规定一个连接同时最多一个读者一个写者,并发写会 panic / 帧错乱;我的设计是「每个连接出生自带唯一的写 goroutine + 带缓冲发送队列,所有人只准投队列」。

展开:并发写的根源:一条 WebSocket 连接上的字节流必须是一帧接一帧完整序列,两个 goroutine 同时往底层 conn 写,帧互相插队就是乱码,库直接 panic 保护自己。而 IM 里想写这条连接的角色一大堆——业务转发、心跳 Ping、系统广播,不可能靠「小心点别同时写」来保证。我的 ConnManager 设计:map[uid]map[*Conn](外层 uid、内层连接集合,天然支持手机 + 电脑多端同时在线),读写锁保护;每个 Conn 创建时启动唯一写 goroutine,跑一个 select 循环:从 send channel 取消息写出去、定时器到点发 Ping、closed 通道关闭就退出。别的 goroutine 只能 TrySend(select + default 非阻塞投递)——这顺手解决了慢消费者问题:队列满说明这个客户端收不动了,直接断开,一个卡死的客户端拖不垮整个网关。

面试官想听的关键词:一个连接一个写者、写 goroutine + 发送队列、多端登录 map 套 map、非阻塞投递、慢消费者保护、读写锁。

常见失分点:知道「不能并发写」但解法是「加一把写锁」——锁能防 panic 但写慢的连接会阻塞所有持有锁的人,串行写 goroutine + 队列才是正解;漏说慢消费者保护,等于设计里没有容量思维。

面试官
【追问】 TCP 本身就是可靠传输,为什么 IM 还要自己搞一套 ACK 和幂等?
候选人

一句话结论:TCP 的「可靠」只覆盖单条连接内的字节流;消息一旦跨了节点、经过 Kafka、或者断线重连换了一条连接,TCP 的保证就鞭长莫及——端到端的可靠性必须由应用层自己构建。

展开:这是一道「用看似矛盾的事实考你边界感」的题。TCP 确实保证:同一连接上,发出去的字节流不丢、不乱、不重。但 IM 的消息路径是「客户端 → 网关 → Kafka → 业务服务 → Kafka → 另一个网关 → 接收端」,中间每一段都是独立的连接甚至独立的机器:① 生产者把消息发给 Kafka 时 acks 配置不当,broker 宕机消息就丢了——TCP 保证不了 Kafka 的磁盘;② 消费者处理完、还没提交 offset 就崩了,重启后 Kafka 重投——消息重复了,这是「至少一次」语义的必然;③ 客户端发完消息断线,服务端其实收到了——但 ACK 回不去,客户端重连重发——又重复;④ 服务端处理完、正要下发,接收端掉线——消息在这条连接上就丢了。所以应用层的分工是:消息什么时候算「真的收到」(先落库再 ACK)、重复怎么吞(幂等键)、顺序谁来定(会话内 ID)、对方不在怎么办(离线暂存)——每一件都是 TCP 语义之外的事。这个思想和 Kafka 消费端幂等完全同源:中间件的投递语义(至少一次)+ 消费端幂等 = 业务上的恰好一次。

面试官想听的关键词:可靠性边界、单连接字节流、跨节点鞭长莫及、端到端、至少一次 + 幂等、ACK 丢失导致重发。

常见失分点:被「TCP 可靠」带偏,答「是的 TCP 够用了」——直接暴露没理解可靠性是端到端的属性;或者只会背「TCP 是传输层不是应用层」这种空话,举不出消息路径上四个具体的丢失 / 重复场景。

面试官
【追问】 什么是重连风暴?怎么防?
候选人

一句话结论:重连风暴 = 服务端一抖,几十万客户端同时断线又同一时刻冲回来重连,刚恢复的服务被打死、再重启、再被打死的循环;防法是客户端重连必须带指数退避 + 随机抖动,服务端发布要走优雅停机。

展开:风暴的成因是「同步性」:所有客户端的断线是同一个原因(服务重启 / 网络抖动),如果重连策略是「立刻重连」,它们的重连也天然同步——瞬间流量峰值堪比一次 DDoS。解法两把刀:① 指数退避:第 1 次等 1s、第 2 次 2s、4s、8s……封顶 60s——重试越失败越保守,把「一瞬间」摊成「一段时间」;② 随机抖动:在退避时间上加随机偏移,把各客户端的重连时刻错开——别让几十万人同一毫秒一起冲。两把刀合用,重连流量从脉冲变成平缓的斜坡。但风暴还有另一半要防——服务端自己别制造风暴:网关发版不能直接 kill(kill = 主动制造几十万断线),要走三步优雅停机:先从负载均衡摘除(不接新连接)→ 广播「请重连」系统消息、给客户端一个宽限期主动迁走 → 冲刷发送队列、清理路由 key 再退出。客户端和服务端各管一半,风暴才防得住。

面试官想听的关键词:同步性、脉冲流量、指数退避、随机抖动(jitter)、优雅停机三步、摘流量。

常见失分点:只答「加重试」——立刻重连恰恰是风暴的燃料;只从客户端角度答,漏了「服务端发版也可能自制造风暴」这半个面;说不出抖动的作用(错开时刻)。

面试官
【追问】 网关服务要发新版,上面挂着几十万在线连接,怎么办?
候选人

一句话结论:不能直接 kill——要走「摘流量 → 广播系统消息给宽限期 → 冲刷队列清理资源」的三步优雅停机,把几十万连接的断开摊平成一段温和的迁移;更根本的预防是网关与后端分离,让发版根本不碰连接。

展开:先算账:直接 kill 的后果是几十万连接瞬间断开,触发全员重连风暴——服务还没起来就被自己制造的事故打瘫,用户感知是「整个 IM 挂了」。正确姿势三步:① 从负载均衡摘除本节点——注册中心删 key(ETCD lease 到期自动删)、LB 不再分发新连接,存量连接不受影响;② 优雅关闭窗口:向所有存量连接广播一条「服务器重启,请重连」的系统消息,客户端收到后主动断开、按指数退避连到其他节点——主动迁移比被动断开的体验好得多(「闪了一下,消息没丢」);窗口期内发送队列还在冲刷,保证已接收的消息下发完;③ 窗口结束、队列排空后退出进程,并清理 Redis 里的在线路由 key(否则其他节点还会往这个已死的地址转发)。而治本的设计是网关与后端服务分离:网关只管连接、一年难得改一次;业务逻辑天天发版只动后端,通过 Kafka 与网关解耦——多数「发版断连」问题在架构层面就不该发生。

面试官想听的关键词:重连风暴、摘流量 / 摘除 LB、系统消息宽限期、冲刷队列、清理路由 key、网关后端分离、ETCD lease。

常见失分点:答「直接 kill 反正客户端会重连」——重连风暴恰恰是最不能接受的后果;漏了清理在线路由(残留路由 = 消息转发到死节点);只答操作步骤说不出「为什么要摊平」的本质。

面试官
【追问】 公司要给产品加私聊功能,你自研还是用开源方案(比如 OpenIM)?
候选人

一句话结论:先问一个问题——IM 是不是产品的核心竞争力:是(做的是 IM 产品 / 有强定制和合规要求)→ 自研;不是(私聊只是辅助功能,比如内容社区的作者读者沟通)→ 接 OpenIM 这类开源方案,半天上线。

展开:这是一道考察「工程判断」而非「技术背诵」的开放题。自研的成本:本章全部内容(长连接、分布式投递、可靠投递四件套、心跳重连迁移)只是能跑起来的下限,还有群组、关系、审核、多端同步等整座冰山,以及后续 7×24 的运维;开源方案这些全有,还带监控(OpenIM 自带 Prometheus + Grafana,关键就看 message/s 和 failures/s 两个指标)。接入的关键工程点:① 用户同步——业务系统注册的用户要在 OpenIM 里也有账号才能收发,推荐 Canal 监听 binlog 异步同步(业务零侵入,OpenIM 挂了也不影响注册),而不是在注册接口里同步调用(业务从此依赖 OpenIM 可用性);② 请求带 secret 鉴权、带 operationID(推荐用 trace ID)方便链路追踪。最后补一句立场加分:「无论选哪个,原理都得懂——接开源也要看得懂它的分层(接入层 / service 层 / 消息总线 / 存储),出问题才知道往哪查;原理学透了,看任何开源方案都是换皮的自己的设计」。

面试官想听的关键词:核心竞争力判断、造 vs 买的成本核算、Canal 异步同步、解耦(业务不依赖 IM 可用性)、分层同构、看得懂才敢用。

常见失分点:二极管答案——「肯定自研显得有技术」或「肯定用开源省事」,没有判断框架;选了 OpenIM 却把用户同步写成注册接口同步调用——把解耦做回了耦合,暴露没真做过集成。


八、本章小结

复习提示:
  • IM 的本质:服务端主动推送 + 两座大山——长连接维护(连接常驻占资源、心跳、重连、迁移)和分布式投递(跨节点找人);HTTP 请求/响应模型天生做不到服务端主动开口
  • 轮询为什么不行:流量放大(99% 的回答是「没有」)+ 雪崩恶性循环(服务慢 → 前端加频 → 更慢)
  • 协议三选一:XMPP 跨系统互通(XML 老协议)、WebSocket 普通消息主流(全双工低延迟)、WebRTC 音视频点对点——三者不互斥,微信全用
  • WebSocket 原理:借 HTTP 的壳握手(101 协议升级)→ 之后只剩帧(全双工、帧头极小);101 之后不是 HTTP,回不了 401,JWT 必须升级前校验;保活两层——TCP 维持 + 应用层 Ping/Pong
  • 两个必踩坑:CheckOrigin 放行一切 = CSWSH 跨站劫持(浏览器自动带 Cookie);并发写同一连接 = panic——一个连接一个写 goroutine,其余人只准投队列
  • ConnManager:map[uid]map[*Conn] 支持多端登录;串行写 goroutine + 发送队列;非阻塞投递 = 慢消费者保护(队列满就断开,不拖垮网关)
  • 分布式两路线:注册机制精准但压注册中心(上线/下线/心跳高频写 + 单点);广播机制广撒网但压中间件——没有免费午餐,只有把瓶颈挪到可控处;生产主流 Kafka 广播
  • Kafka 广播的关键:每个网关独立消费组(组名带节点地址)——共用一个组会被分摊掉;本地过滤下发(90% 无效消费是广播的天生代价);我们的双 topic:im-message 业务总线(全网一组)+ im-forward 投递总线(每节点一组)
  • 可靠投递四件套:不丢(先落库再 ACK + 客户端超时重发)、不重((sender, seq) 唯一键幂等)、有序(只保证会话内有序:自增主键 / Redis INCR / 同群同分区)、离线可达(Redis List 暂存 + 上线补拉 + 时效过期)
  • 第一性原理(面试直接用):至少一次投递 + 幂等消费 = 效果上的恰好一次——重试只给至少一次,重复必须靠幂等吞(Kafka 消费者、Canal 同步、IM 三处同源)
  • 心跳:半开连接(对端死了你不知道)+ NAT 表项过期是两大死因;读超时当死活判定 + Pong 续期 + 定时 Ping 三件套;30s60s 心跳 + 23 倍判定超时;TCP Keep-Alive 默认 2 小时太迟钝
  • 重连与迁移:重连必须指数退避 + 随机抖动防风暴(把脉冲摊成斜坡);发版三步走——摘流量 → 广播系统消息给宽限期 → 冲刷队列清路由再退出;治本是网关与后端分离(网关一年不动、业务天天发版)
  • OpenIM:分层与我们的设计同构(SDK / access / service / MQ / 存储);接入推荐 Canal 监听 binlog 异步同步用户(业务零侵入、不依赖 IM 可用性);知道什么时候造、什么时候用,比只会造值钱

到这里,全书 21 章的业务篇正式收官:从用户、帖子、评论、Feed 到搜索、支付、IM,一个内容社区的后端拼图只剩最后一块——复盘与展望。下一章(也是最后一章)我们做三件事:把 21 章的知识点串成一张总地图(哪些是通用分布式套路、哪些是业务特有设计)、给出一套可落地的进阶路线(源码阅读、开源贡献、系统性进阶方向),以及回答「学完这门课,你离生产级后端还差什么」。翻过去,这门课就毕业了。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!