适用读者:长连接上线后收到"消息没收到""重复收到""莫名断开"投诉的人。
前置:S02-B07(四种实时通信)。
实测环境:Ubuntu 24.04.1 / Linux 6.8;内核参数为 2026-11-05 实测读取。
覆盖旧文:A-639(WebSocket 断线怎么办,160 点)。
覆盖扩展:E-S02-015(keepalive vs 心跳)、E-S02-016(幂等与去重)。
上一篇:S02-B07 · WebSocket、SSE、长轮询:四种实时通信怎么选
下一篇:S02-B09 · HTTP 与 RPC。
1. 一句话结论
长连接的"稳定性"不是"不断",而是"断了能发现、能重连、能补数据、且不重复处理"。 四件事缺一个,用户就会投诉。
| 能力 | 缺了会怎样 |
|---|---|
| 能发现断(心跳) | 连接已经死了,服务端还在往里写(写进黑洞) |
| 能重连(退避 + 抖动) | 一次网络抖动 → 全量客户端同时重连 → 打垮服务端 |
| 能补数据(序号/游标) | 断线期间的消息永久丢失 |
| 能去重(幂等) | 重连后重发 → 重复扣款、重复发消息 |
2. 断线有四种,处理方式不同
| 类型 | 表现 | 谁先发现 |
|---|---|---|
| 网络抖动 | 短暂丢包/切换网络 | 通常 TCP 自己恢复(不一定要重连) |
| 对端崩溃 | 进程死了,没有 FIN | 只能靠心跳/keepalive |
| 中间设备超时 | NAT/负载均衡清理空闲连接 | 空闲一段时间后突然断(应用层心跳可避免) |
| 主动关闭 | 收到 FIN/RST | 立刻知道(可以优雅重连) |
最危险的是第 2、3 类:没有 FIN,应用层以为连接还在,写出去的数据全进黑洞。
3. TCP keepalive 为什么不够用(实测内核默认值)
$ cat /proc/sys/net/ipv4/tcp_keepalive_time
7200 # 空闲 7200 秒(2 小时)后才发第一个探测
$ cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75 # 之后每 75 秒一次
$ cat /proc/sys/net/ipv4/tcp_keepalive_probes
9 # 9 次无响应才判定断开
算一下:最坏情况下 2 小时 + 9×75 秒 ≈ 2 小时 11 分钟 才判定连接已死。
| 项 | TCP keepalive | 应用层心跳 |
|---|---|---|
| 默认探测间隔 | 7200 s | 由你定(常见 20–60 s) |
| 能穿透应用层代理吗 | ❌(纯 TCP 层) | ✅(走业务协议) |
| 能否携带业务状态 | ❌ | ✅(可带 load、seq) |
| 对 NAT 保活的帮助 | 有,但太慢 | ✅(NAT 老化通常 5–30 分钟) |
| 可观测性 | 差(只能看到连接断) | 好(能记录 RTT、丢心跳次数) |
结论:应用层心跳是必需的,keepalive 只能作为兜底。 而且要把 tcp_keepalive_time 调小(如 60–300 秒),否则崩溃的连接会一直占着资源。
3.1 心跳间隔怎么定(不是拍脑袋)
| 约束 | 取值建议 |
|---|---|
| 短于 NAT/负载均衡的空闲超时 | 一般 LB 是 60 s,心跳取 20–30 s |
| 短于"用户可接受的发现延迟" | 移动端弱网下 30–60 s 可接受 |
| 不要太频繁 | 每条连接每分钟一次心跳 × 10 万连接 = 1666 QPS,要算进容量 |
公式:心跳间隔 < min(NAT 空闲超时, LB 空闲超时) / 2,再乘以连接数估算额外 QPS。
判据:心跳不是"越短越稳"——它本身是负载。10 万连接 × 30 s 间隔 = 3,333 次/秒的服务端压力。
3.2 超时判定与重连触发
发送 ping(带时间戳) → 收到 pong
若连续 2–3 次未收到 → 判定连接死亡 → 主动 close → 重连
为什么是"2–3 次":一次丢包太常见(弱网),一次就断会造成"频繁重连";太多次则发现太慢。3 次 × 30 s = 90 s 是常见的折中。
4. 重连:指数退避 + 抖动(必须)
第 1 次:立即(或 0.5 s)
第 2 次:1 s
第 3 次:2 s
第 4 次:4 s
……上限:30 s
为什么要"抖动"(jitter):如果服务端重启,10 万客户端会在同一秒全部重连——这叫重连风暴,足以把刚恢复的服务再次打挂。
import random
delay = min(cap, base * (2 ** attempt))
delay = delay * (0.5 + random.random() * 0.5) # 加 50% 抖动
| 策略 | 无抖动 | 有抖动 |
|---|---|---|
| 服务端重启后 1 秒内连接数 | 几乎全部 | 均匀分散 |
| 恢复时间 | 可能二次雪崩 | 平稳恢复 |
额外两条实践:
- 重连要带"断点":
Last-Event-ID(SSE)或业务游标(自定义协议),否则重连后丢消息。 - 重连成功后要做"状态对齐":客户端可能需要拉一次全量快照,而不只是接增量。
5. 消息可靠性:不丢、不重、有序(E-S02-016)
5.1 不丢:序号 + 确认 + 补发
服务端 ── seq=101 ──► 客户端
服务端 ── seq=102 ──► (丢了)
客户端 ── ack=101 ──► 服务端 ← 服务端知道 102 没到,重发
服务端必须保留"每连接的发送缓冲"(最近 N 条),才能补发。没有缓冲 = 断了就丢。
5.2 不重:幂等键
| 方案 | 说明 | 适用 |
|---|---|---|
| 客户端生成消息 ID | 服务端按 ID 去重(Redis SETNX / 唯一索引) | 客户端→服务端 |
| 服务端序号 + 客户端去重 | 客户端记录"已处理到的最大 seq" | 服务端→客户端 |
| 业务幂等键 | 订单号、支付流水号 | 关键交易 |
判据:凡是"重发"链路上的操作,都必须幂等。 至少一次(at-least-once)+ 去重 = 实际上的恰好一次。
5.3 有序:单连接内天然有序,跨连接不保证
| 场景 | 顺序保证 |
|---|---|
| 同一条 TCP 连接 | ✅ 有序(B05 的字节流) |
| 重连后 | ❌ 需序号排序 |
| 多设备/多连接 | ❌ 需服务端统一排序(或用业务时间戳 + 客户端合并) |
6. 一个可落地的参数表
| 参数 | 建议值 | 依据 |
|---|---|---|
| 应用心跳间隔 | 20–30 s | < LB/NAT 空闲超时的一半 |
| 心跳超时判定 | 连续 3 次未响应 | 容忍单次丢包 |
服务端 tcp_keepalive_time |
60–300 s | 兜底清理死连接 |
| 重连初始延迟 | 0.5–1 s | 快速恢复 |
| 重连退避上限 | 30 s | 避免无限拉长 |
| 重连抖动 | 50% | 防重连风暴 |
| 服务端发送缓冲 | 最近 100–1000 条/连接 | 与内存预算权衡 |
| 客户端去重窗口 | ≥ 缓冲条数 | 覆盖补发范围 |
内存估算示例:10 万连接 × 每条缓冲 100 条 × 每条 200 B ≈ 2 GB。这条算式在 J03(容量)会展开。
7. 排障清单
| 现象 | 可能原因 | 验证 |
|---|---|---|
| 消息"发了但没收到" | 连接已死但未发现(无心跳) | 服务端写失败日志、连接存活时长 |
| 重连后消息重复 | 缺幂等/去重 | 查业务日志里的重复 ID |
| 断线后消息丢失 | 无序号/无缓冲/无 Last-Event-ID | 对比 seq 缺口 |
| 服务重启后大面积超时 | 重连无抖动(风暴) | 观察重连 QPS 曲线 |
| 连接数持续增长不下降 | 死连接未清理 | ss -s 的 estab 数、keepalive 配置 |
8. 五个误区
| 误区 | 真相 |
|---|---|
| "开了 TCP keepalive 就不用应用心跳" | 默认 2 小时才探测,且穿不透应用层代理 |
| "心跳越频繁越可靠" | 心跳本身是负载,10 万连接下很可观 |
| "重连就是重试" | 无退避无抖动 = 重连风暴 |
| "at-least-once 就够了" | 必须配去重,否则重复扣款 |
| "断线重连后接着推增量就行" | 可能已经丢了中间状态,需要对齐快照 |
9. 自检
- 为什么"对端崩溃"这种断线只能靠心跳发现?
- 从内核默认值算出 TCP keepalive 最坏判定时间是多少?
- 心跳间隔的取值依据有哪三条约束?
- 重连抖动解决的具体问题是什么?
- "至少一次 + 去重"为什么等价于"恰好一次"(工程意义上)?
10. 下一篇
S02-B09 · HTTP 与 RPC:为什么 RPC 更快,又为什么仍要 HTTP
长连接与推送讲完,回到"调用"本身:RPC 凭什么比 HTTP 快(协议开销?序列化?连接复用?),以及为什么对外接口仍然普遍用 HTTP。下一篇给可测量的对比维度与选型判据。
本篇属于系列《S02 · 计算机与网络基础》。旧文来源:A-639;扩展点 E-S02-015/016。内核 keepalive 参数为 2026-11-05 实测。