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:
1 parent
607b67a2ed
commit
cb1ef1dff4
9 files changed
+149
-36
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 分钟后应重新允许记录")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
fixedBadge: '平台留存历史',
|
||||
badge: '平台留存历史',
|
||||
title: '每日日志量',
|
||||
subtitle: '每天的总量趋势,数据由平台每日留存(Loki 只保留 24 小时,历史在这里)',
|
||||
subtitle: '每天的日志总量,平台保留历史趋势',
|
||||
empty: '还没有历史数据,平台每天会自动记录一次',
|
||||
lastDays: '{{count}} 天',
|
||||
oneYear: '1 年',
|
||||
|
||||
@@ -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 }: {
|
||||
<AreaChart data={data} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={48} />
|
||||
<Tooltip />
|
||||
{/* 流图是"条数",不是字节:用 万/亿 缩写 + 条,避免裸数字看不懂 */}
|
||||
<YAxis tick={{ fontSize: 11 }} width={56} tickFormatter={(value: number) => compactNumber(value)} />
|
||||
<Tooltip formatter={(value: number) => [`${compactNumber(value)} 条`, '']} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{topApps.map((app, index) => {
|
||||
const color = chartColors[index % chartColors.length]
|
||||
|
||||
@@ -288,10 +288,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
<div ref={dailyView.ref}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
|
||||
<span className="rounded-full bg-success/10 px-2 py-0.5 text-[11px] font-medium text-success">{t('daily.badge')}</span>
|
||||
</div>
|
||||
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t('daily.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Reference in new issue
Block a user