fix(stats): 今日计数不再被每日缓存冻结,日期分组不依赖库会话时区
自查部署结果时发现两处正确性问题,都是本页上线时引入的: 1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」, 实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取, 60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针 (那会被并发读踩成数据竞争)。 2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库 会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。 实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 —— 日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。 现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自 time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。 过滤条件仍是裸列,索引照用。 另外两处顺手修: - PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。 - 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时, 排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。 测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次) 与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。
This commit is contained in:
+88
-10
@@ -96,20 +96,35 @@ var (
|
||||
// ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用,
|
||||
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
|
||||
ovRefresh sync.Mutex
|
||||
|
||||
// todayMu 保护「今日」两项的短缓存。
|
||||
todayMu sync.Mutex
|
||||
todayQ int64
|
||||
todayA int64
|
||||
todayAt time.Time
|
||||
todayDate string
|
||||
)
|
||||
|
||||
// todayTTL 「今日」两项的缓存时长。
|
||||
//
|
||||
// 不长于 60 秒:页面写的是「实时」,但也不能让每次刷新都打两条 COUNT,
|
||||
// 否则一个爱刷新的访客就能持续骚扰数据库。
|
||||
const todayTTL = 60 * time.Second
|
||||
|
||||
// OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。
|
||||
//
|
||||
// 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天);
|
||||
// 完全没有可用结果才返回错误。
|
||||
func OverviewData(ctx context.Context) (*Overview, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
ovMu.Lock()
|
||||
cached, day := ovCached, ovDay
|
||||
ovMu.Unlock()
|
||||
if cached != nil && day == today {
|
||||
return cached, nil
|
||||
// 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。
|
||||
return withToday(ctx, cached, now), nil
|
||||
}
|
||||
|
||||
ovRefresh.Lock()
|
||||
@@ -120,7 +135,7 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
||||
cached, day = ovCached, ovDay
|
||||
ovMu.Unlock()
|
||||
if cached != nil && day == today {
|
||||
return cached, nil
|
||||
return withToday(ctx, cached, now), nil
|
||||
}
|
||||
|
||||
if db.Analytics == nil {
|
||||
@@ -128,17 +143,24 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
||||
return nil, errNoDatabase
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := db.Analytics.SiteAnalytics(ctx, now)
|
||||
// 聚合用一条独立于请求的 context:第一个访客在聚合途中关掉页面时,
|
||||
// 请求 ctx 会被取消,排在后面等锁的请求就会各自重跑一遍全表 GROUP BY
|
||||
// (1C1G 上等于连环超时)。WithoutCancel 保留 trace 等 value、只摘掉取消信号。
|
||||
aggCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
raw, err := db.Analytics.SiteAnalytics(aggCtx, now)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败")
|
||||
logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败")
|
||||
if cached != nil {
|
||||
return cached, nil
|
||||
return withToday(ctx, cached, now), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := buildOverview(raw, now)
|
||||
// 今日两项单独取(60 秒缓存),不进整天复用的那份快照。
|
||||
v.TodayQuestions, v.TodayAnswers = todayNumbers(ctx, now, 0, 0)
|
||||
|
||||
ovMu.Lock()
|
||||
ovCached = v
|
||||
@@ -147,12 +169,68 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// withToday 返回一个「今日」两项已更新到最新的副本。
|
||||
//
|
||||
// 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读,
|
||||
// 原地写字段就是数据竞争。Daily / Monthly / Heatmap 都是只读共享,浅拷贝安全。
|
||||
func withToday(ctx context.Context, base *Overview, now time.Time) *Overview {
|
||||
q, a := todayNumbers(ctx, now, base.TodayQuestions, base.TodayAnswers)
|
||||
if q == base.TodayQuestions && a == base.TodayAnswers {
|
||||
return base
|
||||
}
|
||||
out := *base
|
||||
out.TodayQuestions, out.TodayAnswers = q, a
|
||||
return &out
|
||||
}
|
||||
|
||||
// todayNumbers 取「今日提问 / 今日回答」,带 60 秒缓存与跨日失效。
|
||||
// 查询失败时优先沿用上次的已知值,再退到调用方给的回退值 —— 页面不该因为
|
||||
// 两条 COUNT 失败就打不开。
|
||||
func todayNumbers(ctx context.Context, now time.Time, fallbackQ, fallbackA int64) (int64, int64) {
|
||||
date := now.Format("2006-01-02")
|
||||
|
||||
todayMu.Lock()
|
||||
q, a, at, d := todayQ, todayA, todayAt, todayDate
|
||||
todayMu.Unlock()
|
||||
|
||||
// 跨日必须先失效:昨天 23:59:50 算出的「今日」在 00:00:30 仍在 TTL 内。
|
||||
if d == date && !at.IsZero() && now.Sub(at) < todayTTL {
|
||||
return q, a
|
||||
}
|
||||
if db.Analytics == nil {
|
||||
return fallbackQ, fallbackA
|
||||
}
|
||||
|
||||
nq, na, err := db.Analytics.TodayCounts(ctx, time.Date(
|
||||
now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败")
|
||||
if !at.IsZero() && d == date {
|
||||
return q, a
|
||||
}
|
||||
return fallbackQ, fallbackA
|
||||
}
|
||||
setTodayCache(nq, na, now)
|
||||
return nq, na
|
||||
}
|
||||
|
||||
func setTodayCache(q, a int64, now time.Time) {
|
||||
todayMu.Lock()
|
||||
todayQ, todayA, todayAt, todayDate = q, a, now, now.Format("2006-01-02")
|
||||
todayMu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。
|
||||
func InvalidateOverview() {
|
||||
ovMu.Lock()
|
||||
ovCached = nil
|
||||
ovDay = ""
|
||||
ovMu.Unlock()
|
||||
|
||||
// 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。
|
||||
todayMu.Lock()
|
||||
todayQ, todayA, todayAt, todayDate = 0, 0, time.Time{}, ""
|
||||
todayMu.Unlock()
|
||||
}
|
||||
|
||||
var errNoDatabase = errString("database not initialized")
|
||||
@@ -175,8 +253,6 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
|
||||
ActiveBoxes: raw.ActiveBoxes,
|
||||
AnsweringBoxes: raw.AnsweringBoxes,
|
||||
TotalMessages: raw.TotalMessages,
|
||||
TodayQuestions: raw.TodayQuestions,
|
||||
TodayAnswers: raw.TodayAnswers,
|
||||
Heatmap: raw.Heatmap,
|
||||
HeatmapDays: raw.HeatmapDays,
|
||||
LengthBins: raw.LengthBins,
|
||||
@@ -211,7 +287,9 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
|
||||
if p.N > v.DailyMax {
|
||||
v.DailyMax = p.N
|
||||
}
|
||||
if p.N >= v.PeakDay.N {
|
||||
// 用严格大于:并列时保留更早的一天,与 busiestSlot 的取值规则一致
|
||||
// (>= 会让「最忙的一天」随更新漂移到最近一个同值日期)。
|
||||
if p.N > v.PeakDay.N {
|
||||
v.PeakDay = p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -110,3 +111,72 @@ func TestOverviewDerived(t *testing.T) {
|
||||
// 复用到句子里会读成「提问最集中在三 8 点前后」。
|
||||
require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。")
|
||||
}
|
||||
|
||||
// fakeAnalytics 最小实现,用来卡住缓存的边界:
|
||||
// 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。
|
||||
type fakeAnalytics struct {
|
||||
todayQ, todayA int64
|
||||
siteCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) {
|
||||
f.siteCalls++
|
||||
return &db.SiteAnalytics{TotalQuestions: 10, Answered: 4}, nil
|
||||
}
|
||||
|
||||
func (f *fakeAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) {
|
||||
return f.todayQ, f.todayA, nil
|
||||
}
|
||||
|
||||
// TestTodayNumbersNotFrozen 回归:「今日」两项曾被塞进每日缓存,
|
||||
// 导致页面顶着「实时」的说明一整天显示常量。
|
||||
func TestTodayNumbersNotFrozen(t *testing.T) {
|
||||
prev := db.Analytics
|
||||
t.Cleanup(func() {
|
||||
db.Analytics = prev
|
||||
InvalidateOverview()
|
||||
})
|
||||
|
||||
f := &fakeAnalytics{todayQ: 3, todayA: 1}
|
||||
db.Analytics = f
|
||||
InvalidateOverview()
|
||||
|
||||
first, err := OverviewData(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 3, first.TodayQuestions)
|
||||
|
||||
// 今天又来了几条,且短缓存已过期
|
||||
f.todayQ, f.todayA = 8, 2
|
||||
todayMu.Lock()
|
||||
todayAt = todayAt.Add(-2 * todayTTL)
|
||||
todayMu.Unlock()
|
||||
|
||||
second, err := OverviewData(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 8, second.TodayQuestions, "今日数不能被每日缓存冻结")
|
||||
require.EqualValues(t, 2, second.TodayAnswers)
|
||||
require.Equal(t, 1, f.siteCalls, "整页聚合当天只应算一次")
|
||||
|
||||
// 返回的是副本,不能把已经交出去的 pages 改掉
|
||||
require.EqualValues(t, 3, first.TodayQuestions)
|
||||
}
|
||||
|
||||
// TestTodayCacheExpiresAcrossMidnight 跨日必须重算:昨天 23:59 算出的是昨天的数。
|
||||
func TestTodayCacheExpiresAcrossMidnight(t *testing.T) {
|
||||
prev := db.Analytics
|
||||
t.Cleanup(func() {
|
||||
db.Analytics = prev
|
||||
InvalidateOverview()
|
||||
})
|
||||
|
||||
f := &fakeAnalytics{todayQ: 7, todayA: 5}
|
||||
db.Analytics = f
|
||||
|
||||
justAfterMidnight := time.Date(2026, 10, 24, 0, 0, 30, 0, time.Local)
|
||||
// 昨天午夜算出来的缓存(日期不同,即使还在 60 秒内也不该复用)
|
||||
setTodayCache(99, 99, justAfterMidnight.Add(-24*time.Hour))
|
||||
|
||||
q, a := todayNumbers(context.Background(), justAfterMidnight, 0, 0)
|
||||
require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字")
|
||||
require.EqualValues(t, 5, a)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user