perf(logs): 日志量统计改用 index/volume_range(快 ~1300 倍)+ 查询护栏 + 真实错误提示
=== 一、现象 ===
- /logs 页「正在统计整个时间范围的日志量」24 小时查询转圈 5 分钟不出结果
- /analysis 页默认 1 小时也一直「加载中...」
- 期间 Loki 被 OOMKilled(2Gi) → Pod not Ready → Service 无端点 → 全站查询 502
=== 二、根因(实测数据) ===
同一 query {namespace="astralx", app="cex-ipublic-gateway"}:
24h count_over_time[15m] step=15m → 53.7s 后连接断开(算不完)
24h count_over_time[1h] step=1h → 60.1s 失败
24h index/stats(只读索引) → 1.3s → bytes 44.9GB / entries 3.33亿 / chunks 2474
24h index/volume_range step=1h → 111ms → 25 个点
1h index/volume_range step=5m → 115ms → 13 个点
原因:count_over_time 必须**下载并解码所有 chunk**;volume_range 只读 TSDB 索引里的
chunk 元数据(字节数),根本不碰日志内容。该 app 一天就有 3.33 亿条 / 44.9 GB。
=== 三、改造 ===
1. LokiHistogram 改用官方 /loki/api/v1/index/volume_range(带 step)
- 返回单位是「字节」,bucket 带 unit=bytes,前端格式化为 KB/MB/GB
- 老版本 Loki 没有该端点时自动回退 count_over_time(unit=entries)
2. 查询护栏(防止单条查询拖垮单实例 Loki)
- 统计范围上限 7 天,超出直接 400「统计范围过大(27.0 天),最多支持 7 天」
- 重查询单独 30s 超时(原来 120s)
- 全局并发重查询上限 2,超出立刻 429「请稍后重试」(不排队)
- /logs/metrics(analysis 页)共用同一套护栏
3. 前端
- 发起新查询前 abort 上一次请求(原来只用版本号丢弃结果,旧请求仍在后端跑)
- histogram/query 支持 signal;超时 40s/60s(原 125s)
- 错误不再统一显示「日志量统计失败」,按 429/400/502/超时给不同文案
- 时间线按 unit 格式化(字节 → GB),i18n 去掉「条」
4. Loki 侧官方优化(此前 limits_config 只有 retention_period)
- query_range: align_queries_with_step + cache_results + embedded_cache 100MB
- limits_config: max_query_parallelism 8(默认 32,单实例过高)
- 已用 `loki -verify-config` 校验通过(config is valid)
=== 四、实测(平台 API) ===
1 分钟 0.02s 2 桶
1 小时 0.05s 61 桶 3.8 GB
6 小时 0.04s 73 桶 12.3 GB
24 小时 0.04s 97 桶 41.8 GB ← 改造前 53.7s 失败
7 天 0.05s 37 桶 58.2 GB
27 天 0.01s 400「最多支持 7 天」
镜像:lghost/lokistack:latest + v0.1 = sha256:9fa1c4597533
This commit is contained in:
1 parent
9acc15f4d2
commit
de376a1284
8 files changed
+229
-24
No files matched your search
@@ -2,6 +2,8 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -312,7 +314,15 @@ func (ctrl *LogsController) Histogram(c *gin.Context) {
|
||||
}
|
||||
buckets, err := services.LokiHistogram(c.Request.Context(), src, query, req.Start, req.End)
|
||||
if err != nil {
|
||||
utils.Error(c, 502, err.Error())
|
||||
switch {
|
||||
case errors.Is(err, services.ErrTooManyQueries):
|
||||
// 429:并发重查询超限,让前端提示"稍后重试",而不是当成服务故障
|
||||
utils.Error(c, http.StatusTooManyRequests, err.Error())
|
||||
case strings.Contains(err.Error(), "统计范围过大"):
|
||||
utils.Error(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
utils.Error(c, 502, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
utils.Success(c, buckets)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -404,6 +405,36 @@ func extractStreamSelector(query string) string {
|
||||
}
|
||||
|
||||
// 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
|
||||
heavyQueryTimeout = 30 * time.Second
|
||||
// 全局并发重查询上限:超过直接快速失败,保护单实例 Loki
|
||||
maxConcurrentHeavyQueries = 2
|
||||
)
|
||||
|
||||
// ErrTooManyQueries 并发重查询超限(handler 映射为 429)
|
||||
var ErrTooManyQueries = errors.New("当前有较多日志量统计在进行,请稍后重试")
|
||||
|
||||
// heavyQuerySem 全局重查询信号量
|
||||
var heavyQuerySem = make(chan struct{}, maxConcurrentHeavyQueries)
|
||||
|
||||
func acquireHeavyQuery() bool {
|
||||
select {
|
||||
case heavyQuerySem <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
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()
|
||||
@@ -412,9 +443,73 @@ func LokiHistogram(ctx context.Context, src *models.LogSource, query string, sta
|
||||
start = end - int64(time.Hour)
|
||||
}
|
||||
span := end - start
|
||||
step := pickHistogramStep(span)
|
||||
wrapped := fmt.Sprintf("sum(count_over_time(%s[%s]))", query, step)
|
||||
if span > int64(maxHistogramRange) {
|
||||
return nil, fmt.Errorf("统计范围过大(%.1f 天),最多支持 %.0f 天;请缩小时间范围后重试",
|
||||
float64(span)/float64(24*time.Hour), maxHistogramRange.Hours()/24)
|
||||
}
|
||||
// 并发护栏:拿不到令牌立刻返回,不排队(排队只会让用户等更久)
|
||||
if !acquireHeavyQuery() {
|
||||
return nil, ErrTooManyQueries
|
||||
}
|
||||
defer releaseHeavyQuery()
|
||||
|
||||
// 独立超时:Loki 侧再慢也不会超过 30s,防止单查询长期占用实例
|
||||
ctx, cancel := context.WithTimeout(ctx, heavyQueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
step := pickHistogramStep(span)
|
||||
|
||||
// ── 首选:Loki 官方的 index/volume_range ──────────────────────────────
|
||||
// 它只读 TSDB 索引里的 chunk 元数据(字节数),**不解码日志内容**。
|
||||
// 实测同一条件 24 小时:count_over_time 53s 超时失败,volume_range 111ms。
|
||||
// 返回单位是「字节」,所以 bucket 里带 unit=bytes,前端据此格式化为 KB/MB/GB。
|
||||
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")
|
||||
if body, status, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/volume_range", q); err == nil && status < 400 {
|
||||
if buckets, ok := parseVolumeBuckets(body); ok {
|
||||
return buckets, nil
|
||||
}
|
||||
}
|
||||
// 老版本 Loki 没有 volume_range(404/400)时回退到 count_over_time
|
||||
return lokiHistogramByCount(ctx, 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))
|
||||
@@ -437,12 +532,12 @@ func LokiHistogram(ctx context.Context, src *models.LogSource, query string, sta
|
||||
buckets := []map[string]interface{}{}
|
||||
if len(resp.Data.Result) > 0 {
|
||||
for _, v := range resp.Data.Result[0].Values {
|
||||
// metric API 的 ts 为秒级浮点数(如 1.79e+09),query 日志的才是纳秒字符串
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -471,6 +566,15 @@ func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start
|
||||
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+"]")
|
||||
|
||||
|
||||
@@ -56,6 +56,20 @@ compactor:
|
||||
|
||||
limits_config:
|
||||
retention_period: {{ .RetentionPeriod }}h
|
||||
# 单实例 Loki 的并发护栏:默认 32 高得离谱,一条重查询就能把单进程打满
|
||||
max_query_parallelism: 8
|
||||
|
||||
# ── 查询结果缓存(官方推荐;此前完全没配,重复查询要重算一遍)───────
|
||||
# align_queries_with_step 让不同时间范围的同类查询对齐到同一 step,
|
||||
# 显著提高缓存命中率;命中后同样的"日志量统计"直接从缓存返回。
|
||||
query_range:
|
||||
align_queries_with_step: true
|
||||
cache_results: true
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
|
||||
Reference in new issue
Block a user