perf(logs): label 缓存 TTL 30s + 平台变更即时失效(回答"新 app 会不会延迟")
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

问题:加了 60 秒缓存后,新增 app / 新副本 / 新主机采集器要等最久 60 秒
才出现在下拉里,抢修时"刚上线的服务查不到"是不可接受的。

两层处理:

1) TTL 60s → 30s(前后端一致)
   纯 Loki 侧的变更(新 Pod 开始打日志、新副本、主机上新进程写日志)
   最长 30 秒可见。这类变化平台无从感知,只能靠 TTL 兜住。

2) 平台侧变更**立即失效**(不等 TTL)
   新增 InvalidateLabelCache(),在下列入口 defer 调用:
     CollectorService.Create / Update        (主机模式 app 标签来源)
     DeploymentService.Delete                (连带清理托管数据源)
     CreateLogSource / UpdateLogSource / DeleteLogSource
   也就是"用户在平台上做了什么导致候选值集合变化",缓存当场清掉,
   下一个请求必然回源。这正好覆盖"主机采集的 app 标签"这个场景 ——
   采集器一改,下拉里马上就有。

关于「要不要用定时任务预热」:
   不做。预热只能省掉一次 25~40ms 的网络往返,却要多一个常驻任务和
   一份额外的失败路径;而"时效性"问题已经由上面的即时失效解决了。
   如果将来真要削掉这 40ms,更划算的做法是把 label 值随部署一起下发缓存,
   而不是定时轮询 Loki。
This commit is contained in:
opsx committed 2026-10-31 18:27:17 +00:00
1 parent 25a5e57cb3
commit 5f42d9f696
5 files changed
+31 -3

No files matched your search

+3
View File
@@ -240,6 +240,8 @@ func (s *CollectorService) PreviewConfig(id uint, updates map[string]interface{}
} }
func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) { func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) {
// 采集器变更可能带来新的 app 标签(主机模式),下拉候选值必须失效
defer InvalidateLabelCache()
switch req.Type { switch req.Type {
case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar": case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar":
default: default:
@@ -358,6 +360,7 @@ func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64,
} }
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error { func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
defer InvalidateLabelCache()
db := repository.GetDB() db := repository.GetDB()
db.Lock() db.Lock()
defer db.Unlock() defer db.Unlock()
+2
View File
@@ -319,6 +319,8 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
} }
func (s *DeploymentService) Delete(id uint) error { func (s *DeploymentService) Delete(id uint) error {
// 部署删除会连带清理数据源,候选值失效
defer InvalidateLabelCache()
deployment, err := s.Get(id) deployment, err := s.Get(id)
if err != nil { if err != nil {
return err return err
+15 -1
View File
@@ -376,7 +376,9 @@ const labelValuesDefaultWindow = time.Hour
// 而值本身的时效性要求很低(新上线的 app 晚 1 分钟出现完全可接受)。 // 而值本身的时效性要求很低(新上线的 app 晚 1 分钟出现完全可接受)。
// 缓存后重复请求从 25~40ms 降到微秒级,体感是"打开就有"。 // 缓存后重复请求从 25~40ms 降到微秒级,体感是"打开就有"。
const ( const (
labelCacheTTL = 60 * time.Second // 30 秒:新上线的 app / 新副本要尽快出现在下拉里,
// 但也不必每点一次都打 Loki(重复请求间隔通常远小于 30 秒)。
labelCacheTTL = 30 * time.Second
labelCacheMax = 500 labelCacheMax = 500
) )
@@ -431,6 +433,18 @@ func labelCachePut(key string, values []string) {
labelCache[key] = labelCacheEntry{values: values, expiresAt: time.Now().Add(labelCacheTTL)} labelCache[key] = labelCacheEntry{values: values, expiresAt: time.Now().Add(labelCacheTTL)}
} }
// InvalidateLabelCache 清空 label 候选值缓存。
//
// 什么时候必须调:平台侧发生了"会改变候选值集合"的变更 ——
// 采集器新增/修改/卸载(主机模式下 app 标签来自采集器配置)、
// 部署删除、数据源增删改。这些场景下等 30 秒 TTL 自然过期会让用户
// 觉得"刚配好的 app 在下拉里找不到"。
func InvalidateLabelCache() {
labelCacheMu.Lock()
labelCache = map[string]labelCacheEntry{}
labelCacheMu.Unlock()
}
func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector string, start, end int64) ([]string, error) { func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector string, start, end int64) ([]string, error) {
// 缓存按调用方传入的原始 start/end 计算 key(下方默认窗口不影响 key 的稳定性) // 缓存按调用方传入的原始 start/end 计算 key(下方默认窗口不影响 key 的稳定性)
cacheKey := labelCacheKey(src, name, selector, start, end) cacheKey := labelCacheKey(src, name, selector, start, end)
+3
View File
@@ -149,6 +149,7 @@ func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query st
} }
func CreateLogSource(s *models.LogSource) (*models.LogSource, error) { func CreateLogSource(s *models.LogSource) (*models.LogSource, error) {
defer InvalidateLabelCache()
if err := encryptSourceCredentials(s); err != nil { if err := encryptSourceCredentials(s); err != nil {
return nil, err return nil, err
} }
@@ -212,6 +213,7 @@ func validSourceHeader(name string) bool {
} }
func UpdateLogSource(id uint, patch map[string]interface{}) error { func UpdateLogSource(id uint, patch map[string]interface{}) error {
defer InvalidateLabelCache()
db := repository.GetDB() db := repository.GetDB()
db.Lock() db.Lock()
defer db.Unlock() defer db.Unlock()
@@ -327,6 +329,7 @@ func ValidateSourceAccess(access []models.LogSourceAccess) error {
} }
func DeleteLogSource(id uint) error { func DeleteLogSource(id uint) error {
defer InvalidateLabelCache()
db := repository.GetDB() db := repository.GetDB()
db.Lock() db.Lock()
defer db.Unlock() defer db.Unlock()
+8 -2
View File
@@ -34,12 +34,18 @@ export interface DailyVolumePoint {
* *
* 为什么前端也要缓存:快捷 LogQL 的下拉在「切数据源 / 切命名空间 / 重开面板」 * 为什么前端也要缓存:快捷 LogQL 的下拉在「切数据源 / 切命名空间 / 重开面板」
* 时都会重新走一遍请求链,后端虽然只要 25~40ms,但串起来仍是三四次往返, * 时都会重新走一遍请求链,后端虽然只要 25~40ms,但串起来仍是三四次往返,
* 用户看到的就是转圈。缓存后同一组合在 60 秒内直接命中内存,体感是"打开就有"。 * 用户看到的就是转圈。缓存后同一组合在 30 秒内直接命中内存,体感是"打开就有"。
*
* 时效性说明:新增 app / 新副本最长 30 秒后出现在下拉里。平台侧的变更
* (采集器增删改、部署删除、数据源增删改)会由后端立即清缓存,
* 所以"刚配好的 app 找不到"这类问题不会被缓存放大。
* *
* 用 Promise 作为缓存值:并发的相同请求共享同一个飞行中的请求(去重), * 用 Promise 作为缓存值:并发的相同请求共享同一个飞行中的请求(去重),
* 失败时立刻从缓存剔除,避免把错误结果缓存住。 * 失败时立刻从缓存剔除,避免把错误结果缓存住。
*/ */
const LABEL_CACHE_TTL = 60_000 // 与后端 TTL 对齐(30 秒):新上线的 app 要在半分钟内可被选中,
// 同时又能挡住"切命名空间/重开面板"造成的重复请求。
const LABEL_CACHE_TTL = 30_000
const labelValueCache = new Map<string, { expires: number; data: Promise<unknown> }>() const labelValueCache = new Map<string, { expires: number; data: Promise<unknown> }>()
function cachedRequest<T>(key: string, run: () => Promise<T>): Promise<T> { function cachedRequest<T>(key: string, run: () => Promise<T>): Promise<T> {