fix(alerts,analysis): 告警引擎稳定性 5 项修复;每日量接口去掉同步 Loki 查询(20s→10ms)

=== 一、告警引擎稳定性审查(引擎本身是异步的:ticker + goroutine)===
1) 【高】ticker 无重叠保护
   ticker 每 1 分钟触发;一轮规则检查 8 条 × 20~30s = 2~4 分钟,远超一分钟。
   原来每过一分钟就再开一轮 → 查询越堆越多,抢走页面查询的 Loki 资源,
   每轮越来越慢,最终表现为"告警不响了"。
   → 新增 alertCheckRunning(atomic.Bool),上一轮未完成直接跳过本轮并记日志。

2) 【高】单条规则无超时
   RunAlertCheck(context.Background()) 没有 deadline:Loki 一旦挂住,整轮永久运行,
   叠加问题 1 会让引擎再也不启动新一轮(静默死亡)。
   → 每轮总超时 5 分钟(alertRoundTimeout)+ 单条规则 90s(alertRuleTimeout)。

3) 【中】panic 只在整个 tick 层 recover
   一条规则 panic 会让本轮后续规则全部跳过。
   → per-rule recover,panic 记为一条 failed 投递记录,后续规则继续。

4) 【中】冷却记录每分钟写一条,把真实发送记录挤光
   AlertDeliveries 只保留最近 50 条,而处于冷却期时每个 tick 都写一条 cooldown,
   一小时内真实的"已发送"记录就全被挤掉了,发送历史没法看。
   → recordCooldownThrottled():同一规则 10 分钟最多写一条。附单测。

5) 规则串行本身保留(避免同时压垮 Loki),但配合 1) 才安全。

=== 二、每日日志量接口:20 秒 → 10 毫秒 ===
根因:GetDailyVolumeHistory 里**同步** await 一次"整天 index/stats"补抓当天数据,
那次查询超时 20s → 整个接口 20s → 前端超时 → 页面显示"每日日志量没数据";
更糟的是这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502
(访问日志实证:daily-volume latency_ms 20000.997,stats/histogram 502)。
→ 接口改为**只读库**,不触发任何 Loki 查询;当天数据由后台每 30 分钟的
  定时任务补(CaptureRecentDailyVolumes)。
→ 同时把整天 index/stats 的超时从 20s 提到 90s(370GB/天,20s 不够)。
实测:10ms。

=== 三、通知通道改名「默认告警」(schemaVersion v8→v9) ===
"lark 默认告警" / "默认通知通道(待配置 Webhook)" → 统一为「默认告警」。
只改名,不动 Webhook、不动 enabled:
首次部署创建的占位通道必须保持「空 Webhook + 禁用」,由管理员自己填,
否则会出现"已启用但空地址"的通道,告警一命中就全部投递失败。

=== 四、其它文案 ===
「每日日志量」副标题精简为「每天的日志总量,平台保留历史趋势」,
并移除「平台留存历史」徽标(用户反馈多余)。

=== 实测(303134c0c718)===
  /logs/daily-volume:10ms(原 20000ms)
  通道列表:id=1 name="默认告警" enabled=true hasWebhook=true
  go test ./... 全绿(含新增 TestRecordCooldownThrottled)
This commit is contained in:
opsx committed 2026-10-29 03:38:30 +00:00
1 parent 607b67a2ed
commit cb1ef1dff4
9 files changed
+149 -36

No files matched your search

+69 -5
View File
@@ -7,8 +7,11 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"lokistack/models"
@@ -47,6 +50,17 @@ func overStormLimit() (bool, int) {
const alertCheckInterval = time.Minute
const (
// alertRoundTimeout 一轮检查的总时限。超过就中断本轮,
// 否则重叠保护会让引擎一直"在运行",后续轮次全部被跳过。
alertRoundTimeout = 5 * time.Minute
// alertRuleTimeout 单条规则的查询时限:Loki 单次查询不该超过它。
alertRuleTimeout = 90 * time.Second
)
// alertCheckRunning 防止 ticker 重叠执行。
var alertCheckRunning atomic.Bool
var alertTicker *time.Ticker
// StartAlertEngine 启动周期检查(main 调用)
@@ -57,17 +71,32 @@ func StartAlertEngine() {
alertTicker = time.NewTicker(alertCheckInterval)
go func() {
for range alertTicker.C {
// ⚠️ 重叠保护(重要):
// ticker 是每分钟触发,而一轮规则检查(每条规则都要扫一遍日志内容,
// 实测 20~30s/条 × 8 条 = 2~4 分钟)远超一分钟。
// 原来没有这个保护,于是每过一分钟就再开一轮 → 查询越堆越多,
// 既抢走页面查询的 Loki 资源,又让每轮越来越慢,最后看起来像"告警不响了"。
// 现在:上一轮没跑完就直接跳过本轮,宁可少评估一次也不堆积。
if !alertCheckRunning.CompareAndSwap(false, true) {
log.Printf("[alert] 上一轮检查仍在运行,跳过本轮")
continue
}
start := time.Now()
// 告警引擎是后台 goroutine:卡死或 panic 时页面完全看不出来,
// 只能靠这两个指标发现(配合 Grafana 告警)
ok := true
func() {
defer func() {
// 整轮兜底:即使有未预期的 panic 也不能让引擎 goroutine 挂掉
if r := recover(); r != nil {
ok = false
log.Printf("[alert] 本轮检查 panic: %v", r)
}
alertCheckRunning.Store(false)
}()
RunAlertCheck(context.Background())
// 每轮总超时:防止个别慢查询把整轮拖成"永久运行",
// 那种情况下重叠保护会让引擎再也不启动新一轮。
ctx, cancel := context.WithTimeout(context.Background(), alertRoundTimeout)
defer cancel()
RunAlertCheck(ctx)
}()
metrics.ObserveAlertEvaluation(time.Since(start).Seconds(), ok)
}
@@ -180,6 +209,24 @@ func lokiSampleLines(ctx context.Context, src *models.LogSource, query string, w
return out
}
// cooldownRecordLast 记录每条规则上次写"冷却"记录的时间,用于节流。
var (
cooldownRecordMu sync.Mutex
cooldownRecordLast = map[uint]time.Time{}
)
// recordCooldownThrottled 同一规则 10 分钟内只允许写一条冷却记录。
func recordCooldownThrottled(ruleID uint) bool {
cooldownRecordMu.Lock()
defer cooldownRecordMu.Unlock()
now := time.Now()
if last, ok := cooldownRecordLast[ruleID]; ok && now.Sub(last) < 10*time.Minute {
return false
}
cooldownRecordLast[ruleID] = now
return true
}
func recordDelivery(ruleID uint, ruleName string, count int, status, reason string) {
db := repository.GetDB()
newID := db.NextID("alertDeliveries")
@@ -209,7 +256,19 @@ func RunAlertCheck(ctx context.Context) {
db.RUnlock()
for i := range rules {
checkRule(ctx, &rules[i])
// 单条规则独立超时 + 独立 panic 隔离:
// 一条规则查询卡住或代码 panic,不能连带把后面所有规则都跳过。
ruleCtx, cancel := context.WithTimeout(ctx, alertRuleTimeout)
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[alert] 规则 %q 检查 panic: %v", rules[i].Name, r)
recordDelivery(rules[i].ID, rules[i].Name, 0, "failed", fmt.Sprintf("规则检查异常: %v", r))
}
}()
checkRule(ruleCtx, &rules[i])
}()
cancel()
}
}
@@ -237,7 +296,12 @@ func checkRule(ctx context.Context, rule *models.AlertRule) {
}
db.RUnlock()
if !lastFired.IsZero() && time.Since(lastFired) < time.Duration(cooldown)*time.Minute {
recordDelivery(rule.ID, rule.Name, count, "cooldown", "冷却中,未重复发送")
// 冷却记录节流:命中但处于冷却时,**每 10 分钟最多记一条**。
// 原来每个 tick(1 分钟)都写,50 条上限下,真实的"已发送"记录
// 一小时内就被冷却记录挤光了,发送历史基本没法看。
if recordCooldownThrottled(rule.ID) {
recordDelivery(rule.ID, rule.Name, count, "cooldown", "冷却中,未重复发送")
}
return
}