perf(logs): 快捷 LogQL 下拉加前后端双层缓存,重复打开不再转圈
=== 先测清楚瓶颈在哪 === 后端单点实测(都很快,不是问题所在): /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:
1 parent
4d582e62eb
commit
5f42ac5486
2 files changed
+113
-5
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user