Files
LokiStack/backend/services/daily_volume.go
T
opsx 5c7a3ec81d
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
perf(daily-volume): 每日量改用 volume_range(20s → 54ms),图表改圆柱
=== 核心技巧:体积用 index/volume_range,不要用 index/stats ===
同为 24 小时、同一 selector,两条索引查询差 370 倍:
    index/stats      : 整天聚合,实测 20s 直接超时(之前每日量接口卡 20s 的根因)
    index/volume_range: 按 step 分桶只累加 chunk 字节,实测 54ms,97 个桶

原因:index/stats 要跨全部 chunk 去重统计 streams/entries;
volume_range 只把索引里记录的 chunk 字节数按时间桶求和,不解码内容。

=== 后端改动 ===
services/daily_volume.go:
  体积:LokiHistogram(走 volume_range,step 由跨度自动选)→ 累加 54ms
  条数:只能 index/stats,降级为「尽力而为」25s 超时;
        拿不到就保留上一次的值(不能因为一次超时把历史条数清零),
        绝不让它拖住体积入库。
  前端图只画体积,条数缺失不影响可读性。

=== 前端改动 ===
DailyVolumeChart:AreaChart(面积)→ BarChart(圆柱)
  顶部圆角 radius=[6,6,0,0]、渐变填充、maxBarSize=44
  当天未结束的柱子用半透明 + 虚线描边区分,避免把"才过半天"误读成"量掉了"

=== 实测 ===
  24h volume_range: 54ms,97 桶,合计 369.61 GB(与全集群 ~370GB/天 吻合)
  /logs/daily-volume 接口: 10ms(读库)
  Pod = Hub latest = 4cdc8b9c4841
2026-10-30 16:40:49 +00:00

263 lines
8.1 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"
"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
}
// ── 体积: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 分钟)补,慢几分钟完全可接受。
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()
}
}()
}