做平台监控最怕什么?不是数据不准,而是你盯着十几个指标却看不出整体哪里在报警。PLFM_RADAR 就是为解决这个问题写的——一个把多平台、多服务、多设备的运行信号统一投到一块“雷达屏”上的轻量监控系统。说白了,它像一个雷达扫描器,不停往外发探测信号,把各处返回的状态、延迟、错误率收回来,绘制成一张一眼能看懂的全景图。适合谁用?运维、后端、甚至做 IoT 和设备管理的人都合适,特别是手上有多个内部系统、却还没上重量级监控平台的小团队。
1. 整体设计思路:为什么监控要做成“雷达”
1.1 传统监控方式的三个痛点
先聊聊我为什么会有这个念头。最早做平台监控,我用的是最朴素的办法:写几个 shell 脚本,crontab 定时 curl 一下接口,通了就发“正常”,不通就发告警。后来服务多了,脚本变成几十个,告警群天天炸,而且谁挂了我得一个个脚本看才知道。这就有三个问题:
第一是信息碎片化。每个服务单独一个小脚本,状态分散在各个日志和群里,没有一个统一的视角告诉你“现在整个平台健康状况如何”。第二是响应滞后。crontab 最短也就一分钟一次,加上脚本执行时间不确定,发现问题往往已经过去了好几分钟。第三是无法横向对比。同一时刻,A 服务延迟 200ms、B 服务延迟 800ms,如果没有一张聚合的图摆在一起,你很难立刻判断谁是瓶颈。
PLFM_RADAR 的设计目标,就是把这三点一次性解决:用一个持续运行的扫描器,以固定频率对所有目标做探测;把所有数据集中到同一个数据管道里做归一化;最终把结果渲染成一个雷达图,同一时刻所有目标的状态、延迟、错误一目了然。
1.2 “雷达”这个模型到底妙在哪
雷达模型最核心的优势,是它天生就是为“全方位观察”设计的。真实雷达一圈圈扫,屏幕上每个光点代表一个目标,距离越近说明越危险,飞行员扫一眼就能判断该把注意力放在哪。
我把这个逻辑搬到了平台上:每个被监控的服务就是一个“目标”,探测频率就是雷达的“扫描周期”,延迟和错误率就是目标的“距离和方位”。目标越偏离正常范围,在雷达图上离中心越近,颜色越红,优先级越高。这样,运维人员不需要逐个翻日志,只要看一眼雷达图,就知道当前平台上最需要处理的是哪个服务。
这个模型的另一个好处是扩展性好。雷达可以同时跟踪很多目标,平台也一样——今天监控 10 个服务,明天加 5 个,只需要往目标列表里加几行配置,扫描器就能自动覆盖到。
1.3 功能定位与边界
当然,PLFM_RADAR 不是要做一个取代 Prometheus + Grafana 的重型监控平台。我做它的定位是“轻量、快速、够用”:
- 轻量:整个项目一个 Python 服务加一个 Redis,不需要部署一堆组件;
- 快速:从启动到看到雷达图,两分钟之内;
- 够用:采集、聚合、告警、可视化四件事都覆盖,但每件事都做得简单直接。
适合的场景是内部平台、中小型系统、个人开发者的多服务管理,或者作为大型监控系统的一个补充视角。团队再大一点、指标再多一点,那就该认真评估正式监控体系了,这类轻量工具反而不合适。
2. 核心模块拆解:一个雷达系统由哪几块组成
2.1 四个核心模块
PLFM_RADAR 的整体结构分四块:采集器、数据管道、雷达视图、告警引擎。每块职责单一,互相之间只通过数据接口通信,这是我刻意做的解耦。
**采集器(Collector)**负责以设定的周期向所有目标发送探测请求。它关心的是“目标现在还活着吗、响应快不快、状态码对不对”,不关心业务语义。采集器我用了异步方式实现,用的是 aiohttp,因为扫描目标多了以后,如果一个个同步请求,周期一长就起不到“实时”作用了。异步并发可以把几十个目标的探测压缩到一两秒内完成。
**数据管道(Pipeline)**接收采集器产生的原始信号,做三件事:清洗、归一化、聚合。清洗是去掉明显异常的空值和超时数据;归一化是把不同来源的指标统一成同一个结构;聚合是把一定时间窗口内的多条记录聚合成一条摘要,方便前端展示和后续判断。
**雷达视图(Dashboard)**是面向人的那层。它接收聚合后的数据,在网页上用 Canvas 画一个雷达图,每个目标是一个扇形区域,区域里显示最近一次探测的状态、延迟和错误率。视图还支持按告警级别排序,红色高亮的永远排在前面。
**告警引擎(Alert Engine)**独立运行,定时去查询最近一个窗口的聚合数据,对照阈值规则决定要不要触发通知。触发后通过 Webhook 推到钉钉、企业微信或者 Slack 这类群机器人,这也是为了不打扰——只在真正异常的时候才有人被叫醒。
2.2 数据模型的设计取舍
数据模型是整个项目的地基,这里设计得好不好直接决定了后面所有功能做起来顺不顺手。我最后定下的核心数据结构是这样:
{ "target": "auth-service", "type": "http", "status": "up", "latency_ms": 156, "error_rate": 0.002, "checked_at": "2025-03-22T10:15:30.123Z" }字段不多,但每一个都有讲究。target是服务标识,唯一;type标记探测方式,目前支持 http 和 tcp;status是三态枚举——up、degraded、down,比单纯的二进制更符合真实世界的中间状态;latency_ms是延迟毫秒数;error_rate是最近一分钟内错误请求占比;checked_at是探测时间戳,这个字段特别重要,后面排查数据时序问题全靠它。
在存储上,我选了 Redis 而不是 MySQL。原因很直接:这种监控数据是典型的时序数据,写入频率高、读取模式固定(按时间范围拉取),而且基本不需要复杂查询和事务。Redis 的 Sorted Set 天然适合按时间戳存取数据,用 ZADD 写入,用 ZRANGEBYSCORE 按时间范围读取,效率很高。内存占用方面,一个目标一分钟存一条,100 个目标跑一天也就 14 万条记录,每条约 200 字节,总共不到 30MB,完全可以接受。
2.3 技术选型:为什么是 Python + FastAPI + Redis
技术选型这事,很多人喜欢纠结哪个语言好、哪个框架新,我自己的原则就一条:团队里大家最容易上手、最不容易写错的那个,就是最好的。PLFM_RADAR 选 Python 有几个实际考虑。
第一,Python 的异步生态很成熟。采集器要同时探测大量目标,asyncio+aiohttp写起来非常顺,代码量小,逻辑清晰。第二,FastAPI 自带 WebSocket 支持和异步接口,雷达视图的实时刷新正好需要这两样。第三,数据处理脚本用 Python 写,后期想加点统计分析逻辑,可以直接用 pandas 之类的库,不用换语言。
Redis 那边,除了上面说的存时序数据,我还用它做了两个额外的事:一是做采集器之间的分布式锁,防止多个采集器实例同时扫描同一批目标;二是做告警的去重和冷却窗口,存最近一次通知时间,避免同一个告警在几分钟内反复轰炸。
3. 关键实现细节:从扫描到告警全链路
3.1 采集器:异步扫描的节奏控制
采集器是整个系统的“雷达波”。它要有稳定的节奏,既不能太密集把目标服务压垮,也不能太稀疏导致延迟发现问题。我实测下来,默认 10 秒一个扫描周期比较合适:对常见的 Web 服务来说,10 秒一次探测产生的压力基本可以忽略,而你在雷达图上看到的数据最多滞后 10 秒,体感上算是实时。
采集器核心代码长这样:
# collector.py import asyncio import aiohttp from datetime import datetime, timezone SCAN_INTERVAL = 10 # 秒 class SignalCollector: def __init__(self, targets: list[dict]): self.targets = targets async def scan_one(self, session, target): """对单个目标发起探测,返回标准信号结构""" start = datetime.now(timezone.utc) try: async with session.get( target["url"], timeout=aiohttp.ClientTimeout(total=5) ) as resp: latency_ms = ( datetime.now(timezone.utc) - start ).total_seconds() * 1000 status = "up" if resp.status >= 500: status = "degraded" elif resp.status >= 400: status = "degraded" return { "target": target["name"], "type": target["type"], "status": status, "latency_ms": round(latency_ms, 2), "error_rate": 0.0, "checked_at": datetime.now(timezone.utc).isoformat(), } except asyncio.TimeoutError: return self._down_signal(target, "timeout") except Exception: return self._down_signal(target, "error") def _down_signal(self, target, reason): return { "target": target["name"], "type": target["type"], "status": "down", "latency_ms": -1, "error_rate": 1.0, "checked_at": datetime.now(timezone.utc).isoformat(), "reason": reason, } async def run_forever(self): async with aiohttp.ClientSession() as session: while True: tasks = [self.scan_one(session, t) for t in self.targets] results = await asyncio.gather(*tasks) # 把结果交给 pipeline 做后续处理 await pipeline.write(results) await asyncio.sleep(SCAN_INTERVAL)几个容易踩的细节:
- 超时统一设成 5 秒。比扫描周期短一半,这样单个目标就算完全无响应,也不会拖累整个批次。
- 状态码的判断要区分 4xx 和 5xx。4xx 通常是客户端问题,服务本身是活的,标记成 degraded 更合理;5xx 才是服务端真的出问题了。直接把所有非 2xx 都标成 down,会误报。
- 超时和无响应的信号一定要区分。我在 down 信号里加了 reason 字段,排查时一眼就能看出目标是超时了还是连接被拒了,省很多事。
3.2 数据处理管道:归一化与时间窗口聚合
采集器吐出来的信号是最原始的记录,直接画到雷达图上会很乱。比如某个目标在一次扫描中刚好遇到 GC 停顿,延迟飙到 2 秒,下一次又恢复正常了,这种瞬时抖动会在图上形成毛刺,干扰判断。所以管道要加一个聚合层。
我的聚合策略是:每 60 秒一个窗口,把窗口内每个目标的若干条信号聚合成一条摘要。延迟取 95 分位而不是平均值,这样能避免少数极端值把整体数据拉高,更贴近“用户真实感受到的延迟”。错误率取窗口内错误次数除以总探测次数。状态取窗口内最差的那个状态——比如窗口内有 5 条 up、1 条 down,那这个窗口的状态就是 degraded,宁可保守一点,也不要漏报。
# pipeline.py from collections import defaultdict from statistics import quantiles def aggregate(signals: list[dict], window_ms: int = 60000) -> list[dict]: groups = defaultdict(list) for sig in signals: groups[sig["target"]].append(sig) result = [] for target, items in groups.items(): latencies = [i["latency_ms"] for i in items if i["latency_ms"] >= 0] errors = sum(1 for i in items if i["status"] in ("down", "degraded")) p95 = quantiles(latencies, n=20)[18] if len(latencies) >= 20 else ( max(latencies) if latencies else -1 ) worst_status = "up" for i in items: if i["status"] == "down": worst_status = "down" break if i["status"] == "degraded": worst_status = "degraded" result.append({ "target": target, "status": worst_status, "latency_p95": round(p95, 2), "error_rate": round(errors / len(items), 4), "window_end": max(i["checked_at"] for i in items), }) return result注意一个细节:quantiles需要至少 20 个样本才能稳定计算 95 分位。如果窗口内样本不够(目标少或者扫描周期长),我做了降级处理,直接用最大值。这样至少不会因为数据量少而算出离谱的数值。
3.3 雷达视图:Canvas 绘制与 WebSocket 实时推送
雷达视图是 PLFM_RADAR 的门面,也是最初吸引我决定做下去的功能。前端用一个 Canvas 画正多边形,每个顶点对应一个目标,从中心到顶点的连线方向就是这条目标的“方位”。目标离中心越近表示越危险,颜色从绿色渐变到红色。
绘制逻辑不算复杂:先把雷达图画成 N 条等角度的径向线,然后根据每个目标的指标计算它到中心的偏移量。偏移量由延迟和错误率共同决定,我用的公式是:
offset = 1 - (latency_norm * 0.6 + error_norm * 0.4)延迟归一化就是把原始延迟映射到 0-1000ms 这个区间,超过 1000ms 直接算 1;错误率同理映射到 0-1。这样延迟 800ms、错误率 5% 的目标,offset 大约是1 - (0.8*0.6 + 0.05*0.4) = 0.5,也就是画在半径中点的位置,视觉上已经足够醒目。
服务端用 WebSocket 推送聚合数据。FastAPI 里建一个/ws/dashboard端点,每当管道写入新的聚合数据,就通过broadcast推给所有连接的前端。前端收到数据后重绘 Canvas,不需要刷新页面。这里有个优化细节:前端不是收到一条就画一次,而是用一个 requestAnimationFrame 把多次更新合并在同一帧内渲染,避免页面卡顿。
3.4 告警引擎:阈值规则与冷却窗口
告警引擎是雷达系统里真正干“叫醒人”的活的部分。它不直接消费采集信号,而是每隔 30 秒查一次最近的聚合数据,挨个目标比对规则。
我的规则文件设计成 JSON,方便不写代码的同事也能改:
{ "rules": [ { "target": "auth-service", "metric": "latency_p95", "op": ">", "threshold": 500, "severity": "warning", "cooldown": 300 }, { "target": "payment-service", "metric": "status", "op": "==", "threshold": "down", "severity": "critical", "cooldown": 600 } ] }cooldown字段是冷却秒数,这是防止告警风暴的关键。比如 payment-service 挂了,采集器每 10 秒探测一次,如果每次探测都触发告警,5 分钟内一条服务能生成 30 条告警,群直接炸穿。有了冷却窗口,同一个规则触发后要等 600 秒才能再次触发,中间即使持续异常也只通知一次。
告警推送我用的是群机器人 Webhook。代码很简单,构造一个 JSON POST 过去就行,但有一点要注意:Webhook 调用是同步阻塞的,如果网络抖动可能导致告警积压。我改成放进一个独立的异步队列里,由单独的 worker 线程去发送,确保告警引擎本身不会因为推送慢而卡住。
4. 实操记录:五步搭建一套 PLFM_RADAR
4.1 第一步:项目结构与依赖准备
我习惯先把项目结构搭好,再往里填代码。PLFM_RADAR 的目录很简单:
plfm_radar/ ├── app/ │ ├── __init__.py │ ├── collector.py # 采集器 │ ├── pipeline.py # 数据管道 │ ├── alert.py # 告警引擎 │ ├── config.py # 配置加载 │ ├── redis_client.py # Redis 封装 │ └── server.py # FastAPI 入口 ├── frontend/ │ ├── index.html │ └── radar.js ├── config/ │ ├── targets.json # 目标列表 │ └── rules.json # 告警规则 ├── requirements.txt └── docker-compose.yml依赖只有四个:fastapi、uvicorn、aiohttp、redis,外加前端用一个原生 Canvas,不需要任何框架。前端不用框架是有意的——这个项目规模下,引入 React 或 Vue 徒增复杂度,原生 JS 加上几个辅助函数完全够用。
4.2 第二步:配置目标与启动采集
目标配置长这样:
[ {"name": "auth-service", "type": "http", "url": "http://10.0.1.12:8080/health", "interval": 10}, {"name": "payment-service", "type": "http", "url": "http://10.0.1.13:8080/health", "interval": 10}, {"name": "redis-cache", "type": "tcp", "host": "10.0.1.14", "port": 6379, "interval": 15} ]注意每个目标的探测地址都用/health这类专门的健康检查端点,不要直接扫业务接口。业务接口往往包含复杂逻辑,一次探测耗时长、抖动大,得到的信号不能真实反映服务“活没活着”。健康检查端点通常只做依赖联查和进程状态返回,延迟稳定,是标准的探测入口。
采集器启动后,我在日志里观察了一轮扫描的输出。正常情况下所有目标都在 10 秒左右返回数据,延迟 20-80ms,状态都是 up。这时候我故意把一个服务停掉,10 秒后雷达图上那个靶点立刻变成深红色、移向中心,告警约 30 秒后发到群里,体感很直接。
4.3 第三步:把聚合数据写进 Redis
管道聚合完的数据要进 Redis,我用 Sorted Set,member 是 JSON 字符串,score 是时间戳的毫秒数。
import redis import time import json r = redis.Redis(host="localhost", port=6379, decode_responses=True) def save_aggregated(aggregates: list[dict]): pipe = r.pipeline() for agg in aggregates: key = f"radar:agg:{agg['target']}" member = json.dumps(agg, ensure_ascii=False) score = int(time.time() * 1000) pipe.zadd(key, {member: score}) # 保留最近 2 小时的数据,超出部分定期清理 pipe.zremrangebyscore(key, "-inf", score - 2 * 3600 * 1000) pipe.execute()zremrangebyscore这行是必要的。如果不清理,Redis 里会一直堆积历史数据,虽然短期看数据量不大,但跑上一两个月,key 越来越多,内存和查询效率都会变差。我在聚合写入时顺手保留最近 2 小时窗口,足够支撑雷达视图和告警查询了;真要追溯更久的监控数据,那应该导出到专门的时序数据库,不是这个轻量项目该干的活。
4.4 第四步:实现雷达图前端
前端核心是 Canvas 绘制。radar.js里主要函数有三个:drawGrid()画雷达图的网格和轴线,drawTarget(signal)绘制每个目标的靶点,renderAll(signals)遍历所有信号完成一帧的渲染。
function drawTarget(ctx, cx, cy, radius, signal, index, total) { const angle = (Math.PI * 2 * index) / total - Math.PI / 2; const offset = 1 - (normalizeLatency(signal.latency_p95) * 0.6 + signal.error_rate * 0.4); const x = cx + radius * offset * Math.cos(angle); const y = cy + radius * offset * Math.sin(angle); const color = signal.status === "down" ? "#e74c3c" : signal.status === "degraded" ? "#f39c12" : "#2ecc71"; ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.fillStyle = "#333"; ctx.font = "12px sans-serif"; ctx.fillText(signal.target, x + 10, y + 4); }这个函数有两点经验要提:第一,角度的基准偏移我用了-Math.PI / 2,让第一个目标从正上方开始,顺时针排列,视觉上更符合雷达的直觉。第二,标签文字放在靶点右下方,避免目标多的时候文字互相遮挡。如果目标达到 20 个以上,标签就改成点击靶点后悬浮显示,否则必然挤成一团。
WebSocket 连接断线重连的代码也必须写。我见过太多实时页面,服务器一重启前端就永远停在旧数据上,需要手动刷新。加了重连逻辑后,断线后每 3 秒尝试重新连接,恢复后自动拉一次最近数据补帧。
4.5 第五步:告警与部署运行
告警引擎作为一个独立任务跑在 FastAPI 里,用asyncio.create_task(alert_loop())启动。推送我用的企业微信 Webhook,套路都一样:
# alert.py import httpx async def send_alert(rule, current_value): payload = { "msgtype": "text", "text": { "content": f"[PLFM_RADAR] {rule['target']} 触发告警:" f"{rule['metric']} {rule['op']} {rule['threshold']}," f"当前值 {current_value}(严重级别:{rule['severity']})" } } async with httpx.AsyncClient() as client: await client.post(rule["webhook_url"], json=payload)部署我用 docker-compose 起两个容器:一个是 Python 服务(collector+pipeline+alert+server 都在一个进程里跑),一个是 Redis。为什么不用 Kubernetes?这种单机规模的轻量工具,上 K8s 那是杀鸡用牛刀。docker-compose 的 healthcheck 加上 restart: always,已经足够保证服务一直活着。
# docker-compose.yml services: redis: image: redis:7-alpine restart: always volumes: - redis-data:/data plfm: build: . restart: always ports: - "8000:8000" depends_on: redis: condition: service_healthy environment: - REDIS_HOST=redis - SCAN_INTERVAL=10 volumes: redis-data:这一步做完,浏览器打开http://<服务器IP>:8000,应该就能看到一张正在实时跳动的雷达图。
5. 踩坑记录与排查技巧
写代码的过程总不会一帆风顺,PLFM_RADAR 从开做到稳定跑起来,我踩了五个比较典型的坑,整理一下给后来人参考。
5.1 WebSocket 连接老断开
现象是雷达图看几分钟就停住不动了,打开浏览器开发者工具发现 WebSocket 连接被关闭。排查了一圈,原因出在服务器和客户端之间没有心跳机制。我用的内网机器,中间经过了 Nginx 代理,Nginx 默认 60 秒会断开空闲连接。而雷达前端只在数据更新时才收到消息,如果连续这段时间没有新告警也没有聚合更新,连接就一直是空闲的,超过 60 秒就被 Nginx 掐断。
解决办法是加心跳包:服务端每 30 秒向所有连接发送一个{"type": "ping"},客户端收到后回复{"type": "pong"},这样连接始终处于活跃状态。另外,Nginx 那侧的proxy_read_timeout也顺手调大了一点,双保险。
5.2 数据时序错乱
这个问题出现的场景是服务器负载高的时候,采集器的异步任务出现排队,导致某些信号的时间戳和实际写入时间偏差很大。雷达图上就出现“时间倒流”的错觉:先看到 10:15:30 的数据,下一秒变成 10:15:20 的数据。
后来我在管道层加了一个简单的去乱序逻辑:写入 Redis 之前,按checked_at做一次排序,保证同一次批次写入的记录时间戳单调递增。同时,前端在收到时间戳比当前帧更老的数据时直接丢弃。这两个措施一加,时序错乱就再没出现过。
5.3 告警风暴
第一次联调测告警时,我一台服务停了 10 分钟,结果告警群收到了一百多条消息。原因前面提到过:没有冷却窗口。我把冷却机制加上之后,还额外加了一个全局告警频率限制——任何一条规则在 10 分钟内最多触发 3 次。这么做虽然极端情况可能漏掉连续反复的告警,但对真实运维场景来说,更重要的不是每条变化都被广播,而是“异常状态在持续”这件事本身被传达到。连续 3 次通知足够让人知道问题还在,再多就是骚扰了。
5.4 采集器把目标服务压垮
有一次我把扫描间隔调成 1 秒,结果把一个老项目的接口日志瞬间刷爆了。原因是那个服务的健康检查端点里做了数据库查询和日志记录,每秒钟被探测一次,负载直接翻了倍。教训是:采集间隔要结合目标的处理能力来设,不要一刀切。我给这个目标单独设了 15 秒的间隔,其他目标保持 10 秒,同时把健康检查端点的日志级别调成了 WARNING 以下不记录。从那以后我就学乖了,每个目标都可以单独配置 interval,不再全局统一。
5.5 内存缓慢增长
跑了一个月后,我发现 Redis 的内存比预期高了不少。查了半天,问题出在 Sorted Set 的清理策略上:我只在写入时清理当前 key 的旧数据,但如果某个目标中间被移除出配置,它的 key 就成了孤儿,永远不会被清理。
补了一个定时任务:每小时扫描所有radar:agg:*的 key,检查最近两小时有没有新写入,没有就删除。这种“孤儿数据清理”机制看着小,但对长期稳定运行来说非常重要。
6. 一个扩展思路:把告警规则做成可热加载
最后分享一个我自己在后面迭代中加的功能,也算给读到这里的你一个扩展思路。初期告警规则放在 JSON 文件里,每次改动要重启服务才能生效,很不方便。后来我把规则也存到 Redis 里,告警引擎每次触发前从 Redis 拉最新规则,改完规则客户端的同事用一条 API 就能热更新,不用动服务进程。
# 热更新 API from fastapi import APIRouter, Request router = APIRouter() @router.post("/rules/reload") async def reload_rules(request: Request): body = await request.json() r.set("radar:rules", json.dumps(body)) return {"ok": True, "message": "rules updated"}这个改动很小,但对交付体验的提升特别大——以前每次调阈值都要求我重启服务,现在运营同学自己在页面上调完就能生效。我个人做这类监控工具最大的体会是:看的人越多,需求就越五花八门,与其把功能做复杂,不如把“改配置”这件事做到最简单,让大家自己去调。PLFM_RADAR 目前还在持续迭代,下一步我打算把聚合数据增加一个按小时归档的出口,方便做日报和周报,等做出来再单独整理一篇分享。