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 整体架构:把三件事串起来
这三件事不是孤立的。错误率统计是数据源,日志频率监控是保护机制,自动告警是输出动作。它们应该在一个统一的框架里协作。
下面这张图展示了它们的关系:
从图上可以看到,日志输入后兵分三路:一路进频率监控做限流,一路进错误率统计做计算,一路进存储做持久化。告警引擎从三个方向收集数据,综合判断后输出告警。
这个架构我用了好几年,在嵌入式设备和后端服务上都跑过。核心就一句话:数据要实时,告警要克制,频率要限制。
避坑指南:我曾经在一个项目里把告警阈值设得太敏感,结果测试环境天天告警,开发人员都麻木了。后来线上真的出问题时,告警反而没人看。所以阈值一定要根据实际运行数据来调,别拍脑袋定。
好了,这一章的内容就这些。统计、监控、告警,三件事做好,你的日志系统才算真正有了「大脑」。下次系统出问题时,它会第一时间告诉你,而不是等你翻日志才发现。