=== 一、告警引擎稳定性审查(引擎本身是异步的: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)
233 lines
7.1 KiB
Go
233 lines
7.1 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"sort"
|
||
"time"
|
||
|
||
"lokistack/models"
|
||
"lokistack/repository"
|
||
)
|
||
|
||
// DailyVolumePoint 返回给前端的单日数据点
|
||
type DailyVolumePoint struct {
|
||
Date string `json:"date"`
|
||
Bytes int64 `json:"bytes"`
|
||
Entries int64 `json:"entries"`
|
||
// Partial 当天还没过完(或抓取时数据不全),前端用虚线/浅色区分
|
||
Partial bool `json:"partial"`
|
||
// FromHistory true=来自平台留存历史;false=刚从 Loki 实时查的
|
||
FromHistory bool `json:"fromHistory"`
|
||
}
|
||
|
||
const (
|
||
// dailyVolumeBackfillDays 启动时回补天数。
|
||
// 取 2 天:昨天(完整)+ 今天(进行中),足够覆盖"重启导致漏抓"的常见情况。
|
||
// 不设更大是因为 Loki retention 通常只有 24h,更早的已经查不到了。
|
||
dailyVolumeBackfillDays = 2
|
||
// dailyVolumeRetentionDays 平台自己留多久的历史。一年足够看年度趋势。
|
||
dailyVolumeRetentionDays = 400
|
||
)
|
||
|
||
// dateKey 返回本地时区的 YYYY-MM-DD。
|
||
func dateKey(t time.Time) string {
|
||
return t.In(time.Local).Format("2006-01-02")
|
||
}
|
||
|
||
// dayRange 返回某天本地时区的 [00:00, 次日 00:00) 纳秒区间。
|
||
func dayRange(day time.Time) (int64, int64) {
|
||
local := day.In(time.Local)
|
||
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
|
||
return start.UnixNano(), start.AddDate(0, 0, 1).UnixNano()
|
||
}
|
||
|
||
// CaptureDailyVolume 抓取指定日期的日志量并落库(按天幂等:同一天重复抓会覆盖)。
|
||
//
|
||
// 数据来源是 Loki 的 index/stats(只读索引元数据),实测毫秒级,
|
||
// 所以就算是补几十天历史也不会拖慢任何东西。
|
||
func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) error {
|
||
if source == nil {
|
||
return nil
|
||
}
|
||
start, end := dayRange(day)
|
||
// 当天未结束时截到"现在",否则会把未来时间也查进去
|
||
now := time.Now().UnixNano()
|
||
if end > now {
|
||
end = now
|
||
}
|
||
if start >= end {
|
||
return nil
|
||
}
|
||
// 回补历史时,如果那天的起点已经超出 Loki 保留期(默认 24h),
|
||
// Loki 里根本没那么多数据 —— 必须标成"不完整",
|
||
// 否则图上会把"数据被删了"误读成"那天日志量真的很少"。
|
||
if start < time.Now().Add(-24*time.Hour).UnixNano() {
|
||
partial = true
|
||
}
|
||
|
||
// 只查总量(不带 namespace 条件),一次拿到该源当天的条数与体积
|
||
// 整天(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 {
|
||
return err
|
||
}
|
||
|
||
date := dateKey(day)
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return nil
|
||
}
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
|
||
nextID := uint(0)
|
||
// 用下标取地址:`for _, snap := range` 拿到的是**副本**,
|
||
// 改 snap 的字段不会写回切片(第一版就是这里写错了,
|
||
// 导致同一天永远保持首次抓取的值、partial 不会更新)。
|
||
for i := range db.DailyVolumeSnapshots {
|
||
snap := &db.DailyVolumeSnapshots[i]
|
||
if snap.ID > nextID {
|
||
nextID = snap.ID
|
||
}
|
||
// 同一天同源:覆盖(幂等)
|
||
if snap.Date == date && snap.SourceID == source.ID && snap.Namespace == "" {
|
||
snap.Bytes = stats.Bytes
|
||
snap.Entries = stats.Entries
|
||
snap.Partial = partial
|
||
snap.CapturedAt = time.Now()
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
}
|
||
nextID++
|
||
db.DailyVolumeSnapshots = append(db.DailyVolumeSnapshots, models.DailyVolumeSnapshot{
|
||
ID: nextID,
|
||
Date: date,
|
||
SourceID: source.ID,
|
||
Namespace: "",
|
||
Bytes: stats.Bytes,
|
||
Entries: stats.Entries,
|
||
Partial: partial,
|
||
CapturedAt: time.Now(),
|
||
})
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
|
||
// CaptureRecentDailyVolumes 回补最近 N 天(含今天),供启动时与定时任务调用。
|
||
func CaptureRecentDailyVolumes() {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.RLock()
|
||
sources := make([]models.LogSource, 0, len(db.LogSources))
|
||
for _, src := range db.LogSources {
|
||
sources = append(sources, src)
|
||
}
|
||
db.RUnlock()
|
||
|
||
now := time.Now()
|
||
for _, src := range sources {
|
||
src := src
|
||
for offset := dailyVolumeBackfillDays - 1; offset >= 0; offset-- {
|
||
day := now.AddDate(0, 0, -offset)
|
||
// 今天是不完整的一天
|
||
partial := offset == 0
|
||
if err := CaptureDailyVolume(&src, day, partial); err != nil {
|
||
log.Printf("[daily-volume] 抓取 %s %s 失败: %v", src.Name, dateKey(day), err)
|
||
}
|
||
}
|
||
}
|
||
pruneDailyVolumeSnapshots()
|
||
}
|
||
|
||
// pruneDailyVolumeSnapshots 丢弃超过保留期的历史。
|
||
func pruneDailyVolumeSnapshots() {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
cutoff := dateKey(time.Now().AddDate(0, 0, -dailyVolumeRetentionDays))
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
if snap.Date >= cutoff {
|
||
kept = append(kept, snap)
|
||
}
|
||
}
|
||
if len(kept) != len(db.DailyVolumeSnapshots) {
|
||
log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept))
|
||
db.DailyVolumeSnapshots = kept
|
||
db.RequestSave()
|
||
}
|
||
}
|
||
|
||
// GetDailyVolumeHistory 从平台留存的历史里取最近 days 天,按日期升序返回。
|
||
//
|
||
// 今天如果还没抓过,会实时补一次(这样用户切到页面时当天数字是最新的)。
|
||
func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||
if days <= 0 {
|
||
days = 7
|
||
}
|
||
if days > dailyVolumeRetentionDays {
|
||
days = dailyVolumeRetentionDays
|
||
}
|
||
|
||
// 这个接口**只读库**,绝不触发任何 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()
|
||
if db == nil {
|
||
return points
|
||
}
|
||
db.RLock()
|
||
defer db.RUnlock()
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
if snap.SourceID != sourceID || snap.Namespace != "" {
|
||
continue
|
||
}
|
||
if snap.Date < from {
|
||
continue
|
||
}
|
||
points = append(points, DailyVolumePoint{
|
||
Date: snap.Date,
|
||
Bytes: snap.Bytes,
|
||
Entries: snap.Entries,
|
||
Partial: snap.Partial,
|
||
FromHistory: true,
|
||
})
|
||
}
|
||
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||
return points
|
||
}
|
||
|
||
// StartDailyVolumeCapture 后台每日抓取。
|
||
//
|
||
// 每 30 分钟检查一次:只要发现"昨天"还没落库就立刻补,
|
||
// 而不是死等 00:10 —— 平台可能整晚都没运行,定时器会错过那个点。
|
||
func StartDailyVolumeCapture() {
|
||
go func() {
|
||
// 启动先补最近两天(覆盖重启场景)
|
||
time.Sleep(20 * time.Second)
|
||
CaptureRecentDailyVolumes()
|
||
|
||
ticker := time.NewTicker(30 * time.Minute)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
CaptureRecentDailyVolumes()
|
||
}
|
||
}()
|
||
}
|