Files
LokiStack/backend/services/app_volume.go
T
opsx d0adc095dd
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
feat(analysis): 长范围应用排行改走「小时级预聚合」,不再扫日志内容
=== 问题(实测)===
按应用排行必须读日志内容(Loki 3.4 的 index/volume 不支持 app 维度聚合,
aggregateBy=app 无效),成本 ∝ 时间范围:
   5 分钟  ≈ 1.3GB  →  9.8s
  15 分钟  ≈ 3.9GB  → 19.5s
   1 小时  ≈  15GB  → 预计 60~90s
   6 小时  ≈  92GB  → 5~8 分钟
  24 小时  ≈ 370GB  → 不可用
用户在时间范围里选 6h/24h 时,页面必然卡死。

=== 方案 ===
新增 models.AppVolumeSnapshot + services/app_volume.go:
  - 后台任务每小时第 5 分钟抓「上一个完整小时」的 per-app 条数
  - 用**瞬时查询**(/loki/api/v1/query + [1h])而不是 query_range:
    单点评估、扫描量相同、省掉多步开销;并绕开 LokiMetrics 的 90s 上限
    与重型查询信号量(后台任务不该和前台抢令牌)
  - 每源每小时只留 top 50 应用,保留 90 天
  - 幂等:同 (hour, source, ns, app) 更新而非重复插入
  - 超出 Loki 26h 保留期的窗口跳过(抓也抓不到,白等几分钟)
新增 GET /api/v1/logs/app-ranking?sourceId&from&to&limit → 读库毫秒级

前端:analysis 判断范围 > 60 分钟就走预聚合接口,并停掉实时重查询
(rankingEnabled 置 false);表格副标题说明「读的是预聚合快照,已留存 N 小时,
不含错误数与趋势」。

=== 顺带修掉的根因 bug ===
useInView 原来用 useRef + useEffect([inView]):分析页首屏 data===null 时
内容块不渲染,ref.current 为 null,effect 直接 return;等数据到了 DOM 才挂上,
但依赖没变 → effect 不重跑 → IntersectionObserver 从未创建 → inView 永远 false
→ 重查询永不发出 → 页面一直显示"没有应用维度的数据"。
改成 callback ref(节点挂载即作为依赖),并加 scroll/resize 兜底 + 挂载瞬间量一次。
另外分析页加 8 秒无条件兜底:可见性判定再失灵,8 秒后也一定查一次。
默认时间范围 15 分钟 → 5 分钟(19.5s → 9.8s)。

=== 实测 ===
  Pod = Hub latest = 98de87e7f895
  5 分钟窗口 by-app:9.8s(75 序列);15 分钟:19.5s
2026-10-30 16:35:35 +00:00

280 lines
7.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"
"encoding/json"
"fmt"
"log"
"net/url"
"sort"
"strconv"
"time"
"lokistack/models"
"lokistack/repository"
)
// 按应用的日志量:**小时级预聚合**。
//
// 背景:按应用排行必须读日志内容(Loki 3.4 的 index/volume 不支持 app 维度聚合),
// 成本 ∝ 时间范围 —— 5 分钟 10 秒、15 分钟 20 秒、6 小时要 5~8 分钟、24 小时不可用。
//
// 方案:后台每小时把「上一个完整小时」的 per-app 条数算好落库;
// 页面查 6h/24h/7d 时直接读库(毫秒级),不再扫日志。
// 前台只在「≤1 小时」的短范围走实时查询(那种范围本来就快)。
const (
// appVolumeRetentionDays 小时级 per-app 快照保留天数
appVolumeRetentionDays = 90
// appVolumeSampleTopN 每个小时只保留条数最多的 N 个应用,避免快照爆炸
appVolumeSampleTopN = 50
// appVolumeTimeout 单次抓取的超时(1 小时窗口 ≈ 15GB,给足时间)
appVolumeTimeout = 5 * time.Minute
)
// AppRankRow 排行结果的一行
type AppRankRow struct {
App string `json:"app"`
Namespace string `json:"namespace"`
Entries int64 `json:"entries"`
// FromHistory 该行来自平台预聚合(而不是实时扫日志)
FromHistory bool `json:"fromHistory"`
}
// hourKey 把时间对齐到整点,格式 YYYY-MM-DDTHH(本地时区,与快照一致)
func hourKey(t time.Time) string {
return t.Local().Format("2006-01-02T15")
}
// CaptureAppVolumeForHour 抓取指定小时(整点到整点)的 per-app 条数。
func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error {
if src == nil {
return fmt.Errorf("数据源为空")
}
start := hour.Truncate(time.Hour)
end := start.Add(time.Hour)
key := hourKey(start)
ctx, cancel := context.WithTimeout(context.Background(), appVolumeTimeout)
defer cancel()
// 用**瞬时查询**而不是 query_range:
// - 只评估一个时间点(窗口覆盖整个小时),扫描量与 query_range 相同
// 但省掉多步评估的开销
// - 直接走 lokiProxyGET + 自己 5 分钟超时,绕开 LokiMetrics 的 90 秒
// 上限与重型查询信号量(后台任务不该和前台查询抢令牌)
query := fmt.Sprintf(`sum by (app, namespace) (count_over_time({namespace=~".+", app!="loki"}[%dm]))`, int(time.Hour.Minutes()))
q := url.Values{}
q.Set("query", query)
q.Set("time", fmt.Sprintf("%d", end.UnixNano()))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q)
if err != nil {
return err
}
var resp struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("解析 Loki 响应失败: %w", err)
}
type row struct {
ns, app string
entries int64
}
rows := make([]row, 0, len(resp.Data.Result))
for _, item := range resp.Data.Result {
app := item.Metric["app"]
if app == "" {
app = item.Metric["service_name"]
}
if app == "" {
app = item.Metric["container"]
}
if app == "" {
continue
}
value, _ := strconv.ParseFloat(fmt.Sprintf("%v", item.Value[1]), 64)
if value <= 0 {
continue
}
rows = append(rows, row{ns: item.Metric["namespace"], app: app, entries: int64(value)})
}
// 只留 top N,控制快照体积
sort.Slice(rows, func(i, j int) bool { return rows[i].entries > rows[j].entries })
if len(rows) > appVolumeSampleTopN {
rows = rows[:appVolumeSampleTopN]
}
if len(rows) == 0 {
return nil
}
db := repository.GetDB()
if db == nil {
return fmt.Errorf("数据库未初始化")
}
db.Lock()
defer db.Unlock()
nextID := db.NextID("appVolumeSnapshots")
for _, r := range rows {
// 幂等:同 (hour, source, ns, app) 已存在则更新,不重复插入
found := false
for i := range db.AppVolumeSnapshots {
snap := &db.AppVolumeSnapshots[i]
if snap.Hour == key && snap.SourceID == src.ID && snap.Namespace == r.ns && snap.App == r.app {
snap.Entries = r.entries
snap.CapturedAt = time.Now()
found = true
break
}
}
if found {
continue
}
db.AppVolumeSnapshots = append(db.AppVolumeSnapshots, models.AppVolumeSnapshot{
ID: nextID, Hour: key, SourceID: src.ID, Namespace: r.ns, App: r.app,
Entries: r.entries, CapturedAt: time.Now(),
})
nextID++
}
pruneAppVolumeLocked(db)
db.RequestSave()
log.Printf("[app-volume] %s 已留存 %d 个应用的量", key, len(rows))
return nil
}
// pruneAppVolumeLocked 按保留期裁剪(调用方必须已持写锁)。
func pruneAppVolumeLocked(db *repository.JsonDB) {
cutoff := hourKey(time.Now().AddDate(0, 0, -appVolumeRetentionDays))
keep := db.AppVolumeSnapshots[:0]
for _, snap := range db.AppVolumeSnapshots {
if snap.Hour >= cutoff {
keep = append(keep, snap)
}
}
db.AppVolumeSnapshots = keep
}
// CaptureMissingAppVolume 回补最近 N 个**完整小时**里还没抓过的那些。
func CaptureMissingAppVolume(hours int) {
if hours <= 0 {
hours = 2
}
db := repository.GetDB()
if db == nil {
return
}
db.RLock()
sources := make([]models.LogSource, 0, len(db.LogSources))
for _, s := range db.LogSources {
sources = append(sources, s)
}
captured := map[string]bool{}
for _, snap := range db.AppVolumeSnapshots {
captured[fmt.Sprintf("%d|%s", snap.SourceID, snap.Hour)] = true
}
db.RUnlock()
now := time.Now()
for _, src := range sources {
for i := 1; i <= hours; i++ {
hour := now.Add(-time.Duration(i) * time.Hour).Truncate(time.Hour)
if captured[fmt.Sprintf("%d|%s", src.ID, hourKey(hour))] {
continue
}
// 超出 Loki 保留期的窗口不抓(抓也抓不到,白等几分钟)
if time.Since(hour.Add(time.Hour)) > 26*time.Hour {
continue
}
if err := CaptureAppVolumeForHour(&src, hour); err != nil {
log.Printf("[app-volume] %s %s 抓取失败: %v", src.Name, hourKey(hour), err)
}
}
}
}
// StartAppVolumeCapture 后台任务:每小时第 5 分钟抓上一个完整小时。
func StartAppVolumeCapture() {
go func() {
time.Sleep(45 * time.Second) // 避开启动时的其它任务
CaptureMissingAppVolume(2)
for {
now := time.Now()
// 下一个整点后 5 分钟
next := now.Truncate(time.Hour).Add(time.Hour + 5*time.Minute)
time.Sleep(time.Until(next))
CaptureMissingAppVolume(3)
}
}()
}
// GetAppRanking 从**预聚合快照**里取指定时间范围的应用排行(毫秒级)。
func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow {
if limit <= 0 {
limit = 10
}
fromHour := hourKey(from)
toHour := hourKey(to)
db := repository.GetDB()
if db == nil {
return nil
}
db.RLock()
defer db.RUnlock()
type agg struct {
ns, app string
entries int64
}
sum := map[string]*agg{}
for _, snap := range db.AppVolumeSnapshots {
if snap.SourceID != sourceID || snap.Hour < fromHour || snap.Hour > toHour {
continue
}
key := snap.Namespace + "\x00" + snap.App
item, ok := sum[key]
if !ok {
item = &agg{ns: snap.Namespace, app: snap.App}
sum[key] = item
}
item.entries += snap.Entries
}
rows := make([]AppRankRow, 0, len(sum))
for _, item := range sum {
rows = append(rows, AppRankRow{App: item.app, Namespace: item.ns, Entries: item.entries, FromHistory: true})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Entries == rows[j].Entries {
return rows[i].App < rows[j].App
}
return rows[i].Entries > rows[j].Entries
})
if len(rows) > limit {
rows = rows[:limit]
}
return rows
}
// AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。
func AppVolumeCoverage(sourceID uint) int {
db := repository.GetDB()
if db == nil {
return 0
}
db.RLock()
defer db.RUnlock()
hours := map[string]bool{}
for _, snap := range db.AppVolumeSnapshots {
if snap.SourceID == sourceID {
hours[snap.Hour] = true
}
}
return len(hours)
}