=== 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
495 lines
17 KiB
Go
495 lines
17 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"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
|
||
|
||
// entriesMaxAttempts 条数(index/stats)每天最多尝试次数。
|
||
// 整天跨度要去重统计全部 chunk:实测空闲时 6ms、有并发查询时 20s 以上,
|
||
// 属于"附加指标",不值得无限重试。
|
||
entriesMaxAttempts = 3
|
||
// entriesRetryInterval 两次条数尝试之间的最小间隔。
|
||
entriesRetryInterval = time.Hour
|
||
)
|
||
|
||
// 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()
|
||
}
|
||
|
||
// formatGB 用于日志输出,运维看日志时比裸字节直观。
|
||
func formatGB(bytes int64) string {
|
||
return fmt.Sprintf("%.2f GB", float64(bytes)/1e9)
|
||
}
|
||
|
||
// CaptureDailyVolume 抓取指定日期的**日志体积**并落库(按天幂等:同一天重复抓会覆盖)。
|
||
//
|
||
// 只用 index/volume_range:它只读 TSDB 索引里记录的 chunk 字节数,不解码日志内容。
|
||
// 同一 selector 24 小时实测(本集群 ~370GB/天):
|
||
//
|
||
// index/volume_range 54ms ~ 4s ← 本函数走的路径
|
||
// index/stats 6ms ~ 20s ← 条数才需要,见 captureDailyEntries
|
||
// count_over_time 60s 超时 ← 永远不要用在"每日量"上
|
||
//
|
||
// 条数不在这里抓:它只能走 index/stats 且整天跨度很贵,独立成 captureDailyEntries,
|
||
// 每天最多尝试 3 次,不跟着 30 分钟的定时任务反复跑。
|
||
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
|
||
}
|
||
|
||
// 起点已超出常见保留期(24h)时,Loki 里可能已经没有那天的数据了。
|
||
// 这种情况下查出来的 0 字节**绝不能落库**:
|
||
// 落库就会把"数据被 retention 删掉了"画成"那天没有日志",
|
||
// 比灰色柱(未留存)误导得多。所以按"跳过落库 = 保持 Missing"处理。
|
||
stale := start < time.Now().Add(-24*time.Hour).UnixNano()
|
||
|
||
begin := time.Now()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
buckets, volErr := LokiHistogram(ctx, source, `{namespace=~".+"}`, start, end)
|
||
elapsed := time.Since(begin)
|
||
if volErr != nil {
|
||
return fmt.Errorf("volume_range: %w", volErr)
|
||
}
|
||
|
||
var totalBytes int64
|
||
for _, b := range buckets {
|
||
switch v := b["count"].(type) {
|
||
case int64:
|
||
totalBytes += v
|
||
case float64:
|
||
totalBytes += int64(v)
|
||
}
|
||
}
|
||
|
||
if totalBytes == 0 && stale {
|
||
log.Printf("[daily-volume] %s 超出保留期且查不到数据,跳过落库(图上显示「未留存」)", dateKey(day))
|
||
return nil
|
||
}
|
||
|
||
date := dateKey(day)
|
||
if err := upsertVolumeSnapshot(source.ID, date, totalBytes, partial); err != nil {
|
||
return err
|
||
}
|
||
log.Printf("[daily-volume] %s 体积 %s 已落库(volume_range %s,%d 个桶)",
|
||
date, formatGB(totalBytes), elapsed.Round(time.Millisecond), len(buckets))
|
||
return nil
|
||
}
|
||
|
||
// upsertVolumeSnapshot 写入/更新某天的体积快照(幂等)。
|
||
// 只改体积与 partial,**不动已经抓到的条数**(条数贵,抓到就留着)。
|
||
func upsertVolumeSnapshot(sourceID uint, date string, bytes int64, partial bool) error {
|
||
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 == sourceID && snap.Namespace == "" {
|
||
snap.Bytes = bytes
|
||
snap.Partial = partial
|
||
snap.CapturedAt = time.Now()
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
}
|
||
nextID++
|
||
db.DailyVolumeSnapshots = append(db.DailyVolumeSnapshots, models.DailyVolumeSnapshot{
|
||
ID: nextID,
|
||
Date: date,
|
||
SourceID: sourceID,
|
||
Namespace: "",
|
||
Bytes: bytes,
|
||
Partial: partial,
|
||
CapturedAt: time.Now(),
|
||
})
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
|
||
// captureDailyEntries 给"已经过完、但还没有条数"的天补一次条数(尽力而为)。
|
||
//
|
||
// 为什么不等同于体积每 30 分钟抓一次:
|
||
// 条数只能走 index/stats,要跨该天全部 chunk 做 streams/entries 去重统计,
|
||
// 实测 6ms(空闲)~ 20s(有并发查询时)。每 30 分钟 × 每一天都重跑一遍,
|
||
// 既浪费 Loki 的 CPU,又会和用户页面上的查询抢同一台单实例的算力。所以:
|
||
//
|
||
// - 只处理 Date < 今天 的天(今天没结束,条数没有意义)
|
||
// - 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval
|
||
// - 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试"
|
||
// - 拿不到就留 0,前端图只画体积,不影响可读性
|
||
func captureDailyEntries(source *models.LogSource) {
|
||
if source == nil {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
today := dateKey(now)
|
||
|
||
var dates []string
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.RLock()
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
if snap.SourceID != source.ID || snap.Namespace != "" {
|
||
continue
|
||
}
|
||
if snap.Date >= today || snap.Entries > 0 {
|
||
continue
|
||
}
|
||
if snap.EntriesAttempts >= entriesMaxAttempts {
|
||
continue
|
||
}
|
||
if !snap.LastEntriesAttempt.IsZero() && now.Sub(snap.LastEntriesAttempt) < entriesRetryInterval {
|
||
continue
|
||
}
|
||
dates = append(dates, snap.Date)
|
||
}
|
||
db.RUnlock()
|
||
|
||
for _, date := range dates {
|
||
// 先把"尝试"记下来再查:查的过程中进程重启,也不会变成无限重试
|
||
markEntriesAttempt(source.ID, date)
|
||
|
||
day, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
start, end := dayRange(day)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||
begin := time.Now()
|
||
stats, statErr := LokiStatsFor(ctx, source, `{namespace=~".+"}`, start, end)
|
||
cancel()
|
||
if statErr != nil {
|
||
log.Printf("[daily-volume] %s 条数统计失败(体积已落库,不影响图表): %v", date, statErr)
|
||
continue
|
||
}
|
||
if stats.Entries <= 0 {
|
||
continue
|
||
}
|
||
setSnapshotEntries(source.ID, date, stats.Entries)
|
||
log.Printf("[daily-volume] %s 条数 %d(index/stats %s)",
|
||
date, stats.Entries, time.Since(begin).Round(time.Millisecond))
|
||
}
|
||
}
|
||
|
||
// markEntriesAttempt 记录一次条数尝试(先写库再查询,失败也计数)。
|
||
func markEntriesAttempt(sourceID uint, date string) {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
for i := range db.DailyVolumeSnapshots {
|
||
snap := &db.DailyVolumeSnapshots[i]
|
||
if snap.Date == date && snap.SourceID == sourceID && snap.Namespace == "" {
|
||
snap.EntriesAttempts++
|
||
snap.LastEntriesAttempt = time.Now()
|
||
db.RequestSave()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// setSnapshotEntries 写入条数。
|
||
func setSnapshotEntries(sourceID uint, date string, entries int64) {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
for i := range db.DailyVolumeSnapshots {
|
||
snap := &db.DailyVolumeSnapshots[i]
|
||
if snap.Date == date && snap.SourceID == sourceID && snap.Namespace == "" {
|
||
snap.Entries = entries
|
||
db.RequestSave()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// CaptureRecentDailyVolumes 回补最近 N 天(含今天),供启动时与定时任务调用。
|
||
//
|
||
// 两条路径分开:
|
||
//
|
||
// 体积(volume_range,毫秒级)→ 今天 + 昨天,每 30 分钟刷新,当天柱子保持新鲜
|
||
// 条数(index/stats,可能 20s)→ 只给已结束且还没抓到条数的天补,每天最多 3 次
|
||
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)
|
||
}
|
||
}
|
||
captureDailyEntries(&src)
|
||
}
|
||
pruneDailyVolumeSnapshots()
|
||
}
|
||
|
||
// pruneDailyVolumeSnapshots 丢弃超过保留期的历史,并修正历史遗留的 partial 标记。
|
||
func pruneDailyVolumeSnapshots() {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
cutoff := dateKey(time.Now().AddDate(0, 0, -dailyVolumeRetentionDays))
|
||
today := dateKey(time.Now())
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
known := make(map[uint]bool, len(db.LogSources))
|
||
for _, src := range db.LogSources {
|
||
known[src.ID] = true
|
||
}
|
||
changed := false
|
||
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
||
droppedOrphan := 0
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
// 超过留存期
|
||
if snap.Date < cutoff {
|
||
continue
|
||
}
|
||
// 数据源已被删除:它的快照没有任何入口能查到,留着只会混淆取证
|
||
// (实测遇到过库里留着 id=2 的历史,查"到底抓没抓"时白查一轮)
|
||
if !known[snap.SourceID] {
|
||
droppedOrphan++
|
||
continue
|
||
}
|
||
kept = append(kept, snap)
|
||
}
|
||
if droppedOrphan > 0 {
|
||
log.Printf("[daily-volume] 清理已删除数据源的快照 %d 条", droppedOrphan)
|
||
}
|
||
if len(kept) != len(db.DailyVolumeSnapshots) {
|
||
log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept)-droppedOrphan)
|
||
db.DailyVolumeSnapshots = kept
|
||
changed = true
|
||
}
|
||
|
||
// 历史遗留修正:**过去的天不可能"还在进行中"**。
|
||
// 早期版本把「起点超出 24h」也当成 partial,于是昨天/前天的柱子一直是虚线空心,
|
||
// 看起来像"每天都在采样中"。只有当快照是在那天**结束之后**才抓的
|
||
// (说明抓的是完整一天)才改成完整;当天中途抓到的继续保持 partial —— 那时确实只有半天。
|
||
for i := range db.DailyVolumeSnapshots {
|
||
snap := &db.DailyVolumeSnapshots[i]
|
||
if !snap.Partial || snap.Date >= today {
|
||
continue
|
||
}
|
||
day, err := time.ParseInLocation("2006-01-02", snap.Date, time.Local)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if _, dayEnd := dayRange(day); snap.CapturedAt.UnixNano() >= dayEnd {
|
||
snap.Partial = false
|
||
changed = true
|
||
}
|
||
}
|
||
if changed {
|
||
db.RequestSave()
|
||
}
|
||
}
|
||
|
||
// GetDailyVolumeHistory 从平台留存的历史里取最近 days 天,按日期升序返回。
|
||
//
|
||
// 这个接口**只读库,绝不触发任何 Loki 查询** —— 图表刷新不应该产生任何 Loki 负载。
|
||
// 当天那根柱子由后台定时任务(每 30 分钟,volume_range 毫秒级)补,慢几分钟可接受。
|
||
//
|
||
// 事故复盘:这里原来同步 await 一次"整天 index/stats",那次查询超时 20 秒,
|
||
// 于是 /logs/daily-volume 耗时 20s → 前端超时 → 页面显示"每日日志量没数据";
|
||
// 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。
|
||
func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||
if days <= 0 {
|
||
days = 7
|
||
}
|
||
if days > dailyVolumeRetentionDays {
|
||
days = dailyVolumeRetentionDays
|
||
}
|
||
|
||
// 始终返回 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 分钟跑一轮(体积是毫秒级的 volume_range,跑得勤一点让"今天"这根柱子接近实时):
|
||
// - 今天 + 昨天的体积:每轮都刷
|
||
// - 条数:只在"已结束且还没有条数"的天触发,每天最多 3 次(见 captureDailyEntries)
|
||
//
|
||
// 用"检查式回补"而不是死等 00:10 的原因:平台可能整晚都没运行,定时器会错过那个点;
|
||
// 每轮都检查"昨天有没有落库",漏了就立刻补,天然幂等。
|
||
func StartDailyVolumeCapture() {
|
||
go func() {
|
||
// 启动先补最近两天(覆盖重启场景)
|
||
time.Sleep(20 * time.Second)
|
||
log.Printf("[daily-volume] 启动回补最近 %d 天", dailyVolumeBackfillDays)
|
||
CaptureRecentDailyVolumes()
|
||
|
||
// 两条节奏:
|
||
// 30 分钟 ticker → 刷新"今天"的柱子(当天数据一直在涨)
|
||
// 零点后 1 分钟 → **结算昨天**(把它定稿成完整一天)
|
||
//
|
||
// 为什么零点结算必须对齐而不能靠 30 分钟 ticker:
|
||
// Loki 保留期默认 24h,昨天 00:00 的数据在今天 00:00 之后就开始被删。
|
||
// 如果等到 00:30 那次 ticker 才抓,昨天最早的 30 分钟已经没了,
|
||
// 结算结果会系统性偏低,而图上又标着"完整一天",属于静默错数。
|
||
// 00:01 结算时最多只丢 1 分钟,误差可忽略。
|
||
ticker := time.NewTicker(30 * time.Minute)
|
||
defer ticker.Stop()
|
||
midnight := time.NewTimer(time.Until(nextMidnightFinalize()))
|
||
defer midnight.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
CaptureRecentDailyVolumes()
|
||
case <-midnight.C:
|
||
FinalizeYesterdayVolume()
|
||
midnight.Reset(time.Until(nextMidnightFinalize()))
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// nextMidnightFinalize 返回下一个"零点后 1 分钟"的时间点。
|
||
func nextMidnightFinalize() time.Time {
|
||
now := time.Now()
|
||
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 1, 0, 0, time.Local)
|
||
return next
|
||
}
|
||
|
||
// FinalizeYesterdayVolume 零点后结算昨天:把昨天定稿(partial=false),
|
||
// 同时刷新今天的 partial=true 柱子。多数据源逐个处理,单个失败不影响其它。
|
||
func FinalizeYesterdayVolume() {
|
||
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()
|
||
yesterday := now.AddDate(0, 0, -1)
|
||
for _, src := range sources {
|
||
src := src
|
||
if err := CaptureDailyVolume(&src, yesterday, false); err != nil {
|
||
log.Printf("[daily-volume] 零点结算 %s %s 失败: %v", src.Name, dateKey(yesterday), err)
|
||
continue
|
||
}
|
||
log.Printf("[daily-volume] 零点结算完成 %s %s", src.Name, dateKey(yesterday))
|
||
// 昨天定稿后,条数也可以补一次(走 index/stats,慢但一天一次可接受)
|
||
captureDailyEntries(&src)
|
||
if err := CaptureDailyVolume(&src, now, true); err != nil {
|
||
log.Printf("[daily-volume] 刷新今天 %s 失败: %v", src.Name, err)
|
||
}
|
||
}
|
||
}
|