设计一个监控告警系统:指标采集、阈值计算、告警聚合去重
设计一个监控告警系统:指标采集、阈值计算、告警聚合去重
适读人群:Java中高级工程师、需要做可观测性建设的技术人员 | 阅读时长:约18分钟 | 难度:★★★☆☆
开篇故事
凌晨两点,我接到了告警电话。打开手机,看到了200条未读消息,全是告警。心里咯噔一下,以为出了大事。结果打开监控系统一看,是因为一台机器的CPU使用率从78%短暂升到了82%,超过了80%的告警阈值,然后在这台机器的10个实例上各触发了一次,乘以自动重试次数,就变成了200条。
那天晚上啥事没有,我白白被吵醒,第二天上班精神萎靡。
这个问题叫告警风暴,是告警系统设计不合理最典型的症状:阈值设置过于灵敏,没有聚合同类告警,没有抑制短暂毛刺。好的监控告警系统应该是"有用告警多,无用告警少",让值班的人能快速判断问题的严重程度,而不是被警报淹没然后开始屏蔽所有告警。
一、需求分析与规模估算
监控系统的四个维度(LUSE)
- Latency(延迟): 接口P50/P99/P999响应时间
- Utilization(资源利用率): CPU、内存、磁盘、网络
- Saturation(饱和度): 连接池使用率、队列积压
- Errors(错误率): HTTP 5xx率、异常数量
规模估算
指标采集规模:
- 监控的服务数:50个
- 每个服务的实例数:平均10台
- 每台每秒上报指标数:100个(CPU、内存、接口延迟、错误率等)
- 总指标写入QPS:50 × 10 × 100 = 50000 QPS
存储估算:
- 每个指标点:时间戳(8B) + 值(8B) + 标签(约50B) = 约70字节
- 50000 QPS × 70字节 = 3.5MB/s
- 保留30天:3.5MB/s × 86400 × 30 ≈ 9TB
这个规模下,时序数据库(VictoriaMetrics / Prometheus + Thanos)是标配,MySQL存时序数据会被写入量压垮。
二、系统架构设计
三、关键代码实现
3.1 Spring Boot集成Micrometer(指标上报)
@Configuration
public class MetricsConfig {
/**
* 自定义业务指标:订单创建QPS
*/
@Bean
public Counter orderCreateCounter(MeterRegistry registry) {
return Counter.builder("order.create.total")
.description("订单创建总量")
.tag("env", "production")
.register(registry);
}
/**
* 自定义业务指标:支付耗时直方图
*/
@Bean
public Timer paymentTimer(MeterRegistry registry) {
return Timer.builder("payment.duration")
.description("支付接口耗时分布")
.publishPercentiles(0.5, 0.95, 0.99) // P50/P95/P99
.publishPercentileHistogram()
.register(registry);
}
}@Service
@Slf4j
public class OrderService {
@Autowired
private Counter orderCreateCounter;
@Autowired
private Timer paymentTimer;
/**
* 创建订单时记录指标
*/
public Order createOrder(CreateOrderRequest request) {
// 记录请求数
orderCreateCounter.increment();
// 记录耗时
return paymentTimer.recordCallable(() -> {
// 实际业务逻辑...
return doCreateOrder(request);
});
}
/**
* 用注解方式记录接口耗时(更简洁)
*/
@Timed(value = "order.query.duration",
description = "订单查询耗时",
percentiles = {0.5, 0.95, 0.99})
public Order getOrder(Long orderId) {
return orderMapper.findById(orderId);
}
}3.2 告警规则引擎(动态阈值)
静态阈值(CPU > 80%)有很多问题:业务低峰期和高峰期的正常CPU使用率差异很大,固定阈值要么太敏感(高峰期误报多)要么太宽松(低峰期漏报)。
动态阈值:基于历史同期数据(比如上周同一天同一时段)计算正常范围,超出才告警。
@Service
@Slf4j
public class AlertRuleEngine {
@Autowired
private VictoriaMetricsClient vmClient;
@Autowired
private AlertNotifyService notifyService;
@Autowired
private AlertDeduplicator deduplicator;
/**
* 定期执行告警规则检查(每分钟一次)
*/
@Scheduled(fixedDelay = 60000)
public void evaluate() {
List<AlertRule> rules = alertRuleMapper.findActiveRules();
rules.parallelStream().forEach(rule -> {
try {
evaluateRule(rule);
} catch (Exception e) {
log.error("告警规则执行失败, ruleId={}", rule.getId(), e);
}
});
}
private void evaluateRule(AlertRule rule) {
// 查询当前指标值
double currentValue = vmClient.queryInstant(rule.getPromQL());
boolean isAlerting;
if (rule.isDynamicThreshold()) {
// 动态阈值:查询过去7天同期数据,计算均值+3倍标准差
double[] historicalValues = vmClient.queryRange(
rule.getHistoricalPromQL(),
"7d", // 过去7天
"1h" // 按小时聚合
);
double mean = Arrays.stream(historicalValues).average().orElse(0);
double stdDev = calculateStdDev(historicalValues, mean);
double threshold = mean + 3 * stdDev;
isAlerting = currentValue > threshold;
log.debug("动态阈值检查, rule={}, current={}, threshold={}, alerting={}",
rule.getName(), currentValue, threshold, isAlerting);
} else {
// 静态阈值
isAlerting = evaluateStaticThreshold(currentValue, rule);
}
if (isAlerting) {
triggerAlert(rule, currentValue);
} else {
// 指标恢复正常:发送恢复通知(如果之前有告警)
resolveAlert(rule);
}
}
private boolean evaluateStaticThreshold(double value, AlertRule rule) {
switch (rule.getOperator()) {
case ">": return value > rule.getThreshold();
case ">=": return value >= rule.getThreshold();
case "<": return value < rule.getThreshold();
case "<=": return value <= rule.getThreshold();
case "==": return value == rule.getThreshold();
default: return false;
}
}
private void triggerAlert(AlertRule rule, double currentValue) {
AlertEvent alert = AlertEvent.builder()
.ruleId(rule.getId())
.ruleName(rule.getName())
.service(rule.getService())
.currentValue(currentValue)
.threshold(rule.getThreshold())
.severity(rule.getSeverity())
.triggerTime(LocalDateTime.now())
.build();
// 去重:同一规则5分钟内不重复告警
if (deduplicator.shouldAlert(rule.getId(), 5)) {
notifyService.sendAlert(alert);
}
}
private double calculateStdDev(double[] values, double mean) {
double variance = Arrays.stream(values)
.map(v -> (v - mean) * (v - mean))
.average()
.orElse(0);
return Math.sqrt(variance);
}
}3.3 告警去重与聚合
@Service
@Slf4j
public class AlertDeduplicator {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 判断是否应该发送告警(防止告警风暴)
* @param ruleId 告警规则ID
* @param suppressMinutes 抑制时间(分钟内不重复发送)
* @return true=应该发送, false=应该抑制
*/
public boolean shouldAlert(Long ruleId, int suppressMinutes) {
String key = "alert:suppress:" + ruleId;
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(key, "1", suppressMinutes, TimeUnit.MINUTES);
return Boolean.TRUE.equals(isNew);
}
}
/**
* 告警聚合器:把相同类型的多条告警聚合成一条
* 比如:同一服务的10个实例同时CPU高,聚合成"xxx服务(10/10实例)CPU使用率超阈值"
*/
@Component
public class AlertAggregator {
private final Map<String, List<AlertEvent>> pendingAlerts = new ConcurrentHashMap<>();
// 每30秒聚合一次
@Scheduled(fixedDelay = 30000)
public void aggregate() {
Map<String, List<AlertEvent>> snapshot = new HashMap<>(pendingAlerts);
pendingAlerts.clear();
snapshot.forEach((groupKey, alerts) -> {
if (alerts.size() == 1) {
// 单条告警直接发
sendSingleAlert(alerts.get(0));
} else {
// 多条聚合成一条
sendAggregatedAlert(groupKey, alerts);
}
});
}
public void addAlert(AlertEvent alert) {
// 聚合Key:同一服务 + 同一规则类型的告警聚合在一起
String groupKey = alert.getService() + ":" + alert.getRuleName();
pendingAlerts.computeIfAbsent(groupKey, k -> new CopyOnWriteArrayList<>())
.add(alert);
}
private void sendAggregatedAlert(String groupKey, List<AlertEvent> alerts) {
int count = alerts.size();
AlertEvent sample = alerts.get(0);
String message = String.format(
"[聚合告警] %s - %s 触发%d次\n" +
"最高值: %.2f, 阈值: %.2f\n" +
"首次触发: %s",
sample.getService(),
sample.getRuleName(),
count,
alerts.stream().mapToDouble(AlertEvent::getCurrentValue).max().orElse(0),
sample.getThreshold(),
sample.getTriggerTime()
);
notifyService.send(sample.getSeverity(), message);
}
}3.4 告警通知分级(不同严重程度走不同渠道)
@Service
public class AlertNotifyService {
@Autowired
private DingTalkClient dingTalkClient;
@Autowired
private PagerDutyClient pagerDutyClient;
@Autowired
private OnCallService onCallService;
/**
* 根据告警严重程度,选择不同的通知渠道
*/
public void sendAlert(AlertEvent alert) {
switch (alert.getSeverity()) {
case P1: // 最严重:立即通知值班人员(电话)
pagerDutyClient.triggerIncident(alert);
onCallService.callDutyEngineer(alert);
dingTalkClient.sendUrgentMessage(alert);
break;
case P2: // 严重:钉钉@相关人
dingTalkClient.sendWithMention(alert, getMentionUsers(alert));
break;
case P3: // 一般:钉钉群通知
dingTalkClient.sendGroupMessage(alert);
break;
case P4: // 低:只记录,不推送
log.info("低优先级告警: {}", alert);
break;
}
// 所有告警都写入DB(用于历史追踪和报表)
alertHistoryMapper.insert(buildAlertHistory(alert));
}
}四、扩展性设计
自适应阈值(机器学习)
对于周期性很强的指标(比如电商平台的QPS每天有固定的早晚高峰),简单的静态阈值误报率很高。用机器学习(Prophet、ARIMA)做时序预测,设置"预测值 ± N倍标准差"为正常范围,超出才告警,误报率可以降低80%。
五、踩坑实录
坑1:告警风暴(最常见)
50台机器同时触发同一个告警规则,发了50条短信,值班人员直接崩溃。解决方案:告警聚合(相同规则的多条告警合并)+ 抑制(5分钟内同一规则只发一次)。
坑2:告警恢复后没有通知
某次数据库连接池耗尽,告警发出去了,但问题自行恢复后(连接池空闲了)没有发送恢复通知,导致值班人员一直以为问题还在,在夜里折腾了两个小时。解决方案:每次规则检查时,如果之前有未恢复的告警,且本次检查正常,发送"告警已恢复"通知。
坑3:Prometheus内存暴涨
Prometheus默认把所有时序数据放在内存里,当监控指标量很大(几十万个时序)时,内存占用超过32GB。解决方案:迁移到VictoriaMetrics,相同数据量内存占用只有Prometheus的1/7,且读写性能更好。
六、总结
好的监控告警系统的评价标准:
- 准确率高: 告警大多数都是真实的问题,不是误报
- 响应及时: 问题发生后5分钟内告警
- 信息完整: 告警消息包含足够的上下文(是什么服务、什么指标、当前值是多少、阈值是多少)
- 不打扰: 不会在凌晨把人叫醒去看"CPU到了82%"这种不重要的问题
| 功能 | 技术选型 |
|---|---|
| 指标采集 | Prometheus + Micrometer |
| 时序存储 | VictoriaMetrics |
| 可视化 | Grafana |
| 告警路由 | Alertmanager |
| 值班通知 | PagerDuty / 自研 |
