Files
LokiStack/backend/services/daily_volume.go
T
opsx 7b1ffd0274
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
fix(daily-volume): 30/90/365 天切换"没反应";/logs/stats 加短缓存
=== 1. 切换范围没反应(真 bug)===
现象:7/30/90/365 天切换后图完全一样。
根因:GetDailyVolumeHistory 只返回**已抓到的快照**。平台只有 4 天数据,
      所以任何 days 都只返回 4 个点 → 图表一模一样。
修复:始终返回 days 个点,缺失的天用 Missing=true 标记。
      实测:days=7→7 点(缺3)、30→30 点(缺26)、90→90(缺86)、365→365(缺361)。

为什么必须区分 Missing 和"0 字节":
  前者是"平台没留存",后者是"那天确实没日志"。
  混在一起会让 30/90 天的图上出现大片 0,被误读成"日志量暴跌/系统停摆"。
前端:缺失的天画灰色浅柱,tooltip 显示"未留存",图下方提示
      "其中 N 天平台没有留存记录(灰色柱),不是当天没有日志"。

=== 2. /logs/stats 加 90 秒缓存 ===
分析页每次进入/切范围都打 index/stats,实测 5 分钟窗口冷查询 1.2 秒
(60 分钟更慢)。这类聚合值可容忍分钟级陈旧,加与指标查询一致的缓存:
  90s TTL + 时间窗按 2 分钟取整 + 上限 200 条 + 过期清理
实测:重复请求 1227ms → 8ms。

=== 3. 顺带确认已生效的优化 ===
  histogram(24h)   : 54ms(volume_range)
  daily-volume     : 10ms(只读库)
  stats 二次请求    : 8ms(新缓存)
  长范围(>1h)       : 不跑 count_over_time(预聚合快照 + 跳过内容扫描)

Pod = Hub latest = 12453ea723c1
2026-10-30 16:46:40 +00:00

274 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"log"
"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"`
// Missing 该天平台没有抓到快照(超过留存期/平台当天没运行)。
// 必须和"当天日志量真的是 0"区分开:前者是"不知道",后者是"确实没日志"。
// 否则 30/90/365 天的图上会出现大片 0,被误读成"日志量暴跌"。
Missing bool `json:"missing"`
}
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
}
// ── 体积:index/volume_range(只读 TSDB chunk 元数据,不解码日志内容)──
// 实测同一条件 24 小时:index/stats 要 20s(遇超时),volume_range 111ms。
// 用户关心的"每日日志量"就是体积,所以主路径走它。
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
buckets, volErr := LokiHistogram(ctx, source, `{namespace=~".+"}`, start, end)
var totalBytes int64
for _, b := range buckets {
switch v := b["count"].(type) {
case int64:
totalBytes += v
case float64:
totalBytes += int64(v)
}
}
// ── 条数:只能 index/stats,慢(整天约 20s)──
// 降级为"尽力而为":拿不到就把条数留 0,绝不让它拖住体积入库。
// 前端图只画体积,条数缺失不影响可读性。
var totalEntries int64
entryCtx, entryCancel := context.WithTimeout(context.Background(), 25*time.Second)
if stats, err := LokiStatsFor(entryCtx, source, `{namespace=~".+"}`, start, end); err == nil {
totalEntries = stats.Entries
if totalBytes == 0 {
totalBytes = stats.Bytes
}
} else {
log.Printf("[daily-volume] %s 条数统计超时/失败(不影响体积): %v", dateKey(day), err)
}
entryCancel()
if volErr != nil && totalBytes == 0 {
return volErr
}
if totalBytes == 0 {
return nil
}
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 = totalBytes
// 条数拿不到时保留上一次的值,避免"一次超时把历史条数清零"
if totalEntries > 0 {
snap.Entries = totalEntries
}
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: totalBytes,
Entries: totalEntries,
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 分钟)补,慢几分钟完全可接受。
// 始终返回 days 个点(缺失的那天标 Missing)。
// 之前只返回"已抓到的快照",于是 7/30/90/365 全都返回同样 4 个点,
// 用户切范围看不到任何变化,以为"切换没反应"。
now := time.Now()
byDate := map[string]models.DailyVolumeSnapshot{}
db := repository.GetDB()
if db != nil {
db.RLock()
for _, snap := range db.DailyVolumeSnapshots {
if snap.SourceID != sourceID || snap.Namespace != "" {
continue
}
byDate[snap.Date] = snap
}
db.RUnlock()
}
points := make([]DailyVolumePoint, 0, days)
for i := days - 1; i >= 0; i-- {
date := dateKey(now.AddDate(0, 0, -i))
if snap, ok := byDate[date]; ok {
points = append(points, DailyVolumePoint{
Date: snap.Date,
Bytes: snap.Bytes,
Entries: snap.Entries,
Partial: snap.Partial,
FromHistory: true,
})
continue
}
points = append(points, DailyVolumePoint{Date: date, Missing: true, FromHistory: true})
}
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()
}
}()
}