16、日志统计与告警:错误率统计,日志频率监控,自动告警机制

日志系统写到这一步,其实已经能干活了。但真正让日志发挥价值的,不是它记了多少东西,而是它能不能在关键时刻「喊一嗓子」。我见过太多系统,日志文件堆了几个G,出问题时翻半天找不到关键线索。说白了,日志如果没有统计和告警,就是一潭死水。

这一章,我们来聊聊怎么让日志系统活起来。核心就三件事:错误率怎么算日志频率怎么监控什么时候自动报警

16.1 错误率统计:别等用户告诉你系统崩了

错误率统计,说白了就是算一下「一段时间内,错误日志占总日志的比例」。我个人习惯用滑动窗口来做,而不是每天清零的计数器。为什么?因为滑动窗口能反映实时状态,而日结统计容易掩盖突发问题。

举个例子:某天前23小时系统都很稳,最后1小时疯狂报错。日结统计一看,错误率才4%,觉得还行。但滑动窗口一看,最近5分钟的错误率已经飙到80%了。你想想看,哪个更有用?

核心思路:维护一个固定大小的环形缓冲区,每个槽位记录一个时间窗口内的错误数和总日志数。每次新日志进来,更新当前槽位,同时计算所有槽位的加权错误率。

#define WINDOW_COUNT 12      // 12个时间窗口
#define WINDOW_SECONDS 5     // 每个窗口5秒

typedef struct {
    uint32_t total_count;    // 窗口内总日志数
    uint32_t error_count;    // 窗口内错误日志数
    time_t   window_start;   // 窗口起始时间
} LogWindow;

static LogWindow s_windows[WINDOW_COUNT];
static int s_current_index = 0;

void log_stats_record(int is_error) {
    time_t now = time(NULL);
    LogWindow *cur = &s_windows[s_current_index];
    
    // 如果当前窗口过期,切换到下一个
    if (now - cur->window_start >= WINDOW_SECONDS) {
        s_current_index = (s_current_index + 1) % WINDOW_COUNT;
        cur = &s_windows[s_current_index];
        cur->total_count = 0;
        cur->error_count = 0;
        cur->window_start = now;
    }
    
    cur->total_count++;
    if (is_error) cur->error_count++;
}

float log_stats_get_error_rate(void) {
    uint32_t total = 0, errors = 0;
    for (int i = 0; i < WINDOW_COUNT; i++) {
        total += s_windows[i].total_count;
        errors += s_windows[i].error_count;
    }
    if (total == 0) return 0.0f;
    return (float)errors / (float)total * 100.0f;
}

这段代码我用了很多年,结构简单但很实用。注意一点:窗口数量和时间粒度要根据你的业务场景调。我曾经在一个高频交易项目里把窗口设成1秒一个,结果CPU开销太大,后来改成3秒一个就平衡了。

16.2 日志频率监控:别让刷屏拖垮系统

日志频率监控,说白了就是防止某个模块「疯狂刷日志」。我记得有一次线上事故,一个网络重连模块在循环里打印错误日志,每秒输出上千条。结果日志文件把磁盘撑爆了,系统直接挂掉。

嗯,从那以后,我写的每个日志系统都会加一个频率限制器。核心思路是:对每个日志来源(比如模块名+文件名+行号)记录最近一次打印的时间戳,如果间隔太短,就丢弃或降级

#define RATE_LIMIT_MS 100   // 同一来源至少间隔100ms

typedef struct {
    const char *source;      // 来源标识
    uint64_t    last_time_ms;
} RateLimitEntry;

static RateLimitEntry s_rate_limits[32];
static int s_rate_count = 0;

int log_rate_check(const char *source) {
    uint64_t now = get_time_ms();
    for (int i = 0; i < s_rate_count; i++) {
        if (strcmp(s_rate_limits[i].source, source) == 0) {
            if (now - s_rate_limits[i].last_time_ms < RATE_LIMIT_MS) {
                return 0;  // 频率过高,丢弃
            }
            s_rate_limits[i].last_time_ms = now;
            return 1;
        }
    }
    // 新来源,加入列表
    if (s_rate_count < 32) {
        s_rate_limits[s_rate_count].source = source;
        s_rate_limits[s_rate_count].last_time_ms = now;
        s_rate_count++;
    }
    return 1;
}

小技巧:频率限制不要只做丢弃,还可以做「累计计数」。比如每100ms内只打印第一条,但把丢弃的条数记下来,等间隔到了再输出一条「xxx模块在过去100ms内产生了N条日志,已抑制」。这样既保护了系统,又不丢失信息。

16.3 自动告警机制:让系统自己打电话

统计和监控都有了,最后一步就是告警。我个人习惯把告警分成三个等级:

等级 触发条件 处理方式
黄色告警 错误率 > 5% 持续30秒 写告警日志,记录现场
橙色告警 错误率 > 15% 持续10秒 触发回调函数,通知上层
红色告警 错误率 > 30% 或 连续100条错误 紧急处理,比如重启模块

告警触发后,不能只写一条日志就完事。我建议做一个告警去重机制:同一个原因在短时间内只触发一次告警,避免告警风暴。

typedef void (*AlarmCallback)(int level, const char *msg);

typedef struct {
    int          level;
    const char  *reason;
    uint64_t     last_trigger_time;
    uint32_t     cooldown_seconds;  // 冷却时间
} AlarmEntry;

static AlarmEntry s_alarms[] = {
    {1, "error_rate_high", 0, 60},    // 黄色,冷却60秒
    {2, "error_rate_critical", 0, 120},
    {3, "error_burst", 0, 300},
};

void alarm_check_and_trigger(int level, const char *reason) {
    uint64_t now = get_time_sec();
    for (int i = 0; i < 3; i++) {
        if (s_alarms[i].level == level && 
            strcmp(s_alarms[i].reason, reason) == 0) {
            if (now - s_alarms[i].last_trigger_time > s_alarms[i].cooldown_seconds) {
                s_alarms[i].last_trigger_time = now;
                // 触发回调
                if (g_alarm_callback) {
                    g_alarm_callback(level, reason);
                }
                log_write(LOG_ALARM, "告警触发: 等级%d, 原因%s", level, reason);
            }
            break;
        }
    }
}

注意:告警回调函数里不要做耗时操作。我曾经见过有人在告警回调里写数据库,结果告警触发时数据库连接超时,回调卡死,整个日志线程都挂了。正确的做法是:回调里只做信号通知,具体处理交给其他线程。

16.4 整体架构:把三件事串起来

这三件事不是孤立的。错误率统计是数据源,日志频率监控是保护机制,自动告警是输出动作。它们应该在一个统一的框架里协作。

下面这张图展示了它们的关系:

日志统计与告警系统架构 日志输入 频率监控 错误率统计 日志存储 告警引擎 告警输出 丢弃高频日志 计算滑动窗口错误率 持久化 三路数据汇聚 回调/重启/通知

从图上可以看到,日志输入后兵分三路:一路进频率监控做限流,一路进错误率统计做计算,一路进存储做持久化。告警引擎从三个方向收集数据,综合判断后输出告警。

这个架构我用了好几年,在嵌入式设备和后端服务上都跑过。核心就一句话:数据要实时,告警要克制,频率要限制

避坑指南:我曾经在一个项目里把告警阈值设得太敏感,结果测试环境天天告警,开发人员都麻木了。后来线上真的出问题时,告警反而没人看。所以阈值一定要根据实际运行数据来调,别拍脑袋定。

好了,这一章的内容就这些。统计、监控、告警,三件事做好,你的日志系统才算真正有了「大脑」。下次系统出问题时,它会第一时间告诉你,而不是等你翻日志才发现。


公众号:蓝海资料掘金营,微信deep3321