From cb1ef1dff4b4b87803a2e449486b0a90539997a8 Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 29 Oct 2026 03:38:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(alerts,analysis):=20=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E7=A8=B3=E5=AE=9A=E6=80=A7=205=20=E9=A1=B9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9B=E6=AF=8F=E6=97=A5=E9=87=8F=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8E=BB=E6=8E=89=E5=90=8C=E6=AD=A5=20Loki=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=EF=BC=8820s=E2=86=9210ms=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、告警引擎稳定性审查(引擎本身是异步的: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) --- backend/repository/db.go | 29 +++++++- backend/seed/alert_rules.go | 2 +- backend/services/alert.go | 74 +++++++++++++++++-- backend/services/alert_test.go | 34 +++++++++ backend/services/daily_volume.go | 31 +++----- frontend/src/i18n/locales/en/analysis.ts | 2 +- frontend/src/i18n/locales/zh/analysis.ts | 2 +- .../analysis/components/AppStreamChart.tsx | 6 +- frontend/src/pages/analysis/index.tsx | 5 +- 9 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 backend/services/alert_test.go diff --git a/backend/repository/db.go b/backend/repository/db.go index 4ac7d7e..9876527 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -114,6 +114,27 @@ func (d *JsonDB) raiseLegacyAlertThresholds() { } } +// normalizeDefaultChannelName 把默认通道的名字统一成「默认告警」。 +// +// 只改名,不动 Webhook 与 enabled:首次部署的占位通道必须保持"空地址 + 禁用", +// 让管理员自己填(避免填了个空地址的启用通道,告警一命中就全部投递失败)。 +func (d *JsonDB) normalizeDefaultChannelName() { + const canonical = "默认告警" + legacyNames := map[string]bool{ + "lark 默认告警": true, + "默认通知通道(待配置 Webhook)": true, + "默认通知通道": true, + } + for i := range d.NotifyChannels { + ch := &d.NotifyChannels[i] + if legacyNames[ch.Name] { + log.Printf("[schema] 通知通道 %q 改名为 %q", ch.Name, canonical) + ch.Name = canonical + ch.UpdatedAt = time.Now() + } + } +} + // removeOrphanManagedLogSources 清掉"部署已不存在、但数据源还在"的孤儿记录。 // // 判据:Managed=true(平台托管)且没有任何同名部署。 @@ -409,7 +430,7 @@ func (d *JsonDB) dedupeLogSourceIDs() { // // v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理) // v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job) -const CurrentSchemaVersion = 8 +const CurrentSchemaVersion = 9 // migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。 func (d *JsonDB) migrateSchema() bool { @@ -480,6 +501,12 @@ func (d *JsonDB) migrateSchema() bool { // 500~5000 这一档实测在业务高峰是常态量,贴着基线就等于天天报。 d.raiseLegacyAlertThresholds() d.SchemaVersion = 8 + case 8: + // v8 → v9:通知通道统一改名为「默认告警」。 + // 首次部署创建的占位通道保持**禁用 + 空 Webhook**(由管理员自己填), + // 这里只改名字,不动 Webhook、不动启用状态。 + d.normalizeDefaultChannelName() + d.SchemaVersion = 9 default: // 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏 log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion) diff --git a/backend/seed/alert_rules.go b/backend/seed/alert_rules.go index a7553f4..2ba8244 100644 --- a/backend/seed/alert_rules.go +++ b/backend/seed/alert_rules.go @@ -49,7 +49,7 @@ func ensurePlaceholderChannel(db *repository.JsonDB) uint { id := next + 1 db.NotifyChannels = append(db.NotifyChannels, models.NotifyChannel{ ID: id, - Name: "默认通知通道(待配置 Webhook)", + Name: "默认告警", Enabled: false, CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/backend/services/alert.go b/backend/services/alert.go index b27ce11..72e9966 100644 --- a/backend/services/alert.go +++ b/backend/services/alert.go @@ -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 } diff --git a/backend/services/alert_test.go b/backend/services/alert_test.go new file mode 100644 index 0000000..3698690 --- /dev/null +++ b/backend/services/alert_test.go @@ -0,0 +1,34 @@ +package services + +import ( + "testing" + "time" +) + +// 回归:冷却记录节流。 +// +// 线上问题:命中但处于冷却期时,原来**每个 tick(1 分钟)都写一条** cooldown 记录。 +// AlertDeliveries 只保留最近 50 条,于是真实的"已发送"记录在一小时内就被 +// 冷却记录挤光了,页面的「发送历史」基本没法看。 +func TestRecordCooldownThrottled(t *testing.T) { + const ruleID = 99001 + // 第一次:允许写 + if !recordCooldownThrottled(ruleID) { + t.Fatal("第一次应允许记录") + } + // 紧接着第二次:必须被节流 + if recordCooldownThrottled(ruleID) { + t.Fatal("10 分钟内的第二次应被节流") + } + // 不同规则互不影响 + if !recordCooldownThrottled(ruleID + 1) { + t.Fatal("不同规则不应被互相节流") + } + // 把上次时间推到 11 分钟前,应重新允许 + cooldownRecordMu.Lock() + cooldownRecordLast[ruleID] = time.Now().Add(-11 * time.Minute) + cooldownRecordMu.Unlock() + if !recordCooldownThrottled(ruleID) { + t.Fatal("超过 10 分钟后应重新允许记录") + } +} diff --git a/backend/services/daily_volume.go b/backend/services/daily_volume.go index a5fe4f8..e85241d 100644 --- a/backend/services/daily_volume.go +++ b/backend/services/daily_volume.go @@ -67,7 +67,9 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e } // 只查总量(不带 namespace 条件),一次拿到该源当天的条数与体积 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // 整天(24h)的 index/stats:大集群上比 15 分钟的查询慢得多, + // 实测 20s 会超时(这也是之前每日量接口卡 20s 的原因)。给 90s。 + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() stats, err := LokiStatsFor(ctx, source, `{namespace=~".+"}`, start, end) if err != nil { @@ -177,29 +179,16 @@ func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint { days = dailyVolumeRetentionDays } - // 今天实时补抓,保证"今天"这根柱子是活的 - db := repository.GetDB() - if db != nil { - db.RLock() - var target *models.LogSource - for i := range db.LogSources { - if db.LogSources[i].ID == sourceID { - src := db.LogSources[i] - target = &src - break - } - } - db.RUnlock() - if target != nil { - if err := CaptureDailyVolume(target, time.Now(), true); err != nil { - log.Printf("[daily-volume] 实时补抓今天失败: %v", err) - } - } - } + // 这个接口**只读库**,绝不触发任何 Loki 查询。 + // + // 事故复盘:这里原来同步 await 一次"整天 index/stats",那次查询超时 20 秒, + // 于是 /logs/daily-volume 耗时 20s → 前端超时 → 页面显示"每日日志量没数据"; + // 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。 + // 当天那根柱子由后台定时任务(每 30 分钟)补,慢几分钟完全可接受。 from := dateKey(time.Now().AddDate(0, 0, -(days - 1))) points := []DailyVolumePoint{} - db = repository.GetDB() + db := repository.GetDB() if db == nil { return points } diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index 56d26e6..60388ce 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -54,7 +54,7 @@ export default { fixedBadge: 'Platform history', badge: 'Platform history', title: 'Daily volume', - subtitle: 'Daily totals kept by the platform (Loki only retains 24h, history lives here)', + subtitle: 'Daily log volume — history kept by the platform', empty: 'No history yet — the platform records one snapshot per day', lastDays: '{{count}}d', oneYear: '1y', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 659204e..325e77a 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -54,7 +54,7 @@ export default { fixedBadge: '平台留存历史', badge: '平台留存历史', title: '每日日志量', - subtitle: '每天的总量趋势,数据由平台每日留存(Loki 只保留 24 小时,历史在这里)', + subtitle: '每天的日志总量,平台保留历史趋势', empty: '还没有历史数据,平台每天会自动记录一次', lastDays: '{{count}} 天', oneYear: '1 年', diff --git a/frontend/src/pages/analysis/components/AppStreamChart.tsx b/frontend/src/pages/analysis/components/AppStreamChart.tsx index 2bce9ec..3a9a44d 100644 --- a/frontend/src/pages/analysis/components/AppStreamChart.tsx +++ b/frontend/src/pages/analysis/components/AppStreamChart.tsx @@ -1,6 +1,7 @@ import { memo, useMemo } from 'react' import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { useTranslation } from 'react-i18next' +import { compactNumber } from './chart-shared' import type { AppRow } from '../hooks/useAnalysisMetrics' import { chartColors } from './chart-shared' import { EmptyChart } from './EmptyChart' @@ -25,8 +26,9 @@ export const AppStreamChart = memo(function AppStreamChart({ apps, appTimes }: { - - + {/* 流图是"条数",不是字节:用 万/亿 缩写 + 条,避免裸数字看不懂 */} + compactNumber(value)} /> + [`${compactNumber(value)} 条`, '']} /> {topApps.map((app, index) => { const color = chartColors[index % chartColors.length] diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index cca68ce..cd4df63 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -288,10 +288,7 @@ const AnalysisPage = memo(function AnalysisPage() {
-
- {t('daily.title')} - {t('daily.badge')} -
+ {t('daily.title')}

{t('daily.subtitle')}