DevOps2026年10月8日· 约 15 分钟

基于日志的监控告警:我们是怎么用一套轻量方案把线上问题"盯"住的

#日志监控#告警系统#阈值判断#性能优化
Twitter 微博

基于日志的监控告警:我们是怎么用一套轻量方案把线上问题"盯"住的

bad923c7962cd966ea83daa27bb5cbb0_8a48Ba4yBQf8kQtKkJ2l8Cn5vXZBLcbALav9RoFNxoFNRgFAhaqgfvMoG3OYEmnSBr80zApz4YhbWDDhwHkwD755u79Ff6mwAAAABJRU5ErkJggg==
业务接口反馈慢,但从监控面板,CPU、内存、QPS 全都绿绿的,看不出哪里不对。最后查日志,发现某个下游服务调用耗时飙到了 5 秒,但因为它没报错,只是慢,所以传统的指标监控根本没捕捉到。

指标监控擅长告诉你"系统是不是挂了",但对于"系统是不是慢了"、"流程是不是卡住了"这类灰度异常,往往力不从心。

我们团队之前也一直被这个问题困扰,后来干脆自己搞了一套基于日志内容的监控告警服务。思路很直接:既然日志里什么信息都有,那就直接从日志里把关键数值"抠"出来,跟预设阈值做对比,超了就报警。当然市面上有很多日志监控,但很大的问题是只能基于关键字匹配存在来告警,而无法按模板提取其中的数值来告警 这就是痛点。

当然有人可能会问为什么不基于prometheus来做? 一是基于prometheus的话就需要服务全部集成prometheus的sdk打点,再将指标全部搜集,从服务数量和时间上来看周期比较长,成本比较高。二是我们有很多服务都是陈年老服务 百把年都不会更新的 而且之前已经打了日志,那基于日志分析就是最省事最快的方式,而且监控只是个旁路监控不影响业务,对新老服务都兼容。所以基于此方案跑了一段时间下来,效果还不错,这里聊一聊整个方案的思路和部分实现细节。

整体思路:定时拉日志 → 模板匹配 → 阈值判断 → 告警通知#

先说大流程。整个服务的工作方式可以用一句话概括:每隔一段时间,从 ES 里捞出最近 N 分钟的日志,用 Grok 模板从日志文本中提取关键变量,再用表达式引擎判断是否超过阈值,超了就通过钉钉机器人推一条消息。

画成流程图大概是这样的:

fc2e072fe234ce6ca362fa70c0194c92_ba2797dd-3bf4-4826-b877-b31d493ac727

定时调度 
  → 从 Redis 读取告警规则配置
  → 遍历每条规则,异步查询 ES 日志
  → 拿到日志后,多线程进行 Grok 模板匹配 + 阈值表达式计算
  → 需要告警?检查 Redis 去重 → 发送钉钉消息 → 记录已告警

没有引入什么重型组件,核心依赖就是 ES + Redis + 钉钉,部署起来也很轻量。

规则配置:一条告警规则长什么样#

我们把告警规则做成了 JSON 数组的形式,存在 Redis 里,key 是 logmonitor:conf。运维同事可以直接在后台管理界面上增删改规则,不需要重启服务。一条典型的规则大概长这样:

{
    "servername": "base—outstore",
    "is_alarm": 1,
    "alarm_threshold": "costtime>2000",
    "where": "run-costTime",
    "template": "run-costTime:%{DATA:costtime} consume",
    "alarmDingDingCode": "dingtalk-log",
    "limit_time": 5,
    "title": "运行耗时超过阈值",
    "link": "http://10.3.87.1:3001/goto/4uNzKjkNz?orgId=1",
    "atMobiles": "15512345678"
}

几个关键字段解释一下:

  • servername:要监控哪个服务。我们会用这个字段去 ES 里按服务名过滤日志。

  • where:日志里必须包含的关键字。相当于一层粗过滤,先把无关日志筛掉。

  • template:这是核心——一个 Grok 表达式。%{DATA:costtime} 的意思是从日志文本中匹配出一段内容,赋值给变量 costtime。这个变量后面会参与阈值计算。

  • alarm_threshold:阈值表达式,直接写 Java 的条件表达式就行,比如 costtime>2000,意思是匹配出来的耗时超过 2000 毫秒就触发告警。

  • limit_time:往前查多少分钟的日志。设 5 就查最近 5 分钟的。

  • is_alarm:开关。设成 0 这条规则就静默了,方便灰度上线或者临时关闭。

  • atMobiles:钉钉 @ 谁的手机号,多个用逗号隔开。

说实话,这套配置设计谈不上多优雅,但胜在直观。新同事看一眼就知道这条规则在干什么:监控 base-outstore 服务,找到包含 run-costTime 的日志,把耗时提取出来,超过 2 秒就告警。

日志从哪来:ES 查询的 DSL 长什么样#

我们的日志采集链路是标准的那套:应用打日志 → Filebeat/Logstash 采集 → 写入 ES。索引按 join-app-* 的方式命名,告警服务直接从 ES 里查。

具体查询的 DSL 放在 esmapper.xml 里,用类似 MyBatis 的方式管理。拿一条规则的查询来说:

{
    "query": {
        "bool": {
            "filter": [
                {
                    "range": {
                        "timestamp.keyword": {
                            "gte": "startTime",
                            "lte": "endTime"
                        }
                    }
                }
            ],
            "must": [
                {
                    "term": {
                        "servername.keyword": {
                            "value": "base-outstore"
                        }
                    }
                },
                {
                    "match_phrase": {
                        "msg": "run-costTime"
                    }
                }
            ]
        }
    },
    "sort": [{ "timestamp.keyword": { "order": "asc" } }],
    "size": 10000
}

逻辑很简单:时间范围 + 服务名 + 关键字匹配。match_phrase 保证日志消息中必须包含 where 字段指定的短语。这样一轮查下来,基本就能把目标日志精准地筛出来。

代码层面,查询条件被封装成了 EsLogQueryCondition 对象,由 SearchLogForMonitorAlarmService 根据规则自动组装时间范围和服务名,然后通过 bboss(一个 ES 客户端框架)发起查询。

核心处理:Grok 模板匹配 + QLExpress 表达式引擎#

这是整个方案里我觉得最有意思的部分。

拿到日志之后,怎么从一行文本里把"耗时"这个数值提取出来?我们用的是 Grok。这个东西搞过 ELK 的人应该不陌生,Logstash 里用它来解析日志格式,本质上就是一组命名正则表达式。比如模板 run-costTime:%{DATA:costtime} consume,面对这样一行日志:

run-costTime:3500 consume orderFlow

Grok 会把它解析成一个 Map:{costtime: "3500"}。

代码实现上,用的是 io.krakens 这个 Java 版 Grok 库。先编译模板,再用 match 方法去匹配日志内容,最后通过 capture 拿到捕获的变量:

GrokCompiler grokCompiler = GrokCompiler.newInstance();
grokCompiler.registerDefaultPatterns();
Grok grok = grokCompiler.compile(template);
Match grokMatch = grok.match(message);
Map<String, Object> result = grokMatch.capture();

拿到变量之后,下一步就是判断是否超阈值。这里我们引入了阿里的 QLExpress 表达式引擎。它的好处是可以直接执行 Java 语法的表达式,而且支持动态注入变量。我们把 Grok 匹配出来的变量塞进上下文,然后直接执行 alarm_threshold 里配的表达式:

ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<>();
// 把 Grok 匹配出的变量放进去,能转数字就转数字
for (Map.Entry<String, Object> entry : grokResultMap.entrySet()) {
    try {
        context.put(entry.getKey(), Double.parseDouble((String) entry.getValue()));
    } catch (Exception e) {
        context.put(entry.getKey(), entry.getValue());
    }
}
// 执行表达式,返回布尔值
Boolean isNeedAlarm = (Boolean) runner.execute("costtime>2000", context, null, true, false);

这么做的灵活性在于,alarm_threshold 可以写得很复杂。比如 costtime>2000 && retryCount>=3,甚至调用外部 Java 方法,QLExpress 都支持。不需要改代码,只需要在配置里调整表达式就行。

去重机制:同一条日志不要反复告警#

这个问题其实很关键。你想想,定时任务每隔几分钟跑一次,如果一条日志已经触发过告警了,下一轮跑的时候它还在时间窗口内,难道再报一次?运维同事估计会疯掉。

我们的做法是在 Redis 里用日志 ID 做一个去重标记。key 的格式是 logmonitor:id:{日志ID},value 随便填个 1 就行,关键是设置过期时间——跟规则的 limit_time 保持一致。

// 钉钉发送成功后,记录已告警
stringRedisTemplate.opsForValue().set(
    "logmonitor:id:" + esLogInfo.getId(), 
    esLogInfo.getId(), 
    logMonitorRule.getLimit_time() * 60, 
    TimeUnit.SECONDS
);

而且在进入 Grok 匹配之前,会先做一轮预过滤——如果 Redis 里已经有这个日志 ID 的标记,直接跳过,连线程资源都不占:

if (stringRedisTemplate.opsForValue().get("logmonitor:id:" + esLogInfo.getId()) != null) {
    continue;
}

这个设计虽然简单,但确实管用。既避免了重复告警的骚扰,又减少了不必要的计算开销。

当然我们的匹配规则有好几种,和阈值比较的操作类型,目前支持数值型: >,<,>=,<=,=,rang;字符型:eq、neq、包含,比如 "alarm_threshold": "data.contains('异常数超阈值')" ,其中还可以有条件组合。

多线程架构:两层线程池分工#

考虑到告警规则可能有很多条,每条规则查出来的日志也可能有成百上千条,如果串行处理,一轮跑下来可能十几分钟都跑不完。所以我们用了两层线程池:

  • asyncLogSearchTaskPool:负责 ES 查询。每条规则一个异步任务,并行去 ES 拉数据。

  • asyncLogCheckTaskPool:负责日志匹配和告警判断。每条日志一个异步任务,并行做 Grok 匹配 + 表达式计算。

线程池参数可以通过配置文件调整,默认核心线程数 50,最大线程数 100,队列容量 200,拒绝策略用的 CallerRunsPolicy——队列满了就让调用线程自己干,至少不会丢任务。

这种两层异步的设计,让整轮告警检查通常能在 1-2 分钟内跑完,即使有十几条规则、上万条日志也不至于卡住。

告警通知:钉钉机器人推送#

告警的最后一环是把消息送出去。我们对接的是钉钉机器人,通过内部的消息服务 FeignJoinMeshMsgServer 发送。告警内容会拼成一段文本,包含告警标题、阈值表达式的实际计算值、服务名、时间戳、日志摘要以及一个跳转链接(一般指向 Grafana 面板或者 Kibana 的日志详情页):

xx服务运行耗时超过阈值,告警规则匹配值:3500>2000;服务名:base-outstore;时间:2025-09-24T10:30:15;日志内容:run-costTime:3500 consume orderFlow...;详情链接:http://10.3.87.1:3001/goto/4uNzKjkNz

如果日志内容超过 200 个字符,会自动截断,避免钉钉消息太长影响阅读。atMobiles 字段指定要 @ 的人,运维值班同学的手机号直接配在规则里。

几点实践中的体会#

1. 规则不要一上来就配一堆。 刚开始做的时候,我们恨不得把每种异常日志都配一条规则,结果每天收几十条告警,到后来大家直接屏蔽了告警群。后来我们做了精简,只保留真正需要关注的场景,告警数量降下来了,响应速度反而上去了。

2. limit_time 和定时调度间隔要配合好。 如果定时任务 10 分钟跑一次,但 limit_time 只设了 5 分钟,中间就有 5 分钟的盲区。我们一般把 limit_time 设成大于等于调度间隔,宁可有一点重叠也别漏。

3. Grok 模板的写法需要调试。 Grok 表达式写起来不难,但有时候日志格式变了会导致匹配失败。建议加规则之前先用几条真实日志跑一下匹配,确认能正确捕获变量再上线。

4. 阈值表达式别搞太复杂。 QLExpress 虽然支持很复杂的表达式,但规则太复杂了不容易维护。简单的 > < >= 就够覆盖大部分场景了,实在需要组合条件的,用 && 拼两个就行。

5. Redis 配置的缓存超时别忘了设。 logmonitor:conf 这个 key 我们一般设 1 小时过期。如果配置平台出了问题没法及时更新 Redis,告警服务最多空跑一小时就会自动停下来,不至于拿着过期的规则一直跑。

这套日志告警服务没有什么高深的技术,Grok、表达式引擎、Redis 去重、钉钉通知,每个单拎出来都不是什么新东西。但把它们串在一起,配合合理的线程池设计和去重策略,就能组成一个够用、好维护、能快速响应线上问题的工具。

对于中小团队来说,不一定所有监控都要上 Prometheus + Grafana 那套重型方案。有时候,直接从日志里抓关键信息,配合几条简单的规则,就能解决 80% 的问题。关键是——让日志不只是躺在 ES 里等你来搜,而是主动把异常推到你面前。


原文链接:https://www.cnblogs.com/zhangs1986/p/23110473

评论

© 2026 松岛川树