编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 体系建设优化

  • 代码品质工坊

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

    • README
    • 通用架构设计方案
    • 组件化方案的设计
    • SDK设计与发布方案
    • 缓存架构设计思想
    • 数据库SQL设计思想
    • 分库分表方案设计
    • 分布式ID生成方案
    • 消息队列方案选型
    • 认证授权方案设计
    • API网关设计方案
    • 路由库设计思想
    • 网络检测方案设计
    • 幂等性设计方案
    • 分布式锁方案设计
    • 限流熔断方案设计
    • 移动端防抓包实践
    • 通用轮训方案设计
    • 状态机设计的思想
    • 20.实时通信设计原理
      • 1. 案例引入
        • 1.1 一段离奇事故
        • 1.2 顺藤摸到根因
        • 1.3 我们要回答什么
      • 2. 架构概览
        • 2.1 五层通信模型
        • 2.2 为什么这么切
        • 2.3 架构决策三角
      • 3. TCP连接本质
        • 3.1 五元组即连接
        • 3.2 长短连接之分
        • 3.3 三次握手代价
        • 3.4 NAT穿透原理
        • 3.5 单机C1M调参
        • 3.6 Nagle与实时通信
        • 3.7 延迟确认的死锁
        • 3.8 半开连接检测
        • 3.9 保活与心跳
        • 3.10 TIME_WAIT状态
      • 4. 消息路由本质
        • 4.1 拉推两种范式
        • 4.2 单播广播组播
        • 4.3 集群路由难题
        • 4.4 会话表结构
        • 4.5 多端在线与弱网体感
      • 5. 协议格式设计
        • 5.1 粘包与拆包
        • 5.2 帧协议三要素
        • 5.3 文本二进制之争
        • 5.4 心跳与保活
        • 5.5 断线重连的数学
      • 6. WebSocket解剖
        • 6.1 HTTP升级握手
        • 6.2 帧格式细节
        • 6.3 掩码防污染
        • 6.4 关闭与Ping
      • 7. MQTT解剖
        • 7.1 发布订阅模型
        • 7.2 QoS三级投递
        • 7.3 遗嘱与保留
        • 7.4 报文控制格式
        • 7.5 主题匹配算法
        • 7.6 会话生命周期
        • 7.7 Broker消息路由
        • 7.8 共享与集群
        • 7.9 安全认证
      • 8. Push推送体系
        • 8.1 APNs工作原理
        • 8.2 FCM链路解析
        • 8.3 国内厂商推送
        • 8.4 推送到达难题
      • 9. 方案横向对比
        • 9.1 协议vs库vs方案
        • 9.2 六大维度对比
        • 9.3 常见库源码级差异
        • 9.4 选型决策树
      • 10. 综合案例串讲
        • 10.1 案例真相揭晓
        • 10.2 一条消息的一生
        • 10.3 设计哲学回扣
        • 10.4 架构速查表
        • 10.5 上线Checklist
  • 性能优化实践

  • 真经
  • 方案设计思想
杨充
2025-02-22
目录

20.实时通信设计原理

# 20.实时通信设计原理

本篇定位:长连接、WebSocket、MQTT、Push、SSE,这些名词在面试、方案评审、事故复盘时反复出现,但它们到底是什么关系?谁是协议、谁是实现、谁是场景?

本文从一次"IM 已读回执延迟 8 秒"的真实事故讲起,从 0 到 1 讲透"服务端把消息送到客户端"这件事的所有底层技术,再回来对比 WebSocket / MQTT / SSE / HTTP2 Push / STOMP / SignalR / Socket.IO 等库/框架的边界与场景。读完这一篇,就能在架构讨论里分清"协议 vs 库 vs 方案"。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段离奇事故
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 五层通信模型
    • 2.2 为什么这么切
    • 2.3 架构决策三角
  • 3. TCP连接本质
    • 3.1 五元组即连接
    • 3.2 长短连接之分
    • 3.3 三次握手代价
    • 3.4 NAT穿透原理
    • 3.5 单机C1M调参
    • 3.6 Nagle与实时通信
    • 3.7 延迟确认的死锁
    • 3.8 半开连接检测
    • 3.9 保活与心跳
    • 3.10 TIME_WAIT状态
  • 4. 消息路由本质
    • 4.1 拉推两种范式
    • 4.2 单播广播组播
    • 4.3 集群路由难题
    • 4.4 会话表结构
    • 4.5 多端在线与弱网体感
  • 5. 协议格式设计
    • 5.1 粘包与拆包
    • 5.2 帧协议三要素
    • 5.3 文本二进制之争
    • 5.4 心跳与保活
    • 5.5 断线重连的数学
  • 6. WebSocket解剖
    • 6.1 HTTP升级握手
    • 6.2 帧格式细节
    • 6.3 掩码防污染
    • 6.4 关闭与Ping
  • 7. MQTT解剖
    • 7.1 发布订阅模型
    • 7.2 QoS三级投递
    • 7.3 遗嘱与保留
    • 7.4 报文控制格式
    • 7.5 主题匹配算法
    • 7.6 会话生命周期
    • 7.7 Broker消息路由
    • 7.8 共享与集群
    • 7.9 安全认证
  • 8. Push推送体系
    • 8.1 APNs工作原理
    • 8.2 FCM链路解析
    • 8.3 国内厂商推送
    • 8.4 推送到达难题
  • 9. 方案横向对比
    • 9.1 协议vs库vs方案
    • 9.2 六大维度对比
    • 9.3 常见库源码级差异
    • 9.4 选型决策树
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一条消息的一生
    • 10.3 设计哲学回扣
    • 10.4 架构速查表
    • 10.5 上线Checklist

# 1. 案例引入

# 1.1 一段离奇事故

某社交 App 灰度上线新版 IM 后,用户反馈 "消息发出去 8 秒对方才显示已读",这本该是长连接系统的荣耀舞台。运维值班调出监控发现指标"一切正常":

接入层长连接数:     1200 万(稳定)
消息投递成功率:     99.98%
接入层 CPU:        45%
消息队列吞吐:       80 万/秒

代码里核心链路也很清爽:

// im_gateway.go , IM 长连接网关,Go + gorilla/websocket
func handleConnection(conn *websocket.Conn, userId string) {
    sessions[userId] = conn                    // 注册会话
    defer delete(sessions, userId)

    for {
        _, data, err := conn.ReadMessage()     // 接收客户端消息
        if err != nil { return }
        routeMessage(data)                     // 发消息队列
    }
}

// 投递逻辑
func deliverToUser(userId string, msg []byte) {
    if conn, ok := sessions[userId]; ok {
        conn.WriteMessage(websocket.BinaryMessage, msg)  // 一次写完
    }
}

看起来毫无问题,但用户实测就是延迟 8 秒。协议对了、连接活着、消息发出去了,那 8 秒到底去哪了?

# 1.2 顺藤摸到根因

带着"8 秒去哪"顺藤摸下:

  • 假设 1:是不是消息队列积压?, 看 Kafka lag 只有几百,1 秒内消化,否定。
  • 假设 2:是不是长连接队头阻塞?, 抓 TCP 包发现,"已读回执"发出到客户端 ACK 收到确实 8 秒;且这 8 秒内其他消息都能实时到达,否定单纯队头阻塞。
  • 假设 3:走到 TCP 层继续抓,发现异常在于:"已读回执"这条消息只有 20 字节,被 Nagle 算法缓存了 200ms 等待更多数据凑一个 MSS,然后又和对端 delayed ACK(200ms)耦合,最终演变成 8 秒的"Nagle × delayed ACK"死锁:
发送端                                    接收端
  │ ─── write 20 字节 (已读回执) ─── ▶│
  │ Nagle: 未满 MSS,等 200ms         │
  │                                    │ delayed ACK: 等更多数据
  │                                    │ 或超时(200ms) 才 ACK
  │ ◀─── ACK (200ms 后) ────────────  │
  │
  ↓ 但如果发送端还没到 200ms 又调 write("你好"):
  │ Nagle: 上一次未 ACK → 继续 buffer
  │                                    │
  │ (等下一次 ACK 才能发) ...           │
  ↓ 累计延迟越来越大 → 8 秒

用 tcpdump -i any -tttt port 8080 -w im.pcap 抓包 + wireshark 分析:"已读回执"每次都恰好触发 Nagle × delayed ACK 恶性组合。

根因:gorilla/websocket 默认没开 TCP_NODELAY,所以每个小消息都会走 Nagle。修复只是一行:

tcpConn := conn.UnderlyingConn().(*net.TCPConn)
tcpConn.SetNoDelay(true)   // 关掉 Nagle

8 秒 → 30ms。

一次事故里至少埋着 7 个原理问题:

① 长连接的"连接"到底是什么? TCP、应用层、还是抽象?     → 第3章
② WebSocket 底层是 TCP 吗? 那和裸 TCP 有什么区别?     → 第3+6章
③ 消息路由到 1200 万连接是怎么做的?                   → 第4章
④ 什么是粘包/拆包? 长连接为什么会有?                  → 第5.1节
⑤ 心跳到底心跳什么? 为什么 30 秒?                     → 第5.4节
⑥ WebSocket vs MQTT vs Push vs SSE 到底谁选谁?       → 第9章
⑦ 为什么 Socket.IO 不等于 WebSocket?                → 第9.3节

# 1.3 我们要回答什么

本篇路线:

架构总览(第2章)
   ↓
TCP 连接本质 → 路由本质 → 协议格式  (第3-5章) ─→ 底层通用理论
   ↓
WebSocket 解剖 → MQTT 解剖 → Push 体系  (第6-8章) ─→ 三大方案内核
   ↓
横向对比(第9章) ─→ 分清协议/库/方案
   ↓
综合案例(第10章) ─→ 回扣事故 + 一条消息的一生

# 2. 架构概览

# 2.1 五层通信模型

要理解"消息如何从服务端送到客户端",必须把栈拆到五层,因为每一层都可能是延迟/丢消息/雪崩的元凶:

┌─────────────────────────────────────────────────────────────┐
│                     实时通信五层模型                          │
│                                                              │
│  ┌────────────────────────────────────────────────────┐    │
│  │ L5 场景层:IM、直播、行情、协作、通知              │    │
│  │      表现:消息延迟、已读回执、多端同步            │    │
│  └────────────────────────────────────────────────────┘    │
│                          ▼                                   │
│  ┌────────────────────────────────────────────────────┐    │
│  │ L4 会话/路由层:sessionId → connectionId 映射      │    │
│  │      能力:路由、广播、组播、离线消息              │    │
│  └────────────────────────────────────────────────────┘    │
│                          ▼                                   │
│  ┌────────────────────────────────────────────────────┐    │
│  │ L3 应用协议层:WebSocket / MQTT / STOMP / 自定义    │    │
│  │      核心:帧格式、心跳、订阅模型、QoS             │    │
│  └────────────────────────────────────────────────────┘    │
│                          ▼                                   │
│  ┌────────────────────────────────────────────────────┐    │
│  │ L2 传输层:TCP / QUIC / UDP                        │    │
│  │      核心:连接、拥塞控制、粘包、Nagle             │    │
│  └────────────────────────────────────────────────────┘    │
│                          ▼                                   │
│  ┌────────────────────────────────────────────────────┐    │
│  │ L1 网络层:IP / NAT / LB / DNS                     │    │
│  │      核心:路由、NAT 超时、地域接入                │    │
│  └────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

每层的关注点:

层 关注点 常见问题
L5 场景 用户体验 已读延迟、消息乱序
L4 会话 路由效率 跨节点消息、离线消息
L3 协议 帧/订阅/QoS 心跳失败、消息丢失
L2 传输 拥塞/粘包 Nagle 延迟、断连
L1 网络 可达/NAT 弱网、NAT 超时

# 2.2 为什么这么切

疑惑:为什么把通信拆成五层?直接说"用 WebSocket 就完了"不行吗?

论证:

  1. 可替换性:L1-L5 每一层都可被替换,L2 从 TCP 换成 QUIC,L3 从 WebSocket 换成 MQTT,业务代码(L5)不用改。这是分层协议的核心红利。
  2. 故障定位:本篇开篇的 8 秒延迟在 L2(Nagle),只有分层理解才能一层层排查,否则永远怀疑"WebSocket 有 bug"。
  3. 可组合性:MQTT 可以跑在 TCP 上、TLS 上、WebSocket 上(MQTT over WebSocket);SSE 跑在 HTTP 上。协议之间可以叠加,只有分层才能理解这种叠加。
  4. 反向验证:Socket.IO 就是一个"跨 4 层"的作品,它在 L3 上叠加了自己的协议、L4 用了 room 概念、L1 用了 HTTP polling 兜底。理解它必须先分层。

结论:分层不是学术洁癖,是能不能在事故里 30 分钟定位根因的分水岭。下面从最底层的 TCP 开始。

# 2.3 架构决策三角

疑惑:分层清楚后,落地时怎么权衡?为什么"银弹方案"不存在?

论证:长连接/实时通信系统本质上是在三个方向上做联合最优化,任意两两之间都存在冲突:

              实时性(延迟 <100ms、消息不丢)
                     ▲
                    /│\
                   / │ \
                  /  │  \
                 / 长 │ 连\
                /  接  │ 系 \
               /   系  │  统 \
              ────────┼──────
          可用性(99.99%)    省电(心跳/流量/CPU)
        + 海量在线(千万级)

三对典型冲突:

决策 一侧收益 另一侧代价
推 vs 拉 拉(轮询)省心简单 实时性归零
心跳 30s vs 300s 300s 省电 掉线感知晚(NAT 超时窗外,收不到 FIN)
同机路由 vs 广播路由 同机快 对端挂了消息投不出去;广播 O(N) 扇出崩

结论:架构选择的本质,是选择"要接受哪个代价",而不是"哪个方案最好":

  • IoT 场景:省电优先,接受秒级延迟(MQTT + 长心跳)
  • IM 场景:实时性优先,接受手机稍微费电(WebSocket + 智能心跳)
  • 股票行情:实时+可用双优先,直接砸钱堆机器
  • 系统通知:可用性优先,接受分钟级延迟(Push 兜底)

这个三角是贯穿全篇的骨架,后续每一节讲的具体机制(心跳周期、路由方案、协议选型),都是在这个三角上的一次落子。

工程落地视角:如果你要设计一个"800 万 IoT 设备"的推送系统,本文只讲协议原理,具体的心跳错峰、退避抖动、路由中心化、多端在线等工程落地经验,请配套阅读 09.长链接方案的设计。

# 3. TCP连接本质

# 3.1 五元组即连接

疑惑:程序员挂在嘴上的"长连接",操作系统里到底是什么?

论证:

在内核里,一个"TCP 连接"就是五元组(five-tuple):

(源 IP, 源端口, 目的 IP, 目的端口, 协议 = TCP)

内核用这个五元组在 socket hash table 里查连接状态:

// Linux 内核 net/ipv4/tcp_ipv4.c 简化版
struct sock* tcp_lookup(struct sk_buff* skb) {
    u32 src_ip   = ip_hdr(skb)->saddr;
    u32 dst_ip   = ip_hdr(skb)->daddr;
    u16 src_port = tcp_hdr(skb)->source;
    u16 dst_port = tcp_hdr(skb)->dest;
    
    // 用五元组哈希查表
    return __inet_lookup(net, hashinfo, src_ip, src_port, dst_ip, dst_port);
}

所以:

  • 同一个客户端到同一个服务器可以有多个 TCP 连接,只要源端口不同
  • 服务端 65536 个连接的上限是伪命题,上限是"同一源 IP"发到"同一服务端 IP:port"最多 65536(源端口空间限制);总连接数只受内存与文件描述符限制
  • 单机百万连接(C10K → C1M):给内核调大 fs.file-max + 每个 socket 4-8KB 内存开销 → 1M 连接 = 4-8GB 内存

# 3.2 长短连接之分

疑惑:长连接和短连接在协议层没有区别,TCP 协议里没有"长/短"字段。那"长/短"到底指什么?

论证:

"长/短"是应用层的使用模式,不是协议特性:

短连接:三次握手 → 一次请求/响应 → 四次挥手
   ├─ 每次请求都建连
   ├─ HTTP/1.0 默认
   └─ 每次多 1 RTT

长连接:三次握手 → 多次请求/响应 (循环) → 四次挥手
   ├─ 复用连接
   ├─ HTTP/1.1 Keep-Alive、WebSocket、MQTT
   └─ 每次只有 0 RTT(不含握手)

HTTP Keep-Alive 是长连接吗?,技术上是,但和"长连接系统"里的长连接不一样:

特性 HTTP Keep-Alive IM/推送长连接
连接持续 秒级(LB 默认 60s 超时) 小时/天级
通信方向 单向(客户端问,服务端答) 双向
保活 无 心跳
消息推送 不支持 核心能力

# 3.3 三次握手代价

疑惑:为什么每次握手是 1 RTT?不能更快吗?

论证:

标准 TCP 握手流程:

Client                                    Server
  │  ─── SYN, seq=x ────────────────▶│
  │                                     │
  │  ◀── SYN+ACK, seq=y, ack=x+1 ─── │
  │                                     │
  │  ─── ACK, ack=y+1 ──────────────▶│
  │                                     │
  │  ─── DATA (第一次请求可以携带) ─▶│    ← 这里已经过了 1 RTT
  ↓
   总耗时 = 1 RTT + 处理时间

RTT 现实值:

  • 同机房:< 1ms
  • 同城:1-10ms
  • 跨省:30-50ms
  • 跨国:150-300ms

优化 1:TCP Fast Open (TFO),第一次握手时服务端下发 Cookie,第二次握手客户端直接在 SYN 里带数据:

第二次连接:
Client                                    Server
  │  ─── SYN + Cookie + DATA ──────▶│
  │                                     │  处理请求
  │  ◀── SYN+ACK + RESPONSE ────────│
   总耗时 = 0 RTT ✅

优化 2:QUIC,UDP 之上的应用层协议,握手与 TLS 合并到 0-1 RTT。HTTP/3 全面基于 QUIC。

优化 3:连接池,彻底避免重复握手,复用已建连接。这是所有长连接方案的核心。

# 3.4 NAT穿透原理

疑惑:手机 4G 网络下的长连接为什么会莫名断开?

论证:

移动网络的运营商 NAT 网关会给每个内网 IP 分配一个"NAT 表项",超时后清理:

┌─────────────────────────────────────────────────────────┐
│               运营商 NAT 网关                            │
│                                                          │
│  NAT 表:                                                │
│  ┌─────────────────────────────────────────────────┐   │
│  │ 手机 10.0.1.1:5678 → 公网 220.181.1.1:11234    │   │
│  │ 手机 10.0.1.2:1234 → 公网 220.181.1.1:11235    │   │
│  │ ...                                              │   │
│  │ TTL: 3 分钟无流量则删除 ⚠️                      │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

     NAT 表项被删后:
     服务端还以为连接活着,继续 send
     → 数据包到 NAT 网关,找不到映射,丢弃
     → 服务端认为发出去了,客户端却收不到
     → "半开连接" 死亡状态

NAT 超时的常见值:

网络 NAT 超时 推荐心跳
WiFi 家用路由 5-30 分钟 3 分钟
4G 运营商 3-5 分钟 2-3 分钟
5G 独立组网 5-10 分钟 3 分钟
企业 NAT 10-30 分钟 5 分钟
海外/跨境 1-2 分钟 30-60 秒

结论:长连接必须心跳,心跳周期必须 < NAT 超时。这就是心跳保活的物理根因,不是"以防万一",是"NAT 表会被清"。

# 3.5 单机C1M调参

疑惑:3.1 节讲"单机百万连接" (C1M) 是可行的,但默认参数下真跑不起来——为什么?瓶颈在哪一层?

论证:先算一笔账,单条 TCP 连接的物理成本:

资源 每连接开销 100 万连接需要
文件描述符(fd) 1 个 1M(默认 65535 远不够)
内核 socket 缓冲区 读 87KB + 写 16KB(默认) 103 GB(吓人)
应用层 Channel 对象 Netty ≈ 2KB 2 GB
epoll 红黑树节点 ≈ 200B 200 MB
合计(理论最小) ≈ 105 KB ≈ 100 GB

结论:默认参数下,单机 100 万连接需要 100GB 内存,普通机器完全扛不住。工程上必须调参。

调参"六件套"(长连接系统上线必调):

# 1. 系统级 fd 上限(默认 1024,必调)
ulimit -n 2000000
echo "* soft nofile 2000000" >> /etc/security/limits.conf
echo "fs.file-max = 2000000" >> /etc/sysctl.conf

# 2. TCP 缓冲区(默认过大,长连接消息小,可以压缩)
sysctl -w net.ipv4.tcp_rmem="4096 8192 16384"     # 从 87K 压到 16K
sysctl -w net.ipv4.tcp_wmem="4096 8192 16384"

# 3. TIME_WAIT 快速回收(对客户端友好,主动断连场景关键)
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_fin_timeout=15

# 4. SYN 队列(防握手洪水,大促/雪崩场景关键)
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.somaxconn=65535

# 5. 端口范围(服务端不受限,但作代理时关键)
sysctl -w net.ipv4.ip_local_port_range="1024 65535"

# 6. epoll(Linux 天然支持,无需调,但要用 ET 模式)

调完效果:一台 32C64G 机器可以稳撑 50~100 万长连接(连接稀疏、消息量小的场景),这就是"单机 C1M(Concurrent 1 Million)"。

C10M 的进阶路径(想突破 C1M 到 C10M):

技术 收益 代价
DPDK 用户态协议栈 绕过内核,直接操作网卡 需要网卡驱动改造、部署复杂
SO_REUSEPORT 多进程 单机多进程,每进程独立 accept 需要业务侧支持连接复制
eBPF 卸载心跳 心跳在内核直接响应,不进用户态 逻辑受限、调试难
边缘接入 + 内层聚合 接入层只做协议解析,业务层聚合 架构复杂度+1

工程哲学:多数公司选择 单机 C1M + 横向扩展——比死磕单机 C10M 稳、可运维、可弹性。大规模场景的正确解法通常是"扩容 + 错峰",而不是"堆单机极限"。

# 3.6 Nagle与实时通信

疑惑:开篇事故里,一句话就修好了 8 秒延迟:SetNoDelay(true)。为什么关掉一个叫 "Nagle" 的东西就从 8 秒变成 30ms?Nagle 到底做了什么?

论证,Nagle 算法(RFC 896,1984 年)诞生的背景是 Telnet 时代——用户在终端里敲一个字就产生一个 TCP 包(1 字节 payload + 40 字节 IP+TCP 头 = 41 倍带宽浪费)。Nagle 的设计意图是把小包攒成大包再发:

Nagle 算法规则(只有两条):
┌─────────────────────────────────────────────────────┐
│ 规则 1:如果当前有已发送但未 ACK 的数据,            │
│          新数据必须等 ACK 到了才能发。                │
│                                                      │
│ 规则 2:如果没有未 ACK 的数据,                      │
│          立刻发(不用等)。                          │
│                                                      │
│ 例外:如果累积的数据 ≥ MSS(Maximum Segment Size),  │
│         无论规则 1 是否满足,都立刻发。               │
└─────────────────────────────────────────────────────┘

Nagle 与实时通信的矛盾:

实时通信的消息特征:
  ├─ 消息体积小(已读回执 20B、心跳 10B、IM 文本 100B)
  ├─ 消息间隔不固定(用户输入的节奏)
  └─ 要求低延迟(毫秒级)

Nagle 的"攒包"逻辑正是这些小消息的杀手:
  1. 发一条 20B 的"已读回执"
  2. Nagle:payload=20 << MSS(1460),且上一个包还没 ACK → buffer 等着
  3. 等 200ms(等 ACK 或更多数据)
  4. 200ms 后发出去
  5. 下一条消息再等 200ms……
  → 累积延迟 = N × 200ms

哪些协议受 Nagle 影响?

协议 受害场景 严重程度
WebSocket 小帧(ping、已读回执、短文本) ⭐⭐⭐⭐⭐
MQTT CONNECT/PINGREQ/PUBLISH 小 payload ⭐⭐⭐⭐
自研 TCP 协议 自定义帧协议的消息体小 ⭐⭐⭐⭐⭐
HTTP/1.1 Keep-Alive 分块传输的最后一个 chunk ⭐⭐
Push (APNs 服务端侧) HTTP/2 帧不受 Nagle 影响 ⭐

修复方式(所有 TCP 长连接系统都该做):

// C 语言
int flag = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
// Go
tcpConn := conn.UnderlyingConn().(*net.TCPConn)
tcpConn.SetNoDelay(true)
// Java (Netty)
bootstrap.option(ChannelOption.TCP_NODELAY, true);

何时不该关 Nagle?,只有一种情况:大块数据传输(文件上传、视频流)。此时 Nagle 的攒包是正面效果——把分散的写合并成满 MSS 的包,降低协议头占比。

结论:任何实时通信场景(WebSocket、MQTT、自研 TCP 协议),TCP_NODELAY 是第一行该写的代码。不是调优选项,是正确性前提。

# 3.7 延迟确认的死锁

疑惑:3.6 节讲 Nagle 会延迟 200ms,但为什么事故是 8 秒而不是 200ms × N?

论证,Nagle 并不孤立存在——它和 Delayed ACK 形成了恶性耦合:

**Delayed ACK(RFC 1122)**的设计意图:

接收方收到数据后,不立刻回 ACK,而是等:
  条件 A:积累了 2 个满 MSS 的段 → 立刻 ACK
  条件 B:200ms 超时了 → 立刻 ACK(不管有没有满)
  条件 C:有响应数据要发 → ACK 可以"搭车"(piggyback)

目的:把 ACK 和响应数据合并到一个包,减少纯 ACK 包

Nagle × Delayed ACK 的死锁链条:

场景:客户端连续发多个小消息(已读回执 20B → 文本 50B → emoji 10B)

  消息① 20B write
    │ Nagle: payload < MSS,但上一包已 ACK → 立刻发 ✅
    │ 
    ├──▶ 服务端收到
    │      │ Delayed ACK:等 200ms 看有没有响应数据一起回
    │      │ (消息① 本身没有响应数据,只能等超时)
    │      │
    │      ◀── ACK(200ms 后)
    │
  消息② 50B write(消息① 发出去后 10ms 到来)
    │ Nagle:消息①还没 ACK → buffer 等着 ❌
    │ 等……
    │ 等……(200ms)
    │ 消息① ACK 到了 → 消息② 立刻发
    │ 
    ├──▶ 服务端收到
    │      │ Delayed ACK:再等 200ms
    │      │
    │      ◀── ACK(又 200ms)
    │
  消息③ 10B write
    │ Nagle:消息②还没 ACK → 继续等…… ❌
    │ 
    ↓ 循环往复,每次 200+200=400ms 的"无效等待"

  总延迟 = N × 400ms(不是 200ms!)
  如果 N=20 条小消息 → 20 × 400ms = 8 秒 🎯

这就是开篇事故的完整根因链——不是 Nagle 一个人的问题,而是 Nagle + Delayed ACK 互相等待对方的经典死锁:

Nagle:  "我等 ACK 到了再发下一段"
Delayed ACK: "我等响应数据到了再回 ACK"
        ↓
      死锁

每次打破死锁的成本 = 200ms 超时 × 2(发端等 ACK + 收端等数据)

修复方式(3 条路径):

方案 操作 副作用
① 关 Nagle(服务端) TCP_NODELAY = true 可能多发几个小包,带宽略增
② 关 Delayed ACK(服务端) TCP_QUICKACK = true 每次收数据立刻 ACK,ACK 包变多
③ 客户端合并写 先把多条小消息攒成一个大的再 write 客户端逻辑复杂,延迟增加

业界最佳实践:① + ③ 组合——服务端开 TCP_NODELAY,客户端攒 5ms 的写缓冲区。这样既解决了死锁,又不会产生过多小包。

结论:Nagle + Delayed ACK 的死锁是所有 TCP 实时通信系统的"隐藏性能杀手"。WebSocket、MQTT、自研 TCP 协议,只要连续发小消息,必然触发。开 TCP_NODELAY 能解决 90% 的场景,剩下的 10% 靠客户端攒写。

# 3.8 半开连接检测

疑惑:TCP 连接断了,双方不就知道了吗?为什么还要心跳?

论证,TCP 断连分两种,只有一种会通知对方:

正常关闭(四次挥手):
  A ──FIN──▶ B ──ACK──▶ A ──FIN──▶ B ──ACK──▶ A
  ✅ 双方都知道连接已关闭

异常断开(进程崩溃/断电/网线拔了):
  A 崩溃了,没发 FIN,没发 RST
  B 还在内核 accept 队列里挂着,socket 状态 = ESTABLISHED
  ❌ B 完全不知道 A 已经死了
  
  → 这就是"半开连接"(Half-Open Connection)

半开连接的"死亡螺旋":

B 应用层 send("你在吗?") ──▶ 内核发出去
  │ 没有 ACK 回来
  │ TCP 重传机制启动:
  │   第 1 次重传:1.5s 后
  │   第 2 次重传:3s 后  
  │   第 3 次重传:6s 后
  │   ...(指数退避)
  │   第 15 次重传:~13 分钟后
  │   最终:内核放弃,socket 关闭(ETIMEDOUT)
  │
  └── 从 B 应用层看来,send() 成功了(写到了内核缓冲区)
      但实际上消息全丢了,且 13 分钟后才知道连接死了

Linux 默认重传参数(net.ipv4.tcp_retries2 = 15):

参数 默认值 含义
tcp_retries2 15 数据重传最大次数。15 次 ≈ 924s ≈ 15 分钟才发现死连接
重传 RTO 初值 200ms 第 1 次重传等待 200ms
重传 RTO 上限 120s 到达最大的单次等待

对实时通信的影响:

WebSocket 长连接:
  手机没电了 → 服务端 15 分钟后才知道
  这 15 分钟内所有给这个用户的推送 → 全丢,且上报告警"投递成功"

MQTT:
  传感器断电 → Broker 认为设备还在线
  Will Message 要等 15 分钟才触发 → 下游系统延迟看到"离线"状态

Push 推送:
  Token 对应的设备其实不在线了 → APNs/FCM 返回成功
  用户收不到 → 投诉

解决方案只有一种:应用层心跳。TCP 自己提供的 SO_KEEPALIVE 太慢(下节讲),唯一可靠的死亡检测就是"我主动发心跳,N 倍心跳周期没收到回复 → 判定死亡"。

结论:TCP 保证的是"如果 RST/FIN 到了,我一定通知你",不保证"对方死了尽快通知你"。所有长连接系统都必须自己实现心跳死亡检测,时间窗口 = 2~3 × 心跳周期,绝对不是 15 分钟。

# 3.9 保活与心跳

疑惑:TCP 不是有 SO_KEEPALIVE 吗?为什么不直接用,还要自己做心跳?

论证,SO_KEEPALIVE 和普通人的想象差距巨大:

TCP Keep-Alive 的工作原理:

┌────────────────────────────────────────────────────┐
│          TCP Keep-Alive 探测机制                    │
│                                                     │
│  默认配置(Linux):                                 │
│  ├─ 空闲多久开始探测?  tcp_keepalive_time = 7200s   │
│  │   → 2 小时!连接空闲 2 小时后才发第一个探测包     │
│  │                                                    │
│  ├─ 探测间隔?  tcp_keepalive_intvl = 75s             │
│  │   → 每 75 秒发一次                                │
│  │                                                    │
│  ├─ 探测几次宣布死亡?  tcp_keepalive_probes = 9      │
│  │   → 9 次无响应 = 死亡                             │
│  │                                                    │
│  └─ 总死亡检测时间 = 7200 + 75×9 ≈ 2h 11min ⚠️     │
│                                                     │
│  调优后(企业级最佳实践):                           │
│  ├─ tcp_keepalive_time  = 600  (10 分钟)             │
│  ├─ tcp_keepalive_intvl = 30   (30 秒)               │
│  ├─ tcp_keepalive_probes = 3    (3 次)               │
│  └─ 总检测时间 = 600 + 30×3 = 690s ≈ 11.5 分钟       │
└────────────────────────────────────────────────────┘

SO_KEEPALIVE vs 应用层心跳 对比:

维度 TCP SO_KEEPALIVE 应用层心跳
探测速度 2 小时(默认)/ 10 分钟(调优) 30~300 秒
NAT 保活 ❌ 不能穿透(探测包无应用数据,NAT 可能不刷新表项) ✅ 心跳包走应用层,NAT 当成正常流量
携带数据 ❌ 纯 TCP ACK,无 payload ✅ 可携带 seq、RTT、设备状态、电量
跨语言 ✅ 内核级,所有语言直接可用 ⚠️ 需应用层实现
对中间件透明 ✅ LB/Proxy 能识别并断开死连接 ❌ LB/Proxy 看到的是正常业务流量
适用场景 LB 与后端之间的连接清理 客户端 ↔ 服务端(跨 NAT 必用)

对实时通信各协议的影响:

WebSocket:
  ├─ SO_KEEPALIVE 太慢 → 必须用 WebSocket Ping/Pong(opcode 0x9/0xA)
  └─ 浏览器 WebSocket API 甚至没法直接开 SO_KEEPALIVE

MQTT:
  ├─ PINGREQ/PINGRESP 是规范的一部分(§7.4),IoT 设备的标准做法
  └─ Broker 侧建议开 SO_KEEPALIVE 做僵尸连接清理的兜底

Push:
  ├─ APNs/FCM 自己维护到设备的长连接,心跳是 Apple/Google 的事
  └─ 服务端到 APNs 走 HTTP/2,不需要 SO_KEEPALIVE

最佳实践:

# 调优 SO_KEEPALIVE(作为兜底,不是主力)
sysctl -w net.ipv4.tcp_keepalive_time=600   # 10 分钟空闲开始探测
sysctl -w net.ipv4.tcp_keepalive_intvl=30    # 30 秒探测间隔
sysctl -w net.ipv4.tcp_keepalive_probes=3    # 3 次无响应判定死亡

结论:SO_KEEPALIVE 和应用层心跳不是替代关系,是互补关系。应用层心跳负责"快"(30~300s 级死亡感知 + NAT 保活),SO_KEEPALIVE 负责"稳"(兜底清理,防止应用层心跳 bug 导致僵尸连接堆积)。长连接系统两者都要,但主力是应用层心跳。

# 3.10 TIME_WAIT状态

疑惑:连接关了,为什么端口还被占用 60 秒?"TIME_WAIT"到底在保护什么?

论证,TCP 关闭不是瞬间完成的:

主动关闭方(Active Closer)的状态机:

ESTABLISHED
    │ send FIN
    ▼
FIN_WAIT_1 (等对方 ACK)
    │ recv ACK
    ▼
FIN_WAIT_2 (等对方 FIN)
    │ recv FIN
    ▼
TIME_WAIT ─── 等待 2MSL(Maximum Segment Lifetime = 30~60s)
    │          2MSL 在 Linux 默认 60 秒
    ▼
CLOSED

TIME_WAIT 存在的两个原因:

原因 1:确保最后的 ACK 被对方收到
  ┌─────┐                    ┌─────┐
  │  A  │ ── FIN ──────────▶ │  B  │
  │     │ ◀── ACK ────────── │     │
  │     │ ◀── FIN ────────── │     │
  │     │ ── ACK ──────────▶ │     │  ← 最后一个 ACK
  │     │   (进入 TIME_WAIT)  │     │     如果这个 ACK 丢了
  │     │                     │     │     B 会重发 FIN
  └─────┘                    └─────┘
  
  如果 A 不保持 TIME_WAIT 直接 CLOSED:
  B 重发的 FIN → 端口已关闭 → A 内核回 RST → B 看到的是"异常关闭"

原因 2:让网络中残留的旧包"过期"
  如果 A 立刻复用这个五元组建新连接
  → 网络中还有一个旧的延迟数据包没到达
  → 这个旧包会被新连接错误接收
  → 2MSL 保证所有旧包在 60s 内"死光"

TIME_WAIT 对长连接系统的实战影响:

场景:800 万设备重连风暴,服务端主动关闭旧连接

  服务端 800 万 TIME_WAIT:
  ├─ 每个 TIME_WAIT 占用一个本地端口(对于客户端角色)
  │   对服务端来说:监听的端口不变,但每个 TIME_WAIT 的 socket
  │   占用 ~280 bytes 内存
  │   800 万 × 280B = 2.2 GB 内存被"死连接"占用
  │
  ├─ 端口耗尽风险(服务端作代理/客户端时):
  │   本地可用端口 65535 - 1024 ≈ 64000 个
  │   如果 800 万 TIME_WAIT → 端口耗尽 → 无法建新连接
  │
  └─ 对纯服务端(listen 端口):影响较小
      因为服务端 listen 的端口不受 TIME_WAIT 限制
      (只有在 bind 到具体端口时才受限制)

应对策略:

策略 操作 适用场景
让客户端主动关闭 WebSocket/MQTT 交互中,客户端发 Close 帧,由它进入 TIME_WAIT ✅ 最佳实践,服务端无 TIME_WAIT 压力
SO_REUSEADDR 允许 bind 到 TIME_WAIT 状态的地址 服务端快速重启
tcp_tw_reuse 允许客户端复用 TIME_WAIT 端口(前提:timestamp 开启) 出站连接多的服务
tcp_tw_recycle 快速回收 TIME_WAIT(Linux 4.12 已废弃,NAT 下有严重 bug) ❌ 不推荐
连接池 复用已建连接,避免频繁创建/销毁 所有场景 ✅

各协议的断开语义:

WebSocket:
  ├─ Close 帧(opcode 0x8):应用层优雅关闭
  ├─ 先发 Close 帧的一方 = 主动关闭方 → 进入 TIME_WAIT
  └─ 最佳实践:客户端先发 Close 帧

MQTT:
  ├─ DISCONNECT 报文:应用层正常关闭
  ├─ 也是先发 DISCONNECT 的一方进入 TIME_WAIT
  └─ IoT 设备通常主动 DISCONNECT(省电后关闭)

Push:
  ├─ HTTP/2 连接池 → 连接复用 → TIME_WAIT 几乎不出现
  └─ 这也是 HTTP/2 相比 HTTP/1.1 的重要优势

结论:TIME_WAIT 是 TCP 正确性的代价,不是 bug。实战第一原则:让客户端做主动关闭方,服务端永远被动回应,这样 TIME_WAIT 的压力就压在千万台客户端上,而不是压在寥寥几台服务器上。

# 4. 消息路由本质

# 4.1 拉推两种范式

疑惑:既然可以推,为什么还有拉?

论证,四个真实约束(前一篇已提,此处从"通信范式"再拆一层):

拉模型(Pull):
  客户端主动 → 服务端被动响应
  ├─ 优点:无状态、易水平扩展
  ├─ 缺点:延迟 = 轮询间隔
  └─ 典型:HTTP 短轮询、REST API

推模型(Push):
  服务端主动 → 客户端被动接收
  ├─ 优点:延迟 → 0
  ├─ 缺点:状态化(要维护连接)、扩展难
  └─ 典型:WebSocket、MQTT、SSE

半推半拉(Long Poll):
  客户端问 → 服务端 hold → 有数据才响应
  ├─ 无状态(连接是短的)+ 准实时
  └─ 典型:Comet、部分 IM 协议

关键洞察:"推"的所有难题最终都归结为一件事,如何在服务端维护"用户 → 连接"的映射。这就是下一节的核心。

# 4.2 单播广播组播

疑惑:一条消息到底"发给谁"?

论证,三种基本投递模式:

模式 应用场景 关键数据结构
单播 私聊、订单状态 Map<userId, connection>
广播 全站公告、系统通知 遍历所有连接
组播 群聊、直播弹幕、协作 Map<roomId, Set<connection>>

扩展关系:

$$ \text{广播} \subseteq \text{组播}(\text{room="all"}) \subseteq \text{单播}(\text{批量}) $$

工业上都归结为**"根据订阅关系找连接列表 → 遍历发送"**,这就是所有 pub/sub 系统的核心。

# 4.3 集群路由难题

疑惑:1200 万连接不可能落在一台服务器上,一定要集群。集群里"发给 userA"怎么找到 userA 的连接在哪台机器?

论证,三种主流架构:

方案 A:集中式路由表(Redis 存 userId → nodeId)

┌────────┐
│Client A│──── 连到 node1 ────┐
└────────┘                     │
                               ▼
                        ┌──────────────┐
                        │   node1      │
                        └──┬───────────┘
                           │ 注册
                           ▼
                    ┌──────────────┐
                    │ Redis 路由表 │
                    │ userA→node1 │
                    │ userB→node2 │
                    └──────┬───────┘
                           ▲ 查询
                           │
                        ┌──┴──────┐
业务想发消息给 userA →  │ Gateway │
                        └─────────┘
                             │
                        转发 node1 ─▶ userA

方案 B:一致性哈希(Session Affinity)

所有 node 环形分布:
node1 ─── node2 ─── node3 ─── node4 ─── (环)

userA 的 hash → 落在 node1 与 node2 之间 → node2 负责
业务想发给 userA → hash(userA) → node2 → 转发

优点:无需 Redis 路由表
缺点:node 上下线时会有一段"路由抖动"(用 vnode 环缓解)

方案 C:广播路由(Fanout)

业务想发给 userA → 广播到所有 node → 只有存 userA 的 node 转发
优点:最简单
缺点:node 之间流量 N×N,规模大时爆炸

规模对比:

方案 连接规模 缺点
集中式 千万级 Redis 单点、路由表压力
一致性哈希 亿级 迁移抖动
广播路由 十万级 内部流量爆炸

实战选型:微信选一致性哈希,滴滴选集中式(Redis Cluster),Discord 选一致性哈希 + 房间广播分区。

# 4.4 会话表结构

疑惑:路由表在内存里到底长什么样?

论证,一个可上线的会话表设计:

// SessionRegistry , 单机会话管理
type SessionRegistry struct {
    // 用户 → 连接列表(一个用户多端登录 = 多个连接)
    userConns sync.Map  // userId → *ConnSet
    // 房间 → 连接列表
    roomConns sync.Map  // roomId → *ConnSet
    // 连接 ID → 元数据(反查用)
    connMeta  sync.Map  // connId → *ConnMeta
}

type ConnMeta struct {
    UserId    string
    DeviceId  string
    Platform  string          // iOS/Android/Web
    Rooms     []string        // 加入的房间列表
    Conn      *websocket.Conn
    LastActive int64
}

type ConnSet struct {
    mu sync.RWMutex
    conns map[string]*ConnMeta  // connId → meta
}

// 消息投递
func (r *SessionRegistry) SendToUser(userId string, msg []byte) int {
    v, ok := r.userConns.Load(userId)
    if !ok { return 0 }
    set := v.(*ConnSet)
    set.mu.RLock()
    defer set.mu.RUnlock()
    
    sent := 0
    for _, meta := range set.conns {
        select {
        case meta.SendChan <- msg:   // ⚠️ 用 channel 异步,防慢客户端阻塞
            sent++
        default:
            // channel 满 → 慢客户端 → 断开
            r.forceClose(meta.ConnId)
        }
    }
    return sent
}

关键设计点:

  • 一个用户多端 → userConns 是 userId → Set<Conn>
  • channel 异步写 → 防止一个慢客户端拖垮整个 goroutine
  • 反查 connId → meta → 断连时清理各种索引
  • 本地表 + Redis 全局路由表 → 集群工作

# 4.5 多端在线与弱网体感

疑惑:4.4 节的会话表已经把 userId → Set<Conn> 的数据结构讲清楚了,但同一条消息该只发一份还是发四份?弱网频繁抖动,为什么用户还觉得"一直在线"?

论证一:多端在线的三种投递策略

策略 语义 场景
全端投递 所有在线端都收到 微信 IM(消息漫游)
单端投递 只发给主设备(最近活跃的) 部分银行 App(只推手机不推 PC)
智能投递 PC 活跃时不推手机(避免打扰) 钉钉、飞书

全端投递的会话表结构:

key: session:userId:{123}
value: hash {
    "iOS":     { "gateNode": "gate-01", "connectionId": "..." },
    "Android": { "gateNode": "gate-05", "connectionId": "..." },
    "PC":      { "gateNode": "gate-02", "connectionId": "..." }
}

投递时遍历所有端,每端独立跟踪 ACK 与已读状态。已读同步(一端读了另一端也变已读)通过额外的"已读消息广播"实现——本质是把"用户"从"连接"里解耦出来。

论证二:弱网体感的"一直在线"错觉

弱网场景(信号 2 格、地铁、电梯)的核心矛盾:连接会频繁断,但用户不想看到"连接中……"。业界通用解法:

  1. UI 与连接解耦:客户端 SDK 内部维护连接状态机,UI 层只看"消息队列"是否有积压——只要发送队列在推进,UI 就显示"在线"。
  2. 发送即入队:用户点发送时,消息立刻入本地待发送队列并显示在会话列表(带"发送中"角标),SDK 异步保证送达。
  3. 快速切换网络:Wi-Fi ↔ 4G 切换时,客户端不等旧连接超时,检测到 NetworkChanged 立即主动断开 + 重连(业务无感)。
  4. 背景保活:iOS 后台通过 PushKit VoIP、Android 通过厂商推送通道(华为/小米/OPPO Push)保底——长连接不通就走 Push(详见 §8)。

核心哲学:"连接是资源,不是实体"——一条 TCP 连接不代表"用户在线"。用户是逻辑实体,连接只是它的当前化身。任何设计都要允许连接消失、迁移、重建,不能把业务状态挂在连接对象上。

结论:用户体感的"一直在线",是多重通道兜底 + 客户端 SDK 智能重连 + UI 状态解耦共同作用的结果,任何单一手段都不够。

# 5. 协议格式设计

# 5.1 粘包与拆包

疑惑:TCP 是"字节流",发一条 200 字节的消息,为什么接收方可能收到"半条 + 半条"或者"两条一起"?

论证:

TCP 没有"消息"概念,它只保证字节顺序和不丢,不保证读一次 = 写一次:

发送端 3 次 write:
  write("HELLO")      5 字节
  write("WORLD")      5 字节
  write("BYE")        3 字节

接收端可能的 recv 结果:
  ① recv → "HELLOWORLDBYE"     一次收全(粘包)
  ② recv → "HELLO", "WORLDBYE" 两次拆开
  ③ recv → "HEL", "LOWORLD", "BYE"  拆得更碎
  ④ recv → "HELLOWORL", "DBYE" 混合

根因:内核有发送缓冲区、Nagle 算法、MTU 分片、接收缓冲区,任何一个环节都可能重组数据。

解决,在字节流上"人工"分帧:

┌────────────┬─────────┬─────────┐
│ Magic (2B) │ Len (4B)│ Payload │
└────────────┴─────────┴─────────┘
    魔数        长度      数据

接收端:
  1. 读 6 字节头
  2. 校验魔数(不匹配 → 断连,防错误协议)
  3. 从 len 字段知道 payload 长度
  4. 读 len 字节 payload
  5. 拼装完成 → 一条消息

# 5.2 帧协议三要素

疑惑:帧协议除了长度还需要什么?

论证,一个健壮的帧协议至少三要素:

┌────────┬───────┬────────┬────────┬─────────┬────────┐
│ Magic  │ Ver   │ Type   │ Len    │ Payload │ CRC    │
│  2B    │ 1B    │  1B    │  4B    │   ...   │  4B    │
└────────┴───────┴────────┴────────┴─────────┴────────┘
字段 作用 必要性
Magic 快速识别协议、防错连 ⭐⭐⭐
Version 协议演进 ⭐⭐⭐
Type 消息类型(登录/聊天/心跳/ACK) ⭐⭐⭐
Len 分帧关键 ⭐⭐⭐
Payload 业务数据 ⭐⭐⭐
CRC/Checksum 防篡改/防错传 ⭐⭐

Netty 的现成组件:

public class MyProtocolDecoder extends LengthFieldBasedFrameDecoder {
    public MyProtocolDecoder() {
        super(
            /* maxFrameLength    */ 16 * 1024 * 1024,  // 16MB 上限
            /* lengthFieldOffset */ 4,                  // Magic(2) + Ver(1) + Type(1) 之后
            /* lengthFieldLength */ 4,
            /* lengthAdjustment  */ 0,
            /* initialBytesToStrip */ 0
        );
    }
}

WebSocket / MQTT / HTTP2 每个都有自己的帧协议,我们下面就拆它们。

# 5.3 文本二进制之争

疑惑:JSON、Protobuf、CBOR、FlatBuffers,到底怎么选?

论证:

格式 编码后大小 编解码性能 可读性 跨语言
JSON 100% 中等 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
XML 150-200% 慢 ⭐⭐⭐ ⭐⭐⭐⭐
Protobuf 20-40% 快 ⭐ ⭐⭐⭐⭐⭐
CBOR 50-70% 快 ⭐⭐ ⭐⭐⭐⭐
FlatBuffers 60-80% 零拷贝 ⭐ ⭐⭐⭐⭐
MessagePack 40-60% 快 ⭐⭐ ⭐⭐⭐⭐

实测(1KB JSON 编码为 Protobuf 是 300 字节,5 万 QPS 场景):

指标 JSON Protobuf
带宽 50 MB/s 15 MB/s
CPU(编码) 30% 8%
CPU(解码) 40% 10%
消息延迟 P99 25ms 8ms

结论:

  • 管理接口/低频调试:JSON
  • IM/推送高频:Protobuf
  • 超大消息(几 MB+):FlatBuffers(零拷贝)
  • 物联网低功耗:CBOR(MQTT 官方推荐)

# 5.4 心跳与保活

疑惑:心跳到底"心跳什么"?

论证,心跳承担的三重职责:

① NAT 保活:让 NAT 表项不过期     → 第 3.4 节
② 死亡检测:发现半开连接           → 
③ 双向探活:客户端和服务端互相确认

心跳的实现,通常是协议里的一种特殊消息类型:

WebSocket:   Ping (0x9) / Pong (0xA) 内建帧
MQTT:        PINGREQ / PINGRESP 报文
自定义协议:  Type=0x01 (heartbeat) 特殊类型
HTTP2:       PING 帧

心跳周期设计:

NAT 超时约束下的心跳窗口:

  NAT_TIMEOUT
  ├─── 90% ────┤
  │            │
  ├─ heartbeat 触发 ─┤
  0            T*0.9   NAT_TIMEOUT
  
  心跳周期 T = NAT_TIMEOUT * 0.7 ~ 0.9

具体值:

网络 NAT 超时 心跳周期
WiFi 5-30 min 3 min
4G 3-5 min 2-3 min
弱网 1-2 min 30-60 sec
后台 - 5-10 min(省电优先)

智能心跳,微信的经典做法:

初始心跳: 4.5 分钟
   ↓ 成功
下次: 5 分钟(+30 秒探测)
   ↓ 成功
下次: 5.5 分钟
   ↓ ...
   ↓ 失败(NAT 已 timeout)
回退: 上次成功的周期(e.g. 5 分钟)
   ↓ 稳定使用这个周期

收益:普通固定 3 分钟心跳的功耗,微信智能心跳可以降到 5 分钟,电池省 40%。

# 5.5 断线重连的数学

疑惑:心跳保活是"防连接被回收",但连接真断了怎么办?重连是不是很简单——加个定时器 1 秒重试一次就行?

论证:恰恰相反——重连是长连接系统最容易翻车的地方。想象一下:800 万台设备同时断连,每台都以 1 秒频率狂重连,这就是**"800 万台自研 DDoS"**——服务端瞬间 800 万 QPS,扛不住就集体雪崩。

任何生产级重连都由三个正交机制组合:

  1. 指数退避(Exponential Backoff):interval = min(base × 2^n, cap)
  2. 随机抖动(Jitter):interval = interval × (1 + rand(-0.5, 0.5))
  3. 上限截断(Cap):interval ≤ 60s

为什么必须"退避+抖动"缺一不可? 用数学证明:假设 N 个客户端同时断连,我们想让服务端每秒重连 QPS ≤ M。

方案 A:固定 1 秒重连(新手做法)

  • t=1 时刻 QPS = N。必炸。

方案 B:只指数退避不抖动

  • 所有客户端都是 1, 2, 4, 8, 16, ...
  • QPS 分布:t=1 时 N,t=3 时 N,t=7 时 N……
  • 峰值 QPS 不变,只是把炸的时间点推后了。

方案 C:只随机不退避

  • interval = rand(0, 60s)
  • 首波 QPS = N/60,看似平稳
  • 但如果服务端首波扛不住,客户端会不断重连,每分钟都是 N/60,稳态 QPS = N/60
  • 规模够大照样炸。

方案 D:退避 + 抖动(唯一正解)

  • 第 n 轮重连时刻分布在 $[base \cdot 2^{n-1}, base \cdot 2^n]$
  • 每一轮的窗口越来越宽,QPS 随时间指数衰减
  • 同一轮内 QPS 均匀分布
  • 这才是数学上无懈可击的方案。

标准实现(Java 参考):

public class ReconnectStrategy {
    private static final long BASE_INTERVAL = 1000;   // 1s
    private static final long CAP = 60_000;            // 60s
    private int attempts = 0;

    public long nextDelay() {
        // 1. 指数退避
        long backoff = Math.min(BASE_INTERVAL * (1L << Math.min(attempts, 10)), CAP);

        // 2. 抖动:full jitter 或 equal jitter
        // Full jitter:区间 [0, backoff]  ── AWS 推荐,最激进的打散
        long delay = (long)(Math.random() * backoff);

        // 或者 Equal jitter:区间 [backoff/2, backoff] ── 更保守
        // long delay = backoff / 2 + (long)(Math.random() * backoff / 2);

        attempts++;
        return delay;
    }

    public void onConnectSuccess() {
        attempts = 0;  // 关键:成功后必须重置
    }
}

三个容易踩的坑:

  • attempts 用 Math.min(attempts, 10) 防止 1L << 63 整型溢出。
  • 成功连接后必须重置 attempts——否则一次网络抖动会永久拉高退避。
  • 首次断连可以不退避(attempts=0 时立即重试一次),提升用户体感。

进阶:服务端引导重连

真正大规模的做法是服务端主动引导重连时机。当接入层过载时,服务端不是直接断开,而是返回:

{ "action": "reconnect", "after_ms": 30000, "target": "gate-cluster-b.example.com" }

客户端遵循 after_ms 延迟,甚至切到另一个集群。这就是"优雅降级 + 引流",微信、支付宝都在用。

核心哲学:"客户端是不可信的定时器"——N 个客户端的定时器天然同步(都从 T0 开始计时),任何"每 X 秒做一次 Y"的行为都会形成尖峰。永远加抖动、永远做退避、永远设上限,这是长连接工程师的三大铁律。

# 6. WebSocket解剖

# 6.1 HTTP升级握手

疑惑:WebSocket 底层是 TCP,为什么要先发 HTTP?

论证:

WebSocket 复用 HTTP 端口(80/443)的核心目的是穿透企业防火墙,大部分企业防火墙允许 80/443 出站,但屏蔽自定义端口。

握手流程:

Client                                    Server
  │  ─── HTTP GET Upgrade ─────────────▶│
  │                                       │
  │  GET /chat HTTP/1.1                   │
  │  Host: server.com                     │
  │  Upgrade: websocket                   │
  │  Connection: Upgrade                  │
  │  Sec-WebSocket-Key: dGhlIHNhbXBs...   │  ← 客户端随机
  │  Sec-WebSocket-Version: 13            │
  │                                       │
  │  ◀── HTTP 101 Switching Protocols ── │
  │                                       │
  │  HTTP/1.1 101 Switching Protocols     │
  │  Upgrade: websocket                   │
  │  Connection: Upgrade                  │
  │  Sec-WebSocket-Accept: s3pPLMBiTxa... │  ← key 计算得来
  │                                       │
  │  ─────  从此以后是 WebSocket 帧  ───▶│
  │  ◀─────────────────────────────────  │

Sec-WebSocket-Accept 计算方式:

concat(Sec-WebSocket-Key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")  
   ↓ SHA-1
   ↓ Base64
= Sec-WebSocket-Accept

那串 GUID 是 RFC 6455 固定值,唯一目的是"证明服务端真的懂 WebSocket 协议",防止 HTTP 服务器意外返回 101。

# 6.2 帧格式细节

疑惑:WebSocket 帧长什么样?

论证,RFC 6455 帧格式:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|     Extended payload length continued, if payload len == 127  |
+---------------------------------------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
|    Masking-key (continued)    |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

关键字段:

字段 长度 含义
FIN 1 bit 是否最后一帧(分片时用)
RSV1-3 3 bit 保留(协议扩展用,如压缩)
Opcode 4 bit 0x0=continuation, 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
MASK 1 bit 客户端→服务端必须 1,反向必须 0
Payload Len 7-71 bit 变长编码:≤125 直接 7bit;126→后 16bit;127→后 64bit
Masking-key 32 bit 若 MASK=1
Payload 变长 载荷

帧头开销:最小 2 字节(无掩码 + 短消息)、最大 14 字节。比 HTTP header 小两个数量级。

# 6.3 掩码防污染

疑惑:为什么客户端必须给消息加掩码(MASK=1)?服务端反向反而不用?

论证,历史包袱:

RFC 6455 制定时(2011),发现了一个攻击:恶意 JS 在浏览器里发 WebSocket 消息,如果没掩码,中间某些"透明代理"(Squid 等)可能把 WebSocket 帧误认为是 HTTP 缓存指令,从而毒化下游代理的缓存。

掩码算法(RFC 6455 §5.3):

// 客户端发送时
const maskingKey = randomBytes(4);  // 32 bit 随机
for (let i = 0; i < payload.length; i++) {
    payload[i] ^= maskingKey[i % 4];  // XOR
}

// 服务端解掩:同样的 XOR(异或的自逆性)

代价:客户端每字节多一次 XOR,吞吐略降。所以 Node.js 上高性能 WebSocket 服务端会显式关掩码校验(如果客户端可信),但这已经不是标准 WebSocket 了。

结论:客户端掩码是为了不信任的浏览器环境,服务端已经是可信的,反向没必要。

# 6.4 关闭与Ping

疑惑:WebSocket 怎么优雅关闭?

论证,Close 帧(opcode 0x8):

关闭流程:
  A → B: Close 帧 (code=1000, reason="normal closure")
  B → A: Close 帧 (code=1000)     ← 回应
  A: 关闭 TCP
  B: 关闭 TCP

关闭状态码(RFC 6455 §7.4):

Code 含义
1000 Normal Closure
1001 Going Away(服务端重启)
1002 Protocol Error
1003 Unsupported Data
1006 Abnormal Closure(TCP RST,客户端强制标记)
1008 Policy Violation
1011 Server Error

Ping / Pong 帧(opcode 0x9 / 0xA):

  • 任一方发 Ping,对方必须回 Pong
  • Pong 可以主动发("unsolicited pong"),对方必须忽略
  • Ping 里可带 0-125 字节数据,Pong 里必须原样返回

心跳最佳实践:

// 客户端
setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
        ws.ping(Date.now().toString());  // 带时间戳
    }
}, 30_000);

ws.on('pong', (data) => {
    const rtt = Date.now() - parseInt(data);
    console.log(`RTT: ${rtt}ms`);        // 顺便测 RTT
});

// 服务端
ws.on('ping', (data) => {
    // 自动 pong(大部分库默认行为)
    // 记录上次活跃时间
    lastActive = Date.now();
});

# 7. MQTT解剖

# 7.1 发布订阅模型

疑惑:MQTT 和 WebSocket 都是"长连接",为什么还需要 MQTT?

论证,MQTT 天然是发布订阅模型,且专为不稳定网络设计:

核心概念:

  • Topic:分层路径 home/livingroom/temperature
  • 通配符:+(单层)、#(多层)
  • Broker:中心化的消息路由服务器
  • QoS:三级投递保证

MQTT 3.1.1 与 5.0:

特性 MQTT 3.1.1 MQTT 5.0
发布 支持 支持 + Reason Code
遗嘱 支持 支持 + 延迟遗嘱
保留消息 支持 支持
共享订阅 不支持 ✅ 支持
请求响应 不支持 ✅ 支持
Session Expiry 不支持 ✅ 支持

# 7.2 QoS三级投递

疑惑:MQTT 的 QoS 0/1/2 到底什么区别?

论证,三种投递保证:

QoS 0:至多一次(At most once)

Publisher ──PUBLISH──▶ Broker ──PUBLISH──▶ Subscriber
   一次即完      不确认        一次即完
   
   ↓ 场景:传感器数据(丢一两个无所谓)
   ↓ 特点:性能最高、可能丢消息

QoS 1:至少一次(At least once)

Publisher ──PUBLISH────▶ Broker ──PUBLISH────▶ Subscriber
                          │                     │
Publisher ◀──PUBACK────── │  ◀──PUBACK──────── │
                          
   ↓ 场景:命令下发(丢了要重发)
   ↓ 特点:可能重复投递(幂等性由业务保证)

QoS 2:恰好一次(Exactly once)

Publisher ──PUBLISH────▶ Broker
Publisher ◀──PUBREC──── Broker
Publisher ──PUBREL────▶ Broker
Publisher ◀──PUBCOMP── Broker
                        (然后再到 Subscriber,4 步握手)

   ↓ 场景:金融交易、订单
   ↓ 特点:4 次交互,性能最低

性能对比(相同硬件):

QoS 吞吐 单消息 RTT 存储需求
0 100 万/秒 5ms 无
1 30 万/秒 15ms 客户端 outbox
2 5 万/秒 40ms 双端持久化

# 7.3 遗嘱与保留

疑惑:设备断电了怎么通知其他订阅者?

论证,Last Will(遗嘱消息):

连接时告诉 Broker:
  "如果我异常断开,请代我发布 topic=device/status, payload=offline"

设备正常断开: DISCONNECT 报文 → 遗嘱不触发
设备异常断开: 心跳超时/RST     → Broker 代发遗嘱

Retained Message(保留消息):

Publisher ──PUBLISH (retain=1) topic=status──▶ Broker
                                                 │
                                                 │ 存下来
                                                 │
后续新订阅者订阅 topic=status ──▶ Broker ─PUBLISH─▶ 新订阅者
                                    立即拿到最后一条保留消息

典型用途:

  • 在线状态:retain=1, payload=online,新订阅者立即知道设备状态
  • 配置下发:retain=1, payload=<config>,新设备连上立即拿到最新配置

# 7.4 报文控制格式

疑惑:MQTT 报文长什么样?

论证,MQTT 报文头部极简(IoT 场景对包大小极敏感):

固定头 (2-5 字节):
┌─────────────────┬─────────────────┐
│ Msg Type + Flag │ Remaining Length│
│      1 字节      │   1-4 字节      │
└─────────────────┴─────────────────┘

Msg Type (4 bit):
  1  CONNECT       连接
  2  CONNACK       连接确认
  3  PUBLISH       发布
  4  PUBACK        发布确认
  5  PUBREC        QoS2 收到
  6  PUBREL        QoS2 释放
  7  PUBCOMP       QoS2 完成
  8  SUBSCRIBE     订阅
  9  SUBACK        订阅确认
  10 UNSUBSCRIBE   取消订阅
  12 PINGREQ       心跳请求
  13 PINGRESP      心跳响应
  14 DISCONNECT    断开

Remaining Length 变长编码(VBI - Variable Byte Integer),低 7 bit 存数据,高 1 bit 标记"还有下一字节":

0-127 bytes:              1 字节  ── payload len ≤ 127
128-16383 bytes:          2 字节  ── payload len ≤ 16 KB
16384-2097151 bytes:      3 字节  ── payload len ≤ 2 MB
2097152-268435455 bytes:  4 字节  ── payload len ≤ 256 MB

极致精简:最小 MQTT 报文 = 2 字节(PINGREQ / PINGRESP),比 HTTP header 短 100 倍。这就是为什么它是 IoT 的默认协议。

# 7.5 主题匹配算法

疑惑:7.1 节讲了 Topic 和通配符 + / #,但 Broker 收到 sensor/+/temperature 时怎么快速找出所有匹配的订阅者?遍历所有订阅规则 O(N) 肯定不行。

论证,主题匹配是 MQTT Broker 的核心引擎——匹配速度直接决定 Broker 吞吐上限。业界主流使用的是字典树(Trie):

Trie 订阅树结构:

订阅:
  subscriber-A → sensor/temp
  subscriber-B → sensor/#
  subscriber-C → sensor/+/humidity
  subscriber-D → home/livingroom/light

构建的 Trie:

                    root
                   /    \
              sensor     home
             /  |  \        \
          temp  +   #     livingroom
           ↓    ↓    ↓        ↓
          [A]  humidity   [B] light
                  ↓           ↓
                 [C]         [D]

[ ] = 订阅者列表

匹配逻辑伪代码:

type TrieNode struct {
    children    map[string]*TrieNode  // 精确匹配的子节点
    plusChild   *TrieNode             // '+' 单层通配
    hashChild   *TrieNode             // '#' 多层通配
    subscribers []*Subscriber         // 匹配到该节点的订阅者
}

func (n *TrieNode) Match(levels []string, depth int, result *[]*Subscriber) {
    // 1. 如果当前节点有订阅者,全部加入结果
    *result = append(*result, n.subscribers...)

    // 2. 如果已经到最后一层
    if depth >= len(levels) { return }

    // 3. '#' 通配:匹配当前及之后所有层级
    if n.hashChild != nil {
        *result = append(*result, n.hashChild.subscribers...)
        // # 之后不能继续匹配(MQTT 规范:'#' 必须是最后一级)
    }

    // 4. '+' 通配:匹配当前这一层
    if n.plusChild != nil {
        n.plusChild.Match(levels, depth+1, result)
    }

    // 5. 精确匹配
    if child, ok := n.children[levels[depth]]; ok {
        child.Match(levels, depth+1, result)
    }
}

复杂度分析:

方案 插入复杂度 匹配复杂度 删除复杂度
朴素遍历 O(1) O(N) O(1)
Trie O(L) O(L) O(L)
Hash 表 + 展开通配 O(N) O(N) O(N)

注:L = topic 层级深度(通常 ≤ 5),N = 订阅规则总数

Trie 的匹配复杂度是 O(L),与订阅规则总数 N 无关——这是它能支撑百万级订阅规则的核心原因。

特殊场景优化:

场景:100 万个 IoT 设备全部订阅 sensor/{deviceId}/temp
      → 100 万条 sensor/device-001/temp 精确匹配规则
      → Trie 的 children map 在 sensor 下有 100 万个孩子
      
优化:子树压缩(Path Compression)
      sensor/device-* 前缀合并为一个"前缀节点"
      → 孩子数从 100 万压缩到 O(1)

更常见做法:sensor/{deviceId}/temp → 订阅 server/+ 的模板
            匹配前先做前缀哈希,减少 Trie 宽度

结论:Trie 是 MQTT Broker 主题匹配的唯一正解。所有生产级 Broker(EMQX、Mosquitto、VerneMQ)都基于 Trie 或其变体(Radix Tree / Prefix Tree)。这也是为什么 Broker 能支撑"千万设备、百万 topic"的规模——匹配复杂度与订阅数脱钩。

# 7.6 会话生命周期

疑惑:设备断连后,Broker 是否还记得"你是谁、订阅了什么、哪些消息还没发完"?

论证,MQTT 通过 Clean Session 标志(3.1.1)和 Session Expiry(5.0) 控制会话生命周期:

Clean Session = true(默认):

设备 CONNECT 时 Clean Session=true:
  ├─ 连接建立:清空该 ClientID 的所有历史会话数据
  ├─ 在连期间:新订阅 → 都在内存
  └─ 断开时:Broker 立即清空所有:
       ├─ 订阅关系(topic subscriptions)
       ├─ 未完成的 QoS1/QoS2 消息队列
       └─ Will Message(遗嘱)
       
   → 设备每次断连都像"第一次来"——无状态、干净、简单

Clean Session = false(持久会话):

设备 CONNECT 时 Clean Session=false:
  ├─ 首次连接:Broker 创建新会话
  ├─ 运行期间:Broker 持久化跟踪:
  │    ├─ 订阅列表(subscribe topics)
  │    ├─ QoS1/QoS2 未完成的消息(outbound queue)
  │    │   → 设备离线期间,消息积压在 Broker 侧队列
  │    └─ 已投递但未 ACK 的消息(inflight messages)
  │
  └─ 离线后:Broker 保持会话
       ├─ 新消息攒在队列里(有上限,默认 1000 条)
       └─ 设备重新 CONNECT → 批量下发积压消息 → 恢复如初

   → 适用于"断断续续"的 IoT 设备(传感器省电周期上报)

MQTT 5.0 的 Session Expiry(更精细的控制):

CONNECT 包里可以设置 Session Expiry Interval(秒):
  ├─ 0      → 断开立刻删除会话(等价 Clean Session=true)
  ├─ N      → 断开后保留 N 秒,超时删除
  └─ 0xFFFFFFFF → 永不过期(等价 Clean Session=false)

场景:
  设备说:"我可能会离线 30 分钟(省电),请帮我保留会话"
  → Session Expiry = 1800(秒)
  → 30 分钟内重连,消息不丢;超时了就别等了

Broker 内部会话数据结构:

type Session struct {
    ClientID     string
    CleanStart   bool                  // MQTT 5.0 改名
    ExpiryAt     time.Time             // 过期时间
    Subscriptions map[string]QoSLevel  // topic → QoS
    Inflight     *InflightWindow       // 已发出待 ACK 的消息
    OfflineQueue []*PendingMessage     // 离线积压队列
    Will         *WillMessage          // 遗嘱消息
}

type InflightWindow struct {
    maxInflight int                    // 最大并发(默认 10)
    packets     map[uint16]*Packet     // packetId → 数据
    // 收到 PUBACK/PUBREC/PUBCOMP 删除对应项
}

离线队列的安全水位:

风险:设备离线 3 天 → Broker 积压了 50 万条消息
     → 设备重连时一次性推送 → Broker OOM / 对端被打爆

防护:
  ├─ 离线队列上限:MaxQueuedMessages = 1000
  ├─ 超过上限策略:
  │    ├─ DROP_OLDEST(丢弃最早的消息)
  │    └─ DROP_NEWEST(不接收新消息)
  └─ 重连后限速下发(每秒 100 条,避免冲垮客户端)

结论:会话生命周期是 MQTT "专为不可靠网络设计"的核心体现。Clean Session 控制记忆深度,Session Expiry 控制遗忘时机。正确理解这两个参数,是 MQTT 生产部署不丢消息的前提。

# 7.7 Broker消息路由

疑惑:Broker 收到一条 PUBLISH 后,内部链路是怎样从"一个 socket 收进来"到"N 个 socket 发出去"的?

论证,以 QoS 1 消息 topic=sensor/temp, payload=25.5°C 为例,完整内部链路:

                    ┌─────────────────────────────┐
                    │         MQTT Broker          │
                    │                              │
  Publisher ──TCP──▶│ ① 连接层                     │
  (sensor-01)       │   recv PUBLISH 报文           │
                    │   解析固定头+可变头+Payload    │
                    │   ──────────────────────────  │
                    │ ② 协议层                     │
                    │   校验 QoS、Topic 合法性       │
                    │   ▼                           │
                    │ ③ 主题匹配引擎(§7.5)       │
                    │   topic → 匹配订阅者列表       │
                    │   ["sub-A", "sub-B", ...]     │
                    │   ▼                           │
                    │ ④ 订阅者遍历                 │
                    │   for each subscriber:        │
                    │     ├─ 查会话表找到 Conn      │
                    │     ├─ 降级 QoS               │
                    │     │  (pub QoS≥sub QoS 时降) │
                    │     ├─ 入 inflight 窗口       │
                    │     │  (packetId→msg 映射)    │
                    │     └─ write 到 socket        │
                    │   ▼                           │
                    │ ⑤ ACK 跟踪                   │
                    │   Publisher 回 PUBACK         │
                    │   → Broker 从 inflight 删     │
                    │   → 记录 msgId 已投递        │
                    │                              │
               ──TCP──▶ Subscriber-A              │
               ──TCP──▶ Subscriber-B              │
                    └─────────────────────────────┘

关键细节:QoS 降级(重要!)

MQTT 规范规定:Broker 投递给订阅者时,QoS 可以**降级但不能升级**:

Publisher QoS 2  →  Subscriber QoS 1  →  Broker 用 QoS 1 投递
Publisher QoS 0  →  Subscriber QoS 2  →  Broker 仍用 QoS 0 投递(不升级)

原因:订阅者说"我只要 QoS 1 就行",Broker 没理由做更重的投递。
      但订阅者说"我要 QoS 2",Publisher 只发 QoS 0,Broker 无法凭空变出可靠性。

流量控制:Inflight Window

Inflight Window 防止 Broker 对单个订阅者"消息轰炸":

  maxInflight = 10(默认)
  
  窗口满的时候:
    ├─ 新消息不发给这个订阅者,入离线队列等
    └─ 等收到一个 PUBACK,窗口空出一个槽 → 发下一条

  效果:对每个订阅者,最多同时有 10 条 QoS 1/2 消息在飞
        → 限速自然生效,不用另搞限流器

结论:Broker 的核心价值不是"转发消息"——负载均衡器也能转发——而是 QoS 降级决策 + Inflight 窗口流量控制 + 离线积压存储。这三件事任何 TCP Proxy 都做不了,只有"懂 MQTT 协议"的 Broker 能做。这就是为什么"用 Nginx stream 转发 MQTT"只是初级运维,"用 EMQX/Mosquitto"才是生产部署。

# 7.8 共享与集群

疑惑:单机 Broker 扛不住怎么办?MQTT 能集群吗?

论证,分两种扩展思路:

一、共享订阅(MQTT 5.0)——消息级负载均衡:

场景:100 台执行器都订阅 topic=cmd/execute
     但同一条命令只需要**一台**执行器处理(不是 100 台都执行)

传统订阅:
  Pub → cmd/execute → 所有 100 台执行器都收到(广播)
  ❌ 同一条命令执行 100 次

共享订阅(Shared Subscription):
  Pub → $share/mygroup/cmd/execute
     → Broker 从 mygroup 组里**轮询选一台**执行器
     → 只发给 1 台
  ✅ 实现了消费者组级别的负载均衡
订阅语法:$share/{GroupName}/{TopicFilter}

示例:
  执行器 1:SUBSCRIBE $share/workers/cmd/execute
  执行器 2:SUBSCRIBE $share/workers/cmd/execute
  执行器 3:SUBSCRIBE $share/workers/cmd/execute

  发一条 cmd/execute → 轮询选一台(e.g. 执行器 2)
  
  分发策略:
    ├─ 轮询(Round Robin):最简单
    ├─ 随机(Random)
    ├─ 哈希(Sticky by ClientID):同设备始终同执行器
    └─ 最少未 ACK(Least Inflight):当前最空闲的

二、Broker 集群 —— 横向扩展:

┌────────────────────────────────────────────────────────┐
│                   MQTT Broker 集群                       │
│                                                         │
│  设备 → Broker-1 ────┐                                 │
│  设备 → Broker-2 ────┤                                 │
│  设备 → Broker-3 ────┤                                 │
│                       │                                  │
│                       ▼                                  │
│            ┌─────────────────────┐                      │
│            │  集群路由层          │                      │
│            │  (Mnesia/etcd/Raft) │                      │
│            └─────────────────────┘                      │
│                       │                                  │
│            ┌──────────┴──────────┐                      │
│            ▼                     ▼                      │
│    共享订阅路由表           会话广播                    │
│    topic → worker节点       ClientID → 所在节点         │
│                                                         │
│    跨节点消息投递:                                      │
│     设备 A 在 Broker-1 → pub → 匹配到 Broker-2 的订阅   │
│      → Broker-1 通过集群内 RPC 转发给 Broker-2           │
└────────────────────────────────────────────────────────┘

三种集群架构:

架构 原理 适用规模 代表
全连接 Mesh 每台 Broker 互连,订阅全量同步 ≤ 10 节点 Mosquitto Bridge
中心化路由 共享 Redis/etcd 存订阅路由表 ≤ 100 节点 自研
对等式 Mnesia 分布式数据库,无中心节点 ≤ 20 节点 EMQX

EMQX 集群示例(对等式,Erlang/OTP):

集群发现:
  Broker-1@10.0.1.1
  Broker-2@10.0.1.2
  Broker-3@10.0.1.3
  ↓ Erlang 分布式协议(EPMD)
  自动发现 + 互连 + 同步路由表

设备连接:
  A 连 Broker-1,订阅 sensor/#
  B 连 Broker-2,pub sensor/temp
  → Broker-2 查路由表 → 转发给 Broker-1 → Broker-1 投给 A

节点上下线:
  节点加入 → 自动同步路由表(增量)
  节点故障 → Mnesia 自动 failover → 路由指向健康节点

结论:MQTT 集群的本质是把"N 台独立 Broker"变成"一台虚拟的大 Broker"。共享订阅解决消息级负载均衡,集群解决连接级负载均衡——两者正交但协同,实现"连接可达千万、消息吞吐百万"的企业级规模。

# 7.9 安全认证

疑惑:MQTT 协议本身不定义认证——客户端连 Broker,怎么防止"谁都能 pub/sub"?

论证,MQTT 安全需要在多个层次叠加:

第一层:传输加密(TLS)

CONNECT 前先 TLS 握手:
  客户端 ──TLS Client Hello──▶ Broker
         ◀──Server Cert─────
         验证证书(CA链)
         ──Client Cert──────▶  (mTLS 模式)
         
  ├─ 单向 TLS:客户端验证 Broker 身份(防止连到假 Broker)
  └─ 双向 mTLS:双方互验(适用于设备证书出厂烧录的场景)

第二层:CONNECT 认证

CONNECT 报文里携带认证信息(明文,TLS 加密后传输):

  3.1.1 版本:
    ├─ Username + Password(最基础)
    └─ ClientID(设备唯一标识)

  5.0 版本(增强认证 Enhanced Authentication):
    ├─ Auth Method:认证方法(SCRAM-SHA-256, Kerberos 等)
    ├─ Auth Data: 认证数据(可多次交互)
    └─ 支持 SASL 框架,可插接自定义认证插件

增强认证的握手流程(MQTT 5.0):

Client                        Broker
  │ ── CONNECT                   │
  │    AuthMethod=SCRAM-SHA-256  │
  │    AuthData=client-first-msg │
  │                              │
  │ ◀── AUTH (Continue)          │
  │    ReasonCode=0x18           │
  │    AuthData=server-first-msg │
  │                              │
  │ ── AUTH (Continue)           │
  │    AuthData=client-final-msg │
  │                              │
  │ ◀── CONNACK (Success)        │
  │    ← 认证通过,连接建立      │

  支持多次 AUTH 往返,适配任意挑战-响应认证协议

第三层:ACL 授权

Broker 连接了 10 万台设备,凭什么相信设备 A 不会
订阅 device_B/private 的话题?

ACL(Access Control List)规则示例:

  # 用户名 = sensor-01 的权限:
  allow sensor-01 pub    topic=sensor/+/data       # 只能上报自己的数据
  allow sensor-01 sub    topic=cmd/sensor-01       # 只能收自己的命令
  deny  sensor-01 sub    topic=sensor/+/data       # 不能偷看别人数据
  deny  sensor-01 pub    topic=cmd/+               # 不能伪造命令

  # 超级用户:
  allow admin     pub    topic=#                   # 可以发任意话题
  allow admin     sub    topic=#                   # 可以订阅任意话题

ACL 存储与匹配:

存储:Redis/MySQL/内置文件
匹配:和 topic 匹配是同一棵 Trie,只是叶子节点存的是 ACL 规则

收到一条 PUBLISH sensor-01/data 时:
  1. 查 Username 对应的 ACL 规则
  2. 匹配 topic 是否在 allow 列表
  3. 匹配 topic 是否在 deny 列表(deny 优先)
  4. pub 操作匹配 pub 规则,sub 操作匹配 sub 规则
  
  → 通过 → 继续路由投递
  → 拒绝 → 断开连接 或 静默丢弃

结论:MQTT 的安全是分层叠加的——TLS 保传输、CONNECT 认证保身份、ACL 保授权。IoT 设备出厂时烧录 Client Cert + ACL 白名单,是"零信任"在物联网领域的经典实践。没有这三层,你的 MQTT Broker 就是"全网公开的 pub/sub 公告板"。

# 8. Push推送体系

# 8.1 APNs工作原理

疑惑:iOS 后台被杀了,为什么还能收到 Push?

论证,手机操作系统级别的长连接:

┌─────────────────────────────────────────────────────────────┐
│                     APNs 推送链路                            │
│                                                              │
│   业务服务器                                                  │
│      │                                                       │
│      │ HTTP/2 POST + JWT/证书                               │
│      ▼                                                       │
│   ┌────────────┐                                            │
│   │ APNs 网关  │  (Apple 的推送服务器)                      │
│   └────┬───────┘                                            │
│        │                                                     │
│        │ 系统级长连接(不是 App 建的)                       │
│        ▼                                                     │
│   ┌─────────────────┐                                       │
│   │  iOS 系统进程   │                                       │
│   │  (SpringBoard/  │                                       │
│   │   dasd/apsd)    │                                       │
│   └────┬────────────┘                                       │
│        │ 系统事件唤醒                                        │
│        ▼                                                     │
│   目标 App                                                   │
│        │                                                     │
│        ▼                                                     │
│   用户看到通知栏 / 唤醒 App                                  │
└─────────────────────────────────────────────────────────────┘

关键点:

  • iOS 系统进程 apsd 维护到 APNs 的持久 TLS 长连接,App 被杀不影响
  • 业务服务器 → APNs 用 HTTP/2(2016 起,之前是二进制协议)
  • JWT/证书鉴权,每次 POST 带 token 或用 mTLS
  • 优先级:apns-priority: 5 普通、10 立即(会打开屏幕)

# 8.2 FCM链路解析

Android 上对应的是 Firebase Cloud Messaging(FCM):

业务服务器 ──HTTPS──▶ FCM ──长连接──▶ Google Play 服务 ──▶ 目标 App

差异点:

  • Android 上 GMS(Google Play 服务)维护到 FCM 的长连接
  • 国内不能用 FCM(不通)
  • 消息类型:Notification Message(系统托管展示)vs Data Message(App 自己处理)

# 8.3 国内厂商推送

疑惑:国内 Android 不能用 FCM,怎么办?

论证,厂商推送:小米、华为、OPPO、VIVO 各自维护自己的推送通道,App 需要分厂商适配:

┌──────────────────────────────────────────────────┐
│               国内 Android 推送体系                │
│                                                    │
│   业务服务器                                       │
│      │                                             │
│      ├── 小米手机  → 小米推送 (MiPush)            │
│      ├── 华为手机  → 华为推送 (HMS Push Kit)      │
│      ├── OPPO 手机 → OPPO 推送 (HeytapPush)       │
│      ├── VIVO 手机 → VIVO 推送 (VPush)            │
│      └── 其他手机  → 应用长连接(自建)            │
│                                                    │
│   聚合方案:                                       │
│   ├─ 极光推送 JPush                                │
│   ├─ 个推 GeTui                                   │
│   ├─ 友盟 UPush                                   │
│   └─ 优势:一套 SDK 屏蔽差异                       │
└──────────────────────────────────────────────────┘

厂商通道优势:

  • 杀不死,系统级别,用户不能"完全退出"
  • 免续费,不需要 App 自建长连接、省电

代价:

  • 每家 SDK 都要接
  • 消息内容审核(不能推广告/敏感词)
  • 单日推送量限制(每家 1-10 万免费)

# 8.4 推送到达难题

疑惑:Push 到达率是不是 100%?

论证,到达率行业均值 60-80%,失败原因:

提高到达率,多通道 + 兜底:

优先级:厂商推送 → Push(APNs/FCM) → 应用长连接 → 短信

到达 → 停止
未到达 → 降级下一通道

"离线消息保底":服务端保存最近 N 天消息,用户 App 打开时主动拉取,这样即使 Push 全部失败,也不会永久丢消息。

# 9. 方案横向对比

# 9.1 协议vs库vs方案

疑惑:WebSocket、Socket.IO、SignalR、Netty、gorilla/websocket,这些到底是同一层的东西吗?

论证,必须严格分清:

分类 定义 例子
协议 (Protocol) RFC 标准、跨语言可实现 WebSocket (RFC 6455)、MQTT (OASIS)、HTTP/2
库 (Library) 某语言里协议的实现 gorilla/websocket、Netty、Autobahn
框架 (Framework) 协议 + 应用层抽象 Socket.IO、SignalR、STOMP、SockJS
方案 (Solution) 完整推送/IM 系统 RocketMQ Push、Ably、Pusher、腾讯 TIM、环信
┌───────────────────────────────────────────────────┐
│                                                    │
│  方案层:完整 IM/推送系统                          │
│  ┌──────────────────────────────────────────┐    │
│  │ Ably │ Pusher │ 腾讯 TIM │ 环信 │ Firebase │    │
│  └──────────────────────────────────────────┘    │
│                    ▲                              │
│  框架层:应用抽象                                  │
│  ┌──────────────────────────────────────────┐    │
│  │ Socket.IO │ SignalR │ STOMP │ SockJS      │    │
│  └──────────────────────────────────────────┘    │
│                    ▲                              │
│  库层:协议实现                                    │
│  ┌──────────────────────────────────────────┐    │
│  │ Netty │ gorilla/websocket │ paho MQTT     │    │
│  └──────────────────────────────────────────┘    │
│                    ▲                              │
│  协议层:跨语言标准                                │
│  ┌──────────────────────────────────────────┐    │
│  │ WebSocket │ MQTT │ HTTP/2 │ SSE │ gRPC     │    │
│  └──────────────────────────────────────────┘    │
│                                                    │
└───────────────────────────────────────────────────┘

# 9.2 六大维度对比

主流方案六维度对比:

维度 长轮询 SSE WebSocket MQTT Push (APNs/FCM) gRPC-stream
传输方向 半双工 单向下行 全双工 全双工 单向下行 全双工
底层协议 HTTP/1.1 HTTP/1.1 TCP (HTTP 握手) TCP HTTP/2 (对服务端) HTTP/2
实时性 秒级 亚秒 毫秒 毫秒 秒-分钟 毫秒
NAT 友好 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
可扩展性 高 高 中 高 极高 中
消息可靠性 无内建 无 无内建 QoS 0/1/2 平台级 应用级
消息路由 应用实现 应用实现 应用实现 内建 pub/sub 平台内建 应用实现
穿透代理 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐(over WS 更好) ⭐⭐⭐⭐⭐ ⭐⭐
移动端后台 ❌ ❌ ❌ ⚠️ 需保活 ✅ ❌
典型场景 通知拉取 服务端事件流 双向 IM IoT/亿设备 系统通知 微服务

# 9.3 常见库源码级差异

Socket.IO 不是 WebSocket,这是很多人误会:

// Socket.IO 客户端
const socket = io('https://server.com');
socket.emit('chat', {msg: 'hello'});
socket.on('chat', (data) => console.log(data));

看起来是 WebSocket,实际内部:

1. 先尝试 HTTP long polling  ← 兜底
2. 升级到 WebSocket           ← 成功后切换
3. 有自己的帧协议(event/ack/binary)
4. 有房间(room)概念
5. 有中间件、命名空间

Socket.IO vs 裸 WebSocket 对比:

特性 裸 WebSocket Socket.IO
帧协议 RFC 6455 二进制帧 文本 event 帧(JSON)
自动重连 需手动实现 内建
房间/命名空间 需手动实现 内建
兜底传输 无 Long Polling
客户端 SDK 各语言不统一 官方多语言
与 WS 客户端互通 ✅ ❌ 只能 Socket.IO 之间

决策:

  • 想要跨语言、协议标准:用裸 WebSocket + 自研上层
  • 想要Web 端 + Node.js 快速上线:用 Socket.IO

Netty vs gorilla/websocket vs uWebSockets 性能(单机压测):

库 语言 连接数上限 单核 QPS
Netty Java 100 万 20 万
gorilla/websocket Go 100 万 30 万
gnet + ws Go 100 万 50 万
uWebSockets C++ 500 万 100 万
Actix Web Rust 200 万 80 万

# 9.4 选型决策树

分场景选型:

场景 首选 备选 理由
IM 聊天 WebSocket + 自研 Socket.IO 双向 + 定制路由
股票行情 WebSocket SSE 极低延迟 + 单向下行也 OK
IoT 亿设备 MQTT AMQP pub/sub + 低带宽
直播弹幕 WebSocket HTTP2 SSE 高并发广播
系统通知 Push 应用长连接 免电量 + 免后台限制
微服务流 gRPC stream Kafka 内网低延迟
通用推送 Socket.IO 自研 快速上线
Web 后台推送 SSE WebSocket 单向 + HTTP 原生

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的 IM 8 秒延迟事件,7 个疑问逐条作答:

疑问 答案
① 长连接的"连接"是什么? 第 3.1 节:内核里的五元组 socket,应用层的抽象是 Conn 对象
② WebSocket 底层是 TCP,和裸 TCP 差别? 第 6:多了 HTTP 握手(穿透代理)+ 帧协议 + 掩码
③ 消息路由 1200 万连接怎么做? 第 4.3 节:一致性哈希/集中式路由/广播路由,本节详解
④ 粘包/拆包为什么产生? 第 5.1 节:TCP 是字节流,Nagle/MTU/缓冲区都会重组
⑤ 心跳到底心跳什么? 第 5.4 节:NAT 保活 + 死亡检测 + 双向探活
⑥ 各方案怎么选? 第 9.4 节:决策树 + 分场景选型
⑦ Socket.IO ≠ WebSocket? 第 9.3 节:Socket.IO 是"框架",自带帧协议、房间、兜底传输

8 秒延迟的完整根因链:

应用层:gorilla/websocket 没开 TCP_NODELAY(默认值)
   ↓ TCP 层:Nagle 算法开启
   ↓ 网络层:小消息 (20 字节) < MSS → Nagle 缓存 200ms
   ↓ 对端:delayed ACK 又等 200ms 才 ACK
   ↓ 死锁:发送端等 ACK 才继续、接收端等数据才 ACK
   ↓ 累计延迟:8 秒
   
修复:SetNoDelay(true) → 关掉 Nagle → 30ms

深层教训:长连接系统的性能问题往往在 TCP 层,而不是 WebSocket 层,这是分层理解的直接价值。

# 10.2 一条消息的一生

从用户 A 发送 "hello" 到用户 B 手机屏幕显示的完整旅程:

用户 A 输入 "hello"
        │
        ├─ 客户端 A
        │   ├─ 应用层:JSON.stringify → Protobuf 序列化   ─── 第 5.3 节
        │   ├─ WebSocket 层:加帧头、掩码                 ─── 第 6.2 节
        │   ├─ TCP 层:Nagle 检查、SetNoDelay=true       ─── 案例根因
        │   └─ IP 层:NAT 映射(运营商)                  ─── 第 3.4 节
        │
        ├─ 网络传输
        │   ├─ 移动网络 → 运营商 NAT → 公网              ─── 第 3.4 节
        │   └─ RTT 30-100ms
        │
        ├─ 接入层网关(1200 万连接)
        │   ├─ TCP 接收缓冲区 → WebSocket 帧解析
        │   ├─ 应用层反序列化 Protobuf                    ─── 第 5.3 节
        │   ├─ 会话查找:connId → userA meta              ─── 第 4.4 节
        │   ├─ 路由查找:Redis 查 userB 在哪台节点         ─── 第 4.3 节
        │   └─ 投递:本机直发 or 跨节点转发
        │
        ├─ 内部消息队列(Kafka)
        │   ├─ topic = chat_events
        │   ├─ 分区键 = userB
        │   └─ 持久化 → 削峰 → 分发
        │
        ├─ 用户 B 端接入网关
        │   ├─ 会话表查找:userB → connB
        │   ├─ WebSocket.WriteMessage
        │   └─ 若 B 离线 → Push 兜底                       ─── 第 8 章
        │
        └─ 用户 B 客户端
            ├─ TCP 层收 → 帧解析 → 反序列化
            ├─ UI 层显示
            ├─ 回发已读回执 → 同样流程反向
            └─ 存本地数据库

每一步都有对应的原理章节,理解一条消息就是理解整个通信栈。

# 10.3 设计哲学回扣

哲学 1:分层是解决复杂系统的第一原则

从 L1 网络 到 L5 场景 , 每一层都有独立的抽象和职责。事故根因藏在哪一层,就必须用哪一层的工具去修(Nagle 是 L2 问题,无法用 L3/L4 修复)。不分层理解,永远解不了跨层事故。

哲学 2:协议是可组合的乐高积木

MQTT over WebSocket、WebSocket over TLS、HTTP/2 over QUIC,这不是"复杂化",是"复用穿透代理能力 + 保留自身协议特性"的最优组合。优秀架构师会把协议当零件用,而不是当"必须整包接受"的黑箱。

哲学 3:无状态是水平扩展的护身符

推的所有问题都源于"服务端要记住客户端"。长轮询之所以扩展性好,就是几乎无状态。设计推送系统时永远问:这一步能不能变无状态? 一致性哈希、Redis 路由表、会话粘性,都是为"有状态"付出的复杂度代价。

哲学 4:韧性是层次化冗余

Push 到达率 60-80%,怎么办?,多通道 + 兜底拉取。这条设计哲学不止推送,DNS 缓存、CDN 多层、多机房容灾都是同一个思路。没有单一方案能达 100%,只有组合能。

# 10.4 架构速查表

协议 vs 库 vs 框架 vs 方案(切勿混淆):

层级 例子
协议 WebSocket, MQTT, SSE, HTTP/2, gRPC
库 Netty, gorilla/websocket, paho.mqtt, autobahn
框架 Socket.IO, SignalR, STOMP, SockJS
方案 Ably, Pusher, 腾讯 TIM, 环信, JPush

场景 → 首选协议:

场景 首选
IM 聊天 WebSocket + Protobuf
股票行情 WebSocket 或 SSE
IoT 设备 MQTT
Web 通知(单向) SSE
系统通知 Push (APNs/FCM/厂商)
微服务流 gRPC stream
兜底 长轮询

心跳周期:

网络 建议心跳
WiFi 3 min
4G/5G 2-3 min
弱网 30-60 sec
后台 5-10 min

排查检查表:

  • [ ] TCP_NODELAY 是否开启(关 Nagle)
  • [ ] SO_KEEPALIVE 是否开启
  • [ ] 帧协议是否完整(Magic/Ver/Type/Len/CRC)
  • [ ] 心跳周期 < NAT 超时
  • [ ] 是否有粘包/拆包处理
  • [ ] 会话表内存是否有回收(连接断开时清理)
  • [ ] 消息路由是否支持集群(一致性哈希 or Redis)
  • [ ] 多端登录是否支持(userId → Set)
  • [ ] 离线消息是否有兜底(MQ + Push + 拉取)
  • [ ] 弱网/穿透是否兜底(长轮询)

一句话总结:通信是"字节从源到目的的旅程",实时通信是"字节尽可能快 + 尽可能可靠 + 尽可能省"的三角平衡。掌握 L1 到 L5 的每一层,就掌握了这三角的所有旋钮。

# 10.5 上线Checklist

从"看懂原理"到"平安上线",还差一份检查表。整理长连接/实时通信系统上线前必过的 22 项:

协议与握手

  • [ ] 协议选型评审(WebSocket / MQTT / 自研)— §6~§7
  • [ ] TLS 1.3(1 RTT 握手)— §3.3
  • [ ] HTTPDNS + 就近接入 — §3.4
  • [ ] CONNECT 包含 lastAckSeq,支持增量拉取

心跳与重连

  • [ ] 客户端心跳周期 < NAT 超时,含 ±10% 抖动 — §5.4
  • [ ] 服务端 2×心跳超时清僵尸连接 — §5.4
  • [ ] 重连指数退避 + 抖动 + 60s 上限 — §5.5
  • [ ] 网络切换主动断开重连(不等 NAT 超时)— §4.5

可靠投递

  • [ ] 应用层 ACK 协议(不能只靠 TCP)
  • [ ] 服务端重传队列(3 次上限)
  • [ ] 客户端 msgId 去重(LRU 1w 条)
  • [ ] 离线消息持久化 + 分片

海量扩展

  • [ ] 系统级 fd 上限 200 万 — §3.5
  • [ ] TCP 缓冲区调优(rmem/wmem 压缩)— §3.5
  • [ ] Redis 中心化会话表 + TTL 保鲜 — §4.3
  • [ ] 单机压测过 50 万连接 — §3.5

多端与体感

  • [ ] 多端在线策略已定(全端/单端/智能)— §4.5
  • [ ] 已读同步方案
  • [ ] UI 与连接状态解耦 — §4.5
  • [ ] 厂商 Push 通道保底(华为/小米/OPPO/APNs)— §8

观测与预案

  • [ ] 四大监控:连接数、心跳 QPS、重连 QPS、消息投递延迟
  • [ ] 大促预案:推送侧分片 + 限流

使用建议:项目上线前逐项过一遍,任何一项打叉都可能在生产环境放大成事故——尤其是"心跳错峰"和"重连抖动"两项,缺一项就是"800 万自研 DDoS"的隐患。

配套工程实践:本 Checklist 的每一项都对应实战场景的踩坑经验,尤其是"心跳错峰、重连雪崩、路由中心化"三类事故的完整复盘,见 09.长链接方案的设计。


下一篇:我们顺着"连接建立好、心跳保活了、消息可以互发了",继续追问,"如何保证消息不丢、不重、不乱序?" 进入 21.消息可靠投递设计 (opens new window),深入拆解 ACK、offset、幂等、去重、时间戳一致性这些"分布式消息传递的皇冠明珠"。

上次更新: 2026/07/07, 10:34:32
状态机设计的思想
README

← 状态机设计的思想 README→

最近更新
01
11.数据库的原理总结
07-12
02
11.操作系统原理总结
07-10
03
7.资深程序员软能力
07-10
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式