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
This commit is contained in:
1 parent
5c7a3ec81d
commit
7b1ffd0274
7 files changed
+118
-26
No files matched your search
@@ -386,7 +386,7 @@ func (ctrl *LogsController) Stats(c *gin.Context) {
|
||||
utils.Forbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End)
|
||||
st, err := services.LokiStatsCached(c.Request.Context(), src, query, req.Start, req.End)
|
||||
if err != nil {
|
||||
// Loki 的 index/stats 只支持标签选择器。查询里带 `|= "error"` 这类行过滤时
|
||||
// Loki 回 400 "only label matchers are supported",原来被包装成 502
|
||||
|
||||
@@ -3,7 +3,6 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"lokistack/models"
|
||||
@@ -19,6 +18,10 @@ type DailyVolumePoint struct {
|
||||
Partial bool `json:"partial"`
|
||||
// FromHistory true=来自平台留存历史;false=刚从 Loki 实时查的
|
||||
FromHistory bool `json:"fromHistory"`
|
||||
// Missing 该天平台没有抓到快照(超过留存期/平台当天没运行)。
|
||||
// 必须和"当天日志量真的是 0"区分开:前者是"不知道",后者是"确实没日志"。
|
||||
// 否则 30/90/365 天的图上会出现大片 0,被误读成"日志量暴跌"。
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -216,30 +219,38 @@ func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||||
// 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。
|
||||
// 当天那根柱子由后台定时任务(每 30 分钟)补,慢几分钟完全可接受。
|
||||
|
||||
from := dateKey(time.Now().AddDate(0, 0, -(days - 1)))
|
||||
points := []DailyVolumePoint{}
|
||||
// 始终返回 days 个点(缺失的那天标 Missing)。
|
||||
// 之前只返回"已抓到的快照",于是 7/30/90/365 全都返回同样 4 个点,
|
||||
// 用户切范围看不到任何变化,以为"切换没反应"。
|
||||
now := time.Now()
|
||||
byDate := map[string]models.DailyVolumeSnapshot{}
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return points
|
||||
if db != nil {
|
||||
db.RLock()
|
||||
for _, snap := range db.DailyVolumeSnapshots {
|
||||
if snap.SourceID != sourceID || snap.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
byDate[snap.Date] = snap
|
||||
}
|
||||
db.RUnlock()
|
||||
}
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
for _, snap := range db.DailyVolumeSnapshots {
|
||||
if snap.SourceID != sourceID || snap.Namespace != "" {
|
||||
|
||||
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
|
||||
}
|
||||
if snap.Date < from {
|
||||
continue
|
||||
}
|
||||
points = append(points, DailyVolumePoint{
|
||||
Date: snap.Date,
|
||||
Bytes: snap.Bytes,
|
||||
Entries: snap.Entries,
|
||||
Partial: snap.Partial,
|
||||
FromHistory: true,
|
||||
})
|
||||
points = append(points, DailyVolumePoint{Date: date, Missing: true, FromHistory: true})
|
||||
}
|
||||
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||||
return points
|
||||
}
|
||||
|
||||
|
||||
@@ -701,6 +701,61 @@ func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// ── 总条数/总体积的短缓存 ─────────────────────────────────────────────
|
||||
// /logs/stats 走 index/stats,实测 5 分钟窗口要 1.2 秒(60 分钟更慢)。
|
||||
// 分析页每次进页面、每次切时间范围都会打它,而这类聚合值的可容忍陈旧度
|
||||
// 与指标查询一致(分钟级),所以用同样的 90s TTL + 2 分钟取整。
|
||||
const (
|
||||
statsCacheTTL = 90 * time.Second
|
||||
statsCacheMax = 200
|
||||
)
|
||||
|
||||
type statsCacheEntry struct {
|
||||
stats *LokiStats
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
statsCacheMu sync.Mutex
|
||||
statsCache = map[string]statsCacheEntry{}
|
||||
)
|
||||
|
||||
// LokiStatsCached 带短缓存的总量统计。
|
||||
func LokiStatsCached(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, error) {
|
||||
key := ""
|
||||
if src != nil {
|
||||
bucket := int64(metricCacheBucket)
|
||||
key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket)
|
||||
statsCacheMu.Lock()
|
||||
if entry, ok := statsCache[key]; ok && time.Now().Before(entry.expiresAt) {
|
||||
statsCacheMu.Unlock()
|
||||
return entry.stats, nil
|
||||
}
|
||||
statsCacheMu.Unlock()
|
||||
}
|
||||
|
||||
st, err := LokiStatsFor(ctx, src, query, start, end)
|
||||
if err != nil || key == "" {
|
||||
return st, err
|
||||
}
|
||||
|
||||
statsCacheMu.Lock()
|
||||
if len(statsCache) >= statsCacheMax {
|
||||
now := time.Now()
|
||||
for k, v := range statsCache {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(statsCache, k)
|
||||
}
|
||||
}
|
||||
if len(statsCache) >= statsCacheMax {
|
||||
statsCache = map[string]statsCacheEntry{}
|
||||
}
|
||||
}
|
||||
statsCache[key] = statsCacheEntry{stats: st, expiresAt: time.Now().Add(statsCacheTTL)}
|
||||
statsCacheMu.Unlock()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// lokiMetricsUncached 真正打 Loki 的实现(不带缓存)。
|
||||
func lokiMetricsUncached(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
|
||||
if end <= 0 {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface DailyVolumePoint {
|
||||
entries: number
|
||||
/** 当天未结束或抓取时数据不全 */
|
||||
partial: boolean
|
||||
/** 该天平台没有留存快照(超过留存期/当天平台没运行)——与"日志量真的是 0"不同 */
|
||||
missing?: boolean
|
||||
}
|
||||
export const logsApi = {
|
||||
sources: () => http.get<PageData<LogSource>>('/logs/sources'),
|
||||
|
||||
@@ -52,6 +52,8 @@ export default {
|
||||
other: 'Other',
|
||||
},
|
||||
daily: {
|
||||
notRetained: 'Not retained',
|
||||
missingHint: '{{count}} day(s) have no retained snapshot (grey bars) — that does NOT mean zero logs on those days.',
|
||||
fixedBadge: 'Platform history',
|
||||
badge: 'Platform history',
|
||||
title: 'Daily volume',
|
||||
|
||||
@@ -52,6 +52,8 @@ export default {
|
||||
other: '其他',
|
||||
},
|
||||
daily: {
|
||||
notRetained: '未留存',
|
||||
missingHint: '其中 {{count}} 天平台没有留存记录(灰色柱),不是"当天没有日志"。',
|
||||
fixedBadge: '平台留存历史',
|
||||
badge: '平台留存历史',
|
||||
title: '每日日志量',
|
||||
|
||||
@@ -29,10 +29,15 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
||||
bytes: point.bytes,
|
||||
entries: point.entries,
|
||||
partial: point.partial,
|
||||
missing: Boolean(point.missing),
|
||||
})), [points])
|
||||
|
||||
const hasData = data.some(item => item.bytes > 0)
|
||||
const coverage = points.length
|
||||
// 有留存的天数与"完全没留存"的天数分开统计:
|
||||
// 后者是"不知道",不能画成 0 让人以为那天没日志。
|
||||
const covered = data.filter(item => !item.missing)
|
||||
const missingCount = data.length - covered.length
|
||||
const hasData = covered.some(item => item.bytes > 0)
|
||||
const coverage = covered.length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -68,8 +73,9 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
||||
<YAxis tick={{ fontSize: 11 }} width={64} tickFormatter={(value: number) => formatBytes(value)} />
|
||||
<Tooltip
|
||||
formatter={(value: number, _name, item) => {
|
||||
const partial = (item?.payload as { partial?: boolean } | undefined)?.partial
|
||||
return [`${formatBytes(value)}${partial ? ` · ${t('daily.inProgress')}` : ''}`, t('daily.seriesVolume')]
|
||||
const payload = item?.payload as { partial?: boolean; missing?: boolean } | undefined
|
||||
if (payload?.missing) return [t('daily.notRetained'), t('daily.seriesVolume')]
|
||||
return [`${formatBytes(value)}${payload?.partial ? ` · ${t('daily.inProgress')}` : ''}`, t('daily.seriesVolume')]
|
||||
}}
|
||||
/>
|
||||
{/* 圆柱形柱子:顶部圆角 + 渐变,当天未结束的用空心区分 */}
|
||||
@@ -77,7 +83,13 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
||||
{data.map(item => (
|
||||
<Cell
|
||||
key={item.date}
|
||||
fill={item.partial ? 'hsl(var(--info) / 0.35)' : 'url(#dailyVolumeBar)'}
|
||||
fill={
|
||||
item.missing
|
||||
? 'hsl(var(--muted-foreground) / 0.12)'
|
||||
: item.partial
|
||||
? 'hsl(var(--info) / 0.35)'
|
||||
: 'url(#dailyVolumeBar)'
|
||||
}
|
||||
stroke={item.partial ? 'hsl(var(--info))' : undefined}
|
||||
strokeDasharray={item.partial ? '3 3' : undefined}
|
||||
/>
|
||||
@@ -89,6 +101,14 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
||||
) : (
|
||||
<EmptyChart message={t('daily.empty')} />
|
||||
)}
|
||||
|
||||
{/* 灰柱 = 平台没有留存那天(不是"日志量为 0"),必须显式说明,
|
||||
否则 30/90/365 天视角下会被误读成"以前日志很少" */}
|
||||
{missingCount > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('daily.missingHint', { count: missingCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user