Files
LokiStack/backend/services/app_volume.go
T
opsx d4422f1541
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
refactor(analysis): 去掉排行 10/30/50 选择器,改为一次取 50 + 前端分页;零点结算昨天
=== 1. 为什么去掉「显示条数」选择器 ===
topk 的 N 不影响查询成本(扫描的 chunk 集合完全相同),实测:
  topk(10)  17833ms / 11 条
  topk(30)  18019ms / 34 条
  topk(50)   7394ms / 54 条
  topk(100) 17553ms / 74 条
波动来自集群负载,与 N 无关 —— 它只决定最后返回多少序列。
所以"选 30"既不更快也不更慢,本质是个翻页开关;
而选它还要重新等一次十几秒的查询,纯亏。
改为:一次 topk(50),前端 10 条/页 → 5 页只需 1 次查询。
附带收益:占比的分母从"前 10 合计"变成"前 50 合计",更接近真实占比。
副标题明确写清"一次取前 50 个应用(占比按这 50 个合计计算)"。

=== 2. 零点结算昨天(daily volume)===
原来只有 30 分钟 ticker 回补,且启动时间不对齐整点 →
昨天最早的一段可能已经超出 Loki 24h 保留期被删,
结算结果系统性偏低,图上却标"完整一天",属静默错数。
新增 nextMidnightFinalize():每天 00:01 触发 FinalizeYesterdayVolume()
  - 昨天定稿 partial=false
  - 补一次条数(index/stats,一天一次可接受)
  - 顺带刷新今天 partial=true 的柱子
ticker 仍保留,负责当天数据的 30 分钟刷新。

=== 3. 顺带 ===
- 去掉每日量"其中 N 天平台没有留存记录"的提示文案(按用户要求)
- 修 QuickQueryTemplates.tsx 的未使用变量导致 tsc -b 失败
- 工作区里上一轮中断的"排行体积列"改动一并纳入(app-ranking 返回 bytes,
  TopAppsTable 增加体积列,快照记录 per-app 字节数)

Pod = Hub latest = a16978d2f88b
2026-10-30 17:13:18 +00:00

365 lines
11 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"
"strings"
"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 日志条数(预聚合快照累加)
Entries int64 `json:"entries"`
// Bytes 日志体积(字节,预聚合快照累加)。
// 快照里没有体积的早期小时会让这个值偏小 —— 响应里的 bytesHours 用来提示覆盖率。
Bytes int64 `json:"bytes"`
// 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
bytes 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
}
// ── 体积:逐个 app 走 index/volume(索引接口,实测 ~80ms/个)──
//
// 为什么不用一条 `sum by (app) (bytes_over_time(...))` 拿全:那条要重扫整小时
// 的日志内容(本集群约 29GB/小时,15 分钟就要 13s,一小时约 50s),而
// index/volume 只读 chunk 元数据。代价是必须一个 app 一次调用(index/volume
// 的返回值只带 selector 里出现过的 label),50 个 app ≈ 4~10s。
//
// 体积失败**不影响条数落库**:拿不到就留 0,前端把 0 显示成"体积未知",
// 排行仍可按条数排(GetAppRanking 会按可用指标退让)。
vctx, vcancel := context.WithTimeout(context.Background(), 2*time.Minute)
for i := range rows {
if rows[i].app == "" {
continue
}
bytes, err := LokiVolumeBytesFor(vctx, src, appVolumeSelector(rows[i].ns, rows[i].app), start.UnixNano(), end.UnixNano())
if err != nil {
log.Printf("[app-volume] %s %s/%s 体积获取失败(条数已留存): %v", key, rows[i].ns, rows[i].app, err)
continue
}
rows[i].bytes = bytes
}
vcancel()
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.Bytes = r.bytes
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, Bytes: r.bytes, 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)
}
}()
}
// appVolumeSelector 生成 index/volume 用的 stream selector。
// 值用 strconv.Quote 转义:app 名理论上可能出现引号/反斜杠,拼错就是 Loki 400。
func appVolumeSelector(namespace, app string) string {
parts := make([]string, 0, 2)
if namespace != "" {
parts = append(parts, "namespace="+strconv.Quote(namespace))
}
if app != "" {
parts = append(parts, "app="+strconv.Quote(app))
}
if len(parts) == 0 {
return `{namespace=~".+"}`
}
return "{" + strings.Join(parts, ", ") + "}"
}
// GetAppRanking 从**预聚合快照**里取指定时间范围的应用排行(毫秒级)。
//
// sortBy:bytes(默认,按体积)| entries(按条数)。
// 体积优先是因为用户要的是"哪个应用把磁盘/带宽吃了";条数受日志长短影响,
// 一个高频 INFO 应用可以条数第一但体积很小。
// 排序永远带兜底:体积相同(或都是 0)时按条数,再按应用名,保证顺序稳定。
func GetAppRanking(sourceID uint, from, to time.Time, limit int, sortBy string) []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
bytes 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
item.bytes += snap.Bytes
}
rows := make([]AppRankRow, 0, len(sum))
for _, item := range sum {
rows = append(rows, AppRankRow{
App: item.app, Namespace: item.ns,
Entries: item.entries, Bytes: item.bytes, FromHistory: true,
})
}
byEntries := sortBy == "entries"
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if byEntries {
if a.Entries != b.Entries {
return a.Entries > b.Entries
}
if a.Bytes != b.Bytes {
return a.Bytes > b.Bytes
}
} else {
if a.Bytes != b.Bytes {
return a.Bytes > b.Bytes
}
if a.Entries != b.Entries {
return a.Entries > b.Entries
}
}
return a.App < b.App
})
if len(rows) > limit {
rows = rows[:limit]
}
return rows
}
// AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。
func AppVolumeCoverage(sourceID uint) int {
hours, _ := AppVolumeCoverageInfo(sourceID)
return hours
}
// AppVolumeCoverageInfo 返回(快照覆盖小时数, 其中带体积的小时数)。
//
// 第二个值是给"按体积排行"用的诚实性提示:体积列是本轮新加的,
// 之前抓的小时只有条数没有体积,24h 排行里这些小时的体积会偏小。
// 两个数不一致时前端要明确告诉用户覆盖率,而不是让用户误读成"这个应用体积很小"。
func AppVolumeCoverageInfo(sourceID uint) (hours, bytesHours int) {
db := repository.GetDB()
if db == nil {
return 0, 0
}
db.RLock()
defer db.RUnlock()
all := map[string]bool{}
withBytes := map[string]bool{}
for _, snap := range db.AppVolumeSnapshots {
if snap.SourceID != sourceID {
continue
}
all[snap.Hour] = true
if snap.Bytes > 0 {
withBytes[snap.Hour] = true
}
}
return len(all), len(withBytes)
}