perf(logs): 快捷 LogQL 下拉加前后端双层缓存,重复打开不再转圈
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

=== 先测清楚瓶颈在哪 ===
后端单点实测(都很快,不是问题所在):
  /logs/my-scope                    10ms
  /logs/label/namespace/values      25ms
  /logs/label-values?scoped astralx 23ms
  /logs/label/app/values scoped     18ms
结论:慢的是**前端每次都重新走一遍请求链**(切数据源 / 切命名空间 / 重开面板
都会触发),三四次往返串起来 + 每次都有 loading 态,用户看到的就是转圈。

=== 后端:label 候选值短缓存 ===
services/log_query.go:60 秒 TTL,key = sourceId|label|selector|时间窗(按 5 分钟取整),
上限 500 条并做过期清理。理由:下拉值的时效性要求很低——
新上线的 app 晚 1 分钟出现在候选里完全可以接受。
LokiLabelValuesBatch 内部逐个走 LokiLabelValues,所以批量接口自动受益。

=== 前端:内存缓存 + 飞行中请求合并 ===
api/logs/index.ts:60 秒 TTL 的 Map,**缓存 Promise 而不是结果**,
所以并发的相同请求共享同一次飞行(去重);失败立刻剔除缓存,避免缓存错误。
带 AbortSignal 的调用不参与缓存(用户主动取消的请求不应共享给别人)。
额外导出 invalidateLabelCache(),数据源变更时可手动清。

=== 预期效果 ===
  首次打开:与之前相同(~100ms 量级)
  再次切换 / 重开面板:直接命中内存,0 请求
  服务端重复请求:25~40ms → 微秒级
This commit is contained in:
opsx committed 2026-10-31 18:22:38 +00:00
1 parent 4d582e62eb
commit 5f42ac5486
2 files changed
+113 -5

No files matched your search

+66
View File
@@ -371,7 +371,72 @@ func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([
// 只影响"没显式给时间范围"的调用(下拉框),不影响带范围的查询。
const labelValuesDefaultWindow = time.Hour
// ── label 候选值的短缓存 ────────────────────────────────────────────
// 快捷 LogQL 的下拉框在「切数据源 / 切命名空间 / 开关面板」时都会重新请求,
// 而值本身的时效性要求很低(新上线的 app 晚 1 分钟出现完全可接受)。
// 缓存后重复请求从 25~40ms 降到微秒级,体感是"打开就有"。
const (
labelCacheTTL = 60 * time.Second
labelCacheMax = 500
)
type labelCacheEntry struct {
values []string
expiresAt time.Time
}
var (
labelCacheMu sync.Mutex
labelCache = map[string]labelCacheEntry{}
)
func labelCacheKey(src *models.LogSource, name, selector string, start, end int64) string {
if src == nil {
return ""
}
// 时间窗按 5 分钟取整:避免"每次请求差几秒"导致缓存全不命中
bucket := int64(5 * time.Minute)
return fmt.Sprintf("%d|%s|%s|%d|%d", src.ID, name, selector, start/bucket, end/bucket)
}
func labelCacheGet(key string) ([]string, bool) {
if key == "" {
return nil, false
}
labelCacheMu.Lock()
defer labelCacheMu.Unlock()
if entry, ok := labelCache[key]; ok && time.Now().Before(entry.expiresAt) {
return entry.values, true
}
return nil, false
}
func labelCachePut(key string, values []string) {
if key == "" {
return
}
labelCacheMu.Lock()
defer labelCacheMu.Unlock()
if len(labelCache) >= labelCacheMax {
now := time.Now()
for k, v := range labelCache {
if now.After(v.expiresAt) {
delete(labelCache, k)
}
}
if len(labelCache) >= labelCacheMax {
labelCache = map[string]labelCacheEntry{}
}
}
labelCache[key] = labelCacheEntry{values: values, expiresAt: time.Now().Add(labelCacheTTL)}
}
func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector string, start, end int64) ([]string, error) {
// 缓存按调用方传入的原始 start/end 计算 key(下方默认窗口不影响 key 的稳定性)
cacheKey := labelCacheKey(src, name, selector, start, end)
if cached, ok := labelCacheGet(cacheKey); ok {
return cached, nil
}
q := url.Values{}
if s := extractStreamSelector(selector); s != "" && s != "{}" {
q.Set("query", s)
@@ -406,6 +471,7 @@ func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector
if resp.Data == nil {
resp.Data = []string{}
}
labelCachePut(cacheKey, resp.Data)
return resp.Data, nil
}