☰
自研轻量级平台健康探测系统:从监控盲区到主动雷达
2026/11/2 1:01:10 网站建设 项目流程

凌晨三点零七分,值班群里弹出一条告警:支付服务超时率超过 10%。我打开监控后台,结果发现不光支付服务的数据是断的,整个监控大盘的曲线全部停在了两分钟前。再往下一查,采集 Agent 挂了,消息队列积压,监控系统自己先一步躺平。那一刻我彻底意识到,所有依赖数据上报的监控体系都存在一个致命盲区:如果埋点管道阻塞、采集程序宕机、消息中间件抢占不到资源,那我们对平台的可见度直接归零。

与其被动等数据送上门,不如主动向平台发射探测信号,像雷达一样周期扫描,接收回波判断目标还活着、是否还能干活。这个思路最终演变成了我自研的一个轻量级平台健康探测系统,项目代号 PLFM_RADAR,也就是 Platform Radar。这也是这篇文章想完整分享的东西:它的动机、架构、核心代码、告警策略,以及上线后踩过的一堆实打实的坑。

1. 项目起点:一次"监控自己先挂了"的故障复盘

1.1 被动监控的四个盲区

那次事故让我把现有监控体系从头到尾审视了一遍。老平台的监控链路是"业务侧埋点 → Agent 采集 → 消息队列 → 实时计算大盘 → 告警规则引擎",看起来标准,实际上存在四个在关键时刻要命的盲区。

第一,链路强耦合。采集 Agent 和业务进程跑在同一台机器,业务高负载时 Agent 反而被饿死,于是告警静默;消息队列一旦积压,数据延迟直接分钟级起步。换句话说,监控的可靠性完全依赖它所监控的那套基础设施的可靠性,这是逻辑上的悖论。

第二,阈值是静态的。大多数规则还是"响应时间 > 500ms 就告警""错误率 > 5% 就告警",没有做滑动窗口判断,网络瞬断或者 GC 停顿造成的个位数抖动,能把值班群炸穿,真正的持续故障反而被淹没在告警风暴里。

第三,无法回答"影响面有多大"。某个数据库连接池打满后,受影响的不只是它自己,而是所有下游服务。旧系统只能告诉你"某数据库异常",不能告诉你"以下几个核心商品接口即将超时"。

第四,发现问题靠指标,不靠验证。指标异常只是间接信号,服务也许已经开始 500,也许还扛得住。旧系统没有一个"主动去看一眼现在是不是真的挂了"的机制。

PLFM_RADAR 的定位就是围绕这四个盲区设计的:探测链路独立于业务采集链路,每个目标维护独立状态机,判定结果附带依赖扩散分析,异常判定通过真实请求验证,而不是看曲线猜。

1.2 雷达隐喻是怎么落到系统设计里的

雷达工作有三个动作:发射扫描波、接收回波、在屏幕上画点。PLFM_RADAR 把整套逻辑映射到平台健康探测上,这个映射关系我到现在跟团队讲设计都是这么说的:

信号发射对应调度器,它按照目标字典周期性地发起探测请求。每个目标是一条独立的扫描方向,探测频率允许单独配置,核心服务 5 秒一扫,边缘服务 30 秒一扫。

回波接收对应探针层,支持 TCP 端口连通性检查、HTTP 状态码检查、响应时间计算、DNS 解析耗时、TLS 握手耗时,甚至允许接入自定义脚本探针做业务级验证,比如登个账号、查一条测试数据再返回。

屏幕画点对应判定层,把回波数据换算成一个离散健康状态,正常、抖动、故障、未知四个档位,每个目标的状态变化都被记录成一条事件流,形成"雷达屏"。

这三层就是整个项目的骨架。后面所有的模块,都是为了把这一套逻辑跑得稳定、不误报、可运维。

2. 系统骨架:五层模块划分与数据流转

2.1 模块拆解与选型

PLFM_RADAR 整体是一个 Python 3.10 实现的异步调度系统,入口是一个常驻的 worker 进程,调度器内置;Web 面板和 API 使用 FastAPI 单独起一个进程;历史探测数据写入 SQLite,超过 7 天的按小时做降采样聚合,占不了多少空间。模块划分如下。

模块职责落地方式
探针层执行具体探测动作,产出原始结果Python asyncio + aiohttp + 自定义探针插件
调度层按目标配置周期触发探测任务,控制并发自定义时间轮调度器 + asyncio.Semaphore
判定层原始结果 → 离散健康状态,维护每个目标状态机滑动窗口 + 迟滞阈值 + 状态机
通知层状态翻转事件 → 告警 / 恢复通知,负责去重与升级Webhook 推送 + 内存事件去重表
展示层平台目标拓扑、实时状态、历史事件查询FastAPI + 轻量 HTML 页面 + WebSocket

选型上我没有引入消息队列和时序数据库,这是刻意做的减法。PLFM_RADAR 的定位是一个独立于观测链路之外的健康哨兵,它自身必须足够简单可靠,依赖越多,事故复盘里出现过的"监控系统自己挂了"就会在它身上同样发生。调度器进程无状态,探测目标列表直接从配置文件加载,配置变更通过 SIGHUP 热加载,不依赖服务发现组件。真要说"分布式"的部分,也就是部署了 3 个探测节点,用目标 IP 哈希分摊扫描任务,每个节点只负责自己那一部分,失败探测会写入 Redis 做跨节点确认,这一步是为了抗误报,后面会细说。

2.2 核心数据结构:目标、样本、状态机

系统的三个核心数据结构,一个是目标注册表,一个是探测样本,一个是状态机实例。

目标注册表里的每一项代表一个被扫描的对象:

@dataclass class RadarTarget: name: str # 目标唯一标识,如 pay-service probe_type: str # http / tcp / dns / custom addr: str # http://10.20.1.8:8080/health 或 10.20.1.8:3306 interval: int = 30 # 扫描周期秒 timeout: float = 3.0 # 单次探测超时 retries: int = 2 # 失败重试次数 window_size: int = 10 # 滑动窗口样本数 failure_threshold: int = 5 # 达到该连续失败次数判定故障 recovery_threshold: int = 3 # 达到该连续成功次数判定恢复 grade: str = "normal" # 当前状态 owner: str = "" # 责任人,用于告警按组路由 planned_maintenance: bool = False # 计划维护中不参与告警 dependents: List[str] = None # 直接依赖此目标的下游服务

探测样本是判定层的基本输入:

@dataclass class ProbeResult: target_name: str probe_time: float # 探测发起时刻 latency_ms: float # 本次探测总耗时 success: bool # 探测是否成功 status_code: int # 仅 http 探针有效 error: str = "" probe_node: str = "" # 由哪个探测节点完成

状态机实例不会被简单丢弃。历史状态、连续成功/失败计数、当前状态等级、上次状态翻转时间、最近 10 个样本的滑动窗口,全都保留在内存里,同时每次翻转会落库,方便事后复盘。

3. 核心代码实现:探测调度与状态判定的落地细节

3.1 为什么用 asyncio 而不是多线程

第一批实现我用的线程池,每个目标一个 ThreadPoolExecutor,看起来直观,跑起来问题不小。一是 Python 的 GIL 让纯计算型负载线程并发的优势不明显,二是探测量大时线程上下文切换开销可观,三是给每个探测任务单独设超时比较麻烦,concurrent.futures的Future.result(timeout)其实只是让主线程放弃等待,子线程还在跑,大量超时的 TCP 连接会变成僵尸线程堆积。

换成 asyncio 之后,所有探测动作都是 IO 密集型操作,天生配合单线程事件循环,连接超时用asyncio.wait_for直接控制,挂在协程层面,无泄漏风险。调度器回到单进程循环时,内存占用大概比线程模型下降了 40%。

3.2 调度器核心循环

调度器没有用现成的第三方调度库,而是自己写了一个极小的时间轮。原因是 Celery beat 和 APScheduler 对我们这种"目标动态增减、周期各不相同"的场景都偏重,而且它们本身会带来进程监督复杂性。自定义版本的核心只有三十行。

import asyncio import itertools from collections import defaultdict class RadarScheduler: def __init__(self): self._schedule = defaultdict(list) # next_run_ts -> [probe_task, ...] self._targets = {} self._sema = asyncio.Semaphore(20) def register(self, target: RadarTarget): next_ts = asyncio.get_event_loop().time() + target.interval self._schedule[int(next_ts)].append(target) async def _dispatch(self, target: RadarTarget, prober): async with self._sema: result = await prober.probe(target) self._last_results[target.name] = result next_ts = asyncio.get_event_loop().time() + target.interval self._schedule[int(next_ts)].append(target) async def run(self, prober): while True: now = int(asyncio.get_event_loop().time()) due_targets = self._schedule.pop(now, []) for target in due_targets: asyncio.create_task(self._dispatch(target, prober)) await asyncio.sleep(0.1)

这个实现的关键点在于,每个目标的下一轮扫描时间直接作为字典键,run循环每 100 毫秒检查一次有没有到期的目标,有就创建探测协程。asyncio.Semaphore(20)限制最大并发探测数为 20,避免极端情况下把探测机自己的连接数打满。

后来在生产环境发现一个要命的细节:asyncio.sleep(0.1)的精度在系统时钟调用sleep时受内核调度影响,偶尔会延迟 20 到 30 毫秒。对 5 秒周期的核心目标来说这个误差无所谓,但对定时精度有要求的探针,比如依赖计时检查 TLS 证书剩余有效期,误差会造成临界点误判。解决方案是调度器内部不使用墙上时钟,而是统计进程启动以来的单调时钟,即time.monotonic(),代码里已经在用 event loop 的 time(),天然满足要求,这一点当时写注释时特意标明了。

3.3 探针层实现:HTTP 探测和 TCP 探测

HTTP 探针用 aiohttp 发起 GET 请求,等待响应。核心问题是超时配置要拆开:连接超时和读取超时要分开设置,因为一个目标如果连接建了但迟迟不返回响应体,等待时间不能无限拉长。aiohttp 的ClientTimeout支持sock_connect和sock_read两个独立参数,我分别设置为 2 秒和 3 秒。

import aiohttp import asyncio class HttpProbe: def __init__(self): self._session = None async def __call__(self, target: RadarTarget) -> ProbeResult: if self._session is None: self._session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(sock_connect=2.0, sock_read=3.0) ) start = asyncio.get_event_loop().time() try: async with self._session.get( target.addr, allow_redirects=False, headers={"User-Agent": "PLFM_RADAR/1.0"} ) as resp: latency = (asyncio.get_event_loop().time() - start) * 1000 body = await resp.read() success = resp.status < 500 and len(body) > 0 return ProbeResult( target_name=target.name, probe_time=start, latency_ms=latency, success=success, status_code=resp.status ) except asyncio.TimeoutError as exc: return ProbeResult( target_name=target.name, probe_time=start, latency_ms=(asyncio.get_event_loop().time() - start) * 1000, success=False, status_code=-1, error=f"timeout: {exc}" ) except aiohttp.ClientError as exc: return ProbeResult( target_name=target.name, probe_time=start, latency_ms=(asyncio.get_event_loop().time() - start) * 1000, success=False, status_code=-1, error=f"client_error: {exc}" )

注意allow_redirects=False,健康探测接口不应该跟着 302 跳转,跳了我就认为它行为异常。返回体长度也做了一次校验,避免某些前端路由把所有未知路径都渲染成 index.html 返回 200,导致误判服务存活。

TCP 探针则简单得多,直接用asyncio.open_connection:

class TcpProbe: async def __call__(self, target: RadarTarget) -> ProbeResult: start = asyncio.get_event_loop().time() try: reader, writer = await asyncio.wait_for( asyncio.open_connection(target.addr, target.port), timeout=target.timeout ) await asyncio.wait_for(writer.drain(), timeout=2) writer.close() await writer.wait_closed() latency = (asyncio.get_event_loop().time() - start) * 1000 return ProbeResult(... success=True ...) except Exception as exc: return ProbeResult(... success=False, error=str(exc) ...)

这里有一个我踩过的坑:TCP 探测看到连接成功后立刻关闭,对很多服务来说这没问题,但对 MySQL 这类有"连接建立后如果马上断开会记录一条 warning 日志"的中间件来说,频繁的探测会在目标端产生大量无意义日志。后来对数据库探针专门加了一个SELECT 1的自定义探针类型,既验证端口连通又验证业务可查,目标端日志也干净了。

3.4 状态判定:迟滞阈值和滑动窗口

判定层是整个 PLFM_RADAR 里最需要较真的地方。如果单次失败就标记故障,那么一次网络抖动就会被判定为故障,值班群会收到一波假警报。所以我引入了"迟滞阈值"概念:健康到故障需要连续失败多次才触发,故障到健康也需要连续成功多次才恢复,中间有一个中间状态"抖动"用来记录异常苗头。

class HealthState: HEALTHY = "healthy" DEGRADED = "degraded" CRITICAL = "critical" class TargetStateMachine: def __init__(self): self.state = HealthState.HEALTHY self.consecutive_failures = 0 self.consecutive_successes = 0 self.sliding_window = [] def feed(self, result: ProbeResult): self.sliding_window.append(result.success) if len(self.sliding_window) > 10: self.sliding_window.pop(0) if result.success: self.consecutive_failures = 0 self.consecutive_successes += 1 # 恢复条件:连续成功 >= recovery_threshold if self.state in (HealthState.DEGRADED, HealthState.CRITICAL) and \ self.consecutive_successes >= self.recovery_threshold: self.state = HealthState.HEALTHY else: self.consecutive_successes = 0 self.consecutive_failures += 1 # 升级条件:连续失败 >= failure_threshold if self.consecutive_failures >= self.failure_threshold: self.state = HealthState.CRITICAL elif self.consecutive_failures >= self.failure_threshold - 2: self.state = HealthState.DEGRADED

failure_threshold一般设置为 3 或 5,配合探测周期,就是"连续 3 次探测失败才认为故障"。假设核心服务 5 秒探测一次,failure_threshold=3意味着至少容忍 10 秒内的短暂故障不触发,但如果真的持续故障,首报出现在第 15 秒前后。边缘服务 30 秒周期加大阈值到 5,那就是 2 分半钟才报警,这个时间对非核心目标可以接受。

滑动窗口数组的作用是第二个判定维度:10 个样本里失败占比超过 30% 时标记为"抖动",但不触发告警,只在雷达面板上显示黄色。这个设计非常适合识别间歇性问题,比如 GC 暂停、网络偶发丢包,它不会让值班群响,但面板上能看到异常苗头。

3.5 事件总线和状态翻转触发

状态机每收到一条探测结果就调用feed,总是返回"当前状态变了没"。只有从 Healthy 变 Critical,Critical 变 Healthy,或者进入 Degraded 这种翻转行为,才往事件总线里发一条事件。所有告警通知都基于事件,而不是基于"当前值超过阈值",这是跟传统阈值系统最本质的区别。

def on_state_change(old_state: str, new_state: str, target: RadarTarget): if old_state == new_state: return event = { "target": target.name, "old": old_state, "new": new_state, "time": time.time(), "detail": f"{target.probe_type} to {target.addr} failed {target.consecutive_failures} times" } notify(event)

告警回调notify会进入通知层,通知层再负责"这个事件要不要真的发出去"。比如目标正在计划维护窗口内,事件会被标记为suppressed状态,只入库不推送。

4. 告警策略:压风暴、做去重、给影响面

4.1 告警风暴是怎么被压住的

告警风暴的根因是事件重复。状态机解决了"抖动不告警",但如果在故障期间探测周期是 5 秒一次,状态会始终停在 Critical,而每次feed都判定"状态未变化",事件总线并不会为每次探测都发事件,因为on_state_change只在翻转时触发。这意味着一个持续 10 分钟的故障,告警系统只在故障开始时发一次,恢复时再发一次,总共两条通知,中间没有任何刷屏。

但是只发一次也有问题:值班人员可能错过通知,长时间故障需要升级提醒。所以通知层里加了一个升级机制。目标进入 Critical 后启动一个升级计时器,5 分钟没恢复、15 分钟还没恢复、1 小时还没恢复,分别发一次升级提醒,每次升级都带当前累计故障时长。这个机制既避免了刷屏,又保证长时间故障不会被遗忘。

在实际部署中,我还遇到过一个特殊情况:某个探针节点和某几个目标之间的网络路径恰好有防火墙策略问题,导致这个节点探测全部超时,而其他两个节点探测正常。这种"单点误报"如果不处理,告警风暴会以另一种形式出现。解决方案是跨节点确认机制,也就是前面提到的 Redis 确认:

  • 单个节点探测失败后,结果标记为unconfirmed
  • 判定层不会立刻翻转 Critical,而是通过 Redis 向另外至少一个节点发起"帮我复查这个目标"的请求
  • 至少两个不同探测节点确认失败,才确认故障

这个机制让误报率直线下降,代价是故障首报时间增加了一次探测周期。核心目标 5 秒周期内加一次跨节点确认,大概延迟 2 到 5 秒,这个成本完全可以接受。

4.2 影响面扩散分析是怎么算的

告警文案里附带影响面,是 PLFM_RADAR 上线后大家用得最多的功能。做法不复杂,关键是依赖数据从哪来。我在服务注册中心定时拉取一份服务依赖关系,整理成一张有向图,节点是服务,边表示"上游依赖下游"。

当目标 A 进入 Critical 状态时,影响面分析模块取 A 的所有间接依赖方,做一次反向广度优先遍历。这里的难点是"反向"关系容易绕晕。依赖关系是"支付服务 → 订单服务",意思是支付服务调用了订单服务,那么订单服务故障,支付服务受影响,但支付服务故障不会影响订单服务,所以遍历方向是沿着入边反向走。

def find_affected(target_key: str, dep_graph: Dict[str, List[str]], depth=3): # dep_graph: key -> list of upstream services that call key affected = set() queue = deque([(target_key, 0)]) while queue: node, d = queue.popleft() if d >= depth: continue for upstream in dep_graph.get(node, []): if upstream not in affected: affected.add(upstream) queue.append((upstream, d + 1)) return affected

计算完成的结果会拼进告警文案,比如:

❗ 订单服务 CRITICAL(连续 5 次探测失败) 影响面:支付服务-下单接口、对账服务-对账任务、前端-订单列表 责任人:order-team / @张三 / @李四

depth=3是实践后调出来的值。超过三层的间接影响,业务上已经很难直观理解,告警文案反而会被无关信息稀释,所以做截断。

5. 部署落地实测:从埋点到上线的坑

5.1 探测频率与性能开销的取舍

第一个坑是在压测时发现的。探测周期设太密会给自己制造负载,太疏又失去意义。我最初把全部目标统一设置成 10 秒,结果扫描约 600 个目标时,探测机 CPU 占用达到 30%,连接数峰值接近 800。排查发现 HTTP 探针每个请求都会正常建连、读响应、断连,而目标服务本身的健康检查接口往往还要查数据库,等于我把目标服务的数据库压力也放大了一份。

后来改成分级策略:核心服务 5 秒、重要服务 15 秒、普通目标 30 秒、第三方外部依赖 60 秒。目标从配置文件读取interval,不再全局统一。调整后探测机的 CPU 占用直接降到 4%,目标端健康检查接口的 QPS 增量也控制在可接受范围。

另外还有一个细节,探针目标里的每个 HTTP 请求都要设置合理的User-Agent,并保证不超过健康检查接口的限流阈值。碰到过目标端网关把 PLFM_RADAR 的探测请求当作攻击流量限流掉的案例,后面我们在目标端白名单里加上了探测器 IP。

5.2 网络策略导致的误报:一次完整排查链路

上线第二周,告警群里突然连续三天每天固定出现几条"消息中心 CRITICAL",但每次持续时间不超过一分钟就自动恢复。因为我前面做了状态机和跨节点确认,所以这种"快速翻红又恢复"的事件明显不是持续故障。

排查链路是这样的。第一步,先看探测日志。发现每次告警都是从 A 节点触发的,B、C 节点并没有失败记录。第二步,手动在 A 节点执行curl目标健康检查接口,一瞬间全部正常。这排除了目标本身的问题。第三步,检查 A 节点到目标之间的网络路径,用的是traceroute和 TCP 连接抓包,发现所有失败请求都落在某个出口网关的端口上,而正常请求则走的是另一条路由。第四步,查网关策略,发现目标平台在最近一次防火墙策略变更时只放行了 B、C 两个探测节点的 IP 段,A 节点的网段被误漏了。结果 A 节点探测时直接超时,B、C 节点正常,跨节点确认又把这次误报"确认"下去了。

修复很简单:在目标平台的防火墙白名单里补上 A 节点的 IP 段。但这个案例让我明确了两件事:探测节点本身也要纳入平台网络策略的变更管理;跨节点确认不能只看"两个节点失败",最好两个失败节点的 IP 段要落在不同网段,否则同一个防火墙策略可能导致所有节点同时误报。

5.3 恢复通知丢失和计划维护窗口处理

有一次凌晨目标服务完成版本发布,PLFM_RADAR 的故障告警发了,但恢复通知一直没有发出来。查日志发现,目标在故障期间服务进程一直在重启,探测结果"有时成功有时失败",连续成功计数始终没达到恢复阈值,所以状态一直卡在 Critical,后续的探测样本虽然有一些成功的,但没有满足"连续 3 次成功"这个条件。

这种场景的修复是在恢复判定逻辑中增加一个"后验窗口":如果状态为 Critical 的目标在过去 10 个样本中有 80% 为成功,同时最新一次成功,则直接判定恢复,不再要求连续成功。毕竟恢复条件的目标是"目标已恢复稳定",对刚经过发布重启的服务,短时间内有少量失败是正常的,要允许它在恢复判定上更宽松一些。

计划维护窗口的处理则简单很多。运维在平台维护前把目标标记为planned_maintenance=True,判定层遇到这个标记时只做探测和记录,不参与告警触发。维护结束自动清零状态机,避免带着"连续失败计数"进入正常期,那样会造成维护结束后的误报。

6. 上线三个月的效果数据与后续扩展思路

PLFM_RADAR 在线上稳定跑了一个季度,和之前被动监控体系并存。我统计了几个关键数字:平均异常发现时间从原来的 11 分钟降到了 40 秒,这个提升主要来自主动探测的 5 秒周期和独立探测链路;告警风暴事件从原来的一周 3 次降到零,因为状态机和跨节点确认把误报卡死在了判定层;值班群夜间被打断的次数下降了约 70%,升级机制确保真正长时间故障不会漏。

后续我计划做三件事。第一,把"健康探测"扩展成"容量探测",通过探测响应时长的长期趋势做容量预测,在服务真正打满之前发出黄色预警。第二,接入发布系统,灰度发布时自动把新节点加入探测目标,如果新节点探测异常就自动回滚。第三,把探针的自定义插件机制开放出来,允许业务团队注册"业务语义探针",比如"创建一个测试订单并在 3 秒内查询到该订单",这才是真正的用户视角健康。

做 PLFM_RADAR 最大的收获不是那几千行代码,而是纠正了一个根深蒂固的习惯:监控不是把指标画出来就算完事,而是要靠独立的信号源反复确认平台还能不能干活。现在我们的值班群每天会收到一份雷达扫描摘要,大多数时候是全绿。绿色就是最好的消息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询