From 5f42ac5486e19a1d25b0dc7158a98c69daa74cbe Mon Sep 17 00:00:00 2001 From: opsx Date: Sat, 31 Oct 2026 18:22:38 +0000 Subject: [PATCH] =?UTF-8?q?perf(logs):=20=E5=BF=AB=E6=8D=B7=20LogQL=20?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=8A=A0=E5=89=8D=E5=90=8E=E7=AB=AF=E5=8F=8C?= =?UTF-8?q?=E5=B1=82=E7=BC=93=E5=AD=98=EF=BC=8C=E9=87=8D=E5=A4=8D=E6=89=93?= =?UTF-8?q?=E5=BC=80=E4=B8=8D=E5=86=8D=E8=BD=AC=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 先测清楚瓶颈在哪 === 后端单点实测(都很快,不是问题所在): /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 → 微秒级 --- backend/services/log_query.go | 66 ++++++++++++++++++++++++++++++++++ frontend/src/api/logs/index.ts | 52 ++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 5 deletions(-) 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'),