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:
+74
-29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user