写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 20 章。第 19 章你啃下了 Feed 流,本章是全书最后一块硬骨头业务——即时通讯(IM):评论、点赞、Feed 都是用户主动刷新才看得到,IM 不一样——你发消息,对方屏幕上要立刻弹出来。
一句话预告本章的核心:「服务器主动把消息推给客户端」这件事,请求/响应模型天生做不到——你需要一条反过来由服务端随时开口说话的通道(WebSocket 长连接),再解决「消息跨节点找到人」(分布式转发)和「不丢不重有序离线可达」(可靠投递)三件事。IM 之难,难就难在这三座山。
这一版做了三件事:
- 每个概念先补课再使用。轮询、全双工、帧、半开连接、NAT、指数退避、抖动、ACK、消费组、网关……全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。拨号电话 vs 一直在线的对讲机、营业厅传话、快递柜离线包裹——IM 的每个机制都有生活原型。
- 结尾有 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 注册发现」的互动:实例扩缩容时靠它动态感知,投递服务不用重启。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用
gorilla/websocket起一个 WebSocket 服务:升级连接、读循环、唯一的写 goroutine、Ping 心跳与读超时续期。 - 写出单节点连接管理器 ConnManager:多端登录(
map[uid]map[*Conn])、串行写、慢消费者保护(非阻塞投递,队列满就断开)。 - 搭出 im-service 完整链路:JWT 校验 → Redis 在线路由(
im:route:<uid>,TTL 靠心跳续期)→ Kafka 双 topic(im-message业务处理 /im-forward定向投递)→ 离线暂存 + 上线补拉。 - 会写优雅停机:停 HTTP → 广播「请重连」系统消息 → 冲刷发送队列 → 关连接并清理路由。
理解(能讲清原理和取舍,不要求背代码)
- IM 的两座大山:长连接维护(连接常驻、占内存占文件描述符)和分布式消息投递(跨节点找人)。
- 轮询为什么不行:流量放大 + 雪崩恶性循环。
- WebSocket 原理:HTTP 101 协议升级、帧结构、全双工;「长连接」的两层含义(TCP Keep-Alive vs 应用层心跳)。
- 注册机制 vs 广播机制各自的瓶颈;网关和后端服务为什么必须分离。
- 群聊广播的关键细节:每个网关节点必须是独立消费组,共用一个组消息会被「分摊」掉。
- 可靠投递 = ACK + 先落库再推 + 幂等去重:「至少一次」+ 幂等 ≈ 效果上的恰好一次;顺序性只保证「会话内有序」。
- 心跳间隔与超时的权衡(30s
60s 心跳 + 23 倍判定超时);重连风暴与「指数退避 + 随机抖动」。
了解(知道有这回事,面试能说出名字和一句话定位)
- XMPP(XML 老协议、跨系统互通)和 WebRTC(浏览器间音视频)各自的主场。
- OpenIM:开源 IM 方案,分层架构和我们的设计同构;业务接入推荐 Canal 监听 binlog 异步同步用户。
sync.Map与 ekit 的syncx.Map(泛型封装)。- gorilla/websocket 官方 chat 示例:channel 扮演 Kafka 角色的单节点方案。
- 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,第七节面试问答收尾。
三、正文
3.1 IM 为什么难:从「消息秒到」说起
先抛问题。IM(Instant Message,即时通讯)是一种通过网络实时传递文本、多媒体和文件的通信方式——关键词是即时:你发了消息,对方屏幕上要立刻弹出来。评论、点赞、Feed 我们都做过了,它们有个共同点:用户主动刷新才看得到(拉模式),服务端从不主动找客户端。而 IM 的本质要求恰恰相反:服务端必须能主动把消息推到客户端。这和 HTTP 的请求/响应模型是根本冲突的——HTTP 里永远是客户端先开口,服务端只能「答」,不能「主动说」。不解决这个冲突,就会出现导读里那个 5 万人轮询把网关打挂的事故。
补课胶囊 · 轮询:轮询 = 前端每隔固定时间发一次请求,问服务端「有没有新东西」→ 类比:每 2 分钟开一次信箱看有没有新信,一天开 720 次,绝大多数时候是空的 → 回到主线:轮询是用「高频问」硬凑「实时」,代价全在无效请求上。
给直觉。轮询的两个缺点,放在 IM 场景下都会被放大:
- 流量放大:每个在线用户每 2 秒一次请求,5 万人在线 = 2.5 万 QPS,其中 99% 的答案是「没有新消息」——纯浪费。
- 雪崩风险:服务端压力大 → 响应变慢 → 前端以为「数据还没到」轮询得更勤 → 请求量再涨 → 服务端更慢——恶性循环,和第 14 章讲过的雪崩是同一族问题。
flowchart TD
subgraph 轮询模型
C1[客户端] -->|"每 2 秒问一次:有新消息吗?"| S1[服务端]
S1 -->|"99% 的回答:没有"| C1
end
subgraph 长连接模型
C2[客户端] <-->|"连着,随时双向说话"| S2[服务端]
S2 -->|"有新消息,立刻推"| C2
end长连接是答案,但答案自带两座大山。把「每 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 工具,感受产品形态差异(面试聊开放性问题时用得上):
| 工具 | 特点 |
|---|---|
| 端到端加密、全球覆盖、轻量 | |
| 微信 | 多功能平台,融合社交 / 支付 / 小程序 |
| 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、桌面端也都有成熟库)。
三者对比一张表:
| 维度 | XMPP | WebSocket | WebRTC |
|---|---|---|---|
| 通信类型 | 文本消息 + 在线状态 | 全双工通用通信 | 实时音视频点对点 |
| 灵活性 | 高(开放标准,可扩展) | 中(简洁但不够灵活) | 低(专用) |
| 实时性 | 较高 | 高(全双工低延迟) | 高(取决于网络) |
| 安全性 | TLS/SSL | 加密通信(wss) | 端到端加密 |
| 典型场景 | 跨系统互通 | 普通消息 / 实时推送 | 音视频通话 |
常见坑:把 WebRTC 当成「更好的 WebSocket」。两者解决的问题不同——WebRTC 是为音视频实时流设计的(延迟优先、可容忍丢帧),传普通文本消息反而笨重;文本消息要的是「必达、有序」,这正是 WebSocket 的主场。
小收口:协议选了 WebSocket。它凭什么能「先握手一次、之后双向随时说话」?下一节拆它的原理——握手升级和帧结构。
3.3 WebSocket 原理:一次握手,永久双向(核心概念一)
先抛问题。WebSocket 明明是基于 TCP 的,为什么不直接用 TCP,非要「从 HTTP 升级」?——因为现实约束:浏览器和 App 只会先发 HTTP 请求,中间的代理、网关、防火墙也都只认识 HTTP。WebSocket 的聪明之处在于借用 HTTP 的壳完成第一句对话,然后立刻翻脸换成自己的协议——这样它能穿透一切只为 HTTP 放行的网络环境。
给直觉。整个生命周期四步:
- 客户端发一个特殊的 HTTP 请求,询问「能不能升级成 WebSocket?」
- 服务端同意,回一个特殊状态码,协议从此切换。
- 两端用 WebSocket 的「帧」持续双向通信。
- 任意一端发起关闭,连接结束。
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 库会替你处理),但要知道结构里有哪些角色:
| 字段 | 长度 | 含义 |
|---|---|---|
| FIN | 1 bit | 是否消息的最后一个片段(大消息可拆多帧) |
| RSV | 3 bits | 保留字段 |
| Opcode | 4 bits | 操作码:文本帧 / 二进制帧 / Ping / Pong 控制帧 / 关闭帧 |
| Mask | 1 bit | 负载是否经过掩码处理(客户端→服务端必须掩码) |
| Payload Length | 7 / 7+16 / 7+64 bits | 负载长度(按需扩展) |
| Masking Key | 4 bytes | 仅 Mask=1 时存在 |
| Payload Data | 任意长度 | 实际消息内容 |
「长连接」其实有两层含义(面试爱抠这个概念):
- TCP 层长连接:TCP 协议栈自带的 Keep-Alive 机制,定时发保活探测包,发现对端无响应就关闭连接。问题是它默认约 2 小时才探测一次——对 IM 来说太迟钝了,用户手机切了 WiFi 你要两小时后才知道。
- 应用层长连接:应用自己在连接上约定心跳(比如 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]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 架构通常分两层:
- 网关(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]第一块积木: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),人人都能拿到全量消息,再各自判断「这条消息的目标用户在不在我这」——广播完成。
延伸阅读: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 与重试的配合还要抠三个细节:
- 客户端发消息时先本地标记「发送中」,启动超时计时(如 5 秒);
- 超时未收到 ACK → 重发(设次数上限,比如 3 次),期间 UI 一直转圈;
- 重试用尽仍未 ACK → 标记「发送失败」,交给用户手动点重发,而不是无限重试刷爆服务端。
第二件事:不重——幂等去重。重试天然带来重复:消息其实到了,只是 ACK 在路上丢了,客户端会再发一次。去重两条路:
- 服务端幂等:以
(发送者 uid, 客户端 seq)或服务端消息 ID 为键做唯一约束(DB 唯一索引 / RedisSETNX)。重发的消息插不进去,直接返回成功——重试 + 幂等 = 效果上的恰好一次(这句话面试直接用)。 - 客户端去重:接收方按「会话内序号」检查,发现比已处理的序号旧或相等就丢弃,避免界面出现重复气泡。
第三件事:有序——会话内有序,不求全局有序。
为什么顺序会乱:分布式后端是多节点并发的——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),给网络抖动留余量。
② 重连的姿势——指数退避 + 随机抖动。断线后客户端如果立刻重连,而服务端恰好整体过载,几十万客户端同时重连会把它彻底打死(重连风暴——服务刚恢复就又被压死,循环往复)。标准解法:
- 指数退避:第 1 次等 1s、第 2 次等 2s、然后 4s、8s……设上限(如 60s)——重试越失败越保守;
- 随机抖动(jitter):在退避时间上加一个随机偏移,把各客户端的重连时刻错开——别让几十万人同一毫秒一起冲;
- 重连成功后先补拉离线消息,再恢复正常收发(和 3.8 节离线消息衔接——掉线期间的消息一条不能少)。
③ 连接迁移。网关节点要发布新版本 / 缩容时不能直接 kill——几十万连接瞬间断开 + 全员重连 = 自己制造重连风暴。正确姿势分三步:
- 先从负载均衡摘除本节点(不再接新连接);
- 给存量连接一个优雅关闭窗口:发一条「服务器重启,请重连」的系统消息,客户端收到后主动断开、按退避策略连到别的节点;
- 等窗口结束(队列冲刷完)再退出进程。
用户感知就是「闪了一下,消息没丢」。跟着写第 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[存储层
数据存储 + 消息存储]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
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。要点两步:
- 前端:接入 OpenIM SDK,在网站里嵌入私聊窗口;
- 后端:业务系统的用户要同步到 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。
相比正文的教学版,这次落地要升级四点(每一点都对应正文里的一个坑):
- 协议升级:所有 WebSocket 报文统一装进
Frame外壳——业务消息和信令(心跳、ACK、错误)共用一条连接; - 多端登录:一个用户可能手机 + 电脑同时在线,连接管理从
map[uid]conn升级为map[uid]map[*Conn]; - 串行写:正文 3.4 / 3.5 节的「并发写」坑,用「一个连接一个写 goroutine + 发送队列」根治;
- 双 topic:正文 3.7 节的单 topic 全量广播升级为「
im-message业务处理 +im-forward定向投递」,消息不必在每个节点空跑一遍。
步骤总览:
- 消息协议——Message + Frame 统一 JSON 编解码;
- ConnManager 连接管理器(多端登录 + 串行写 + Ping,核心);
- WebSocket 接入——JWT 校验 + Redis 在线路由 + 心跳(核心);
- Kafka 双 topic 转发——im-message 消费 + im-forward 定向投递(核心);
- 离线消息——Redis List 暂存 + 上线补拉;
- 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) // 非阻塞投递:慢消费者只牺牲自己
}
}
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)
}
}
}
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 // 接收方的回执链路留作练习
}
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(把这一行补上)。
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 产品本身 → 自研。知道什么时候造、什么时候用,比只会造值钱。
六、自测题与动手练习
自测题(合上书能答出来才算懂;答案都在正文对应小节里):
- IM 的两座大山是什么?为什么说 HTTP 的请求/响应模型天生做不到「服务端主动推送」?(3.1 节)
- 轮询的两大缺点是什么?为什么它会引发雪崩式的恶性循环?(3.1 节)
- XMPP / WebSocket / WebRTC 各自主场是什么?微信为什么三者都用得上?(3.2 节)
- WebSocket 握手到底发生了什么?101 之后还能给客户端回 HTTP 401 吗?为什么?(3.3 节)
- gorilla/websocket 的两个必踩坑是什么?分别怎么解决?(3.4 节)
- 注册机制和广播机制各自的瓶颈在哪?为什么生产主流选「Kafka 广播」?(3.6 节)
- Kafka 天生是「一个消费组内分摊消费」的,怎么让它变成「每个节点人手一份」?消费组名为什么要带上节点地址?(3.7 节)
- 可靠投递四件套是什么?TCP 本身就是可靠传输,为什么还必须应用层再搞一套 ACK + 幂等?(3.8 节)
- 什么是半开连接?NAT 怎么悄无声息地杀死你的长连接?为什么 TCP Keep-Alive 救不了场?(3.9 节)
- OpenIM 的分层架构和本章的设计怎么对应?用户数据同步为什么推荐 Canal 异步而不是注册接口里同步调?(3.10 节)
动手练习(建议真做一遍,难度递增):
- 加「对方正在输入」提示(对应第 3 步,体会协议扩展):给 Frame 加
type=typing;网关收到后查出接收者路由,直接投im-forward转发,不进 im-message、不落库、不存离线。做到什么程度算完成:两个 wscat 窗口一端敲字另一端秒级出提示;能说清为什么 typing 事件不需要可靠投递四件套(丢了无所谓 → 没有落库和 ACK 的必要,还能反推「哪些消息类型必须可靠」)。 - 思考题:离线消息从 List 换成 ZSET 会更好吗(对应第 5 步):Redis List 只能从头弹出、无法按会话拉取、无法「标记已读后只拉未读」。设想用
ZSET im:offline:<uid>(score=消息时间戳)重写 Push/Drain。做到什么程度算完成:能说清 List 方案「LPop 出来发送失败要 LPush 回去」的竞态窗口,以及 ZSET 方案怎么用ZRANGEBYSCORE + ZREMRANGEBYSCORE实现原子补拉。 - 压测长连接(对应 3.1 节大山一,亲眼看资源消耗):写一个 Go 模拟器,用 goroutine 开 1000 → 5000 → 10000 个 WebSocket 连接挂着不动,观察服务端进程 RSS 和
lsof | wc -l(文件描述符数量);然后让每个连接每 10 秒发一条消息,观察 CPU。做到什么程度算完成:能报出「单机 1 万空闲连接占多少内存、多少 fd」,并且知道ulimit -n要调大、调大前后各压一次做对比。 - 接入 OpenIM(课程原版作业,选做,对应 3.10 节):Docker Compose 部署 OpenIM,写一个 Canal 消费者把内容社区的新注册用户同步进 OpenIM,前端嵌 SDK 让两个用户互发一条消息。做到什么程度算完成:能画出「用户注册 → binlog → Canal → 消费者 → OpenIM 注册接口」的完整链路图,并回答「OpenIM 挂了会不会影响业务注册」。
七、模拟面试问答
以下 12 题按「基础 → 进阶 → 追问」三档排列,覆盖本章全部核心考点。每题给出一句话结论(先声夺人)、展开(怎么说满 1~2 分钟)、面试官想听的关键词和常见失分点。建议先遮住答案自己说一遍,再对照。
一句话结论:轮询用「高频地问」硬凑实时性,代价是流量放大和雪崩风险;长连接把「服务端被动答」变成「服务端主动推」,实时性从「轮询间隔级」变成「毫秒级」,且空闲时几乎零成本。
展开:轮询的两大缺点在 IM 场景都被放大——① 流量放大:5 万人在线、每 2 秒问一次 = 2.5 万 QPS,其中 99% 的回答是「没有新消息」,CPU 全耗在回答注定为「没有」的问题上;② 雪崩:服务端变慢 → 前端以为数据没到、缩短轮询间隔 → 请求量再涨 → 更慢——恶性循环。长连接(WebSocket)一次握手后连接常驻,服务端有消息立刻推、没消息一个字节都不用发。但要注意长连接不是免费的:它带来了两座新大山——长连接维护(占内存占 fd、心跳、重连)和分布式投递(跨节点找人)。所以答案的后半句应该主动交代「长连接是实时性的答案,但它自带两个新问题」,展示你知道自己在做什么取舍。
面试官想听的关键词:流量放大、99% 无效请求、恶性循环 / 雪崩、服务端主动推送、两座大山。
常见失分点:只说「轮询浪费资源」就停——说不出「无效请求占比」和「雪崩闭环」这两个具体机制;或者反过来把长连接夸成完美方案,暴露没意识到长连接的维护成本。
一句话结论: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 当「广播总线」——所有网关往同一个 topic 发、也都消费这个 topic;关键细节是每个网关节点必须用独立的消费组(组名带节点地址),共用一个组消息会被组内成员「分摊」掉,广播就废了。
展开:Kafka 的默认语义是「一个消费组内各成员分摊分区」——正好和广播(人手一份)相反。解法就是每个网关一个独立消费组:同一条消息,Kafka 会给每个组都投一份,组内只有一个成员所以不会被分摊。节点收到消息后本地过滤:连在我身上的用户就下发,没连就丢弃——90% 的节点收到的是「与自己无关」的消息,这个浪费是广播机制天生的代价,换来的是彻底的水平扩展。我们的完整实现是双 topic 分工:im-message(网关 → 业务处理,全网共用一个消费组做落库和路由决策)+ im-forward(业务处理 → 目标网关定向投递,每节点独立消费组按 gateway_addr 过滤)——前者是「业务总线」,后者是「投递总线」。这个设计的祖师爷是 gorilla/websocket 官方 chat 示例——单节点方案里 channel 扮演了 Kafka 的角色。
面试官想听的关键词:广播总线、独立消费组、组名带节点地址、本地过滤、双 topic、浪费换扩展。
常见失分点:说「所有节点加同一个消费组消费」——直接把广播做成了分摊,消息只到部分节点;能说出独立消费组但讲不清「为什么」,等于背结论没懂原理。
一句话结论: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 的「可靠」只覆盖单条连接内的字节流;消息一旦跨了节点、经过 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 反正客户端会重连」——重连风暴恰恰是最不能接受的后果;漏了清理在线路由(残留路由 = 消息转发到死节点);只答操作步骤说不出「为什么要摊平」的本质。
一句话结论:先问一个问题——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 三件套;30s
60s 心跳 + 23 倍判定超时;TCP Keep-Alive 默认 2 小时太迟钝 - 重连与迁移:重连必须指数退避 + 随机抖动防风暴(把脉冲摊成斜坡);发版三步走——摘流量 → 广播系统消息给宽限期 → 冲刷队列清路由再退出;治本是网关与后端分离(网关一年不动、业务天天发版)
- OpenIM:分层与我们的设计同构(SDK / access / service / MQ / 存储);接入推荐 Canal 监听 binlog 异步同步用户(业务零侵入、不依赖 IM 可用性);知道什么时候造、什么时候用,比只会造值钱
到这里,全书 21 章的业务篇正式收官:从用户、帖子、评论、Feed 到搜索、支付、IM,一个内容社区的后端拼图只剩最后一块——复盘与展望。下一章(也是最后一章)我们做三件事:把 21 章的知识点串成一张总地图(哪些是通用分布式套路、哪些是业务特有设计)、给出一套可落地的进阶路线(源码阅读、开源贡献、系统性进阶方向),以及回答「学完这门课,你离生产级后端还差什么」。翻过去,这门课就毕业了。