为什么需要告警引擎
实时面板展示数据,但没有人 24 小时盯着面板。告警引擎自动检测异常并通知运维人员。
eventd v3 的告警规则:
| 规则 | 条件 | 级别 |
|---|---|---|
| 错误率过高 | 错误率 > 5% 持续 2 分钟 | P1(紧急) |
| 延迟过高 | P99 延迟 > 3s | P2(高) |
| QPS 突降 | QPS < 预期值的 50% | P2(高) |
| 登录失败 | 同一用户 5 分钟内失败 3 次 | P3(中) |
| 大额异常 | 单笔订单 > 10 万元 | P3(中) |
告警引擎架构
Flink DataStream 指标流
↓
AlertRuleFunction (KeyedProcessFunction)
├── 阈值判断
├── 滑动窗口计数
├── 告警抑制(去抖)
└── 告警级别计算
↓
告警事件写入 Redis
↓
eventd-panel SSE 推送
↓
运维人员收到通知
规则模型
public record AlertRule(
String ruleId, // 规则唯一标识
String metricKey, // 监控的指标:qps/error-rate/latency-p99
RuleCondition condition, // 触发条件
Duration duration, // 持续时间(滑动窗口)
AlertLevel level, // P1/P2/P3
String message // 告警消息模板
) {}
public record RuleCondition(
String operator, // ">", "<", "==", "between"
double threshold, // 阈值;between 时是下界
double upper // 仅 between 使用,上界(闭区间)
) {
public RuleCondition(String operator, double threshold) {
this(operator, threshold, threshold);
}
public RuleCondition {
if ("between".equals(operator) && upper < threshold) {
throw new IllegalArgumentException("between 的上界不能小于下界");
}
}
}
public enum AlertLevel { P1, P2, P3 }
// 告警流消费的是「指标名 + 当前值 + 时间」这个标量视图。
// 9.3 的 MetricRecord 是 (metric, dimension, dimensionValue, windowStart,
// windowEnd, payload),字段完全不同,不要混用同一个类名;
// 从窗口结果到 AlertMetric 的转换由上游 map 完成
public record AlertMetric(
String key, // 指标名 + 维度,如 "error-rate:global"
double value, // 该窗口算出的值
long timestamp // 对应窗口的结束时间
) {}
AlertRuleFunction
public class AlertRuleFunction
extends KeyedProcessFunction<String, AlertMetric, AlertEvent> {
private final AlertRule rule;
private transient ValueState<Boolean> alertActive;
private transient ValueState<Long> alertStartTime;
private transient ValueState<Integer> consecutiveViolations;
public AlertRuleFunction(AlertRule rule) {
this.rule = rule;
}
@Override
public void open(OpenContext parameters) throws Exception {
alertActive = getRuntimeContext().getState(
new ValueStateDescriptor<>("alertActive", Types.BOOLEAN));
alertStartTime = getRuntimeContext().getState(
new ValueStateDescriptor<>("alertStartTime", Types.LONG));
consecutiveViolations = getRuntimeContext().getState(
new ValueStateDescriptor<>("consecutiveViolations", Types.INT));
}
@Override
public void processElement(AlertMetric metric, Context ctx, Collector<AlertEvent> out)
throws Exception {
boolean violated = checkCondition(metric, rule.condition());
Integer violations = consecutiveViolations.value();
if (violations == null) violations = 0;
if (violated) {
// 第一次违反时记录起点,而不是到触发条件时才记
if (violations == 0) {
alertStartTime.update(metric.timestamp());
}
int next = violations + 1;
consecutiveViolations.update(next);
// 需要连续 N 次违反才触发告警。
// 这里假设上游每分钟产出一条 AlertMetric(窗口是 1 分钟),
// 所以 N = 持续时间 / 60;换成更快的采样率,这个换算就不成立,
// 必须改成按时间比较 alertStartTime,而不是数条数
int requiredCount = Math.max(1, (int) (rule.duration().getSeconds() / 60));
if (next >= requiredCount && !Boolean.TRUE.equals(alertActive.value())) {
alertActive.update(true);
out.collect(new AlertEvent(
rule.ruleId(),
rule.level(),
rule.message().replace("${value}", String.valueOf(metric.value())),
alertStartTime.value(),
metric.key()
));
}
} else {
// 恢复正常
consecutiveViolations.update(0);
if (alertActive.value() != null) {
alertActive.clear();
alertStartTime.clear();
// 发送恢复通知
out.collect(new AlertEvent(
rule.ruleId(),
// 恢复通知沿用原规则的级别,不能固定成 P3:
// P1 的恢复如果按 P3 路由,会被当成低优先级消息
rule.level(),
"✅ 恢复正常:" + rule.metricKey(),
metric.timestamp(),
metric.key()
));
}
}
}
private boolean checkCondition(AlertMetric metric, RuleCondition condition) {
double value = metric.value();
return switch (condition.operator()) {
case ">" -> value > condition.threshold();
case "<" -> value < condition.threshold();
case "==" -> Math.abs(value - condition.threshold()) < 0.0001;
case "between" -> value >= condition.threshold() && value <= condition.upper();
default -> false;
};
}
}
public record AlertEvent(
String ruleId,
AlertLevel level,
String message,
long timestamp,
String metricKey
) {}
CEP 告警模式
特定场景用 CEP 更直观:
// 规则:同一用户 5 分钟内登录失败 3 次 → P3 告警
// LoginEvent 沿用 6.1 的示例 record(访问器 userId()/status())
Pattern<LoginEvent, ?> loginFailPattern = Pattern.<LoginEvent>begin("fail1")
.where(e -> "FAIL".equals(e.status()))
.next("fail2")
.where(e -> "FAIL".equals(e.status()))
.next("fail3")
.where(e -> "FAIL".equals(e.status()))
.within(Duration.ofMinutes(5));
DataStream<AlertEvent> loginAlerts = CEP.pattern(
loginStream.keyBy(LoginEvent::userId),
loginFailPattern
)
.process(new PatternProcessFunction<LoginEvent, AlertEvent>() {
@Override
public void processMatch(Map<String, List<LoginEvent>> match, Context ctx,
Collector<AlertEvent> out) {
LoginEvent first = match.get("fail1").get(0);
out.collect(new AlertEvent(
"RULE_LOGIN_FAIL",
AlertLevel.P3,
"用户 " + first.userId() + " 5 分钟内登录失败 3 次",
ctx.timestamp(),
first.userId()
));
}
});
CEP 的 within() 走的是事件时间:上游必须先用 assignTimestampsAndWatermarks 分配时间戳,否则模式会按到达顺序的默认时间戳匹配,迟到事件也会被漏掉。另外这条告警的时间戳取 ctx.timestamp()(匹配时刻的事件时间),不要用 System.currentTimeMillis()——后者会让重放结果不可复现。
告警抑制(去抖)
eventd v3 的告警抑制策略:
| 级别 | 最小间隔 | 升级策略 |
|---|---|---|
| P1 | 5 分钟 | 连续 3 次未恢复 → 升级为电话通知(级别仍是 P1,只是换通道) |
| P2 | 15 分钟 | 持续 30 分钟 → 升级 P1 |
| P3 | 1 小时 | 不升级 |
public class AlertSuppressor
extends KeyedProcessFunction<String, AlertEvent, AlertEvent> {
private static final Duration P1_COOLDOWN = Duration.ofMinutes(5);
private static final Duration P2_COOLDOWN = Duration.ofMinutes(15);
private static final Duration P3_COOLDOWN = Duration.ofHours(1);
private transient ValueState<Long> lastAlertTime;
@Override
public void open(OpenContext parameters) throws Exception {
lastAlertTime = getRuntimeContext().getState(
new ValueStateDescriptor<>("lastAlert", Types.LONG));
}
@Override
public void processElement(AlertEvent alert, Context ctx, Collector<AlertEvent> out)
throws Exception {
long cooldown = getCooldown(alert.level());
long now = ctx.timerService().currentProcessingTime();
Long last = lastAlertTime.value();
if (last == null || now - last >= cooldown.toMillis()) {
lastAlertTime.update(now);
out.collect(alert);
}
// 否则丢弃(抑制)
}
private long getCooldown(AlertLevel level) {
return switch (level) {
case P1 -> P1_COOLDOWN.toMillis();
case P2 -> P2_COOLDOWN.toMillis();
case P3 -> P3_COOLDOWN.toMillis();
};
}
}
Redis 存储与推送
// open/close 来自 RichFunction,必须继承 RichSinkFunction;
// 只 implements SinkFunction 时 open 无处可覆盖
public class RedisAlertSink extends RichSinkFunction<AlertEvent> {
private transient RedisClient client;
private transient RedisCommands<String, String> sync;
@Override
public void open(OpenContext openContext) {
client = RedisClient.create("redis://localhost:6379");
sync = client.connect().sync();
}
@Override
public void close() {
if (client != null) {
client.shutdown();
}
}
@Override
public void invoke(AlertEvent alert, Context context) {
// 1. 写入活跃告警列表(TTL 1 小时)
String activeKey = "eventd:alerts:active";
String alertJson = JsonMapper.toJson(alert);
sync.lpush(activeKey, alertJson);
sync.expire(activeKey, 3600);
// 2. 写入级别索引
String levelKey = "eventd:alerts:" + alert.level().name().toLowerCase();
sync.lpush(levelKey, alertJson);
sync.expire(levelKey, 3600);
// 3. 写入时间序列
String tsKey = "eventd:alerts:ts";
sync.zadd(tsKey, alert.timestamp(), alertJson);
sync.expire(tsKey, 86400);
// 4. 发布到 channel(SSE 用)
sync.publish("eventd:alerts", alertJson);
}
}
这段写入在故障重放下会重复:LPUSH 每次都会新增一个元素,Checkpoint 回滚后同一条告警会进列表两次。要幂等,把告警 id 或 (ruleId, windowStart) 作为去重键先 SADD,返回 0 就跳过后续写入;或者直接用 ZADD key score member,member 用告警 id,天然覆盖。另外 localhost:6379 同样只适用于本机调试,容器内要用 compose 的服务名(见 12.2 部署与运维
)。
面板告警展示
@RestController
public class AlertController {
private final ReactiveRedisTemplate<String, String> redisTemplate;
@GetMapping(value = "/api/stream/alerts", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamAlerts() {
// listenTo 内部负责 SUBSCRIBE 与取消时退订,直接给出消息流
return redisTemplate
.listenTo(ChannelTopic.of("eventd:alerts"))
.map(msg -> ServerSentEvent.builder(msg.getMessage())
.event("alert")
.build());
}
@GetMapping("/api/alerts/active")
public Flux<AlertEvent> getActiveAlerts() {
return redisTemplate.opsForList()
.range("eventd:alerts:active", 0, 99)
.map(json -> JsonMapper.parse(json, AlertEvent.class));
}
}
如果直接拿 Lettuce 写:connectPubSub().reactive().subscribe(...) 返回的是 Mono<Void>,它只发起订阅、不携带消息;消息流要从 observeChannels(...) 拿,且仍要再调一次 subscribe(...) 才真正生效,退订与连接释放也得自己管。Spring Data Redis 的 listenTo(Topic...) 把这些步骤封在一起,面板这类场景优先用它。
小结
| 组件 | 职责 |
|---|---|
| AlertRuleFunction | 阈值判断、滑动窗口计数 |
| CEP Pattern | 复杂事件模式触发 |
| AlertSuppressor | 告警抑制、去抖 |
| RedisAlertSink | 存储、索引、Pub/Sub |
| AlertController | SSE 推送 |
下一节 10.3 实时面板数据流 :SSE 实时推送与背压适配。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。