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
}
+47 -5
View File
@@ -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<string, { expires: number; data: Promise<unknown> }>()
function cachedRequest<T>(key: string, run: () => Promise<T>): Promise<T> {
const now = Date.now()
const hit = labelValueCache.get(key)
if (hit && hit.expires > now) return hit.data as Promise<T>
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<PageData<LogSource>>('/logs/sources'),
createSource: (source: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) => http.post<LogSource>('/logs/sources', source),
@@ -70,7 +100,11 @@ export const logsApi = {
/** LogQL 指标查询(admin):query 中的 [$step] 占位符由后端按时间跨度替换 */
metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post<MetricSeries[]>('/logs/metrics', data, { timeout: 125000, ...opts }),
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/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<string[]>(
`lv|${sourceId}|${name}|${query}|${start ?? 0}|${end ?? 0}`,
() => http.get<string[]>(`/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<Record<string, string[]>>(
`/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`,
opts,
),
// 带 signal 的调用(用户主动取消)不参与缓存,避免把"被取消"的请求共享给别人
opts?.signal
? http.get<Record<string, string[]>>(
`/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`,
opts,
)
: cachedRequest<Record<string, string[]>>(
`lvb|${sourceId}|${names.join(',')}|${query}|${start ?? 0}|${end ?? 0}`,
() => http.get<Record<string, string[]>>(
`/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<string, string>; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`),
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),