ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

轻量监控系统PLFM_RADAR:用雷达图实现多服务状态可视化

2026/10/1 12:25:36 拓冰建站 浏览量
轻量监控系统PLFM_RADAR:用雷达图实现多服务状态可视化 做平台监控最怕什么不是数据不准而是你盯着十几个指标却看不出整体哪里在报警。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 和 tcpstatus是三态枚举——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 的异步生态很成熟。采集器要同时探测大量目标asyncioaiohttp写起来非常顺代码量小逻辑清晰。第二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], timeoutaiohttp.ClientTimeout(total5) ) 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, n20)[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_RADAR4.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 Setmember 是 JSON 字符串score 是时间戳的毫秒数。import redis import time import json r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def save_aggregated(aggregates: list[dict]): pipe r.pipeline() for agg in aggregates: key fradar:agg:{agg[target]} member json.dumps(agg, ensure_asciiFalse) 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], jsonpayload)部署我用 docker-compose 起两个容器一个是 Python 服务collectorpipelinealertserver 都在一个进程里跑一个是 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_HOSTredis - SCAN_INTERVAL10 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 目前还在持续迭代下一步我打算把聚合数据增加一个按小时归档的出口方便做日报和周报等做出来再单独整理一篇分享。