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

+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'),