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:
opsx committed 2026-10-30 16:52:55 +00:00
1 parent e4cefa1fa7
commit b7de337c9e
2 files changed
+187 -53

No files matched your search

+11 -2
View File
@@ -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"`
}
+176 -51
View File
@@ -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)