1. 先把“心跳机制”这件事看清楚
写过长连接的人都绕不开心跳机制。客户端和服务端之间挂着一条 TCP 连接,数据能来回跑,但这条连接到底“还活着”还是“早就断了只是没人告诉程序”,在操作系统层面是拿不到确定答案的。心跳机制就是用来补这个信息缺口的一套约定:双方按照固定的节奏互发一个极小的控制消息,收到就说明链路和对方进程都还在,收不到超过一定次数就判定连接不可用,主动清理并重连。
这套东西看起来简单——发个包、等个回包、超时断开——但真正落到线上环境,参数怎么定、谁来发、超时算几次、弱网怎么办、会话表怎么管,每一个点都能翻车。我见过一个长连接服务,心跳间隔设成 5 分钟、超时 30 分钟,结果用户端断网后要半小时才被清掉,那半小时内所有下发消息都在往一个黑洞里写,内存里堆了几十万个僵尸会话。也见过反过来的,心跳 3 秒一次,几万台设备一起报心跳,服务端光处理 PING 就把 CPU 打满。
这篇内容适合三类人看:正在做 IM、推送、网关、设备接入这类长连接业务的同学;做微服务治理、需要判断节点存活状态的研发;以及单纯想把“为什么 TCP 有 keepalive 还要自己做心跳”这个问题想明白的学习者。我下面会从故障场景、方案选型、参数推导、代码实现、排查经验几个层面,把心跳机制掰开讲一遍,尽量给出能直接照抄的数值和能直接跑的代码,也会说清楚哪些参数是拍脑袋的、哪些是有推导依据的。
需要强调的是,心跳机制不是一个孤立的组件,它和超时、重连、熔断、读写分离、会话路由是耦合在一起的。你把心跳单独做好了,但重连策略是 1 秒重试一次、无限重试,那服务端在故障恢复瞬间仍然会被打垮。所以下面每一节我都会顺带讲清楚它和周边机制的关系。
2. 心跳到底在兜什么底:三类必须靠它兜住的故障
2.1 TCP 已经“连着”,为什么还要应用层心跳
很多人第一反应是:TCP 不是有 keepalive 吗?内核不是会自己探测吗?确实有,Linux 上通过SO_KEEPALIVE打开,默认tcp_keepalive_time是 7200 秒,也就是空闲两个小时后才开始探测,探测间隔 75 秒,连续 9 次无响应才判定断开。这个默认值是为“长期空闲的可靠连接”设计的,对在线业务来说完全不可用——两个小时的检测延迟,业务侧早就雪崩了。
更重要的是,即使你把内核参数调到 30 秒,它解决的也只是“对端主机是否可达”这一件事。它探测的原理是发一个不带数据的 ACK 探针,只要对端内核协议栈还在,就会回 ACK。这意味着什么?意味着如果对端进程已经死锁、GC 卡住 30 秒、线程池耗尽、或者业务逻辑陷入死循环,内核照样回 ACK,连接在 TCP 层面依然是“健康”的,但业务消息发过去根本没人处理。这种情况内核保活完全发现不了,只有应用层心跳——由业务进程自己的线程去回一个 PONG——才能暴露。
这里有个常见误区:不少人以为“TCP 连接建立了,服务就一定能收到消息”。实际上从发送缓冲区到对端应用读取之间,有内核缓冲区、有协议栈、有对端事件循环、有业务线程池,任何一环卡住都会导致消息堆积而不是报错。
所以选择很清楚了:内核保活可以作为第一层兜底(成本低、不需业务参与),但应用层心跳才是主判据。两者不是二选一,而是分工。
2.2 三类典型故障:宕机、断链、静默回收
我把需要心跳兜底的故障归纳成三类,这三类的特征完全不同,处理方式也不同。
第一类:对端进程崩溃或主机宕机。这种最好处理。进程崩溃时内核会发 FIN 或 RST,正常情况下你的 socket 会立刻收到可读事件并返回 0 或者抛异常,连接马上就能感知。主机断电宕机则不同,不会有任何通知,你的发送会一直成功(写进本机缓冲区),直到 TCP 重传超时——这个时间在 Linux 上是几分钟到十几分钟量级,取决于重传次数。心跳的价值在这里体现为把这个检测时间从“分钟级”压缩到“秒级”。
第二类:链路中断。比如网线被拔、蜂窝网络切到飞行模式、Wi-Fi 断连。如果是中间链路断,本机网卡还在,发送照样成功入缓冲区,TCP 进入重传,同样要等重传耗尽。如果本机网卡直接 down,会立刻收到错误,这种情况反而快。所以最阴险的是“链路中间某段断了但两端网卡都正常”,这种只能靠心跳超时发现。
第三类:中间设备静默回收连接。这是生产环境里最常见也最容易被忽略的一类。链路上有负载均衡、有防火墙、有运营商侧的会话表,这些设备为了省资源,会把长时间没有数据流动的连接从自己的会话表里删掉,而且删的时候两边都不通知。表现出来就是:客户端认为连接还在,服务端也认为连接还在,但数据发出去全部石沉大海。等客户端下一次真的发业务数据时,才会发现写入成功但永远等不到响应。
第三类是心跳间隔取值的最主要约束条件。很多中间设备的空闲连接回收阈值是 300 秒、900 秒或者 3600 秒,具体值不公开、可配置、还可能被运维调整。你的心跳间隔必须显著小于这个阈值,通常建议不超过它的三分之一。
2.3 心跳的能力边界:它能告诉你什么,不能告诉你什么
得说清楚心跳判定的语义。心跳超时只能得出一个结论:“在最近这个时间窗口内,我无法确认对端可服务”。它不等于对端一定死了——可能只是网络拥塞、可能只是对方 GC 停顿了 20 秒、可能只是你的心跳包在一个拥塞队列里排队。所以心跳超时触发的动作应该是“主动断开并重连”,而不是“把对端标记为永久不可用”。
反过来也成立:心跳正常也不代表业务一定正常。心跳处理线程和业务处理线程如果是分开的,心跳回得快,业务可能已经堵死了。所以更严谨的做法是让心跳走完整的业务处理链路(比如经过同一个事件循环、同一个线程池),代价是心跳会受业务负载影响,误判率上升;或者做“分级心跳”,轻量 PING 走快路径保连接,重量级健康检查走慢路径验业务。这个取舍在网关类服务里尤其关键,我后面在排查那节会展开。
3. 方案选型:几种心跳形态的取舍
3.1 客户端主动、服务端主动与双向心跳
谁发心跳,直接决定了系统复杂度和资源消耗。
客户端主动发是最常见的形态。好处是服务端压力可控——服务端只需要被动响应,不需要维护大量定时器;连接数上百万时,服务端如果每个连接挂一个定时器,内存和调度开销都很可观。坏处是服务端对“客户端是否还活着”的判断依赖客户端配合,如果客户端实现有 bug 或者被恶意改造,服务端就很被动。
服务端主动发在设备接入场景里更常见,尤其是下行指令为主的场景。好处是服务端掌握检测主动权,可以随时调整探测节奏。坏处是定时器数量等于连接数,服务端必须用时间轮这类高效结构来管理。
双向心跳是两边都发,各自独立判定。这种最稳,但流量翻倍,一般只在强一致要求高的场景用,比如金融交易的长连接。
我的经验是:绝大多数场景选客户端主动发 + 服务端被动收 + 服务端侧做超时扫描。这个组合把定时器压力放在客户端(分散),把判定逻辑放在服务端(集中),实现简单,也容易做灰度。
3.2 传输层保活和应用层保活的组合用法
前面说了内核 keepalive 检测慢、检不出进程假死。但它在一种场景下非常好用:作为应用层心跳失效时的第二道防线。设想客户端应用层心跳线程因为某种原因挂了,但进程还在,内核 keepalive 能兜住,至少把连接清掉。
实操上我会这么配:应用层心跳间隔 20~30 秒,超时 2~3 个周期;同时打开SO_KEEPALIVE,把tcp_keepalive_time调到 120 秒、tcp_keepalive_intvl调到 30 秒、tcp_keepalive_probes调到 3。这样即使应用层完全失灵,最迟 210 秒后内核也会把连接拆掉。
# Linux 下调整内核保活参数的示例(需要相应权限) sysctl -w net.ipv4.tcp_keepalive_time=120 sysctl -w net.ipv4.tcp_keepalive_intvl=30 sysctl -w net.ipv4.tcp_keepalive_probes=3注意:这些是全局参数,会影响机器上所有 socket。如果你的机器上跑着多种业务,建议在应用代码里用
setsockopt对单个 socket 设置TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT,避免互相影响。
3.3 消息格式:越简单越好,但别省掉关键字段
心跳包的设计有个原则:字段少,但必须有能测 RTT 的东西。最小可用的心跳消息包含三样:类型标识、时间戳、序列号。服务端收到 PING 后原样回显时间戳和序列号到 PONG 里,客户端拿到后一减就得到 RTT,序列号用来防止乱序和重复。
为什么一定要测 RTT?因为它是自适应调优的唯一依据。固定心跳间隔在弱网下必然要么太频繁(费流量、费电)要么太稀疏(检测慢)。有了 RTT 基线,就可以在 RTT 变大时临时放宽超时阈值,避免把“网络慢”误判成“连接死”。
消息类型上建议用独立的控制帧类型,不要和业务消息混用同一个 type。否则统计口径会乱,服务质量监控里分不清哪些流量是心跳、哪些是业务。另外心跳包不要带业务字段,不要试图在心跳里捎带“顺便拉一下配置”这种逻辑,一旦心跳链路出问题,配置逻辑也跟着挂,故障面被放大。
4. 参数怎么定:从检测延迟倒推心跳间隔
4.1 一条可以直接套用的推导公式
心跳参数只有两个核心值:间隔 T 和超时阈值 D。D 通常写成 N×T,N 是允许连续丢失的次数。
推导顺序应该是从业务能接受的最长检测延迟 D 倒推 T,而不是反过来。假设你的业务要求“客户端断网后 60 秒内服务端必须感知到并清理会话”,那 D 就定 60 秒。然后选 N:N=2 意味着只要连续两次心跳没回就断,误判率偏高;N=3 是比较常用的折中;N 越大越抗抖动但检测越慢。取 N=3,则 T = 60/3 = 20 秒。
| 业务场景 | 可接受检测延迟 D | 建议 N | 推导出的 T | 备注 |
|---|---|---|---|---|
| 移动端 IM | 90 秒 | 3 | 30 秒 | 兼顾耗电与流量 |
| 网页长连接 | 60 秒 | 3 | 20 秒 | 页面可见时才发 |
| 设备接入网关 | 180 秒 | 3 | 60 秒 | 设备数量大,控制信令量 |
| 交易类长连接 | 15 秒 | 3 | 5 秒 | 代价是流量与电量 |
| 内部服务健康探测 | 9 秒 | 3 | 3 秒 | 通常用独立探测而非连接心跳 |
这张表里的数值不是标准答案,只是我在几类场景里验证过、跑得比较稳的起点。实际取值还要往下走两步校正。
4.2 抖动余量:为什么不能把 T 卡得太紧
T 定下来之后必须留余量,因为心跳包的往返时间不是常数。一次心跳从发出到收到回包,要经过客户端事件循环调度、网络排队、服务端事件循环、服务端处理、再回来。任何一环出现几十毫秒到几秒的抖动都很正常。
经验做法是:T 至少要是 P99 RTT 的 10 倍以上。如果 P99 RTT 是 200 毫秒,T 取 20 秒,倍数是 100,很安全。但如果你的服务端偶尔有 2 秒的 GC 停顿,T=20 秒的话,单次心跳丢失的概率就不可忽略了,再加上 N=3 的容错,勉强够用;如果 GC 停顿能到 5 秒,就必须把 N 提到 4 或者把 T 放宽。
还有一个隐形杀手是客户端的定时器精度。移动端进入后台、系统省电模式、浏览器标签页被节流,定时器间隔可能被拉长到几分钟甚至被完全暂停。这种情况在浏览器里尤其明显,后台标签页的setTimeout最小间隔会被限制到 1000 毫秒甚至更长,长时间后台还会被冻结。所以 Web 端的心跳必须结合页面可见性状态:页面隐藏时降低频率或者暂停,页面恢复时立刻补发一次并重置计时。
4.3 和中间设备空闲回收阈值对齐
这一条经常被漏掉。你的心跳间隔不仅要满足业务检测延迟,还要小于链路上所有中间设备的空闲连接回收时间。
实际排查时的做法是:把已知的中间设备阈值列出来,取最小值,然后让 T 不超过它的三分之一。比如链路上一台负载均衡的空闲超时是 900 秒,另一台防火墙是 300 秒,那取 300 秒,T 不超过 100 秒。如果按业务需求算出的 T 是 30 秒,那自然满足,不用管;但如果业务允许 5 分钟检测延迟,算出来 T=100 秒,就正好卡在边界上,很危险,应该主动压到 60 秒以内。
这类阈值往往不在文档里,也不保证不变。运维调整配置、网络改造、换机房,都可能让某个设备的回收时间从 900 秒变成 300 秒。所以 T 的取值要留出足够的冗余,不要为了省流量把心跳压到极限。
5. 实现细节:定时器、会话表与超时判定
5.1 定时器选型:别给每个连接挂一个 Timer
连接数上万之后,定时器的实现方式就决定了服务端的扩展性。
每连接一个 Timer是最直观的写法,Java 里ScheduledExecutorService或者 Go 里time.AfterFunc,Python 里loop.call_later。连接数几千的时候没问题,上到十万级,定时器的堆维护和调度开销就很明显了,而且大量定时器在同一时刻集中触发会造成明显的毛刺。
时间轮是更合适的结构。把时间轴划分成固定数量的槽位,每个槽位挂一个链表,指针每 tick 前进一格,走到哪一格就处理哪一格上的所有会话。插入和删除都是 O(1),内存占用固定,特别适合“大量连接、统一周期”的场景。Netty 的HashedWheelTimer就是典型实现,Go 生态里也有不少时间轮库。
惰性检查 + 扫描是最省事的写法:不维护每连接的定时器,而是起一个固定周期的扫描协程,每隔几秒遍历一遍会话表,检查last_seen是否超时。缺点是精度受扫描周期限制,超时判定会有最多一个扫描周期的误差;优点是实现极简、内存开销小。连接数在十万以内、T 在 20 秒以上时,我通常直接用这个方案,扫描周期取 5 秒,误差完全可接受。
5.2 会话表的结构与并发安全
会话表本质上是一个map[sessionID]*Session,但有几个细节必须处理好。
last_seen 的时间源要用单调时钟,不能用系统时间。系统时间可能被 NTP 校正,往前跳几秒就会导致大批会话被误判超时,往后跳则导致长时间检测不到。Python 用time.monotonic(),Go 用time.Now()配合time.Since(Go 的 Time 内部带单调时钟读数),Java 用System.nanoTime()。
读写锁的粒度要控制好。心跳处理是高频写,扫描是低频读全表。如果用一把全局互斥锁,心跳处理会被扫描阻塞。更好的做法是用分片锁(sharded lock),按 sessionID 哈希到 N 个分片,每个分片一把锁;或者干脆用sync.Map这类读多写少友好的结构。
删除会话时要注意“检查-删除”的竞争。扫描协程判定某会话超时准备删除时,可能正好有一个心跳包进来更新了last_seen。如果不加保护,就会把一个刚复活的会话误删。做法是删除前二次校验,在持锁状态下重新读一次last_seen再决定。
5.3 弱网与移动端的自适应策略
固定参数在弱网下表现很差。蜂窝网络切换、电梯、地铁、地下车库,这些场景下 RTT 会突然从 50 毫秒飙到几秒。如果超时阈值是死的,就会频繁误判断连重连,而重连本身又要握手、要认证,反而加重了网络负担和耗电。
自适应的心跳我做过多轮,比较有效的策略是基于 RTT 基线动态调整超时阈值:
- 维护一个滑动窗口的 RTT 样本(比如最近 20 次),算出中位数 M 和 P95 值 P。
- 超时阈值取
max(基础D, 4×P),并且在连续两次超时后才真正断开。 - 检测到 RTT 明显上升(P > 3×M)时,临时把心跳间隔减半,加快采样,同时不下调超时阈值。
- RTT 恢复平稳后,按指数退避缓慢恢复到基础间隔。
这套逻辑的核心思想是把“网络变慢”和“连接真的断了”区分开。代价是实现复杂度上升,需要维护状态机。如果业务对误判断连不敏感(比如网页长连接,重连成本很低),就别搞自适应,直接用固定参数,简单可靠。
6. 完整落地:一套能跑起来的心跳保活实现
6.1 协议约定
先定协议。我用一个极简的 JSON 帧来演示,实际生产里通常用二进制协议(Protobuf、自定义 TLV)以省带宽。
PING: {"t":"ping","ts":1710000000123,"seq":42} PONG: {"t":"pong","ts":1710000000123,"seq":42}ts是客户端发送时刻的毫秒时间戳,服务端原样回显;seq是自增序列号。客户端收到 PONG 后用当前时间减去ts得到 RTT,用seq匹配请求,防止把迟到的旧 PONG 当成新请求的响应。
6.2 服务端:会话表与超时扫描
Python asyncio 版本,核心就是一个带单调时钟的会话表和一个扫描协程。
import asyncio import time import json HEARTBEAT_TIMEOUT = 60.0 # 秒,对应 T=20, N=3 SWEEP_INTERVAL = 5.0 # 扫描周期 class Session: __slots__ = ("sid", "writer", "last_seen", "closed") def __init__(self, sid, writer): self.sid = sid self.writer = writer self.last_seen = time.monotonic() self.closed = False def touch(self): self.last_seen = time.monotonic() def close(self, reason): if self.closed: return self.closed = True try: self.writer.close() except Exception: pass print(f"session {self.sid} closed: {reason}") class SessionManager: def __init__(self): self.sessions = {} def add(self, sid, writer): self.sessions[sid] = Session(sid, writer) def touch(self, sid): s = self.sessions.get(sid) if s: s.touch() def remove(self, sid, reason="normal"): s = self.sessions.pop(sid, None) if s: s.close(reason) async def sweeper(self): while True: await asyncio.sleep(SWEEP_INTERVAL) now = time.monotonic() for sid, s in list(self.sessions.items()): if now - s.last_seen > HEARTBEAT_TIMEOUT: # 二次校验,避免与刚到达的心跳竞争 if time.monotonic() - s.last_seen > HEARTBEAT_TIMEOUT: self.remove(sid, "heartbeat_timeout") async def handle_conn(reader, writer, mgr): sid = f"conn-{id(writer):x}" mgr.add(sid, writer) try: while True: line = await reader.readline() if not line: break msg = json.loads(line) if msg.get("t") == "ping": mgr.touch(sid) pong = {"t": "pong", "ts": msg["ts"], "seq": msg["seq"]} writer.write((json.dumps(pong) + "\n").encode()) await writer.drain() else: # 业务消息同样算作活跃信号 mgr.touch(sid) # ... 业务处理 except Exception: pass finally: mgr.remove(sid, "peer_closed")这段代码有两个要点值得强调。第一,mgr.touch(sid)在处理任何消息时都调用,而不只是 PING——业务消息本身就是最强的活跃证据,没必要让客户端在发了业务消息之后还额外发一次心跳。第二,sweeper 里的二次校验,是为了挡住“扫描判定超时”和“心跳刚好到达”之间的竞争窗口。
6.3 客户端:发送、RTT 统计与重连退避
客户端侧的逻辑比服务端多一层——重连。重连策略写不好,服务端重启时会被瞬间打爆。
import asyncio import json import time import random T = 20.0 TIMEOUT = 60.0 class Client: def __init__(self, host, port): self.host, self.port = host, port self.seq = 0 self.rtt_samples = [] self.backoff = 1.0 def next_seq(self): self.seq += 1 return self.seq async def run(self): while True: try: await self._connect_and_serve() except Exception as e: print(f"connection error: {e}") # 指数退避 + 抖动,避免集群同时重连 wait = min(self.backoff, 30.0) * (0.5 + random.random()) print(f"reconnect in {wait:.1f}s") await asyncio.sleep(wait) self.backoff = min(self.backoff * 2, 30.0) async def _connect_and_serve(self): reader, writer = await asyncio.open_connection(self.host, self.port) self.backoff = 1.0 # 连上就重置退避 hb_task = asyncio.create_task(self._heartbeat(reader, writer)) try: while True: line = await reader.readline() if not line: break msg = json.loads(line) if msg.get("t") == "pong" and msg.get("seq") == self.seq: rtt = time.time() * 1000 - msg["ts"] self.rtt_samples.append(rtt) if len(self.rtt_samples) > 20: self.rtt_samples.pop(0) finally: hb_task.cancel() writer.close() async def _heartbeat(self, reader, writer): while True: await asyncio.sleep(T) seq = self.next_seq() payload = {"t": "ping", "ts": int(time.time() * 1000), "seq": seq} writer.write((json.dumps(payload) + "\n").encode()) await writer.drain()backoff从 1 秒开始翻倍、封顶 30 秒、每次乘一个 0.5~1.5 的随机因子,这套组合能有效防止“服务端重启后十万客户端同时重连”的惊群问题。这个抖动因子经常被省略,但它在故障恢复场景下价值极高,我强烈建议加上。
6.4 参数验证:别靠猜,压一把
参数定完必须验证。我的验证方法分三步。
第一步,单连接长跑。挂 24 小时,统计心跳成功率和 RTT 分布。重点看 P99 和最大值——如果 P99 接近 T 的十分之一,说明余量不够,得放宽。
第二步,连接数阶梯压测。从 1000 起,每次翻倍,观察服务端的 CPU、内存、GC 频率和心跳处理的 P99 延迟。找到拐点在哪,拐点之后说明服务端的定时器或锁结构撑不住了,该换时间轮或者分片锁。
第三步,故障注入。这是最容易漏的一步。要模拟的场景包括:直接kill -9服务端进程(验证客户端重连和退避)、用防火墙规则丢弃双向数据包(验证心跳超时和会话清理)、把一侧的网络延迟注入 3 秒(验证误判率)。第三步跑出来的问题,往往比前两步加起来还多。
7. 常见问题与排查实录
7.1 典型现象与对应原因速查
| 现象 | 可能原因 | 排查手段 | 处置 |
|---|---|---|---|
| 会话在服务端大量堆积 | 超时判定没生效或阈值过大 | 查会话表大小随时间变化曲线 | 缩短 D,检查 sweeper 是否在跑 |
| 客户端频繁重连 | 超时阈值小于实际 RTT 波动 | 抓包看 PING/PONG 时间差分布 | 放宽 D 或引入自适应 |
| 心跳正常但业务无响应 | 心跳走快路径、业务线程池堵死 | 分开统计心跳与业务 P99 | 让心跳经过业务链路或加健康检查 |
| 服务端 CPU 突增 | 心跳频率过高或集中触发 | 看心跳 QPS 与时间分布 | 加抖动、拉长 T、用时间轮 |
| 心跳包被中间设备丢弃 | 帧大小超过 MTU 或设备策略 | tcpdump 看是否单向可见 | 缩小包体、改用独立端口 |
| 移动端耗电异常 | 后台频繁唤醒 | 统计后台心跳次数 | 结合前后台状态降频 |
7.2 排查顺序:从外到内,从粗到细
遇到“心跳莫名超时”这类问题,我会按这个顺序走一遍。
先看是不是真的断了。在两端同时抓包,看 PING 有没有发出去、有没有到、PONG 有没有回来。这一步能快速把问题切成三类:没发出(客户端问题)、发出了没到(网络问题)、到了没回(服务端问题)。
再看时间戳和时钟。客户端和服务端的时钟不同步是排查时的经典干扰项。如果日志里用系统时间打点,两端差了十几秒,看起来就像心跳延迟巨大。所以日志里统计耗时时一定要用单调时钟,跨机对比时再换回系统时间并注明时区。
然后看事件循环的阻塞情况。asyncio 或 Netty 这类框架,一旦某个处理函数里有同步阻塞调用(读文件、同步数据库查询、长时间的 CPU 计算),整个事件循环都会被卡住,心跳处理自然延迟。Python 里可以开loop.set_debug(True)或加一个每秒执行的“延迟探测器”协程来度量事件循环延迟:
async def lag_monitor(): while True: t0 = time.monotonic() await asyncio.sleep(0.1) lag = time.monotonic() - t0 - 0.1 if lag > 0.05: print(f"event loop lag: {lag*1000:.1f}ms")这个探测器我几乎在每个 asyncio 服务里都会加,它对定位“莫名超时”极其有效——很多所谓的网络问题,实际上是自己代码里藏了一个同步 IO。
7.3 几个我实际踩过的坑
坑一:把心跳当成保活,忘了业务侧的写超时。心跳正常只说明连接可写可读,但服务端往客户端推消息时,如果对端读取慢,写缓冲区会堆积。这时候心跳可能还正常(因为客户端还在回 PONG),但业务消息已经在内存里堆了几十兆。解决办法是给每个连接设一个写缓冲上限,超限就判定为“慢消费者”并主动断开,不能只靠心跳。
坑二:重连时没有复用会话状态。有一次做设备接入,客户端重连后服务端新建了一个会话,但旧会话因为扫描周期没到还没被清理,结果同一个设备对应两个会话,下行指令发到了旧的那个。修复方式是引入设备唯一标识作为会话键,新连接进来时先踢掉同标识的旧会话。
坑三:心跳日志打得太密。十万连接、20 秒一次心跳,QPS 是 5000,如果每次都打一行 INFO 日志,磁盘 IO 直接成为瓶颈,反过来拖慢心跳处理,形成恶性循环。心跳日志一定要采样或者聚合统计,只在异常时打详细日志。
坑四:以为心跳间隔越小检测越快。检测速度的上限是“超时阈值 D”,D = N×T。你把 T 从 20 秒降到 5 秒,D 从 60 秒降到 15 秒,代价是流量和耗电翻了 4 倍。但如果业务本来就能容忍 60 秒,这 4 倍代价纯属浪费。参数永远是从业务需求倒推,不是越小越好。
坑五:忽略服务端主动关闭时的礼貌。服务端因为超时准备断开一个会话时,如果直接close()而不发任何通知,客户端要等到自己下一次心跳超时才知道,白白多等一个周期。更好的做法是先发一个BYE控制帧,再关闭连接,客户端收到后立即重连,整体恢复时间能缩短一半以上。
8. 最后说点个人体会
心跳机制最容易被低估的地方,是它看起来太简单了——不就是定时发个包吗。但真正跑在几十万连接、复杂网络环境、长时间运行的线上系统里,它牵扯的东西非常多:时钟源的选择、定时器的数据结构、锁的粒度、中间设备的行为、移动端系统的节流策略、重连的退避设计,每一项单独拿出来都够写一篇。我个人的建议是,先把“检测延迟 D”这个业务指标明确下来,用它倒推所有参数,然后再一个一个去验证,别一上来就抄别人的 30 秒。
另外,如果你正在做的东西连接数还没到万级,别过度设计。固定间隔 + 扫描式超时判定 + 指数退避重连,这三样组合起来不到两百行代码,能扛住绝大多数场景。等真的到了十万级、百万级,再考虑时间轮、分片锁、自适应调优这些手段。技术选型最怕的不是不够先进,是过早引入复杂度,然后在排查问题时被自己的架构绕晕。