perf(daily-volume): 每日量抓取拆成「体积每 30 分钟 / 条数每天最多 3 次」
=== 现象 ===
用户问:每日日志量不是有定时任务记录吗?为什么还要每次查询?
盘了一遍发现:定时任务确实有(每 30 分钟),接口也确实只读库(实测 0.1ms),
但每轮抓取都顺手跑一次**整天 index/stats** 拿条数(实测 6ms ~ 20s,随负载波动),
2 天 × 多个数据源 × 每 30 分钟 = 一天几十次重查询,纯浪费单实例 Loki 的算力,
还会和用户页面上的查询抢 CPU。
第二条更糟:仓库里所有快照的 partial 全是 true。
根因:partial 里混了两种语义 ——「今天还没过完」和「起点超出 24h」。
后者让昨天/前天的柱子也画成虚线空心,看起来「每天都在进行中」。
=== 修法 ===
1. 抓取按成本拆成两条路径(体积走 volume_range,条数走 index/stats):
- 体积:今天 + 昨天,每 30 分钟刷,54ms~4s,无感
- 条数:只对**已结束**且 entries==0 的天补,每天最多 3 次、间隔≥1h;
尝试次数**先写库再查**,进程中途被杀也不会退化成每 30 分钟重试
2. partial 只表示「今天还没过完」,昨天起是实心柱
3. 0 与「未留存」分开:起点超出 24h 且查不到数据 → **跳过落库**保持 Missing。
否则「数据被 retention 删掉」会被画成「那天没有日志」,比灰色柱误导得多
4. 每次抓取打日志:`[daily-volume] 2026-10-30 体积 291.37 GB 已落库(volume_range 4.27s,34 个桶)`
—— 以后不用猜「到底抓没抓、走的哪条路径」
=== 实测取证(本集群)===
Loki 直接查(port-forward :13100):
10-30(16.8h) volume_range 291.37GB/34桶 index/stats entries=2.21B
10-29(24h) volume_range 121.99GB/19桶 index/stats entries=927.9M
10-28/10-27 volume_range 0 桶、index/stats 0 —— 已被 retention 删掉
同范围 count_over_time 两次 60s 超时(永远别用在每日量上)
平台库里的快照与上述数字一致(288.32 / 121.75 GB),说明落库值可信;
10-27/10-28 的 34.87/25.65 GB 是当时抓的 —— 这正是「Loki 只留 1~3 天,
历史必须自己落库」的实证。
go vet ./services/ ./models/ 干净;go build 通过;go test ./services/ 通过。
This commit is contained in:
1 parent
e4cefa1fa7
commit
b7de337c9e
2 files changed
+187
-53
No files matched your search
@@ -25,6 +25,15 @@ type DailyVolumeSnapshot struct {
|
||||
Entries int64 `json:"entries"` // 日志条数
|
||||
// Partial 该日期是否为"不完整的一天"(当天抓取时还没过完)。
|
||||
// 前端用它区分"真实低峰"和"只统计到一半"。
|
||||
Partial bool `json:"partial"`
|
||||
CapturedAt time.Time `json:"capturedAt"`
|
||||
//
|
||||
// ⚠️ 只表示"今天还没过完"。早期实现把"起点超出 24 小时"也算成 partial,
|
||||
// 导致昨天、前天的柱子也画成虚线空心,看起来像"每天都在进行中"(已修)。
|
||||
Partial bool `json:"partial"`
|
||||
// EntriesAttempts 条数(index/stats)已尝试次数。
|
||||
// 整天跨度统计很贵(实测 6ms ~ 20s,取决于实例负载),
|
||||
// 所以只对"已经过完的天"补,且最多重试 entriesMaxAttempts 次,
|
||||
// 避免每 30 分钟的定时任务无脑重跑一遍。
|
||||
EntriesAttempts int `json:"entriesAttempts"`
|
||||
LastEntriesAttempt time.Time `json:"lastEntriesAttempt"`
|
||||
CapturedAt time.Time `json:"capturedAt"`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,13 @@ const (
|
||||
dailyVolumeBackfillDays = 2
|
||||
// dailyVolumeRetentionDays 平台自己留多久的历史。一年足够看年度趋势。
|
||||
dailyVolumeRetentionDays = 400
|
||||
|
||||
// entriesMaxAttempts 条数(index/stats)每天最多尝试次数。
|
||||
// 整天跨度要去重统计全部 chunk:实测空闲时 6ms、有并发查询时 20s 以上,
|
||||
// 属于"附加指标",不值得无限重试。
|
||||
entriesMaxAttempts = 3
|
||||
// entriesRetryInterval 两次条数尝试之间的最小间隔。
|
||||
entriesRetryInterval = time.Hour
|
||||
)
|
||||
|
||||
// dateKey 返回本地时区的 YYYY-MM-DD。
|
||||
@@ -45,10 +53,22 @@ func dayRange(day time.Time) (int64, int64) {
|
||||
return start.UnixNano(), start.AddDate(0, 0, 1).UnixNano()
|
||||
}
|
||||
|
||||
// CaptureDailyVolume 抓取指定日期的日志量并落库(按天幂等:同一天重复抓会覆盖)。
|
||||
// formatGB 用于日志输出,运维看日志时比裸字节直观。
|
||||
func formatGB(bytes int64) string {
|
||||
return fmt.Sprintf("%.2f GB", float64(bytes)/1e9)
|
||||
}
|
||||
|
||||
// CaptureDailyVolume 抓取指定日期的**日志体积**并落库(按天幂等:同一天重复抓会覆盖)。
|
||||
//
|
||||
// 数据来源是 Loki 的 index/stats(只读索引元数据),实测毫秒级,
|
||||
// 所以就算是补几十天历史也不会拖慢任何东西。
|
||||
// 只用 index/volume_range:它只读 TSDB 索引里记录的 chunk 字节数,不解码日志内容。
|
||||
// 同一 selector 24 小时实测(本集群 ~370GB/天):
|
||||
//
|
||||
// index/volume_range 54ms ~ 4s ← 本函数走的路径
|
||||
// index/stats 6ms ~ 20s ← 条数才需要,见 captureDailyEntries
|
||||
// count_over_time 60s 超时 ← 永远不要用在"每日量"上
|
||||
//
|
||||
// 条数不在这里抓:它只能走 index/stats 且整天跨度很贵,独立成 captureDailyEntries,
|
||||
// 每天最多尝试 3 次,不跟着 30 分钟的定时任务反复跑。
|
||||
func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) error {
|
||||
if source == nil {
|
||||
return nil
|
||||
@@ -62,19 +82,22 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e
|
||||
if start >= end {
|
||||
return nil
|
||||
}
|
||||
// 回补历史时,如果那天的起点已经超出 Loki 保留期(默认 24h),
|
||||
// Loki 里根本没那么多数据 —— 必须标成"不完整",
|
||||
// 否则图上会把"数据被删了"误读成"那天日志量真的很少"。
|
||||
if start < time.Now().Add(-24*time.Hour).UnixNano() {
|
||||
partial = true
|
||||
}
|
||||
|
||||
// ── 体积:index/volume_range(只读 TSDB chunk 元数据,不解码日志内容)──
|
||||
// 实测同一条件 24 小时:index/stats 要 20s(遇超时),volume_range 111ms。
|
||||
// 用户关心的"每日日志量"就是体积,所以主路径走它。
|
||||
// 起点已超出常见保留期(24h)时,Loki 里可能已经没有那天的数据了。
|
||||
// 这种情况下查出来的 0 字节**绝不能落库**:
|
||||
// 落库就会把"数据被 retention 删掉了"画成"那天没有日志",
|
||||
// 比灰色柱(未留存)误导得多。所以按"跳过落库 = 保持 Missing"处理。
|
||||
stale := start < time.Now().Add(-24*time.Hour).UnixNano()
|
||||
|
||||
begin := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
buckets, volErr := LokiHistogram(ctx, source, `{namespace=~".+"}`, start, end)
|
||||
elapsed := time.Since(begin)
|
||||
if volErr != nil {
|
||||
return fmt.Errorf("volume_range: %w", volErr)
|
||||
}
|
||||
|
||||
var totalBytes int64
|
||||
for _, b := range buckets {
|
||||
switch v := b["count"].(type) {
|
||||
@@ -85,28 +108,23 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e
|
||||
}
|
||||
}
|
||||
|
||||
// ── 条数:只能 index/stats,慢(整天约 20s)──
|
||||
// 降级为"尽力而为":拿不到就把条数留 0,绝不让它拖住体积入库。
|
||||
// 前端图只画体积,条数缺失不影响可读性。
|
||||
var totalEntries int64
|
||||
entryCtx, entryCancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
if stats, err := LokiStatsFor(entryCtx, source, `{namespace=~".+"}`, start, end); err == nil {
|
||||
totalEntries = stats.Entries
|
||||
if totalBytes == 0 {
|
||||
totalBytes = stats.Bytes
|
||||
}
|
||||
} else {
|
||||
log.Printf("[daily-volume] %s 条数统计超时/失败(不影响体积): %v", dateKey(day), err)
|
||||
}
|
||||
entryCancel()
|
||||
if volErr != nil && totalBytes == 0 {
|
||||
return volErr
|
||||
}
|
||||
if totalBytes == 0 {
|
||||
if totalBytes == 0 && stale {
|
||||
log.Printf("[daily-volume] %s 超出保留期且查不到数据,跳过落库(图上显示「未留存」)", dateKey(day))
|
||||
return nil
|
||||
}
|
||||
|
||||
date := dateKey(day)
|
||||
if err := upsertVolumeSnapshot(source.ID, date, totalBytes, partial); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[daily-volume] %s 体积 %s 已落库(volume_range %s,%d 个桶)",
|
||||
date, formatGB(totalBytes), elapsed.Round(time.Millisecond), len(buckets))
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertVolumeSnapshot 写入/更新某天的体积快照(幂等)。
|
||||
// 只改体积与 partial,**不动已经抓到的条数**(条数贵,抓到就留着)。
|
||||
func upsertVolumeSnapshot(sourceID uint, date string, bytes int64, partial bool) error {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return nil
|
||||
@@ -123,13 +141,8 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e
|
||||
if snap.ID > nextID {
|
||||
nextID = snap.ID
|
||||
}
|
||||
// 同一天同源:覆盖(幂等)
|
||||
if snap.Date == date && snap.SourceID == source.ID && snap.Namespace == "" {
|
||||
snap.Bytes = totalBytes
|
||||
// 条数拿不到时保留上一次的值,避免"一次超时把历史条数清零"
|
||||
if totalEntries > 0 {
|
||||
snap.Entries = totalEntries
|
||||
}
|
||||
if snap.Date == date && snap.SourceID == sourceID && snap.Namespace == "" {
|
||||
snap.Bytes = bytes
|
||||
snap.Partial = partial
|
||||
snap.CapturedAt = time.Now()
|
||||
db.RequestSave()
|
||||
@@ -140,10 +153,9 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e
|
||||
db.DailyVolumeSnapshots = append(db.DailyVolumeSnapshots, models.DailyVolumeSnapshot{
|
||||
ID: nextID,
|
||||
Date: date,
|
||||
SourceID: source.ID,
|
||||
SourceID: sourceID,
|
||||
Namespace: "",
|
||||
Bytes: totalBytes,
|
||||
Entries: totalEntries,
|
||||
Bytes: bytes,
|
||||
Partial: partial,
|
||||
CapturedAt: time.Now(),
|
||||
})
|
||||
@@ -151,7 +163,116 @@ func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureDailyEntries 给"已经过完、但还没有条数"的天补一次条数(尽力而为)。
|
||||
//
|
||||
// 为什么不等同于体积每 30 分钟抓一次:
|
||||
// 条数只能走 index/stats,要跨该天全部 chunk 做 streams/entries 去重统计,
|
||||
// 实测 6ms(空闲)~ 20s(有并发查询时)。每 30 分钟 × 每一天都重跑一遍,
|
||||
// 既浪费 Loki 的 CPU,又会和用户页面上的查询抢同一台单实例的算力。所以:
|
||||
//
|
||||
// • 只处理 Date < 今天 的天(今天没结束,条数没有意义)
|
||||
// • 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval
|
||||
// • 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试"
|
||||
// • 拿不到就留 0,前端图只画体积,不影响可读性
|
||||
func captureDailyEntries(source *models.LogSource) {
|
||||
if source == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
today := dateKey(now)
|
||||
|
||||
var dates []string
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
db.RLock()
|
||||
for _, snap := range db.DailyVolumeSnapshots {
|
||||
if snap.SourceID != source.ID || snap.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if snap.Date >= today || snap.Entries > 0 {
|
||||
continue
|
||||
}
|
||||
if snap.EntriesAttempts >= entriesMaxAttempts {
|
||||
continue
|
||||
}
|
||||
if !snap.LastEntriesAttempt.IsZero() && now.Sub(snap.LastEntriesAttempt) < entriesRetryInterval {
|
||||
continue
|
||||
}
|
||||
dates = append(dates, snap.Date)
|
||||
}
|
||||
db.RUnlock()
|
||||
|
||||
for _, date := range dates {
|
||||
// 先把"尝试"记下来再查:查的过程中进程重启,也不会变成无限重试
|
||||
markEntriesAttempt(source.ID, date)
|
||||
|
||||
day, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
start, end := dayRange(day)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
begin := time.Now()
|
||||
stats, statErr := LokiStatsFor(ctx, source, `{namespace=~".+"}`, start, end)
|
||||
cancel()
|
||||
if statErr != nil {
|
||||
log.Printf("[daily-volume] %s 条数统计失败(体积已落库,不影响图表): %v", date, statErr)
|
||||
continue
|
||||
}
|
||||
if stats.Entries <= 0 {
|
||||
continue
|
||||
}
|
||||
setSnapshotEntries(source.ID, date, stats.Entries)
|
||||
log.Printf("[daily-volume] %s 条数 %d(index/stats %s)",
|
||||
date, stats.Entries, time.Since(begin).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
// markEntriesAttempt 记录一次条数尝试(先写库再查询,失败也计数)。
|
||||
func markEntriesAttempt(sourceID uint, date string) {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
for i := range db.DailyVolumeSnapshots {
|
||||
snap := &db.DailyVolumeSnapshots[i]
|
||||
if snap.Date == date && snap.SourceID == sourceID && snap.Namespace == "" {
|
||||
snap.EntriesAttempts++
|
||||
snap.LastEntriesAttempt = time.Now()
|
||||
db.RequestSave()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setSnapshotEntries 写入条数。
|
||||
func setSnapshotEntries(sourceID uint, date string, entries int64) {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
for i := range db.DailyVolumeSnapshots {
|
||||
snap := &db.DailyVolumeSnapshots[i]
|
||||
if snap.Date == date && snap.SourceID == sourceID && snap.Namespace == "" {
|
||||
snap.Entries = entries
|
||||
db.RequestSave()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureRecentDailyVolumes 回补最近 N 天(含今天),供启动时与定时任务调用。
|
||||
//
|
||||
// 两条路径分开:
|
||||
//
|
||||
// 体积(volume_range,毫秒级)→ 今天 + 昨天,每 30 分钟刷新,当天柱子保持新鲜
|
||||
// 条数(index/stats,可能 20s)→ 只给已结束且还没抓到条数的天补,每天最多 3 次
|
||||
func CaptureRecentDailyVolumes() {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
@@ -169,12 +290,13 @@ func CaptureRecentDailyVolumes() {
|
||||
src := src
|
||||
for offset := dailyVolumeBackfillDays - 1; offset >= 0; offset-- {
|
||||
day := now.AddDate(0, 0, -offset)
|
||||
// 今天是不完整的一天
|
||||
// 只有今天是不完整的一天
|
||||
partial := offset == 0
|
||||
if err := CaptureDailyVolume(&src, day, partial); err != nil {
|
||||
log.Printf("[daily-volume] 抓取 %s %s 失败: %v", src.Name, dateKey(day), err)
|
||||
}
|
||||
}
|
||||
captureDailyEntries(&src)
|
||||
}
|
||||
pruneDailyVolumeSnapshots()
|
||||
}
|
||||
@@ -203,7 +325,12 @@ func pruneDailyVolumeSnapshots() {
|
||||
|
||||
// GetDailyVolumeHistory 从平台留存的历史里取最近 days 天,按日期升序返回。
|
||||
//
|
||||
// 今天如果还没抓过,会实时补一次(这样用户切到页面时当天数字是最新的)。
|
||||
// 这个接口**只读库,绝不触发任何 Loki 查询** —— 图表刷新不应该产生任何 Loki 负载。
|
||||
// 当天那根柱子由后台定时任务(每 30 分钟,volume_range 毫秒级)补,慢几分钟可接受。
|
||||
//
|
||||
// 事故复盘:这里原来同步 await 一次"整天 index/stats",那次查询超时 20 秒,
|
||||
// 于是 /logs/daily-volume 耗时 20s → 前端超时 → 页面显示"每日日志量没数据";
|
||||
// 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。
|
||||
func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
@@ -212,13 +339,6 @@ func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||||
days = dailyVolumeRetentionDays
|
||||
}
|
||||
|
||||
// 这个接口**只读库**,绝不触发任何 Loki 查询。
|
||||
//
|
||||
// 事故复盘:这里原来同步 await 一次"整天 index/stats",那次查询超时 20 秒,
|
||||
// 于是 /logs/daily-volume 耗时 20s → 前端超时 → 页面显示"每日日志量没数据";
|
||||
// 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。
|
||||
// 当天那根柱子由后台定时任务(每 30 分钟)补,慢几分钟完全可接受。
|
||||
|
||||
// 始终返回 days 个点(缺失的那天标 Missing)。
|
||||
// 之前只返回"已抓到的快照",于是 7/30/90/365 全都返回同样 4 个点,
|
||||
// 用户切范围看不到任何变化,以为"切换没反应"。
|
||||
@@ -256,12 +376,17 @@ func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||||
|
||||
// StartDailyVolumeCapture 后台每日抓取。
|
||||
//
|
||||
// 每 30 分钟检查一次:只要发现"昨天"还没落库就立刻补,
|
||||
// 而不是死等 00:10 —— 平台可能整晚都没运行,定时器会错过那个点。
|
||||
// 每 30 分钟跑一轮(体积是毫秒级的 volume_range,跑得勤一点让"今天"这根柱子接近实时):
|
||||
// - 今天 + 昨天的体积:每轮都刷
|
||||
// - 条数:只在"已结束且还没有条数"的天触发,每天最多 3 次(见 captureDailyEntries)
|
||||
//
|
||||
// 用"检查式回补"而不是死等 00:10 的原因:平台可能整晚都没运行,定时器会错过那个点;
|
||||
// 每轮都检查"昨天有没有落库",漏了就立刻补,天然幂等。
|
||||
func StartDailyVolumeCapture() {
|
||||
go func() {
|
||||
// 启动先补最近两天(覆盖重启场景)
|
||||
time.Sleep(20 * time.Second)
|
||||
log.Printf("[daily-volume] 启动回补最近 %d 天", dailyVolumeBackfillDays)
|
||||
CaptureRecentDailyVolumes()
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
|
||||
Reference in new issue
Block a user