diff --git a/backend/services/log_query.go b/backend/services/log_query.go index f980af4..a0285b2 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -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 } diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index 24388f7..fcb5110 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -29,6 +29,36 @@ export interface DailyVolumePoint { /** 该天平台没有留存快照(超过留存期/当天平台没运行)——与"日志量真的是 0"不同 */ missing?: boolean } +/** + * label 候选值的前端缓存(60 秒 + 同请求合并)。 + * + * 为什么前端也要缓存:快捷 LogQL 的下拉在「切数据源 / 切命名空间 / 重开面板」 + * 时都会重新走一遍请求链,后端虽然只要 25~40ms,但串起来仍是三四次往返, + * 用户看到的就是转圈。缓存后同一组合在 60 秒内直接命中内存,体感是"打开就有"。 + * + * 用 Promise 作为缓存值:并发的相同请求共享同一个飞行中的请求(去重), + * 失败时立刻从缓存剔除,避免把错误结果缓存住。 + */ +const LABEL_CACHE_TTL = 60_000 +const labelValueCache = new Map }>() + +function cachedRequest(key: string, run: () => Promise): Promise { + const now = Date.now() + const hit = labelValueCache.get(key) + if (hit && hit.expires > now) return hit.data as Promise + const promise = run().catch((err: unknown) => { + labelValueCache.delete(key) + throw err + }) + labelValueCache.set(key, { expires: now + LABEL_CACHE_TTL, data: promise }) + return promise +} + +/** 手动失效(数据源被删除/重建后调用,避免拿到旧源的候选值) */ +export function invalidateLabelCache(): void { + labelValueCache.clear() +} + export const logsApi = { sources: () => http.get>('/logs/sources'), createSource: (source: Partial & { basicPass?: string; bearerToken?: string }) => http.post('/logs/sources', source), @@ -70,7 +100,11 @@ export const logsApi = { /** LogQL 指标查询(admin):query 中的 [$step] 占位符由后端按时间跨度替换 */ metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post('/logs/metrics', data, { timeout: 125000, ...opts }), labels: (sourceId: number, start?: number, end?: number) => http.get(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`), - labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), + labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => + cachedRequest( + `lv|${sourceId}|${name}|${query}|${start ?? 0}|${end ?? 0}`, + () => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), + ), /** * 批量取多个 label 的候选值:`/logs/label-values?names=app,service_name,container&query={...}` * @@ -80,10 +114,18 @@ export const logsApi = { * 返回里缺某个 label 或取失败 = 空数组(不会抛错)。 */ labelValuesBatch: (sourceId: number, names: string[], query = '', start?: number, end?: number, opts?: { signal?: AbortSignal }) => - http.get>( - `/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`, - opts, - ), + // 带 signal 的调用(用户主动取消)不参与缓存,避免把"被取消"的请求共享给别人 + opts?.signal + ? http.get>( + `/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`, + opts, + ) + : cachedRequest>( + `lvb|${sourceId}|${names.join(',')}|${query}|${start ?? 0}|${end ?? 0}`, + () => http.get>( + `/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`, + ), + ), /** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */ myScope: (sourceId: number) => http.get<{ labels: Record; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`), policyGroups: () => http.get('/logs/policy-groups'),