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:
tamakyi
2026-10-24 01:20:02 +08:00
parent 64436d9d61
commit 0c282914c2
6 changed files with 294 additions and 43 deletions
+74 -29
View File
@@ -19,6 +19,11 @@ import (
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
type SiteAnalyticsStore interface {
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
// TodayCounts 单独取「今日提问 / 今日回答」。
//
// 它们刻意不进 SiteAnalytics:整页聚合一整天只算一次,而这两个数要跟着
// 页面上的「实时」说明走,必须每次(或每隔几十秒)重新取。
TodayCounts(ctx context.Context, since time.Time) (questions, answers int64, err error)
}
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
@@ -48,9 +53,6 @@ type SiteAnalytics struct {
LengthBins [6]int64 // 提问字数分布
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
TodayQuestions int64
TodayAnswers int64
}
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
@@ -96,26 +98,59 @@ type analyticsDialect struct {
replyMin string
}
func analyticsDialectFor(db *gorm.DB) analyticsDialect {
func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
tz := analyticsTZOffset(at)
if db.Dialector.Name() == "mysql" {
// MySQL 的 DATETIME 不带时区,驱动按 DSN 的 loc(默认 UTC)写入,
// 因此库里存的是 UTC 墙钟,CONVERT_TZ 再搬到进程时区。用偏移而不是
// 时区名:CONVERT_TZ 用时区名要求 mysql.time_zone* 表已导入。
conv := func(col string) string {
return fmt.Sprintf("CONVERT_TZ(%s, '+00:00', '%s')", col, tz)
}
day := conv("created_at")
return analyticsDialect{
day: "DATE_FORMAT(created_at, '%Y-%m-%d')",
month: "DATE_FORMAT(created_at, '%Y-%m')",
dow: "(DAYOFWEEK(created_at) - 1)",
hour: "HOUR(created_at)",
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
hour: fmt.Sprintf("HOUR(%s)", day),
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)",
}
}
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
loc := func(col string) string { return analyticsLocalize(col, at) }
return analyticsDialect{
day: "to_char(created_at, 'YYYY-MM-DD')",
month: "to_char(created_at, 'YYYY-MM')",
dow: "CAST(EXTRACT(DOW FROM created_at) AS INTEGER)",
hour: "CAST(EXTRACT(HOUR FROM created_at) AS INTEGER)",
day: fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", loc("created_at")),
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")),
dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")),
hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")),
replyMin: "CAST(EXTRACT(EPOCH FROM (updated_at - created_at)) / 60 AS INTEGER)",
}
}
// analyticsLocalize 把带时区的列转成「Go 进程所在时区的墙钟时间」再参与日期分组。
//
// 不能依赖数据库会话时区:PG 官方镜像默认 timezone=UTC,而 Go 侧按容器本地时区
// (Dockerfile 里写的是 Asia/Shanghai)切「今天」。两边不一致时,日报表与热力图会
// 整体偏 8 小时 —— 中国时间 0–8 点的提问被算进前一天,且和页面顶部「今日」对不上。
// 这类偏差肉眼看不出来(数字本身都合理),只能靠显式换算消除。
//
// 用固定偏移而不是 IANA 名:Go 在容器未设 TZ 时会给出 "Local",PG 不认这个名字。
// 中国无夏令时,固定偏移与真实时区完全等价;有夏令时的部署在窗口内可能差 1 小时。
func analyticsLocalize(col string, at time.Time) string {
return fmt.Sprintf("%s AT TIME ZONE INTERVAL '%s'", col, analyticsTZOffset(at))
}
// analyticsTZOffset 返回 +08:00 这样的偏移字面量。
func analyticsTZOffset(at time.Time) string {
_, off := at.Zone()
sign := "+"
if off < 0 {
sign, off = "-", -off
}
return fmt.Sprintf("%s%02d:%02d", sign, off/3600, off%3600/60)
}
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
loc := now.Location()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
@@ -129,10 +164,10 @@ func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*Sit
if err := db.aggregateTotals(ctx, out); err != nil {
return nil, err
}
if err := db.aggregateTrends(ctx, out, dailySince, monthSince); err != nil {
if err := db.aggregateTrends(ctx, out, now, dailySince, monthSince); err != nil {
return nil, err
}
if err := db.aggregateHeatmap(ctx, out, heatmapSince); err != nil {
if err := db.aggregateHeatmap(ctx, out, now, heatmapSince); err != nil {
return nil, err
}
if err := db.aggregateContent(ctx, out); err != nil {
@@ -146,19 +181,28 @@ func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*Sit
return nil, errors.Wrap(err, "count messages")
}
// 今日数字单独查:它们不进「每天一次」的缓存窗口,页面顶部要显示实时值。
if err := db.WithContext(ctx).Model(&Question{}).
Where("created_at >= ?", startOfDay).Count(&out.TodayQuestions).Error; err != nil {
return nil, errors.Wrap(err, "count today's questions")
}
if err := db.WithContext(ctx).Model(&Question{}).
Where("answer <> '' AND updated_at >= ?", startOfDay).Count(&out.TodayAnswers).Error; err != nil {
return nil, errors.Wrap(err, "count today's answers")
}
// 「今日」两项不在这里查:本函数的结果整天缓存,把会变的数字放进来
// 会让页面顶着一个「实时」的说明显示一整天不变的值。见 TodayCounts。
return out, nil
}
// TodayCounts 取今日提问数与今日回答数,只扫当天数据(走 created_at 索引)。
//
// 口径:回答数按「今天被回答过」计,用的是 updated_at —— 置顶等写操作也会刷新它,
// 因此这个数偏大。页面只把它当「今天有多少动静」的量级参考,不做精确承诺。
func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) {
var questions, answers int64
if err := db.WithContext(ctx).Model(&Question{}).
Where("created_at >= ?", since).Count(&questions).Error; err != nil {
return 0, 0, errors.Wrap(err, "count today's questions")
}
if err := db.WithContext(ctx).Model(&Question{}).
Where("answer <> '' AND updated_at >= ?", since).Count(&answers).Error; err != nil {
return 0, 0, errors.Wrap(err, "count today's answers")
}
return questions, answers, nil
}
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
//
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
@@ -203,8 +247,8 @@ func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics
}
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, dailySince, monthSince time.Time) error {
d := analyticsDialectFor(db.DB)
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, now, dailySince, monthSince time.Time) error {
d := analyticsDialectFor(db.DB, now)
var daily []struct {
Key string
@@ -238,8 +282,8 @@ func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics
return nil
}
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, since time.Time) error {
d := analyticsDialectFor(db.DB)
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, now, since time.Time) error {
d := analyticsDialectFor(db.DB, now)
var rows []struct {
Dow int
@@ -266,7 +310,8 @@ func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytic
}
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
d := analyticsDialectFor(db.DB)
// 内容分布只用时长差值,不涉及日期分组,时区不影响结果。
d := analyticsDialectFor(db.DB, time.Now())
var lengths []struct {
Bin int