Files
LokiStack/backend/services/log_query.go
T
opsx 5f42ac5486
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
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 → 微秒级
2026-10-31 18:22:38 +00:00

1056 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
// 日志查询代理 — 后端代表前端访问 Loki HTTP API。
// cluster-svc 型经 kube-apiserver service proxy(不依赖 ClusterIP 直通与集群 DNS);
// url 型直连。契约参照 aiops 日志中心(query/labels/values/histogram + cursor 翻页)。
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"k8s.io/client-go/rest"
"lokistack/models"
"lokistack/repository"
)
// maxLogQueryLimit 单页硬上限,与前端下拉选项保持**完全一致**,
// 避免"前端能选 5000、后端静默截成 1000"这种假选项。
// 取值 5000 = Loki limits_config.max_entries_limit_per_query 的默认上限;
// 前端结果表已加虚拟滚动(>100 条只渲染可视区),5000 行不会卡。
const maxLogQueryLimit = 5000
const logQueryTimeout = 120 * time.Second
var lokiHTTPClients = [2]*http.Client{
{Timeout: logQueryTimeout, Transport: newLokiTransport(false)},
{Timeout: logQueryTimeout, Transport: newLokiTransport(true)},
}
func newLokiTransport(skipTLSVerify bool) *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: skipTLSVerify} //nolint:gosec // 数据源显式配置
return transport
}
// ==================== Loki HTTP 访问 ====================
// lokiProxyGET 对数据源执行 GET;cluster-svc 使用 client-go 的认证传输访问 API Server Service Proxy。
func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query url.Values) ([]byte, int, error) {
if src.TargetType == "cluster-svc" {
db := repository.GetDB()
db.RLock()
var cluster *models.Cluster
for i := range db.Clusters {
if src.ClusterID != nil && db.Clusters[i].ID == *src.ClusterID {
c := db.Clusters[i]
cluster = &c
break
}
}
db.RUnlock()
if cluster == nil {
return nil, 500, fmt.Errorf("数据源关联的集群不存在")
}
if !kubernetesNamespacePattern.MatchString(src.Namespace) || !kubernetesNamespacePattern.MatchString(src.Service) || src.Port < 1 || src.Port > 65535 {
return nil, 500, fmt.Errorf("Loki 数据源的命名空间、Service 或端口无效")
}
config, err := clusterRESTConfig(cluster, logQueryTimeout)
if err != nil {
return nil, 500, err
}
client, err := rest.HTTPClientFor(config)
if err != nil {
return nil, 500, fmt.Errorf("创建 Kubernetes API 客户端失败: %w", err)
}
full := strings.TrimRight(config.Host, "/") + fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s",
src.Namespace, src.Service, src.Port, path)
if qs := query.Encode(); qs != "" {
full += "?" + qs
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, 500, err
}
resp, err := client.Do(req)
if err != nil {
return nil, 502, fmt.Errorf("Kubernetes API 代理查询失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, 502, err
}
if resp.StatusCode >= 400 {
return body, resp.StatusCode, fmt.Errorf("Kubernetes API 代理返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300))
}
return body, resp.StatusCode, nil
}
// url 直连
if src.URL == "" {
return nil, 500, fmt.Errorf("数据源未配置访问地址")
}
full := strings.TrimSuffix(src.URL, "/") + path
if qs := query.Encode(); qs != "" {
full += "?" + qs
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, 500, err
}
applyLokiAuth(req, src)
client := lokiHTTPClients[0]
if src.SkipTLSVerify {
client = lokiHTTPClients[1]
}
resp, err := client.Do(req)
if err != nil {
return nil, 502, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, 502, err
}
if resp.StatusCode >= 400 {
return body, resp.StatusCode, fmt.Errorf("Loki 返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300))
}
return body, resp.StatusCode, nil
}
func applyLokiAuth(req *http.Request, src *models.LogSource) {
switch src.AuthType {
case "basic":
if pass, err := DecryptCred(src.BasicPass); err == nil {
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(src.BasicUser+":"+pass)))
}
case "bearer":
if token, err := DecryptCred(src.BearerToken); err == nil {
req.Header.Set("Authorization", "Bearer "+token)
}
}
for name, encrypted := range src.Headers {
if validSourceHeader(name) {
if value, err := DecryptCred(encrypted); err == nil {
req.Header.Set(name, value)
}
}
}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// ==================== 查询实现(契约对齐 aiops) ====================
type LogEntryRow struct {
Timestamp string `json:"timestamp"`
TimestampNs string `json:"timestampNs"`
Level string `json:"level"`
Message string `json:"message"`
Labels map[string]string `json:"labels"`
}
type LogQueryResult struct {
Entries []LogEntryRow `json:"entries"`
HasMore bool `json:"hasMore"`
NextCursor string `json:"nextCursor"`
}
// ContextLogSelector uses stable stream identity labels. Loki can add per-entry
// labels such as detected_level to query results; matching those would hide
// adjacent lines whose detected level differs from the selected line.
func ContextLogSelector(labels map[string]string) (string, error) {
for key := range labels {
if !ValidLogLabelName(key) {
return "", fmt.Errorf("标签名无效")
}
}
keys := []string{"cluster", "namespace", "app", "pod", "container", "filename"}
if labels["pod"] == "" {
keys = append(keys, "job", "instance", "host")
}
parts := make([]string, 0, len(keys))
for _, key := range keys {
if value := labels[key]; value != "" {
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
}
}
if len(parts) == 0 {
for key, value := range labels {
if value == "" || key == "detected_level" || key == "level" || strings.HasPrefix(key, "__") {
continue
}
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
}
sort.Strings(parts)
}
if len(parts) == 0 {
return "", fmt.Errorf("缺少可用于定位日志流的标签")
}
return "{" + strings.Join(parts, ",") + "}", nil
}
// QueryLokiLogs 日志查询:flatten streams → 统一行;cursor(最后一条纳秒时间戳)向后翻页
func QueryLokiLogs(ctx context.Context, src *models.LogSource, query string, start, end int64, limit int, direction, cursor string) (*LogQueryResult, error) {
if limit <= 0 {
limit = 200
}
if limit > maxLogQueryLimit {
limit = maxLogQueryLimit
}
if direction == "" {
direction = "backward"
}
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour) // 默认最近 1 小时
}
if cursor != "" {
if c, err := strconv.ParseInt(cursor, 10, 64); err == nil {
if direction == "forward" {
start = c + 1
} else {
end = c - 1
}
}
}
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("limit", strconv.Itoa(limit))
q.Set("direction", direction)
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
if err != nil {
return nil, err
}
var resp struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Stream map[string]string `json:"stream"`
Values [][2]string `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("解析 Loki 响应失败: %w", err)
}
entries := make([]LogEntryRow, 0, limit)
var lastTs string
for _, stream := range resp.Data.Result {
for _, v := range stream.Values {
entries = append(entries, LogEntryRow{
Timestamp: formatLokiTimestamp(v[0]),
TimestampNs: v[0],
Level: detectLevel(stream.Stream, v[1]),
Message: v[1],
Labels: stream.Stream,
})
}
}
sort.SliceStable(entries, func(i, j int) bool {
a, _ := strconv.ParseInt(entries[i].TimestampNs, 10, 64)
b, _ := strconv.ParseInt(entries[j].TimestampNs, 10, 64)
if direction == "forward" {
return a < b
}
return a > b
})
if len(entries) > limit {
entries = entries[:limit]
}
if len(entries) > 0 {
lastTs = entries[len(entries)-1].TimestampNs
}
nextCursor := ""
if len(entries) >= limit && lastTs != "" {
if ts, err := strconv.ParseInt(strings.Split(lastTs, ".")[0], 10, 64); err == nil {
nextCursor = strconv.FormatInt(ts, 10)
}
}
return &LogQueryResult{Entries: entries, HasMore: nextCursor != "", NextCursor: nextCursor}, nil
}
// formatLokiTimestamp Loki 时间串(纳秒字符串/科学计数法)→ RFC3339
func formatLokiTimestamp(ts string) string {
if i := strings.IndexAny(ts, ".eE"); i > 0 {
ts = ts[:i]
}
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ts
}
// 自适应纳秒/微秒/毫秒/秒
switch {
case n > 1e18:
return time.Unix(0, n).Format(time.RFC3339)
case n > 1e15:
return time.Unix(0, n*1e3).Format(time.RFC3339)
case n > 1e12:
return time.Unix(0, n*1e6).Format(time.RFC3339)
case n > 1e9:
return time.Unix(0, n*1e9).Format(time.RFC3339)
default:
return time.Unix(n, 0).Format(time.RFC3339)
}
}
// detectLevel 优先取 level 标签;否则按行内容推断
func detectLevel(labels map[string]string, line string) string {
if lv, ok := labels["level"]; ok && lv != "" {
return strings.ToLower(lv)
}
l := strings.ToLower(line)
for _, p := range []string{"error", "err", "fatal", "panic"} {
if strings.Contains(l, p) {
return "error"
}
}
if strings.Contains(l, "warn") {
return "warn"
}
return "info"
}
// LokiLabels 标签名列表
func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([]string, error) {
q := url.Values{}
if end > 0 {
q.Set("end", strconv.FormatInt(end, 10))
}
if start > 0 {
q.Set("start", strconv.FormatInt(start, 10))
}
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/labels", q)
if err != nil {
return nil, err
}
var resp struct {
Data []string `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
// Loki 在某 label 无值时会返回 {"data":null},Go nil slice 序列化成 JSON null,
// 前端 setState(null) 后 .map() 直接抛 "Cannot read properties of null"。
// 统一归一化为空切片,前端拿到的永远是数组。
if resp.Data == nil {
resp.Data = []string{}
}
return resp.Data, nil
}
// LokiLabelValues 标签值列表(query 只取 {...} selector 部分)
// labelValuesDefaultWindow 是 label 值下拉框的默认时间窗。
// 只影响"没显式给时间范围"的调用(下拉框),不影响带范围的查询。
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)
}
// 不传时间范围时 Loki 默认扫**最近 6 小时**的索引 —— 在 370GB/天的集群上
// 实测要 10~21 秒(下拉框"一直加载中"的直接原因)。
// 下拉框只需要"现在有哪些 app/pod 在打日志",给 1 小时足够,索引量降到 1/6。
// 调用方显式传了时间范围则完全尊从,不受影响。
if start <= 0 && end <= 0 {
now := time.Now()
start = now.Add(-labelValuesDefaultWindow).UnixNano()
end = now.UnixNano()
}
if end > 0 {
q.Set("end", strconv.FormatInt(end, 10))
}
if start > 0 {
q.Set("start", strconv.FormatInt(start, 10))
}
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/label/"+url.PathEscape(name)+"/values", q)
if err != nil {
return nil, err
}
var resp struct {
Data []string `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
// 同上:label 无值时 Loki 返回 {"data":null},归一化为空切片,
// 避免前端 .map() 遇到 null 崩溃(快捷 LogQL 报错的直接原因)。
if resp.Data == nil {
resp.Data = []string{}
}
labelCachePut(cacheKey, resp.Data)
return resp.Data, nil
}
// LokiLabelValuesBatch 一次取多个 label 的候选值。
//
// 前端快捷查询要对 app / service_name / container 做逐级回退(哪个有值用哪个),
// 原来最多并发 3 个请求、每个请求各走一次 Loki 索引查询 + 一次 kube-apiserver
// service proxy;合并成本接口后前端只发一次,后端内部并发(上限 4)。
//
// 语义:单个 label 失败不影响其它 label(返回空数组),**全部失败**才报错 ——
// 否则某个 label 在特定 selector 下索引异常,整个下拉框都会变成"加载失败"。
func LokiLabelValuesBatch(ctx context.Context, src *models.LogSource, names []string, selector string, start, end int64) (map[string][]string, error) {
out := make(map[string][]string, len(names))
var (
mu sync.Mutex
wg sync.WaitGroup
firstErr error
okCount int
seen = make(map[string]bool, len(names))
)
sem := make(chan struct{}, 4)
for _, raw := range names {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
seen[name] = true
out[name] = []string{}
wg.Add(1)
go func(labelName string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
values, err := LokiLabelValues(ctx, src, labelName, selector, start, end)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
return
}
okCount++
out[labelName] = values
}(name)
}
wg.Wait()
if okCount == 0 && firstErr != nil {
return out, firstErr
}
return out, nil
}
// LokiVolumeBytesFor 用 index/volume 读某个 selector 的日志体积(字节)。
//
// 这是 Loki 的**索引接口**:只读 TSDB 里记录的 chunk 字节数,不解码日志内容。
// 实测单个 app、一小时窗口约 80ms(对比 bytes_over_time 同窗口 13s/15 分钟、
// 约 50s/小时 —— 因为它要把整小时的日志解压出来数行字节)。
//
// 代价:index/volume 返回的 metric 只包含 selector 里出现过的 label,
// 所以"按 app 取体积"没法一条查询拿全,必须一个 app 调一次(见 app_volume.go)。
// aggregateBy=series 让返回值带上 selector 里的全部 label,便于调用方核对。
func LokiVolumeBytesFor(ctx context.Context, src *models.LogSource, selector string, start, end int64) (int64, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour)
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
q := url.Values{}
q.Set("query", selector)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("aggregateBy", "series")
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/volume", q)
if err != nil {
return 0, err
}
var resp struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return 0, fmt.Errorf("解析 volume 响应失败: %w", err)
}
var total int64
for _, item := range resp.Data.Result {
switch v := item.Value[1].(type) {
case float64:
total += int64(v)
case string:
if n, convErr := strconv.ParseFloat(v, 64); convErr == nil {
total += int64(n)
}
}
}
return total, nil
}
// extractStreamSelector 从 LogQL 中提取 {...} 部分(labels API 仅接受 stream selector)
func extractStreamSelector(query string) string {
i := strings.Index(query, "{")
if i < 0 {
return ""
}
j := strings.Index(query[i:], "}")
if j < 0 {
return ""
}
return query[i : i+j+1]
}
// LokiHistogram 日志量直方图:服务端包 sum(count_over_time(q[step]))
// 重查询护栏。设计依据:
// - 单条 27 天的 count_over_time 曾把 Loki 打到 OOMKilled(2Gi),
// 进而 Pod 不 Ready → Service 无端点 → 全站查询 502。
// - 所以"算不完的查询"必须在入口就拒绝,而不是硬跑 120 秒把实例拖垮。
const (
// 日志量统计最多统计 7 天;更长跨度请分段查(前端会提示缩小范围)
maxHistogramRange = 7 * 24 * time.Hour
// 重查询(histogram/metrics)单独的更短超时,避免长时间占用 CPU
// 90s:实测 astralx(约 370GB/天)15 分钟窗口单条 count_over_time
// exec 约 29s、外加排队时间,30s 必超时 → 分析页 502。
// 索引类查询(index/volume_range、index/stats)不受此限制,仍是毫秒级。
heavyQueryTimeout = 90 * time.Second
// 全局并发重查询上限。取 4 而不是 2:
// 日志分析页一次会并发 5 条 count_over_time,限制 2 会让大部分请求拿不到令牌。
maxConcurrentHeavyQueries = 4
// 拿不到令牌时最多排队 10 秒再拒绝。
// 原来"立即 429"会把分析页 5 条并发里的 3 条直接打回,
// 用户看到的就是"当前有较多日志量统计在进行"。
heavyQueryWait = 10 * time.Second
)
// ErrTooManyQueries 并发重查询超限(handler 映射为 429)
var ErrTooManyQueries = errors.New("当前有较多日志量统计在进行,请稍后重试")
// heavyQuerySem 全局重查询信号量
var heavyQuerySem = make(chan struct{}, maxConcurrentHeavyQueries)
// acquireHeavyQuery 先尝试立即获取;拿不到则最多排队 heavyQueryWait。
// 突发(如分析页并发 5 条)会自然排队消化,只有持续超载才返回 429。
func acquireHeavyQuery() bool {
select {
case heavyQuerySem <- struct{}{}:
return true
default:
}
timer := time.NewTimer(heavyQueryWait)
defer timer.Stop()
select {
case heavyQuerySem <- struct{}{}:
return true
case <-timer.C:
return false
}
}
func releaseHeavyQuery() { <-heavyQuerySem }
func LokiHistogram(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]map[string]interface{}, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour)
}
span := end - start
if span > int64(maxHistogramRange) {
return nil, fmt.Errorf("统计范围过大(%.1f 天),最多支持 %.0f 天;请缩小时间范围后重试",
float64(span)/float64(24*time.Hour), maxHistogramRange.Hours()/24)
}
step := pickHistogramStep(span)
// ── 首选:Loki 官方的 index/volume_range ──────────────────────────────
// 它只读 TSDB 索引里的 chunk 元数据(字节数),**不解码日志内容**。
// 实测同一条件 24 小时:count_over_time 53s 超时失败,volume_range 111ms。
// 返回单位是「字节」,所以 bucket 里带 unit=bytes,前端据此格式化为 KB/MB/GB。
//
// ⚠️ 这条路径**不占用重型查询令牌**:
// 索引查询本身是毫秒级的,如果和 count_over_time 之类要读 chunk 的查询抢
// 同一个信号量,就会出现"图表明明 0 秒能出,却因为别人在跑分析查询而排队
// 十秒、最后 429/超时"的怪现象(分析页实际踩过)。
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", step)
q.Set("limit", "50")
idxCtx, idxCancel := context.WithTimeout(ctx, 15*time.Second)
if body, status, err := lokiProxyGET(idxCtx, src, "/loki/api/v1/index/volume_range", q); err == nil && status < 400 {
if buckets, ok := parseVolumeBuckets(body); ok {
idxCancel()
return buckets, nil
}
}
idxCancel()
// ── 回退:老版本 Loki 没有 volume_range 时才走 count_over_time ──
// 这条要真读 chunk,才需要抢重型令牌 + 长超时。
if !acquireHeavyQuery() {
return nil, ErrTooManyQueries
}
defer releaseHeavyQuery()
heavyCtx, cancel := context.WithTimeout(ctx, heavyQueryTimeout)
defer cancel()
return lokiHistogramByCount(heavyCtx, src, query, step, start, end)
}
// parseVolumeBuckets 解析 index/volume_range 的 matrix 响应。
// 成功但无数据也返回 ok=true(空图),只有结构不对才 ok=false 触发回退。
func parseVolumeBuckets(body []byte) ([]map[string]interface{}, bool) {
var resp struct {
Data struct {
Result []struct {
Values [][2]interface{} `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, false
}
buckets := []map[string]interface{}{}
if len(resp.Data.Result) > 0 {
for _, v := range resp.Data.Result[0].Values {
tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
bytes, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
buckets = append(buckets, map[string]interface{}{
"timestamp": time.Unix(int64(tsf), 0).Format(time.RFC3339),
"count": int64(bytes),
"unit": "bytes",
})
}
}
return buckets, true
}
// lokiHistogramByCount 回退实现:count_over_time 逐点统计「条数」。
// 代价高(要真读 chunk),只在 volume_range 不可用时使用。
func lokiHistogramByCount(ctx context.Context, src *models.LogSource, query, step string, start, end int64) ([]map[string]interface{}, error) {
wrapped := fmt.Sprintf("sum(count_over_time(%s[%s]))", query, step)
q := url.Values{}
q.Set("query", wrapped)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", step)
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
if err != nil {
return nil, err
}
var resp struct {
Data struct {
Result []struct {
Values [][2]interface{} `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
buckets := []map[string]interface{}{}
if len(resp.Data.Result) > 0 {
for _, v := range resp.Data.Result[0].Values {
tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
buckets = append(buckets, map[string]interface{}{
"timestamp": time.Unix(int64(tsf), 0).Format(time.RFC3339),
"count": int64(cnt),
"unit": "entries",
})
}
}
return buckets, nil
}
// LokiStats 用官方 /loki/api/v1/index/stats 取「条数/字节/块数」。
//
// 为什么不数日志条数:index/stats 只读 TSDB 索引里的 chunk 元数据,
// 不解码日志内容。实测同一 app 24 小时:count_over_time 53s 失败,
// index/stats 1.3s。用它来算「共 N 条 / 共 M 页」成本可以忽略。
type LokiStats struct {
Streams int64 `json:"streams"`
Chunks int64 `json:"chunks"`
Bytes int64 `json:"bytes"`
Entries int64 `json:"entries"`
}
func LokiStatsFor(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour)
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/stats", q)
if err != nil {
return nil, err
}
var st LokiStats
if err := json.Unmarshal(body, &st); err != nil {
return nil, err
}
return &st, nil
}
// MetricSeriesPoint 指标序列上的一个采样点
type MetricSeriesPoint struct {
Timestamp string `json:"timestamp"`
Value float64 `json:"value"`
}
// MetricSeries 一条按标签分组的指标序列(如 sum by (app)(...) 的每个 app 一条)
type MetricSeries struct {
Labels map[string]string `json:"labels"`
Points []MetricSeriesPoint `json:"points"`
}
// LokiMetrics 通用 LogQL 指标查询(日志分析页用):query 为完整指标表达式,
// 其中所有 [$step] 占位符替换为按时间跨度自适应的步长后走 query_range,
// 返回矩阵多序列(直方图只取单序列,这里按 by (...) 分组整表返回)
// ── 分析页指标查询的短缓存 ────────────────────────────────────────────
//
// 为什么需要:`sum by (app)(count_over_time(...))` 在这台集群上扫 15 分钟
// astralx(约 6.6GB)实测 16~28 秒,Loki 的结果缓存也不命中(请求的时间窗
// 每次都在变)。用户点刷新、切页、换 tab 都要再等半分钟。
//
// 做法:按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 **2 分钟取整**再当 key。
// 代价是命中缓存时数据最多旧 2 分钟 × 90 秒 TTL —— 对"日志量趋势/排行"这种
// 聚合视图完全可接受,换来的是重复访问从 ~26s 降到毫秒级。
const (
metricCacheTTL = 90 * time.Second
metricCacheMax = 200
// 时间窗取整粒度:key 用取整后的值,避免"每次点刷新差几秒就穿透"
metricCacheBucket = 2 * time.Minute
)
type metricCacheEntry struct {
series []MetricSeries
expiresAt time.Time
}
var (
metricCacheMu sync.Mutex
metricCache = map[string]metricCacheEntry{}
)
// LokiMetrics 带短缓存的分析指标查询(语义见 metricCache 注释)。
func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
key := ""
if src != nil {
bucket := int64(metricCacheBucket)
key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket)
metricCacheMu.Lock()
if entry, ok := metricCache[key]; ok && time.Now().Before(entry.expiresAt) {
metricCacheMu.Unlock()
return entry.series, nil
}
metricCacheMu.Unlock()
}
series, err := lokiMetricsUncached(ctx, src, query, start, end)
if err != nil || key == "" {
return series, err
}
metricCacheMu.Lock()
if len(metricCache) >= metricCacheMax {
now := time.Now()
for k, v := range metricCache {
if now.After(v.expiresAt) {
delete(metricCache, k)
}
}
// 清完还满:整体重置,避免无界增长把内存吃掉
if len(metricCache) >= metricCacheMax {
metricCache = map[string]metricCacheEntry{}
}
}
metricCache[key] = metricCacheEntry{series: series, expiresAt: time.Now().Add(metricCacheTTL)}
metricCacheMu.Unlock()
return series, nil
}
// ── 总条数/总体积的短缓存 ─────────────────────────────────────────────
// /logs/stats 走 index/stats,实测 5 分钟窗口要 1.2 秒(60 分钟更慢)。
// 分析页每次进页面、每次切时间范围都会打它,而这类聚合值的可容忍陈旧度
// 与指标查询一致(分钟级),所以用同样的 90s TTL + 2 分钟取整。
const (
statsCacheTTL = 90 * time.Second
statsCacheMax = 200
)
type statsCacheEntry struct {
stats *LokiStats
expiresAt time.Time
}
var (
statsCacheMu sync.Mutex
statsCache = map[string]statsCacheEntry{}
)
// LokiStatsCached 带短缓存的总量统计。
func LokiStatsCached(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, error) {
key := ""
if src != nil {
bucket := int64(metricCacheBucket)
key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket)
statsCacheMu.Lock()
if entry, ok := statsCache[key]; ok && time.Now().Before(entry.expiresAt) {
statsCacheMu.Unlock()
return entry.stats, nil
}
statsCacheMu.Unlock()
}
st, err := LokiStatsFor(ctx, src, query, start, end)
if err != nil || key == "" {
return st, err
}
statsCacheMu.Lock()
if len(statsCache) >= statsCacheMax {
now := time.Now()
for k, v := range statsCache {
if now.After(v.expiresAt) {
delete(statsCache, k)
}
}
if len(statsCache) >= statsCacheMax {
statsCache = map[string]statsCacheEntry{}
}
}
statsCache[key] = statsCacheEntry{stats: st, expiresAt: time.Now().Add(statsCacheTTL)}
statsCacheMu.Unlock()
return st, nil
}
// lokiMetricsUncached 真正打 Loki 的实现(不带缓存)。
func lokiMetricsUncached(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour)
}
// 与 histogram 共用护栏:并发限 2、单次 30s 超时,避免 analysis 页
// 多个卡片同时发起 count_over_time 把单实例 Loki 打满。
if !acquireHeavyQuery() {
return nil, ErrTooManyQueries
}
defer releaseHeavyQuery()
ctx, cancel := context.WithTimeout(ctx, heavyQueryTimeout)
defer cancel()
step := pickHistogramStep(end - start)
wrapped := strings.ReplaceAll(query, "[$step]", "["+step+"]")
q := url.Values{}
q.Set("query", wrapped)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", step)
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
if err != nil {
return nil, err
}
var resp struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Values [][2]interface{} `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
series := make([]MetricSeries, 0, len(resp.Data.Result))
for _, item := range resp.Data.Result {
points := make([]MetricSeriesPoint, 0, len(item.Values))
for _, v := range item.Values {
// metric API 的 ts 为秒级浮点数(如 1.79e+09)
tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
points = append(points, MetricSeriesPoint{
Timestamp: time.Unix(int64(tsf), 0).Format(time.RFC3339),
Value: cnt,
})
}
if len(points) == 0 {
continue
}
series = append(series, MetricSeries{Labels: item.Metric, Points: points})
}
return series, nil
}
// pickHistogramStep 按时间跨度自适应步长(aiops 同款)
func pickHistogramStep(spanNano int64) string {
span := time.Duration(spanNano)
switch {
case span <= 15*time.Minute:
return "30s"
case span <= time.Hour:
return "1m"
case span <= 6*time.Hour:
return "5m"
case span <= 12*time.Hour:
return "15m"
case span <= 7*24*time.Hour:
// 12 小时以上统一 1 小时一桶。
// 24 小时原来是 15m(96 根柱子):横轴挤成一片、且每根柱子只有十来分钟的数据,
// 看不出趋势;1h → 24 根,和"按小时看日志量"的心智一致。
return "1h"
default:
return "6h"
}
}
// DeploymentReady 部署级就绪探测:按部署名匹配自动注册的数据源,经其访问 Loki /ready
func DeploymentReady(ctx context.Context, deploymentName string) (bool, string) {
if deploymentName == "" {
return false, ""
}
db := repository.GetDB()
db.RLock()
var src *models.LogSource
for i := range db.LogSources {
if db.LogSources[i].Name == deploymentName {
s := db.LogSources[i]
src = &s
break
}
}
db.RUnlock()
if src == nil {
return false, "未注册数据源"
}
if LokiReady(ctx, src) {
return true, ""
}
return false, "Loki 未就绪"
}
// LokiReady Loki /ready 探活(部署卡片/查询页状态点)
func LokiReady(ctx context.Context, src *models.LogSource) bool {
body, code, err := lokiProxyGET(ctx, src, "/ready", nil)
if err != nil || code != 200 {
return false
}
return strings.Contains(strings.ToLower(string(body)), "ready")
}
// TestLogSource 数据源连通性(/loki/api/v1/labels 探活)
func TestLogSource(ctx context.Context, src *models.LogSource) map[string]interface{} {
labels, err := LokiLabels(ctx, src, 0, 0)
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("连接成功,发现 %d 个标签", len(labels)),
"labels": labels,
}
}