1. 为什么做PLFM_RADAR:系统跑得好好的,我却总是最后一个知道它出事了
先交代一下背景。我在团队里负责一个业务中台的服务运维和稳定性保障,服务的数量大概在三十个上下,日志散落在好几台机器上,数据库、缓存、消息队列各有各的控制台。以前排查问题基本靠“三件套”:登服务器看日志、问同事有没有动过配置、然后祈祷下次别再犯。最让人崩溃的是,很多时候用户比我先发现问题——人家在群里问“你们系统是不是挂了”,我一脸懵地打开监控面板,发现那个面板只有CPU和内存,业务指标一个都没有。
我开始琢磨一个问题:我需要的不是一块只显示服务器健康度的“仪表盘”,而是一套能从业务数据里主动嗅出异常的“雷达”。雷达扫一圈,看到什么不对劲,直接告诉我,而不是等我手动去查。
这就是PLFM_RADAR这个项目最初的动机。PLFM是我给这套系统起的内部代号,全称是Platform Lifecycle Field Monitor,翻译过来就是“平台全生命周期现场监测”。RADAR则点明了它的工作方式:周期扫描、特征识别、轨迹追踪、分级上报。它要解决的并不是“监控覆盖”的问题——我们的日志和指标从来不缺,缺的是把这些散落的数据变成可执行的判断。
所以这套系统的定位很明确:不是替代Prometheus或者ELK那套正经监控体系,而是做它们不方便做的那部分——把业务日志、接口调用量、订单转化数据、任务执行结果这些跟业务直接相关的信号,通过规则引擎筛一遍,发现规律被打破的时候就派出预警。如果你也在维护一套没有专职SRE、监控全凭自觉的系统,这篇文章应该能给你一些可直接抄作业的思路。
2. 整体架构:采集、判定、触达三层,缺一不可
2.1 三层模型:别一上来就整复杂的微服务
PLFM_RADAR的架构非常朴素,就三层:采集层、规则引擎层、触达层。没有消息队列中间件,没有分布式协调,也没有容器编排。我见过太多人做监控系统,一上来就画了一堆组件框,Kafka、Flink、Elasticsearch全上,最后发现运维这套监控本身的成本比监控对象还高。我只有一个人,两周时间,不想在本该轻量的事情上堆砌重量级组件。
采集层负责从各种数据源把信号捞进来,统一成一条标准格式的事件流。规则引擎层拿到事件流,按预置规则做实时判定,输出预警级别。触达层负责把预警发到该去的地方,同时提供一个Web面板让人能回溯查看。三层之间的通信靠什么?我用了最简单的方案:采集层把数据投递到一个Redis列表里,规则引擎定时拉取批量处理。吞吐量不大,一天也就几百万条事件,Redis完全扛得住。
2.2 技术栈选型:宁可用得顺手,不追新
具体技术选型上,我做了几个比较务实的选择:
| 模块 | 选型 | 理由 |
|---|---|---|
| 采集脚本 | Python 3.10 | 生态全,写文件监听、HTTP拉取都非常方便 |
| 事件缓冲 | Redis 6.x | 部署简单,List数据结构天然适合做队列 |
| 规则引擎 | Python自研 | 规则量不大,自研可灵活定制,不必引Drools |
| 数据存储 | SQLite + 按天分表 | 数据量百万级,单机完全够用 |
| 可视化 | ECharts + 轻量Web后端 | 雷达图、热力图、折线图开箱即用 |
| 预警通道 | 钉钉机器人、邮件、飞书Webhook | 团队日常用的工具,无需额外APP |
这里要特别说一句为什么不直接用Prometheus那套。Prometheus确实强大,但它的数据模型更偏向基础设施和技术指标,比如QPS、延迟、内存占用。而PLFM_RADAR的很多信号源是业务字段,比如“支付回调失败次数”“对账文件延迟到达分钟数”“某商户订单量环比变化”,这些在常规监控里根本不留痕,得自己从日志和数据库里算出来。与其在Prometheus里硬塞业务指标,不如独立做一套针对业务信号的小工具,两边各司其职。
2.3 事件格式:先定义清楚,后面所有逻辑都好写
整个系统的数据核心是一条统一的事件结构,我把它设计成JSON,字段不多但每一层都要用到:
{ "event_id": "evt_20250216_00001337", "source": "pay-service/order_callback", "event_type": "counter", "metric_name": "callback_fail_count", "metric_value": 3, "window_start": "2025-02-16 12:00:00", "window_end": "2025-02-16 12:01:00", "labels": { "channel": "wechat", "env": "prod" }, "raw_message": "callback failed: timeout after 5000ms" }所有采集器最终都要把数据规整成这个结构。event_type不只是counter,还有gauge、latency_histogram这些类型,方便规则引擎针对不同形态做差异化计算。event_id的生成规则里我塞了数据窗口的开始时间,这样同一个窗口内重复采集会被自然去重。labels字段是关键,规则引擎可以根据标签组合出非常细粒度的判断维度,比如只看微信渠道的失败率,或者只看某个机房的任务执行状态。
这套格式定下来之后,后面写规则、写触达逻辑都轻松很多,因为输入已经是统一的,不用每个数据源各自适配一套。
3. 采集层实现:把散落在各处的信号汇成一条河
3.1 三类数据源接入方式
和大多数业务系统一样,我们可用的信号源大致有三类:日志文件、HTTP接口暴露的指标、数据库里的业务计数表。PLFM_RADAR的采集器针对这三类分别做了适配。
日志文件监听是最常用的方式。服务端程序打印的日志里有大量业务信息,关键是日志格式要规整。我们的日志输出都有trace_id和业务标签,比如支付服务会打印一行order_callback|success|channel=wechat|cost=230ms。采集器做的事就是实时盯住日志文件的增量行,用正则把关键字段抠出来转成事件。Python里有个好用的库叫watchdog,可以监听文件修改事件,再配合手动记录读偏移量,就能做到断点续读。
import re import json import redis log_pattern = re.compile( r"order_callback\|(?P<status>\w+)\|channel=(?P<channel>\w+)\|cost=(?P<cost>\d+)ms" ) def parse_line(line: str, source: str): match = log_pattern.search(line) if not match: return None data = match.groupdict() return { "source": source, "metric_value": 1 if data["status"] == "fail" else 0, "event_type": "counter", "metric_name": "callback_fail_count" if data["status"] == "fail" else "callback_success_count", "labels": {"channel": data["channel"]}, "raw_message": line.strip() }HTTP指标接口的接入更简单。很多服务框架自带metrics端点,返回的可能是Prometheus文本格式,也可能是自定义JSON。我写了一个通用的HTTP轮询采集器,只需要在配置里声明URL、采集间隔、JSON路径表达式,采集器就定时去拉,然后从返回结果里取出需要的字段转成事件。这块我强调一下,如果你用的框架没有现成metrics接口,强烈建议自己加一个轻量的/internal/metrics端点,返回最近一分钟的请求量、失败量、耗时分布,代码量不多,但价值非常大。
数据库业务计数表是最容易被人忽略的信号源。比如订单系统里有一张daily_order_summary表,记录了每个小时每个渠道的订单数。这些数据不在日志里,但恰恰是最能反映业务健康度的。我用一个定时SQL采集器,每分钟跑一次聚合查询,把最近五分钟的订单量、支付成功率、客单价这些算出来,再转成gauge事件发给规则引擎。
3.2 断点续读与去重:采集器崩溃了也不能丢数据
日志采集最容易出的问题就是:进程重启了,日志文件已经被轮转,结果漏了一截。我处理这个问题的方法是:每个采集器在本地维护一个offset文件,记录每个日志文件当前读到的字节位置。每读一批数据,就把位置写回文件。进程重启后先从offset文件恢复位置,如果发现文件已经被轮转,就根据文件名的时间戳去历史的归档目录里把缺失的那段捞回来。
数据去重则利用事件里的window_start。因为同一批日志如果被重复读取,产生的event_id是相同的,写入Redis之前先用SETNX检查一下event_id是否存在,存在就跳过。这样做虽然会多一次Redis请求,但对于我们每天几百万的事件量来说,成本可以忽略不计。
提示:断点续读的位置写入不能太频繁,建议每处理100条日志或者每3秒写一次offset,否则磁盘IO会成为性能瓶颈。
3.3 采集器自己是会挂的,要有看门狗
这是我自己踩过的一个大坑。采集器跑了一个多月,突然有一天某台服务器的日志采集停了,但别的采集器还在正常工作,所以谁也没注意到。直到那天晚上业务出现异常,我去查数据,才发现那台机器的指标已经缺失了16个小时。
后来我加了一层简单的看门狗机制:每个采集器进程启动后,除了干本职工作,还要每30秒往Redis里写一个heartbeat心跳键,带上进程ID和最后一次采集时间。规则引擎里加了一条元规则——如果某个source在3个心跳周期内没有更新heartbeat,就直接触发“采集器失联”的P1预警。监控系统本身就是系统的最后一道防线,它自己如果不可靠,那整个体系就是空谈。
4. 规则引擎:雷达的“识别算法”才是核心竞争力
4.1 三类判定规则:阈值、基线、突变,层层递进
采集器把事件流汇进来之后,真正决定这套系统是否有价值的是规则引擎。我一开始只写了最简单的阈值规则,后来发现阈值定死了非常容易误报——白天流量高峰和凌晨低谷完全是两个量级,一个固定阈值不可能同时适应两种场景。于是我在阈值规则之上又补充了基线漂移和趋势突变两类规则,三者在系统里是叠加运行的。
阈值型规则是最容易理解的。配置长这样:
rules: - name: callback_fail_rate_too_high metric: callback_fail_count labels: channel: "*" condition: type: rate window: 5m threshold: 0.03 level: P1语义就是:统计最近五分钟的callback_fail_count相对callback_total_count的比率,如果超过3%就触发P1预警。这里强调一下,用“比率”而不是“绝对次数”来做阈值,能避免不同流量时段带来的偏差。凌晨业务量小的时候有3次失败可能就占比很高了,白天业务量大的时候有30次失败可能占比并不高,用比率就能把这两个场景拉到同一个刻度上比较。
基线漂移规则解决的是“阈值该定多少”的问题。我们不可能给每个业务指标都人工琢磨一个合理阈值,而且业务本身在成长,三个月前的阈值可能现在就完全不适用了。所以基线规则的做法是:拿当前指标和过去同时段的历史数据比。比如“订单支付成功率”,按小时为粒度往前取过去14天同时刻的数据,算出中位数和90分位,当前值如果跌破中位数的一定距离(比如偏离超过3倍标准差),才能判断为异常。这样系统自己能“记住”这个业务平时的表现,不需要人肉维护阈值。
import statistics def is_baseline_abnormal(current_value, history_values, dev_factor=2.5): median_val = statistics.median(history_values) deviation = statistics.pstdev(history_values) if deviation == 0 and current_value != median_val: return True, 0 anomaly_score = (current_value - median_val) / deviation if deviation > 0 else 0 return abs(anomaly_score) > dev_factor, anomaly_score趋势突变规则抓的是短时间内的斜率异常。例如某个接口的响应时间,中位数一直在200ms上下,突然十分钟之内涨到了800ms并持续上升,即便绝对值还没突破阈值,也已经是一个强烈的危险信号。这类规则我用了简单的一元线性回归算最近N个窗口数据的斜率,如果斜率为正且超过设定阈值,就触发预警。这么做能在问题刚冒头的时候提前抓住,而不是等异常已经持续了半小时才反应过来。
4.2 分级、抑制与聚合:不把预警群变成告警轰炸群
预警系统做出来之后,遇到最大的麻烦不是“漏报”,而是“误报”和“轰炸”。我记得刚上线那阵子,半夜两三点钉钉群能响四五次,全是无关痛痒的P2级别提示。大家被骚扰得没脾气,索性把群消息屏蔽了——预警系统一旦被屏蔽,就彻底失去了意义。
解决这个问题我做了三件事。第一是分级制度,P0表示核心链路故障需要立即处理,P1表示明显异常但业务影响还在可控范围,P2表示潜在苗头仅供参考。P0和P1必须直接打电话或者发短信,P2只在工作时段往群里丢,夜里自动静默。
第二是防抖机制。同一个规则在10分钟之内最多触发一次预警,触发后进入冷却期,即使指标仍处于异常状态也不再重复上报。冷却期结束后还没恢复,才发第二次预警,但级别会升级,说明这是一个持续性问题而不是偶发抖动。这个设计非常关键,没有它,任何一个持续异常都会在一小时内刷出几十条消息。
第三是规则聚合。多条规则同时命中时,把它们合并成一条综合预警,列清楚命中了哪些规则,而不是每条规则发一条。比如支付服务同时出现失败率升高、响应时间变长、上游连接数超限三个规则命中,合成一条“支付服务综合异常P1”显然比三条独立的P1让人看得更明白。
4.3 规则的回测与调参:没有历史数据支撑的规则都是“感觉”
在写规则的时候,我一开始犯了个错误:凭直觉定参数。结果上线之后这个规则要么从来不触发,要么一天触发二十次。后来我花时间做了一个简单的回测工具:把过去四周的原始事件数据存了一份,写了个脚本模拟规则跑历史数据,统计每条规则每天触发多少次,分别是在什么时间段触发的。回测跑完之后,我把那些触发次数明显不合理的规则的参数重新调整了一遍,才最终上线。如果你也要做类似的系统,强烈建议留出至少两周的“影子模式”运行周期,规则只记录不报警,人工核对预警的准确率再开启真正的通知。
5. 触达与可视化:让对的人在对的时间看到对的信号
5.1 预警通道配置:把消息送到真正会处理的人手里
预警通道这块,我设计了按级别走不同渠道的策略,并且把团队成员按职责分成了几个维度的订阅组。核心思想是:一条预警信息如果所有人都能收到,那就等于没有人会处理。
我的实现里,每个预警规则除了有级别,还带一个owner_tag字段,比如payment、order、infra。触达层拿到预警后,根据这个标签把消息路由到对应的钉钉群或邮件列表。这样支付相关的P1只发给支付组的同学,不会打扰到负责订单的同学。
具体的触达代码用钉钉机器人举一个例子。钉钉的Webhook机器人非常成熟,只需要一个URL就可以往群里发消息,配合secret加签做安全校验,足够满足大多数场景:
import hmac import hashlib import base64 import time import requests def send_dingtalk(webhook_url, secret, content, at_mobiles=None): timestamp = str(round(time.time() * 1000)) string_to_sign = f"{timestamp}\n{secret}" hmac_code = hmac.new( secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 ).digest() sign = base64.b64encode(hmac_code).decode("utf-8") payload = { "msgtype": "markdown", "markdown": {"title": "PLFM预警", "text": content}, "at": {"atMobiles": at_mobiles or [], "isAtAll": False} } resp = requests.post( f"{webhook_url}×tamp={timestamp}&sign={sign}", json=payload, timeout=5 ) return resp.ok建议每个预警消息里的内容不要只丢一个“指标异常”四个字,而是把关键上下文带上:命中的规则名、异常指标值、最近几个窗口的变化趋势、相关的主机或服务名、以及跳转到Web面板的链接。信息量足够,处理人才能快速判断这到底是要立刻响应的事情,还是可以先观察一阵子的事情。
5.2 Web面板设计:雷达图、热力时间线、预警记录
Web面板我用了ECharts,因为它的雷达图和热力图确实好看又好用。雷达图用来展示当前系统多个核心指标的偏离度,每一条轴代表一个指标,偏离正常范围越远,图形轮廓就越“外凸”,一眼就能看出系统当前在哪些维度上处于亚健康状态。
除了雷达图,我还做了一个“热力时间线”视图:横轴是时间,纵轴是服务或业务线,颜色深浅代表异常评分的高低。这个视图的价值在于回溯——比如某次线上事故是从下午2点开始被人察觉的,但热力时间线会告诉你,其实从1点40分某个服务就已经出现零星异常了,只是颜色还很浅,没有被注意到。有了时间线视图,事故的时间线还原变得非常直观,方便复盘的时候找出真正的问题起点。
表格类的展示也很重要。预警记录表会列出每一条历史预警的状态:触发时间、恢复时间、最长持续时间、处理人、处理结论。我还加了一个“反复预警”的统计列,如果一个规则在一周内触发了太多次,我会去看这个规则本身是不是有问题,或者对应业务是不是长期处于亚健康。
5.3 处理闭环:预警不应该发出来就结束了
很多监控类项目做到了“发出预警”就收工了,但我认为预警只是一个起点,真正完成闭环要确认异常被处理并且恢复。所以PLFM_RADAR里加了一个简单的确认机制:触达层发出预警后,会把这次预警标记为open状态,Web面板上可以看到所有未处理的预警列表。当规则引擎检测到对应指标已连续恢复正常窗口超过15分钟,系统自动把预警标记为resolved,同时发一条“XX预警已恢复”的消息。
如果一条P0/P1预警在15分钟内没有得到确认(点击确认按钮),触达层会自动升级,把预警信息再发一遍,并通过邮件通知团队负责人。这套机制保证了预警不会“发了就完事”,而是真正推动人去处理。
6. 部署上线后的真实效果与踩坑清单
6.1 这套系统改变了我们处理线上问题的节奏
系统用了大概两个月,明显的变化有三点。第一,我们平均发现异常的时间从“用户反馈后”提前到了“异常发生后的3到5分钟内”,有些苗头型的问题是提前15分钟以上就被雷达捕捉到了,这时候处理起来成本很低,甚至可以直接在用户感知前解决。第二,沟通成本下降了,因为每个预警自动带上上下文,群里不用再问“这个指标正常吗”“之前也是这样吗”,信息都在那一条消息里。第三,值班同学的心理压力小了很多,因为晚上睡觉不再怕被突发的“群聊艾特全体”炸醒,而是只有在真正P1级别的时候才会被电话叫起来。
6.2 上线过程中踩过的坑,做个清单给你排雷
这部分是我最想重点分享的,因为每一个坑都是真金白银换来的教训,我按复现难度排了个序:
| 坑 | 表现 | 根因 | 解决 |
|---|---|---|---|
| 告警轰炸 | 凌晨群消息连续刷屏 | 无防抖和冷却期 | 加防抖窗口和级别升级机制 |
| 时钟偏差 | 跨机器事件时间错乱 | 各服务器NTP同步不准 | 统一以日志本地时间为准,同时监控时钟偏差并预警 |
| 基线规则误报 | 上线新业务时天天报警 | 新数据量少、历史基线不成熟 | 数据量不足时降级用阈值规则,积累够14天再启用基线 |
| 正则性能瓶颈 | 日志量大时采集CPU飙高 | 正则表达式回溯匹配开销大 | 尽量用字符串切片替代正则,必要时用re2引擎 |
| 规则配置错误 | 写错label导致规则静默失效 | YAML配置校验缺失 | 增加配置自检:规则必须回测通过才能上线 |
时钟偏差这个坑尤其隐蔽。我们有两台机器的时间差了大概40秒,采集器把事件打上时间戳后,规则引擎按窗口聚合时出现了数据错位,导致某些窗口指标看起来异常偏高。我一度以为是服务真的出问题了,排查了很久才发现是时钟问题。后来我在采集器里加了一个判断,如果本机时间和Redis服务器时间偏差超过10秒,就把事件标上clock_drift标记,同时触发一条基础设施预警。时间基准是所有时序数据的地基,地基歪了,上面盖的楼全白搭。
6.3 关于部署形态的一些个人体会
如果让我重新做一遍,我会在架构上做两个调整。一是把采集器和规则引擎的通信从Redis换成更轻量的内置队列,因为Redis虽然好用,但独立部署意味着多一个需要维护的组件,对单机场景来说其实可以省掉。二是规则引擎我一定会从一开始就接一个像样的规则表达能力,而不是自己硬编码if else。当时图快直接写死了条件判断,后来加规则越加越痛苦,配置和代码纠缠不清,如果当时用JSON Schema来表达规则,后期的可维护性会好很多。
另外一个很重要的体会是:监控预警系统的价值不在于功能多花哨,而在于它能不能在你最需要的时候用最可靠的方式把事情讲清楚。一套“只发必要信息、自动分级、不轰炸、可回溯”的轻量系统,远胜过一个功能复杂但没人看的重型平台。PLFM_RADAR没有用什么高深的技术,全部代码量加起来也就三千多行,但它确确实实地让我从一个经常“被用户通知系统挂了”的被动角色,变成了那个能提前说“我觉得这里要出问题,大家注意一下”的角色。这种感觉,说实话挺值的。