“心跳机制”这四个字,我真正把它当回事,是因为一个凌晨两点的电话。一个长连接推送服务,运维面板上的在线连接数一直很漂亮,可用户投诉收不到消息——客户端界面显示“已连接”,服务端日志里也没报错,双方都以为自己活着。最后翻出来原因很朴素:中间链路早就把空闲连接悄悄回收了,两边谁也不知道,谁也没主动问一句“你还在吗”。从那以后我养成了一个习惯:凡是长连接、凡是跨网络、凡是有“在线状态”这个概念的系统,动手写代码之前先把心跳机制设计清楚。
心跳机制说白了就是一套周期性探测约定:通信双方在长时间没有业务数据往来的时候,还得定期互相打个招呼,确认对面没死、链路没断、自己没被中间设备单方面“拉黑”。它解决的是故障可见性问题——把“我不知道对面还在不在”变成“我每隔几秒就能确认一次”。这套东西适用的面特别广:写长连接网关的、做微服务注册发现的、搞物联网设备接入的、做任务调度和消息队列消费的、甚至只写前端 WebSocket 的同学,都绕不开它。下面我就按自己踩坑的顺序,把原理、选型、参数推导、可跑的代码和排障经验完整捋一遍,新手能照着抄,有经验的朋友可以重点看参数推导和排障那两节。
1. 心跳机制到底在解决什么问题
1.1 一次“假在线”事故的完整回放
那次事故的现象是:推送服务端认为某批连接一切正常,继续往这些连接上写数据,写操作甚至返回成功,但客户端一条都没收到。当时的排查路径很有代表性,我后来把它固定成了一套流程:先看服务端连接状态,全是 ESTABLISHED;再看应用层日志,没有任何异常抛出;然后抓包,发现写出去的数据包确实发了,但没有任何 ACK 回来,重传几次之后内核才把连接标记成异常。也就是说,从链路真正断掉,到应用层感知到,中间隔了十几分钟。
这十几分钟的空窗期,就是心跳机制存在的全部意义。TCP 协议本身不是没有检测能力,问题是它的检测是被动的——只有在你有数据要发、且发出去需要确认的时候,它才会通过重传超时来判断链路出了问题。如果这条连接长期安静,内核手里没有任何待确认的数据,它就不会主动去验证什么。连接安静地烂在那里,socket 描述符还占着,文件句柄还在,连接池里那个对象还能被取出来用,直到你往上写东西才发现是死的。
更麻烦的是中间设备。现在的网络路径上几乎没有直连,中间隔着一堆做连接跟踪的组件:地址转换设备、防火墙、四层负载均衡、云上的安全组网关。这些设备为了节省内存,对空闲连接都有回收策略,常见的空闲超时值分布在 300 秒、900 秒、3600 秒这几个档位,具体多少你通常查不到,因为不在你的运维范围内。它们的回收方式是静默丢弃——不发 RST,不发 FIN,直接把连接跟踪表项删掉。于是两端的内核都还认为连接存在,实际上路径已经断了。这种现象在业内叫“半开连接”,是长连接系统最经典的敌人。
1.2 心跳的本质:用一点点带宽换取故障可见性
把上面这些串起来,心跳机制的定位就清楚了。它不是用来“维持”连接的——连接断没断不由心跳决定;它是用来让故障在可接受的时间内变得可见。这句话我想强调一下,因为很多人对心跳有个误解,觉得心跳是为了“保活”,只要一直发心跳,连接就不会断。事实恰恰相反:如果链路真的断了,你发再多心跳也接不通;心跳能做的是让你在几秒到几十秒内知道这件事,而不是等十几分钟。
那为什么“发心跳”又能顺带起到保活作用呢?原因是中间设备的回收策略判断依据是“这条连接多久没有数据包经过”。只要你定期有包经过,连接跟踪表项就会被刷新,回收计时器重置,连接就不会因为空闲被清理。所以心跳客观上确实保活了,但这是副产品,设计目标仍然应该是故障检测。这个认知差异会直接影响你的参数设计:把心跳当保活的,会倾向于“间隔比中间设备超时短就行”;把心跳当检测的,会去算“我最长能容忍多久发现故障”,然后倒推参数。后者才是正确的思路。
还有一层价值容易被忽略:心跳是应用层活性的探针,而不仅仅是链路活性。这一条特别关键。假设一个服务进程还在,端口还在监听,内核协议栈工作正常,但业务线程池被一个慢查询全部堵死了,这时候你从外部做端口探测、甚至发 TCP 层的探测包,得到的反馈全是“活着”,因为内核会自动回复确认包,根本不需要应用参与。只有应用层心跳,需要业务进程真正调度到处理逻辑、真正返回一个响应,才能暴露这种“活着但没在工作”的状态。这也是为什么监控体系里,端口存活检查和业务健康检查必须分开,前者会骗你,后者不会。
1.3 三类典型场景,关注点完全不同
我在不同项目里落地过心跳,发现场景不同,设计的重心差得很远。这里先做个横向对比,后面几节的参数推导都会围绕这三类的差异展开。
| 场景类型 | 典型形态 | 核心诉求 | 主要风险 |
|---|---|---|---|
| 长连接推送/IM | 网关与客户端保持 WebSocket 或自定义协议长连接 | 快速发现掉线,及时清理连接资源 | 连接数大,心跳本身造成额外负载 |
| 微服务注册发现 | 服务实例定期向注册中心上报 | 实例列表的准确性,避免流量打到死节点 | 网络抖动导致实例被误摘除 |
| 设备接入/物联网 | 海量设备与平台之间的低频通信 | 设备在线状态判断,指令可达性 | 设备侧资源受限,弱网环境丢包严重 |
第一类场景的痛点是规模。十万、百万级的连接,心跳间隔每缩短一秒,整体的心跳消息量就成比例上涨。而且这类系统的连接往往集中在少数几台网关上,一旦某台网关出问题,几十万客户端会在同一秒发起重连,这种雪崩比原始故障更致命。所以这类系统的设计重点往往不在“怎么发心跳”,而在“怎么让重连错开”。
第二类场景的痛点是误判成本不对称。一个健康的实例被错误摘除,流量瞬间打到剩余实例上,可能引发连锁过载;反过来,一个死实例多留三十秒,只是少量请求失败。这两者的代价不一样,所以注册中心的心跳设计通常会留出更宽的超时余量,宁可慢一点,也不要误杀。这个取舍在做容量规划时一定要想明白,别照抄别人的参数。
第三类场景的痛点在对端不可靠。设备可能用的是信号很差的无线网络,可能因为省电策略把射频关掉,可能电池快没电了降频运行。这种情况下,心跳包丢了不代表设备死了,可能只是这一秒信号不好。所以设备侧的心跳设计通常要更宽容,判定离线需要连续多次失败,并且要区分“暂时不可达”和“确定离线”两种状态。
2. 几种主流实现路径与选型考量
2.1 传输层保活:系统参数的真相与陷阱
几乎每个操作系统都提供了传输层的保活开关,打开之后,内核会在这条连接空闲达到某个阈值后,自动发出探测包。这个机制的优点是零代码改动、零应用开销,内核帮你做了。但我在生产环境里几乎不把它当作主要手段,原因有几个,每一条都值得展开说。
第一是默认值普遍不可用。很多系统的默认空闲阈值是 7200 秒,也就是两小时。这意味着一条连接断了,你要等两小时才开始探测,探测再失败几轮,感知时间直接奔着两小时去。虽然这个值可以调,但它是系统级参数,一调影响整台机器上所有连接,包括那些你根本不关心的。在容器化环境里更麻烦,容器内的参数和宿主机的关系、以及编排系统是否允许你设置这类参数,都会成为部署时的额外负担。
第二是探测次数和间隔的组合决定了最坏感知时间,而很多人只改了其中一个。常见的配置是空闲阈值、探测间隔、探测次数三个值,最坏情况是三者相乘。我见过有人把空闲阈值调到了 60 秒,觉得很快了,但探测间隔还是默认的 75 秒、次数 9 次,最坏情况下要十几分钟才判定失败。这种“改了一半”的配置比不改更危险,因为你会误以为已经优化过了。
第三,也是最根本的一条:传输层保活只能证明内核还活着,不能证明应用还活着。前面说过的线程池堵死场景,内核照样回应探测包。所以传输层保活适合当兜底,不适合当主检测手段。
2.2 应用层心跳:自己定协议,自己掌握节奏
应用层心跳就是我在绝大多数项目里的选择。做法很直白:在业务协议里定义一个轻量的探测消息类型,一端定期发,另一端收到后立刻回一个响应。这种方式的控制力最强——间隔你定、超时你定、失败判定规则你定、失败之后做什么也你定。
具体形态上有几种常见做法。最朴素的是纯文本单字符探测,比如有的协议约定发一个特定字符、回同样的字符,开销极小。稍微规范一点的是定义结构化的探测帧,带一个自增序号和时间戳,这样还能顺带算出往返时延,把心跳变成链路质量的采样器。再复杂一点的是双向心跳,双方都定期发,各自独立判定,这种方式在客户端和服务端都可能处于不稳定网络的场景下更可靠,但消息量翻倍。
这里有个实操细节值得强调:心跳响应必须走最短路径。什么意思?就是收到心跳请求的那个处理环节,应该尽可能直接地返回响应,不要排进业务线程池,不要经过复杂的中间件链路,不要去查数据库。我见过一个系统的心跳处理逻辑复用了业务请求的完整处理链路,结果业务一拥堵,心跳响应也跟着延迟,服务端判定客户端超时,把大量健康连接踢掉了,然后这些连接重连又加重了拥堵,形成正反馈。心跳处理路径要单独开一条“快车道”,这是硬要求。
另外,心跳消息要能被中间层放行。如果你的流量要经过某些做协议解析的组件,自定义的心跳帧可能不被识别,需要确认这些组件对未知消息类型的处理策略是放行还是拦截。这个坑我在早期项目里踩过,现象是应用层心跳发出去了,对端永远收不到,抓包发现包到了某个中间层就没了。
2.3 租约模式:把心跳变成一种状态约定
租约是心跳的一个变体,思路不太一样。普通心跳是“我问你答”,租约是“我主动续期,你不续我就当你失效”。注册中心的实现基本是这个模型:服务实例启动时登记,之后定期发送续约请求,注册中心记录每个实例的最近续约时间,后台有个清理任务扫描,超过阈值没续约的就从实例列表里摘掉。
这个模型的优雅之处在于判定责任在服务端。客户端只管闷头发续约,不需要处理超时判定、不需要维护失败计数、不需要管理状态机,逻辑极其简单。而服务端拿着全局视角,可以统一做清理、统一做告警、统一做摘除策略。对于实例数量多、客户端实现语言五花八门的场景,这种“客户端极简、服务端集中”的分工特别划算。
但它也有代价。租约模式的服务端需要一个可靠的定时清理机制,这个清理任务本身会成为单点。如果清理任务因为某些原因卡住了,实例列表就不再更新,死实例会一直留在列表里。我遇到过清理任务因为一次大批量过期集中触发、单次处理超时、进而导致整个清理周期被拉长的情况。所以租约模式的服务端清理逻辑,必须做成分批、可重入、幂等的,一次处理不完就分多次,绝不能一次全量扫描。
还有一个细节:租约的续约请求要带版本或序号。这样服务端能识别出乱序到达的旧请求,避免一个延迟很久的续约包把已经过期的实例“复活”。这个坑不常遇到,但一旦遇到极难排查。
2.4 选型对照:别为了架构而架构
把上面几条路径摆在一起,我给自己的选型原则是这样的:
| 方案 | 实现成本 | 检测精度 | 能发现应用卡死 | 适用判断 |
|---|---|---|---|---|
| 传输层保活 | 极低 | 低,分钟级 | 不能 | 只做兜底,不单独使用 |
| 应用层单向心跳 | 低 | 高,秒级可调 | 能 | 长连接场景首选 |
| 应用层双向心跳 | 中 | 高 | 能 | 两端网络都不稳定的场景 |
| 租约续期 | 中 | 中,取决于续约间隔 | 能(需在续约逻辑里带上业务活性) | 注册发现、实例管理 |
| 连接池校验查询 | 低 | 高 | 能 | 数据库、缓存等资源连接 |
我的实际组合通常是:应用层心跳做主检测,传输层保活做兜底,两者参数互相配合——传输层的空闲阈值设得比应用层心跳间隔大很多,确保正常情况下它永远不触发,只在应用层心跳机制本身出问题时兜一手。这样两层各司其职,不重复也不冲突。
3. 参数怎么定:从故障容忍时间倒推
3.1 先回答“我能容忍多久不知道”
定参数之前必须先回答一个业务问题:这条连接断了,我最长能容忍多久才发现?这个答案不是技术决定的,是业务决定的。支付类、实时协作类的场景,容忍度可能是几秒;设备状态上报类的场景,容忍几分钟也无所谓;纯日志采集的场景,容忍半小时都不是问题。
把这个容忍时间记作 T,那么心跳参数的设计目标就是让最坏情况下的检测时间不超过 T。计算公式很朴素:
最坏检测时间 = 心跳间隔 × 判定失败次数 + 单次超时时间
举个具体例子。假设业务要求 30 秒内必须发现故障。我选心跳间隔 10 秒、连续 2 次失败判定、单次超时 5 秒,最坏情况是 10×2+5=25 秒,满足要求。如果选间隔 5 秒、3 次失败、超时 3 秒,最坏是 18 秒,更保守但心跳消息量翻倍。
这里的“单次超时”指的是发出心跳后等待响应的时间。这个值不能拍脑袋,要有依据。合理的取值是当前链路往返时延的高分位数乘以一个安全系数,比如取 95 分位时延的 3 倍,且不低于 1 秒。为什么要用高分位而不是平均值?因为平均值会被大量快请求拉低,而超时判定关心的是“慢到什么程度算异常”,这个必须看尾部。
3.2 一个完整的参数推导实例
拿一个具体的数字走一遍。假设一个长连接网关,峰值 20 万连接,单机承载 5 万连接,共 4 台。业务上要求断线 20 秒内感知。历史监控数据显示,正常网络下心跳往返时延的 95 分位是 80 毫秒,99 分位是 400 毫秒。
第一步定超时。取 99 分位的 3 倍,也就是 1.2 秒,向上取整到 2 秒。这个值足够宽松,正常抖动不会误判。
第二步定失败次数。我倾向于 2 次,理由是一次失败就判定太激进,网络瞬断、短暂的垃圾回收停顿都可能造成一次失败;3 次又太保守,成本上升明显。2 次是我实测下来性价比最高的一档。
第三步算间隔。20 秒的容忍时间,减去超时 2 秒,还剩 18 秒,除以 2 次失败,得到间隔 9 秒。取整到 8 秒,留一点余量。
第四步校核负载。单机 5 万连接,间隔 8 秒,每秒心跳请求量约 6250 个,加上响应是 12500 个消息。单个心跳帧假设 32 字节,总带宽约 400KB/s,对千兆网卡来说微不足道。但如果每个心跳都要打一条访问日志、做一次指标上报、走一次分布式追踪,那这 6250 次/秒的额外调用就不可忽视了。这一步校核经常会暴露设计问题——不是心跳本身重,而是心跳带出来的周边开销重。
第五步校核中间设备。8 秒的心跳间隔远小于任何常见的空闲回收阈值,这一项基本安全。如果因为业务原因间隔被迫拉长到接近 300 秒,就需要确认链路上所有中间组件的空闲超时配置,这是必须做的动作,不能假设。
3.3 抖动、退避与惊群:让心跳不那么整齐
参数算完之后,还有三个必须处理的工程细节,它们跟“原理”无关,但直接决定系统在大规模下稳不稳。
第一是发送抖动。所有连接如果都从同一时刻开始计时、用同一个间隔,那它们的心跳就会高度同步,形成周期性的流量尖峰。更糟的是,如果这批连接是因为同一个事件(比如网关重启)建立的,它们的计时起点完全一致,尖峰会更尖锐。解决办法很简单:每条连接的首次心跳时间加上一个随机偏移,偏移量取 0 到间隔之间的随机值,并且后续每次心跳都在间隔上叠加一个小幅随机量,比如 ±10%。这个改动只有几行代码,效果立竿见影。
第二是重连退避。这是比心跳本身更容易出事的环节。假设一台网关故障,5 万连接同时断开,这些客户端会立刻重连。如果客户端都是“失败后立即重试”,那新网关会在瞬间被 5 万个连接请求打满,处理不过来又失败,失败后继续立即重试,形成雪崩。正确的做法是指数退避加抖动:第一次失败后等 1 秒,第二次 2 秒,第三次 4 秒,上限设一个值比如 30 秒,每次等待时间再叠加 ±30% 的随机抖动。这样即使 5 万个客户端同时开始重连,也会在几轮之后自然打散。
第三是心跳与业务的关系。有些实现里,只要连接上有业务数据往来,就跳过这一轮心跳。这个优化在低频场景下很划算,但在高频场景下会造成问题:业务繁忙的连接永远不发心跳,一旦业务突然停止,这条连接的心跳检测就出现了盲区。我的做法是不跳过,但降低频率——有业务数据往来的周期里,心跳间隔可以放大到正常值的几倍,但绝不完全省略。
4. 手写一个可用的心跳模块
4.1 整体设计:三条独立的时间线
讲完原理和参数,落一段能跑的代码。我用 Python 的异步框架写,因为它的协程模型把“并发的时间线”表达得最直观。整个模块拆成三条独立运行的时间线:
第一条是发送线,负责按间隔发心跳,带抖动。第二条是接收线,负责处理对端消息,每收到任何消息(不只是心跳响应)就刷新“最近活跃时间”。第三条是判定线,独立于前两条,定期检查“最近活跃时间”距离现在多久,超过阈值就判定连接失效并触发清理。
为什么判定要独立成一条线?因为如果判定逻辑写在接收线里,那接收线本身卡住的时候(比如消息处理阻塞),判定也跟着停了,等于自己检查自己,检测不出问题。独立的判定线只依赖一个时间戳变量,逻辑极简,几乎不可能被阻塞,这样才能真正起到监控作用。
这里有个关键细节:任何收到的消息都要刷新活跃时间,不能只认心跳响应。业务数据往来本身就能证明对端活着,没必要非得等到心跳响应。这个设计让心跳在业务繁忙时自然降级,减少无谓的消息量。
4.2 核心代码:客户端的心跳与重连
import asyncio import random import time import logging log = logging.getLogger("hb") class HeartbeatClient: def __init__(self, connect_fn, interval=8.0, timeout=2.0, max_fail=2): self.connect_fn = connect_fn # 返回 (reader, writer) 的协程 self.interval = interval self.timeout = timeout self.max_fail = max_fail self.reader = None self.writer = None self.last_active = 0.0 # 最近一次收到任意消息的时间 self.alive = False # 时间线一:发送线 async def _sender(self): fail = 0 while self.alive: # 间隔加 ±10% 抖动,打散全局同步 wait = self.interval * random.uniform(0.9, 1.1) await asyncio.sleep(wait) if not self.alive: return try: self.writer.write(b'PING\n') await asyncio.wait_for(self.writer.drain(), timeout=self.timeout) fail = 0 except Exception as e: fail += 1 log.warning("heartbeat send fail %d: %s", fail, e) if fail >= self.max_fail: self.alive = False return # 时间线二:接收线 async def _receiver(self): while self.alive: try: line = await asyncio.wait_for( self.reader.readline(), timeout=self.interval * 3) except asyncio.TimeoutError: continue except Exception: self.alive = False return if not line: self.alive = False return # 任意消息都刷新活跃时间 self.last_active = time.monotonic() if line.strip() == b'PONG': continue # 业务消息交给上层处理 # self.on_message(line) # 时间线三:判定线 async def _checker(self): limit = self.interval * self.max_fail + self.timeout while self.alive: await asyncio.sleep(1.0) idle = time.monotonic() - self.last_active if idle > limit: log.error("connection judged dead, idle=%.1fs", idle) self.alive = False return async def run(self): backoff = 1.0 while True: try: self.reader, self.writer = await self.connect_fn() self.alive = True self.last_active = time.monotonic() backoff = 1.0 log.info("connected") await asyncio.gather( self._sender(), self._receiver(), self._checker(), ) except Exception as e: log.warning("connect failed: %s", e) await self._cleanup() # 指数退避 + 抖动,上限 30 秒 await asyncio.sleep(backoff * random.uniform(0.7, 1.3)) backoff = min(backoff * 2, 30.0) async def _cleanup(self): if self.writer: try: self.writer.close() await self.writer.wait_closed() except Exception: pass self.reader = self.writer = None这段代码有几个地方值得单独说。last_active用的是time.monotonic()而不是墙上时钟,这个必须做对。墙上时钟会因为时间同步服务调整而跳变,一旦向后跳,idle直接变成很大的数,一批健康连接会被集体误判;向前跳则会让判定失效很长时间。单调时钟只保证一件事——永远向前,这就够了。
_receiver里的读超时设成了间隔的 3 倍,这个值不是随意的。它要足够长,保证正常情况下不会因为没消息就退出;同时要有一个上限,防止readline永远阻塞导致接收线彻底僵死。如果这个协程被永久挂起,alive标志永远不会被它改变,判定线就成了唯一的检测手段——而判定线只认last_active,这时候它恰好能补位。三条线互相兜底,这是这个设计的核心。
重连部分,backoff在每次成功连接后重置为 1 秒,失败后翻倍,封顶 30 秒。这里封顶值的选择有讲究:太小,服务端扛不住;太大,故障恢复后客户端要等很久才回来。30 秒是我在几次故障演练里试出来的平衡点,恢复速度可以接受,同时压力足够分散。
4.3 服务端侧:过期清理要分批做
服务端这边的逻辑相对简单,但有一个坑必须提醒:
import time class ConnectionRegistry: def __init__(self, timeout=20.0): self.timeout = timeout self.last_seen = {} # conn_id -> monotonic 时间戳 def touch(self, conn_id): self.last_seen[conn_id] = time.monotonic() def sweep(self, batch=500): """分批清理,每轮最多处理 batch 个,避免长事务""" now = time.monotonic() expired = [] for cid, ts in self.last_seen.items(): if now - ts > self.timeout: expired.append(cid) if len(expired) >= batch: break for cid in expired: self.last_seen.pop(cid, None) # self.close_connection(cid) return len(expired)sweep方法里的batch参数是血的教训换来的。早期版本我做的是全量扫描、一次性关闭所有过期连接,平时没事,一旦遇到上游大面积抖动导致几万连接同时失效,这一轮清理就会变成一个持续几百毫秒的同步阻塞,期间所有心跳请求都无法处理,于是更多连接被判过期,雪崩。改成分批之后,即使有十万条过期连接,也是分批消化,每轮几百条,对主流程的影响降到可忽略。
还有一点,清理任务不能只靠一个固定周期的定时器。如果某次执行耗时超过了周期,任务会堆积。我用的是“上一次执行完成后才安排下一次”的模式,而不是“每隔 N 秒触发一次”,这样天然不会堆积。
4.4 实测记录:参数调优前后的对比
这套东西在一个内部服务上跑过,规模是三万连接左右,我记录了两组数据做对比。第一组用固定间隔 5 秒、无抖动、3 次失败判定、超时 3 秒;第二组用间隔 8 秒、±10% 抖动、2 次失败判定、超时 2 秒。
| 指标 | 调优前 | 调优后 |
|---|---|---|
| 心跳消息量(QPS) | 约 12000 | 约 7500 |
| 故障平均发现时间 | 约 14 秒 | 约 11 秒 |
| 误判断连次数(日均) | 40 次以上 | 个位数 |
| 重连峰值并发 | 集中,尖峰明显 | 分散,无明显尖峰 |
| 单机 CPU 占用(心跳相关) | 约 6% | 约 3% |
最明显的变化其实是误判次数。调优前每天几十次误判,原因基本都是网络瞬断叠加在同步的心跳节奏上,恰好连续三次撞上。加了抖动之后,每条连接的失败时刻被错开,连续两次撞上瞬断的概率大幅下降。故障发现时间反而缩短了,因为失败次数从 3 降到 2 带来的收益,超过了间隔变长带来的损失。
5. 常见问题与排查技巧实录
5.1 高频问题速查
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 连接数正常但收不到消息 | 中间链路静默回收,半开连接 | 抓包看是否有重传无响应;检查心跳间隔与中间设备空闲超时 |
| 心跳发出但从不收到响应 | 中间层拦截了未知消息类型 | 在两端同时抓包,对比发送端发出的帧和接收端收到的帧 |
| 大批量连接同时掉线 | 心跳判定过于激进,或全局同步 | 检查失败次数与抖动配置;检查是否有批量同时建立连接的事件 |
| 服务端先判定超时,客户端仍在发心跳 | 单向链路问题,或服务端处理路径被阻塞 | 查看服务端心跳处理是否走了业务线程池;检查是否有长事务阻塞事件循环 |
| 心跳正常但业务请求超时 | 心跳走了快车道,业务走了慢车道,两者状态不同步 | 检查是否把心跳响应做成了“永远返回成功”的假响应 |
| 移动端频繁判离线 | 系统省电策略挂起了后台任务的执行 | 无法完全避免,需要靠多次失败判定和宽松超时来容忍 |
| 重连风暴导致服务端过载 | 客户端无退避或退避无抖动 | 加指数退避,封顶值配合抖动 |
5.2 一个反直觉的排障案例
有个系统反馈“心跳正常但消息延迟严重”,很矛盾:心跳能一来一回,说明链路通畅,为什么业务消息会延迟?
最后定位到的原因是心跳走了完全独立的通道——它由 IO 线程直接处理,收到就立即回,根本不进入业务队列。而业务消息要经过一个工作队列,队列前面排了太多慢请求。所以心跳测出来的“健康”,其实只证明了 IO 层健康,业务层早就堵了。这个案例让我彻底改变了对心跳的定位:心跳响应必须反映真实的处理能力,不能做成廉价的假响应。
改法是:心跳请求仍然优先处理,但响应里带上当前工作队列的深度;同时增加一条独立的“处理能力探测”,走一遍完整的业务处理链路,只是不做实际业务动作。两条探测结合,前者快、能反映链路状态,后者慢、能反映业务状态。只看一条都会误判。
另一个反直觉的案例是时钟。有个系统在容器里跑,容器的时钟和宿主机偶发不同步,而代码里用的是墙上时间戳做超时判定。结果每隔一段时间就有一批连接被莫名其妙地踢掉。换成单调时钟之后彻底消失。这个问题的迷惑之处在于它是间歇性的,日志里看不出任何规律,只能靠怀疑时间源。
5.3 几条不写在文档里的经验
第一,心跳间隔不要设成能被整除的整秒。整秒值容易和其他周期性任务形成共振,比如某个每秒执行一次的任务,恰好和你的心跳周期重合。用 7 秒、13 秒这类质数或者带小数点的值,天然能避开大部分共振。这条没什么理论依据,纯粹是踩坑踩出来的经验,但确实好用。
第二,心跳日志一定要降级。正常心跳不要打 INFO 日志,那会把日志系统冲垮,而且真正的问题会被淹在噪音里。只在状态变化的时候打日志——从健康变异常、从异常恢复、判定失败计数递增。我见过一个服务因为心跳日志把磁盘写满,然后整个服务崩溃,这个教训太昂贵了。
第三,监控心跳机制本身。心跳是用来发现问题的,那心跳机制自己出问题谁来发现?至少要监控三个指标:判定的断连数、心跳消息的往返时延分布、重连次数的速率。这三个指标出现异常变化,往往意味着网络环境或者上游服务发生了变化,比等到用户投诉要早得多。
第四,在故障演练里验证心跳参数。参数不是算完就完事,必须实际演练。我常用的手段是在链路上模拟丢包和延迟,观察判定是否按预期发生、误判率是多少、恢复是否平滑。演练会发现很多纸面上算不出来的问题,比如某个中间件在丢包时的重传行为会干扰心跳判定。
第五,区分“连接断开”和“对端不可用”。这两件事处理策略完全不同。连接断开需要重连,对端不可用重连也没用,应该退避更久甚至进入熔断。很多实现把两者混为一谈,导致对端已经挂了还在拼命重连,白白消耗资源。判定上可以通过连续多次重连失败来区分,连续失败超过阈值就进入熔断状态,用更长的周期做探测。
第六,给心跳留一条独立的资源通道。如果你的系统里有多个连接池、多个线程池,心跳相关的处理要有自己的位置,不能和业务抢。我见过因为业务把连接池占满,心跳拿不到连接,导致整批连接被误判的情况。哪怕只是给心跳单独留一个很小的处理配额,也比完全没有强。
这套机制我从最开始“照着别人参数抄”,到现在“先问业务容忍度再倒推”,中间踩的坑基本都写在上面了。最深的体会是:心跳的价值不在于那个包本身,而在于它强迫你去想清楚“我怎么知道对面还活着”这个问题。想不清楚这个,参数怎么调都是碰运气。