WebSocket 与长连接:从协议握手到断线重连的完整实战

15 阅读31分钟

Android 网络深度系列 · 第 5 篇

系列导航:第1篇:HTTP 协议全解 | 第2篇:HTTPS 与网络安全 | 第3篇:OkHttp 架构剖析 | 第4篇:Retrofit 原理与实战 | 第5篇:WebSocket 与长连接 | 第6篇:网络实战场景

前言

IM 消息推送、实时股价行情、协作编辑文档、在线游戏对战......当你打开手机上的任意一款"实时"应用,背后几乎都有一条默默工作的长连接。

为什么不用 HTTP 拉数据就好?因为"实时"二字背后藏着巨大的技术债务--传统 HTTP 请求-响应模型天生是为"一问一答"设计的,面对服务端主动推送的场景,显得力不从心。

WebSocket 是目前移动端最主流的长连接方案。但很多 Android 开发者对它的理解停留在"听过、用过、断过、重连过"的阶段--写过 OkHttp 的 WebSocket 代码,却不一定清楚握手协议的具体流程;加了心跳,却不明白为什么心跳间隔设成 30 秒而非 10 秒;实现了断线重连,却用的是粗暴的固定间隔。

这篇文章不讲"怎么用",而是讲"为什么这么用"。从协议字节到生产级代码,把长连接这件事彻底讲透。


1. 为什么需要 WebSocket

在 WebSocket 出现之前,想要让客户端实时收到服务端消息,开发者们尝试了各种方案。每个方案都有其代价。

1.1 短轮询(Polling)

最朴素的思路:客户端每隔一段时间发一次 HTTP 请求,问服务端"有更新了吗?"

工作原理:

Client → GET /api/messages → Server
Server → { messages: [...] } → Client
      ← 等待 n 秒 →
Client → GET /api/messages → Server
Server → { messages: [...] } → Client

核心问题:

  • 资源浪费:大量请求在"空转"。对于每 10 秒轮询一次的 IM 客户端,99% 的请求返回空数据。每个请求都要经过 DNS 解析、TCP 握手、TLS 握手、HTTP 头传输--这些开销远大于数据本身。
  • 延迟不可控:理想情况下延迟 = 轮询间隔 / 2,但最坏情况下 = 轮询间隔。如果要做到 1 秒内延迟,就得每 1 秒发一次请求,这对服务端和客户端电量都是巨大负担。
  • 移动端电量灾难:每次 HTTP 请求都需要唤醒蜂窝/WiFi 模块,而无线模块的电量消耗是"固定成本"--发 1KB 和发 100KB 的功耗几乎相同。频繁唤醒比一次传输大量数据耗电得多。

1.2 长轮询(Long Polling)

短轮询的改进版:客户端发起请求后,服务端不立即返回,而是"挂起"连接,直到有新数据时再响应。

Client → GET /api/messages (Connection kept alive)
      ... 挂起等待 ...
Server → { messages: [newMsg] } → (响应)
Client → (收到后立即发起下一次长轮询)

改进点: 大幅减少了空请求次数,延迟有所降低。

但仍存在问题:

  • 每次请求仍有 HTTP 头的固定开销(通常 400-800 字节),而消息体可能只有几十字节
  • 需要保持大量挂起的服务端连接,每个连接都要占用线程/资源
  • 超时重发机制复杂(代理服务器、网关往往有 30-120 秒的超时限制)
  • 服务端重启或负载均衡切换时,所有长轮询连接会同时断掉,造成"雷击"效应

1.3 SSE(Server-Sent Events)

SSE 是 HTML5 规范的一部分,允许服务端通过 HTTP 连接持续推送事件流给客户端。

Client → GET /api/stream (Accept: text/event-stream)
Server → data: 第一条消息\n\n
Server → data: 第二条消息\n\n

优点: 基于标准 HTTP 协议,兼容性好,对服务端要求低。

局限性:

  • 单向:仅服务端→客户端,客户端要发送数据仍需走 HTTP 请求
  • 浏览器连接数限制:大多数浏览器限制每个域名 6 个 SSE 连接
  • Android 生态不友好:Android 原生没有 SSE 的官方实现,需要引入第三方库或自己解析 text/event-stream 格式

1.4 WebSocket:终极方案

WebSocket 设计之初就瞄准了"全双工"这个目标:

Client ---- (一次握手升级) ----→ Server
      ←- 全双工双向通信 --→
      (一个连接上双向自由发送)
  • 握手完成后,数据传输开销极低(帧头仅 2-14 字节)
  • 天然双向,无需额外 HTTP 请求
  • 支持文本(UTF-8)和二进制(可自定义格式)数据传输
  • 控制帧(Ping/Pong/Close)内建于协议层面

对比总结:

方案方向延迟头开销服务端资源移动端友好度
短轮询单向拉高(间隔决定)差(频繁唤醒)
长轮询单向拉高(挂起连接)一般
SSE服务端推一般(Android 无原生)
WebSocket全双工极低极小好(OkHttp 原生支持)

一句话结论: 如果客户端需要频繁收发双向数据(IM、推送、协作、游戏),WebSocket 是最优选择。如果仅仅是服务端推送通知且不频繁,SSE 或 FCM 可能更简单。


2. WebSocket 协议详解

很多开发者会用 WebSocket,但不一定看过它的"线缆上的样子"。理解协议细节,能帮你更好地解决握手失败、帧解析异常等实际问题。

2.1 协议升级握手

WebSocket 连接不是凭空产生的--它始于一次普通的 HTTP 请求,通过 Upgrade 机制协商切换到 WebSocket 协议。

客户端请求(从 HTTP 升级):

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com

关键字段解释:

  • Upgrade: websocketConnection: Upgrade:告诉服务端"我想把这条 HTTP 连接升级为 WebSocket"
  • Sec-WebSocket-Key:客户端生成的 16 字节随机值,Base64 编码。用于证明请求是"认真的",不是被缓存篡改的
  • Sec-WebSocket-Version: 13:协议版本号,目前仅 13 被主流支持

服务端响应(101 Switching Protocols):

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

服务端收到 Sec-WebSocket-Key 后,拼接一个固定的 UUID 字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,计算 SHA-1 摘要后再 Base64 编码,得到 Sec-WebSocket-Accept。客户端收到后做同样的计算校验,确保握手正确。

// 服务端计算方式(验证用):
val magicKey = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
val accept = MessageDigest.getInstance("SHA-1")
    .digest((key + magicKey).toByteArray())
    .encodeBase64()
// 结果应与 Sec-WebSocket-Accept 一致

握手完成后的关键点:

握手完成后,这条连接不再走 HTTP 协议栈。服务端不再解析 HTTP 头,Agent 和负载均衡器需要显式支持 WebSocket 协议才能传输后续数据帧。

2.2 WebSocket 帧结构

握手完成后,双方开始交换 WebSocket 帧(Frame)。每一帧的结构如下:

 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)                  |
+---------------------------------------------------------------+

逐字段解读:

字段位置长度说明
FINbit 01 bit是否为最后一帧。消息可分多个帧发送,FIN=1 表示结束
RSV1-3bits 1-33 bits保留位,无扩展时必须为 0
opcodebits 4-74 bits帧类型:0x1=文本、0x2=二进制、0x8=关闭、0x9=Ping、0xA=Pong
MASKbit 81 bit是否掩码。客户端→服务端必须为 1,服务端→客户端必须为 0
Payload Lenbits 9-157 bits数据长度。0-125 直接代表长度;126 表示后跟 16 位长度;127 表示后跟 64 位长度
Extended Length-0/2/8 字节当 Payload Len=126 时读 2 字节,=127 时读 8 字节
Masking Key-0/4 字节MASK=1 时有 4 字节密钥,用于解码数据
Payload Data-变长实际数据内容

最简帧范例(客户端发 "Hello" 文本消息):

二进制(HEX):81 85 37 fa 21 3d 5c 78 59 58 69

解析:
81 → FIN=1, opcode=0x1(文本帧)
85 → MASK=1, Payload Len=5(5 字节数据)
37 fa 21 3d → Masking Key(4 字节)
5c 78 59 58 69 → 掩码后的数据

解码时,每字节与 Masking Key 的对应字节进行 XOR:

'5c' XOR '37' = 'H'(0x48)
'78' XOR 'fa' = 'e'(0x65)
'59' XOR '21' = 'l'(0x6C)
'58' XOR '3d' = 'l'(0x6C)
'69' XOR '37' = 'o'(0x6F) ← 注意 Key 循环使用

2.3 数据帧 vs 控制帧

WebSocket 帧分为两类:

数据帧(Data Frames):

Opcode类型说明
0x0Continuation分片消息的后续帧
0x1TextUTF-8 文本数据
0x2Binary二进制数据

控制帧(Control Frames):

Opcode类型说明
0x8Connection Close关闭连接
0x9Ping心跳探测
0xAPong心跳响应

控制帧的特殊规则:

  • 控制帧的 Payload Length 最大为 125 字节
  • 控制帧不能被分片(FIN 必须为 1)
  • 控制帧可以出现在分片消息之间(即一个长消息的分片过程中,服务端可以插播 Ping)
  • 收到 Ping 后应立即回复 Pong,但同一帧中不能同时包含 Ping 和 Pong

2.4 为什么客户端发送要 Mask,服务端不用?

这是 WebSocket 协议中一个著名的问题。原因涉及一种特定的安全攻击--缓存投毒(Cache Poisoning)

攻击场景设想:

早期 HTTP 代理(尤其是透明代理)可能错误地将 WebSocket 数据当成 HTTP 响应缓存。设想一个恶意页面:

  1. 用户访问 http://evil.com
  2. 该页面 JavaScript 连接 ws://victim-proxy/,
  3. 恶意页面通过 WebSocket 发送精心构造的二进制帧,其内容恰好包含 HTTP/1.1 200 OK 和恶意脚本
  4. 如果代理服务器识别不到 Upgrade 握手,可能认为这是一个 HTTP 响应,将其缓存
  5. 后续用户访问同一域名时,代理返回了缓存的"HTTP 响应"(实际上是 WebSocket 数据),导致恶意代码执行

Masking 的解决方案:

要求客户端发送的所有帧必须 Mask。即使攻击者构造了符合 HTTP 响应格式的字节,经过 XOR 掩码后,中间代理看到的是一堆随机字节,无法解析为有效的 HTTP 响应。

服务端→客户端的帧不需要 Mask,因为服务端返回的数据不会经过客户端侧的代理(客户端通常直接连接服务端),而且即使被服务端侧的代理缓存,影响也远小于客户端侧。

一个有意思的注脚: 设计好协议后,工作组才发现 IEEE 的某些 Wi-Fi 嗅探器也会被未 Mask 的数据影响--算是意外收获。


3. OkHttp WebSocket 实战

OkHttp 是 Android 上最广泛使用的 HTTP 客户端,它对 WebSocket 有原生支持。在 Kotlin 协程和 Flow 大行其道的今天,它仍然使用经典的回调模式,但足够稳定好用。

3.1 环境准备

首先,确认 build.gradle 包含 OkHttp(4.x 及以上版本原生支持 WebSocket):

// build.gradle.kts (Module)
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

3.2 建立连接

class WebSocketClient(private val url: String) {
​
    private val client = OkHttpClient.Builder()
        .pingInterval(30, TimeUnit.SECONDS) // 协议层心跳,后面详谈
        .build()
​
    private var webSocket: WebSocket? = null
​
    fun connect() {
        val request = Request.Builder()
            .url(url)
            .addHeader("Origin", "app://myapp") // 可选,一些服务端校验
            .build()
​
        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                // 连接建立成功
            }
​
            override fun onMessage(webSocket: WebSocket, text: String) {
                // 收到文本消息
            }
​
            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
                // 收到二进制消息
            }
​
            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                // 收到服务端关闭帧(1000 表示正常关闭)
                webSocket.close(code, reason)
            }
​
            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
                // 连接已完全关闭
            }
​
            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                // 连接失败或异常断开
            }
        })
​
        // 重要:OkHttp WebSocket 的回调在 OkHttp 的线程池中执行
        // 不能直接更新 UI,需要切换到主线程
    }
​
    fun disconnect() {
        webSocket?.close(1000, "Client closing")
        // 1000 = Normal Closure,表示期望的优雅关闭
    }
}

3.3 WebSocketListener 各回调的触发时机

理解回调的触发顺序和条件,是写好 WebSocket 代码的关键:

连接生命周期(完美情况):
  connect() → onOpen → onMessage(多次)→ onClosing → onClosed
​
异常情况:
  connect() → onOpen → onMessage → onFailure(网络断开)
  connect() → onFailure(连接失败,如 DNS 解析失败、连接超时)
  connect() → onOpen → onClosing → onFailure(服务端关闭后网络异常)
​
优雅关闭流程:
  1 发起方调用 close(1000, "reason")
  2 发送 Close 帧给对端(opcode = 0x8)
  3 对端收到 Close 帧 → 触发 onClosing
  4 在 onClosing 中应调用 webSocket.close(code, reason) 回复
  5 发起方收到 Close 回复 → 触发 onClosed
  6 连接彻底关闭

关注两点:

  • onClosing 不是关闭信号,而是"对方想关闭"的通知。你需要在 onClosing 中回复 close() 来完成握手关闭。
  • onClosed 才是"已关闭" 。只有收到 onClosed 或 onFailure,才能确定连接不再可用。

3.4 发送消息

// 发送文本消息
val textSent = webSocket?.send("Hello 服务端!")
if (!textSent) {
    // 连接已关闭或不可用
}
​
// 发送二进制消息
val data = byteArrayOf(0x00, 0x01, 0x02)
val byteSent = webSocket?.send(ByteString.of(*data))
​
// 注意:send() 返回 Boolean,但不是"送达确认"
// true 仅表示消息已从应用层交付给 OkHttp 的写入缓冲区
// false 表示缓冲区满或连接已关闭

关于 send() 返回值的误解:

webSocket.send("...") 返回 true 只表示消息进入了 OkHttp 内部缓冲区。它没有被服务端确认收到。对于需要可靠投递的场景,必须在应用层实现 ACK(后面第 6 节详谈)。

3.5 优雅关闭 vs 异常断开

// ✅ 优雅关闭(主动)
webSocket?.close(1000, "User disconnected")
// 1000: Normal Closure - 正常关闭// ✅ 优雅关闭(被动)
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
    // 必须回复 close,否则连接不会真正关闭
    webSocket.close(code, reason)
    // 持有 code 和 reason 可以用于业务判断,如:
    // 1001 = Going Away(服务端重启)
    // 1008 = Policy Violation(协议违规被踢)
}
​
// ❌ 强制关闭(不推荐)
// webSocket?.cancel() // 立即断开,不发送 Close 帧

关闭状态码参考(RFC 6455):

状态码含义场景
1000Normal Closure正常关闭
1001Going Away客户端离开/服务端宕机
1002Protocol Error协议错误
1006Abnormal Closure不应该用于 close() ,仅状态指示
1008Policy Violation数据不符合规范,服务端踢人
1009Message Too Big消息超长
1011Internal Error服务端内部错误

3.6 完整 WebSocket 示例(生产级)

class ChatWebSocket(
    private val url: String,
    private val onMessageReceived: (String) -> Unit,
    private val onConnectionChanged: (ConnectionState) -> Unit
) {
    enum class ConnectionState {
        CONNECTING, CONNECTED, DISCONNECTED, FAILED
    }
​
    private var webSocket: WebSocket? = null
    private var isClosedByUser = false
​
    private val client = OkHttpClient.Builder()
        .pingInterval(30, TimeUnit.SECONDS)   // 协议层心跳
        .readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 刚需:禁用超时
        .writeTimeout(0, TimeUnit.MILLISECONDS)
        .connectionSpecs(listOf(
            ConnectionSpec.MODERN_TLS  // 强制 TLS 安全连接
        ))
        .build()
​
    fun connect() {
        if (webSocket != null) return
​
        isClosedByUser = false
        onConnectionChanged(ConnectionState.CONNECTING)
​
        val request = Request.Builder().url(url).build()
        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(ws: WebSocket, response: Response) {
                onConnectionChanged(ConnectionState.CONNECTED)
            }
​
            override fun onMessage(ws: WebSocket, text: String) {
                onMessageReceived(text)
            }
​
            override fun onMessage(ws: WebSocket, bytes: ByteString) {
                // 二进制消息按需处理
            }
​
            override fun onClosing(ws: WebSocket, code: Int, reason: String) {
                ws.close(code, reason)
            }
​
            override fun onClosed(ws: WebSocket, code: Int, reason: String) {
                onConnectionChanged(ConnectionState.DISCONNECTED)
            }
​
            override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
                if (!isClosedByUser) {
                    onConnectionChanged(ConnectionState.FAILED)
                }
            }
        })
    }
​
    fun send(text: String): Boolean {
        return webSocket?.send(text) ?: false
    }
​
    fun disconnect() {
        isClosedByUser = true
        webSocket?.close(1000, "User disconnected")
        webSocket = null
    }
}

几个值得注意的生产细节:

  1. readTimeout(0, ...) 是必须的--WebSocket 的使用场景天然是"持续等待数据",任何超时设置都会导致连接被自动断开。
  2. isClosedByUser 区分主动/被动断开--如果不加这个标志,用户主动断开时一样会触发断线重连。
  3. 回调都在 OkHttp 线程池--需要切到主线程才能更新 UI 或触发 LiveData/Flow。

4. 心跳保活机制

长连接最大的敌人不是"断开",而是"不知不觉断开"。心跳机制是应对这个问题的第一道防线。

4.1 为什么需要心跳

一个 WebSocket 连接建立后,如果双方长时间(通常 60-600 秒,取决于网络环境)没有任何数据交换,连接可能会被中间设备静默断开

罪魁祸首们:

设备/场景典型空闲超时行为
NAT 路由器(电信)5 分钟删除映射表项,连接"假掉"
NAT 路由器(移动 4G/5G)30-120 秒部分运营商会更激进
企业防火墙4-60 分钟取决于规则配置
CDN / 反向代理(Nginx)60 秒(默认)断开空闲连接
云负载均衡器(AWS ALB)350 秒空闲超时
移动基站(空闲态)约 10 秒(RRC 释放)虽然不直接影响 TCP,但影响心跳性能

关键是:这些设备断开连接时不会发送 TCP RST 或 FIN。你的 webSocket 对象看起来还是 "open" 的,但任何数据发送都会静默丢失。这就是所谓的 "僵尸连接"

心跳保活就是定期发送小数据包,让中间设备认为这条连接仍然"活跃",从而保持其 NAT 映射表和连接状态。

4.2 OkHttp 的自动 Ping/Pong

OkHttp 在内置了协议层的心跳支持,通过 pingInterval 配置:

val client = OkHttpClient.Builder()
    .pingInterval(30, TimeUnit.SECONDS) // 每 30 秒发一次 Ping
    .build()

它的工作方式:

时间线:
  T+0s   连接建立
  T+30s  OkHttp 自动发送 Ping 帧(opcode = 0x9)
  T+30s+ 服务端回复 Pong 帧(opcode = 0xA)
  T+60s  再次 Ping → Pong
  ...

如果 Pong 未在超时(约 60 秒,即 2 个 interval)内回复:
  → 触发 onFailure(t = SocketTimeoutException)

OkHttp 自动 Ping/Pong 的优缺点:

优点:

  • 零配置,一行代码搞定
  • 基于 WebSocket 协议层实现,与服务端有标准互通性
  • CPU 和电量开销极低

缺点:

  • 可定制度低--不能自定义心跳间隔策略(移动网络 vs WiFi 不同)
  • 超时检测依赖 Socket 超时,不够灵活
  • 只检测"连接是否活着",无法携带应用层数据(如用户在线状态)

4.3 自定义应用层心跳 vs 协议层心跳

很多大型项目会同时使用两层心跳:

协议层心跳(OkHttp 自动 Ping/Pong):
  检测 TCP 层连通性,间隔 30-60 秒
  → 如果失败,触发 onFailure
  → 由 OkHttp 处理,应用层不需管

应用层心跳(自定义):
  携带业务数据(用户 ID、序列号、时间戳)
  间隔可调整(前台 30 秒、后台 120 秒)
  服务端回复中包含时间信息(用于客户端校准)
  → 应用层自己处理

什么时候需要应用层心跳:

  1. 需要服务端状态确认--协议层 Ping/Pong 是"你还在吗?→ 在。"应用层心跳可以是"用户 1001 还在线"--服务端可以据此清理无效用户数据
  2. 需要动态间隔--前台激活时 30 秒、后台挂起时 120 秒、省电模式下 300 秒
  3. 心跳中带数据--比如客户端时间戳,服务端回复时带回时间差,客户端自动计算到"服务器同步状态"
// 应用层心跳消息示例(JSON)
// 发送:
{ "type": "heartbeat", "clientTime": 1714291200000, "userId": "1001" }

// 收到回复:
{ "type": "heartbeat_ack", "serverTime": 1714291200050, "userId": "1001" }

4.4 心跳间隔的选择(移动网络 vs WiFi)

没有"正确"的间隔,只有"适合场景"的间隔。

几个真实项目的经验值:

场景推荐间隔理由
WiFi 环境60-120 秒NAT 超时通常 5 分钟起,60 秒已足够保活
4G/5G 移动网络25-45 秒运营商 NAT 超时可能低至 30 秒
前台活跃30 秒用户体验优先,保持低延迟
后台运行120-300 秒省电,牺牲一点延迟
海外网络15-25 秒部分国外运营商的 NAT 更激进(低至 20 秒)

Android 开发建议:

fun calculateHeartbeatInterval(context: Context): Long {
    val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
​
    val activeNetwork = connectivityManager.activeNetworkInfo ?: return 30_000L
​
    return when (activeNetwork.type) {
        ConnectivityManager.TYPE_WIFI -> 60_000L   // WiFi 60 秒
        ConnectivityManager.TYPE_MOBILE -> {
            if (isUsingWideAreaNetwork(context)) 25_000L else 45_000L
        }
        else -> 30_000L
    }
}
​
// 更现代的方式(API 23+):
val networks = connectivityManager.getNetworkCapabilities(activeNetwork)
if (networks?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
    return 60_000L
}
if (networks?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) {
    return 30_000L
}

黄金法则: 宁可心跳过于频繁浪费一点流量(每 30 秒大约 150 字节 × 24 小时 ≈ 430KB/天),不要因为心跳间隔太长导致连接频繁断开。连接重建的开销(TLS 握手通常需要 1-3 个 RTT)远远大于心跳消耗的带宽。


5. 断线重连策略

长连接一定会断--这不是"如果"的问题,而是"什么时候"的问题。

网络不稳定、NAT 超时、移动网络切换、后台进程被杀死......你的长连接必然会被各种原因打断。而衡量一个长连接系统好不好的关键指标不是"断不断",而是"断多快恢复"。

5.1 检测断线

断线的检测有两个路径:

路径一:onFailure 回调

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
    // 这是断线的"直接通知"
    when (t) {
        is SocketTimeoutException -> { /* 读写超时,通常是心跳没回复 */ }
        is IOException -> { /* 网络异常:DNS 失败、连接被拒、SSL 握手失败、RST */ }
        is SSLException -> { /* TLS 层错误:证书不匹配、证书过期 */ }
    }
}

路径二:心跳超时

如果 OkHttp 的 pingInterval 设置了,但服务端在网络异常时没有及时回复 Pong,OkHttp 不会立刻触发 onFailure。超时检测依赖于 Socket 的读取超时:

OkHttp 发送 Ping → 等待 Pong → 等待下一个 Ping interval
​
如果没有收到任何数据(包括 TCP keep-alive):
  → 最终触发 onFailure(SocketTimeoutException)
​
但这个过程可能需要:
  pingInterval(30s) + pingInterval(30s) + 余量 = ~60-90

这 60-90 秒的"静默期"对用户体验来说毫无感知--但你在后台可以看到 Socket 层面的异常日志。

最佳实践: 不要只依赖一个检测信号,而是组合使用:

// 组合检测策略
// 1. onFailure 直接触发重连
// 2. 应用层单独维护一个"最近收到消息的时间戳"
// 3. 如果超过 N 秒(如 90 秒)未收到任何数据(包括心跳回复),主动断开并重连class ReconnectManager {
    private var lastMessageTime = 0L
​
    fun onDataReceived() {
        lastMessageTime = System.currentTimeMillis()
    }
​
    fun isStale(timeoutMs: Long = 90_000L): Boolean {
        return System.currentTimeMillis() - lastMessageTime > timeoutMs
    }
}

5.2 重连策略:指数退避

不要用固定间隔重连!

为什么?想象一个场景:WiFi 密码不对,连接 WebSocket 一直失败。

❌ 固定间隔每 3 秒重连:
  第 0 秒:连接失败
  第 3 秒:连接失败(WiFi 密码错,永远好不了)
  第 6 秒:连接失败
  ... 无限循环 ...

  结果:CPU 跑满、电量狂掉、用户卸载你的 App
✅ 指数退避:
  第 0 秒:连接失败 → 等 1 秒
  第 1 秒:连接失败 → 等 2 秒
  第 3 秒:连接失败 → 等 4 秒
  第 7 秒:连接失败 → 等 8 秒
  第 15 秒:连接失败 → 等 16 秒
  第 31 秒:连接失败 → 等 32 秒(上限)
  ... 之后每 32 秒重试一次 ...

  结果:如果网络恢复,很快就能连上;
        如果网络一直坏,不会浪费太多资源

指数退避公式:

// 基础实现
fun getNextDelay(attempt: Int, maxDelay: Long = 30_000L): Long {
    val delay = minOf(
        (1L shl minOf(attempt, 15)) * 1000L,  // 2^attempt 秒
        maxDelay
    )
    // 加随机抖动(jitter),避免多客户端同时重连造成雷击
    return delay + Random.nextLong(0, delay / 2)
}
​
// 重连序列:
// attempt=0 → 1+(0~0.5) 秒
// attempt=1 → 2+(0~1)   秒
// attempt=2 → 4+(0~2)   秒
// attempt=3 → 8+(0~4)   秒
// attempt=4 → 16+(0~8)  秒
// attempt=5 → 30+(0~15) 秒(到达上限)
// attempt=6 → 30+(0~15) 秒(维持上限)// 为什么还加随机抖动?
// 想象 1000 个手机同时断线,如果没有 jitter:
// 它们会在同一秒发起连接 → 服务器瞬间收到 1000 个握手请求 → 雪崩效应
// 加了 0~15 秒的 jitter → 请求均匀分布在 30~45 秒内 → 服务器平稳接收

5.3 最大重连次数与上限间隔

断线重连需要两个上限:

class ReconnectPolicy {
    companion object {
        // 上限 1:单次重连的最大间隔
        const val MAX_DELAY_MS = 30_000L    // 30 秒
​
        // 上限 2:连续重连的最大次数
        const val MAX_RECONNECT_ATTEMPTS = 20  // 20 次
​
        // 达到上限后的行为
        const val RETRY_INTERVAL_AFTER_CAP_MS = 5 * 60_000L  // 5 分钟后降频尝试
    }
}

为什么不无限重试?

如果网络彻底不可用(比如用户在飞机上、手机信号盲区),无限重连就是无限浪费。20 次重试覆盖了大约 1+2+4+8+16+30*15 ≈ 480 秒 ≈ 8 分钟。8 分钟后如果还连不上,说明大概率是网络环境出了问题。

此时降频为每 5 分钟一次(或更久),继续监听网络变化:

fun shouldRetry(attempt: Int): Boolean {
    if (attempt < MAX_RECONNECT_ATTEMPTS) return true
    return System.currentTimeMillis() - lastAttemptTime > RETRY_INTERVAL_AFTER_CAP_MS
}

5.4 网络切换时的重连(ConnectivityManager 监听)

移动设备最典型的断线场景是网络切换:WiFi → 4G、4G → WiFi、飞行模式开关。这些场景下,原有的 TCP 连接已经失效,必须重建 WebSocket。

监听网络变化,在切换发生时主动断开 + 重连,不需要等到心跳超时。

class NetworkAwareReconnect(context: Context) {
​
    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
​
    private var lastNetworkId: Int? = null
    private var callback: NetworkCallback? = null
​
    fun startMonitoring(onNetworkChanged: () -> Unit) {
        val currentNetwork = getNetworkId()
        if (currentNetwork != null) lastNetworkId = currentNetwork
​
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            callback = object : ConnectivityManager.NetworkCallback() {
                override fun onAvailable(network: Network) {
                    val newId = getNetworkId()
                    val oldId = lastNetworkId
                    lastNetworkId = newId
​
                    // 网络类型变化时才触发重连
                    if (oldId != null && newId != null && oldId != newId) {
                        onNetworkChanged()
                    }
                }
​
                override fun onLost(network: Network) {
                    // 当前网络丢失,需要重连
                    onNetworkChanged()
                }
            }
            connectivityManager.registerDefaultNetworkCallback(callback!!)
        } else {
            @Suppress("DEPRECATION")
            val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
            // 旧 API 需要注册 BroadcastReceiver
        }
    }
​
    private fun getNetworkId(): Int? {
        val activeNetwork = connectivityManager.activeNetwork ?: return null
        val caps = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
        return when {
            caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
            caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 2
            caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 3
            else -> 4
        }
    }
​
    fun stopMonitoring() {
        callback?.let { connectivityManager.unregisterNetworkCallback(it) }
    }
}

注意: CONNECTIVITY_ACTION 在 API 28 起已被弃用,应使用 registerDefaultNetworkCallback 替代。另外,不要每次 onAvailable 都重连--WiFi 信号波动可能导致短时间内频繁触发。应结合去抖(debounce)机制:

private var networkChangeJob: Job? = nullfun scheduleReconnect(delayMs: Long = 500L) {
    networkChangeJob?.cancel()
    networkChangeJob = scope.launch {
        delay(delayMs)  // 等待 500ms 确保网络稳定
        onReconnect()
    }
}

5.5 完整的断线重连管理器代码

结合以上所有策略,一个生产级的断线重连管理器:

class ReconnectionManager(
    private val scope: CoroutineScope,
    private val onConnect: () -> Unit,
    private val maxAttempts: Int = 20,
    private val maxDelayMs: Long = 30_000L
) {
    private var currentAttempt = 0
    private var isStopped = false
    private var reconnectJob: Job? = null
​
    fun start() {
        isStopped = false
        currentAttempt = 0
        scheduleReconnect(0)
    }
​
    fun onConnectionSuccess() {
        currentAttempt = 0
        reconnectJob?.cancel()
    }
​
    fun onConnectionFailed() {
        if (!isStopped) {
            scheduleReconnect()
        }
    }
​
    private fun scheduleReconnect(forcedDelay: Long? = null) {
        if (isStopped || currentAttempt > maxAttempts) return
​
        reconnectJob?.cancel()
        reconnectJob = scope.launch {
            val delay = forcedDelay ?: calculateDelay(currentAttempt)
            delay(delay)
            currentAttempt++
            onConnect()
        }
    }
​
    private fun calculateDelay(attempt: Int): Long {
        val exponential = minOf(
            (1L shl minOf(attempt, 15)) * 1000L,
            maxDelayMs
        )
        val jitter = Random.nextLong(0, exponential / 2 + 1)
        return exponential + jitter
    }
​
    fun stop() {
        isStopped = true
        reconnectJob?.cancel()
        currentAttempt = 0
    }
​
    fun resetAttempts() {
        currentAttempt = 0
    }
}
​
// 使用示例
class ChatService(context: Context) {
​
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private var webSocket: ChatWebSocket? = null
​
    private val reconnectionManager = ReconnectionManager(
        scope = scope,
        onConnect = { connect() }
    )
​
    private val networkMonitor = NetworkAwareReconnect(context)
​
    fun start() {
        networkMonitor.startMonitoring {
            reconnectionManager.resetAttempts()
            reconnectionManager.start()
        }
        reconnectionManager.start()
    }
​
    private fun connect() {
        webSocket?.disconnect()
        webSocket = ChatWebSocket(
            url = "wss://chat.example.com/ws",
            onMessageReceived = { msg ->
                reconnectionManager.onConnectionSuccess()
                handleMessage(msg)
            },
            onConnectionChanged = { state ->
                when (state) {
                    ChatWebSocket.ConnectionState.CONNECTED ->
                        reconnectionManager.onConnectionSuccess()
                    ChatWebSocket.ConnectionState.FAILED,
                    ChatWebSocket.ConnectionState.DISCONNECTED ->
                        reconnectionManager.onConnectionFailed()
                    else -> {}
                }
            }
        )
        webSocket?.connect()
    }
​
    fun destroy() {
        networkMonitor.stopMonitoring()
        reconnectionManager.stop()
        webSocket?.disconnect()
        scope.cancel()
    }
}

这个管理器体现了几条关键原则:

  1. 网络变化时重置退避计数器--用户从地铁出来后连上 WiFi,应立刻尝试连接,而不用等 30 秒
  2. 成功连接后重置计数器--确保下次断开时从短间隔开始
  3. 协程驱动--避免线程泄露,生命周期可控
  4. 外部停止机制--Activity/Fragment 销毁时主动停止

6. 消息可靠性

很多开发者误以为"WebSocket 连接建立了,消息就一定能到"。事实并非如此。

WebSocket 基于 TCP,TCP 保证的是传输层的可靠交付--数据包按序到达、不丢包。但这只是底层保证。在应用层面,以下情况都可能导致消息"丢失":

  • 服务端发了消息,但客户端正好在重连中,消息发到了一个已断开的连接
  • 客户端发了消息,服务端收到了但处理失败
  • 客户端在弱网环境下发消息,TCP 缓冲区满了,连接被 reset
  • 进程被系统杀死,内存中未持久化的消息丢失

所以,应用层必须自己保证消息可靠性

6.1 应用层 ACK 机制

ACK(Acknowledgment)是最核心的可靠性保障。思路很简单:每条消息带一个唯一 ID,接收方收到后回一个 ACK,发送方收到 ACK 才算"送达"。

// 消息结构
data class ChatMessage(
    val msgId: String = UUID.randomUUID().toString(),  // 唯一 ID
    val type: String,        // "text", "image", "ack", ...
    val content: String,
    val timestamp: Long = System.currentTimeMillis(),
    val status: MessageStatus = MessageStatus.SENDING
)

enum class MessageStatus {
    SENDING,     // 已发出,等待 ACK
    SENT,        // 收到服务端 ACK
    DELIVERED,   // 收到对方 ACK
    READ,        // 对方已读
    FAILED       // 发送失败
}

ACK 流程:

发送方 A              服务端              接收方 B
  |--- msg(id=123) --->|                    |
  |                    |--- msg(id=123) --->|
  |<-- ack(id=123) ----|                    |
  |   (SENT)           |<-- ack(id=123) ----|
  |<-- delivered(123) -|                    |
  |   (DELIVERED)      |                    |
class AckManager(
    private val scope: CoroutineScope,
    private val sendFunc: (String) -> Unit,
    private val onTimeout: (String) -> Unit
) {
    // 等待 ACK 的消息池
    private val pendingAcks = ConcurrentHashMap<String, Job>()
    private val ACK_TIMEOUT_MS = 10_000L  // 10 秒超时

    fun waitForAck(msgId: String) {
        pendingAcks[msgId] = scope.launch {
            delay(ACK_TIMEOUT_MS)
            // 超时未收到 ACK,触发重发
            pendingAcks.remove(msgId)
            onTimeout(msgId)
        }
    }

    fun onAckReceived(msgId: String) {
        pendingAcks.remove(msgId)?.cancel()  // 取消超时任务
    }
}

6.2 消息队列与重发策略

单靠 ACK 超时重发还不够。如果用户在弱网环境下连续发了 10 条消息,需要一个消息队列来管理发送顺序和重试逻辑。

class MessageQueue(
    private val scope: CoroutineScope,
    private val dao: MessageDao  // Room 数据库
) {
    private val sendQueue = Channel<ChatMessage>(Channel.UNLIMITED)
    private var isProcessing = false
​
    fun enqueue(message: ChatMessage) {
        // 先持久化到数据库,防止进程被杀
        scope.launch(Dispatchers.IO) {
            dao.insert(message.copy(status = MessageStatus.SENDING))
            sendQueue.send(message)
        }
    }
​
    fun startProcessing(webSocket: WebSocket) {
        if (isProcessing) return
        isProcessing = true
​
        scope.launch {
            for (msg in sendQueue) {
                var retryCount = 0
                val maxRetries = 3
​
                while (retryCount < maxRetries) {
                    try {
                        val json = Gson().toJson(msg)
                        val success = webSocket.send(json)
                        if (success) {
                            dao.updateStatus(msg.msgId, MessageStatus.SENT)
                            break
                        }
                    } catch (e: Exception) {
                        // 发送异常
                    }
                    retryCount++
                    delay(1000L * retryCount)  // 线性退避
                }
​
                if (retryCount >= maxRetries) {
                    dao.updateStatus(msg.msgId, MessageStatus.FAILED)
                }
            }
        }
    }
​
    // 重连后,重发所有 SENDING 状态的消息
    fun resendPending(webSocket: WebSocket) {
        scope.launch(Dispatchers.IO) {
            val pendingMessages = dao.getByStatus(MessageStatus.SENDING)
            pendingMessages.forEach { msg ->
                sendQueue.send(msg)
            }
        }
    }
​
    fun stop() {
        isProcessing = false
        sendQueue.close()
    }
}

关键设计点:

  • 先存库再发送--进程被杀也不怕丢消息
  • SENDING 状态--重连后扫描这个状态,把未确认的消息重发
  • 有限重试--避免某条消息卡住整个队列

6.3 离线消息同步

用户断线期间,其他人发来的消息怎么办?服务端需要暂存,客户端重连后主动拉取。

常见的两种方案:

方案一:基于时间戳拉取

// 客户端重连成功后
fun syncOfflineMessages() {
    val lastMsgTimestamp = dao.getLatestTimestamp()  // 本地最新消息时间
    val request = SyncRequest(
        userId = currentUserId,
        since = lastMsgTimestamp,
        limit = 200
    )
    // 通过 WebSocket 或 HTTP 请求离线消息
    webSocket.send(Gson().toJson(mapOf(
        "type" to "sync",
        "data" to request
    )))
}

方案二:基于消息序列号(Sequence)

更可靠的方案是给每条消息分配一个递增的序列号(类似 Kafka 的 offset)。客户端只需要告诉服务端"我收到的最大 seq 是多少",服务端就知道该推哪些消息。

// 服务端为每个会话维护一个递增 seq
// 客户端重连后:
fun syncBySequence() {
    val lastSeq = dao.getMaxSequence(conversationId)
    webSocket.send(Gson().toJson(mapOf(
        "type" to "sync",
        "conversationId" to conversationId,
        "lastSeq" to lastSeq
    )))
}

序列号方案的优势:

  • 不怕时间戳不同步
  • 天然支持"是否有遗漏"的校验--seq 不连续就知道丢了
  • 和消息去重天然配合--相同 seq 不重复入库

面试高频: "你们的 IM 怎么保证消息不丢?"--回答 ACK + 消息持久化 + 离线同步 + 去重(幂等),四个关键词缺一不可。


7. 与其他方案的对比

WebSocket 不是唯一的实时通信方案。不同场景下,其他协议可能更合适。

7.1 WebSocket vs MQTT

MQTT(Message Queuing Telemetry Transport)是 IoT 领域的事实标准,也被用在移动端推送场景(如早期的 Facebook Messenger)。

维度WebSocketMQTT
设计目标通用双向通信低带宽、高延迟网络下的消息传递
协议层应用层,基于 TCP应用层,基于 TCP(也可跑在 WebSocket 上)
消息模型无内置模型,自由定义发布/订阅(Pub/Sub)
QoS 支持无(应用层自己实现)三级 QoS(0=最多一次,1=至少一次,2=恰好一次)
消息大小无限制(受配置约束)协议头极小(最小 2 字节),适合小消息
典型场景IM、实时协作、游戏IoT 传感器数据、消息推送
Android 库OkHttp 内置Eclipse Paho

选 MQTT 的场景: 设备资源有限(嵌入式)、网络极不稳定(2G/卫星)、需要协议级 QoS、Pub/Sub 模型天然匹配业务。

选 WebSocket 的场景: 需要灵活的双向交互(不只是消息分发)、与 Web 前端统一协议栈、自定义协议格式。

7.2 WebSocket vs gRPC Stream

gRPC 基于 HTTP/2,天然支持四种通信模式:Unary、Server Stream、Client Stream、Bidirectional Stream。其中双向流(Bidirectional Stream)在功能上和 WebSocket 很像。

维度WebSocketgRPC Bidirectional Stream
传输协议HTTP/1.1 Upgrade → TCPHTTP/2
序列化自定义(JSON/Protobuf 都行)Protobuf(强类型)
代码生成.proto 文件生成客户端/服务端代码
浏览器支持原生支持需要 gRPC-Web 代理
多路复用一个连接一个通道HTTP/2 原生多路复用
负载均衡成熟(Nginx 等)需要 L7 负载均衡(Envoy 等)
典型场景IM、实时 Web 应用微服务间通信、强类型 RPC

选 gRPC Stream 的场景: 后端微服务已全面使用 gRPC、需要强类型接口约束、Protobuf 的性能优势明显。

选 WebSocket 的场景: 需要 Web 端兼容、团队更熟悉 WebSocket、不想引入 Protobuf 工具链。

7.3 WebSocket vs FCM

FCM(Firebase Cloud Messaging,前身 GCM)是 Android 的官方推送通道。

维度WebSocketFCM
连接维护应用自己维护系统级维护(共享连接)
电量消耗需要 WakeLock/前台服务系统优化,省电
实时性毫秒级通常秒级,高峰期可能延迟
可靠性应用层保证不保证送达顺序和时效
离线消息应用层实现FCM 自动暂存(最多 4 周)
国内可用性❌(需要 Google 服务)
数据限制4KB(data message)
适合场景IM、实时交互通知推送、静默唤醒

实际方案通常是两者结合:

  • FCM 负责唤醒--当 App 在后台被系统杀死,通过 FCM 推送一个信号唤醒 App
  • WebSocket 负责实时通信--App 在前台时走 WebSocket 传输数据
  • 国内市场用各厂商推送通道(小米推送、华为 Push 等)替代 FCM

7.4 选型建议

场景推荐方案原因
IM / 聊天WebSocket + 推送通道兜底需要双向实时通信 + 后台唤醒
实时行情 / 股票WebSocket高频数据推送,服务端主导
IoT 设备通信MQTT低带宽、协议级 QoS
微服务实时流gRPC Stream强类型、多路复用
纯通知推送FCM / 厂商推送省电、系统级保活
协作编辑(如文档)WebSocket + OT/CRDT双向实时 + 冲突解决
在线游戏WebSocket 或 UDP低延迟优先

8. 总结

8.1 要点回顾

这篇文章从"为什么需要 WebSocket"出发,一路深入到协议细节和生产级实践:

  1. 为什么需要 WebSocket--短轮询浪费、长轮询有延迟、SSE 单向,WebSocket 是真正的全双工
  2. 协议握手--HTTP Upgrade 请求 → 101 Switching Protocols → TCP 连接复用
  3. 数据帧格式--FIN、Opcode、Mask、Payload Length,每个字段都有其设计考量
  4. 心跳保活--Ping/Pong 帧 + 应用层心跳,对抗 NAT 超时和连接假死
  5. 断线重连--指数退避 + 随机抖动 + 最大重试次数 + 网络切换监听
  6. 消息可靠性--应用层 ACK + 消息队列 + 离线同步,TCP 可靠 ≠ 业务可靠
  7. 方案对比--WebSocket / MQTT / gRPC / FCM 各有适用场景

8.2 面试高频题速查

问题关键回答
WebSocket 和 HTTP 的关系?握手阶段是 HTTP,升级后是独立的 TCP 全双工协议
为什么需要心跳?检测连接假死 + 维持 NAT 映射
心跳间隔怎么定?小于 NAT 超时(通常 30s),平衡电量和实时性
断线重连用什么策略?指数退避 + 随机抖动 + 最大次数 + 网络变化监听
WebSocket 能保证消息不丢吗?不能,需要应用层 ACK + 消息持久化 + 离线同步
WebSocket vs MQTT 怎么选?通用双向交互选 WebSocket,IoT/低带宽选 MQTT
移动端怎么保活 WebSocket?前台心跳 + 后台降频 + 推送通道兜底唤醒