fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题

在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。

1. 回答耗时不能用 updated_at - created_at(P0)
   GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
   实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
   于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
   和偏低的 24 小时回复占比都是这么来的。
   修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
   写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
   首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
   否则历史值会随着后续的已读/置顶继续变大。

2. 环比句式读不通
   中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
   改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。

3. 脚注说「当日 0 点的快照」不实
   实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
   基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。

4. 数据库故障时请求会排成 N×30s
   ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
   加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
   todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。

测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
This commit is contained in:
tamakyi
2026-10-24 01:45:34 +08:00
parent 0c282914c2
commit 0226956feb
12 changed files with 399 additions and 24 deletions
+47 -4
View File
@@ -39,7 +39,7 @@ type Overview struct {
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
Total30 int64 // 近 30 天提问总数
AvgDailyLast30 float64 // 近 30 天日均提问
GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0)
GrowthMoM float64 // 上月相对上上月的环比,百分比(上上月为 0 时不展示)
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
// 今日实时(不参与每日缓存)
@@ -97,6 +97,14 @@ var (
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
ovRefresh sync.Mutex
// ovFailAt / ovFailErr 记录最近一次聚合失败。
//
// 没有它的话,数据库挂起时(连接不断但查询不返回)每个排队的请求都要各自耗满
// 30 秒超时:第 N 个请求要等 N×30 秒,页面对所有人一起超时。
// 冷却期内直接返回失败(有旧快照就先用旧的),不再碰数据库。
ovFailAt time.Time
ovFailErr error
// todayMu 保护「今日」两项的短缓存。
todayMu sync.Mutex
todayQ int64
@@ -120,23 +128,29 @@ func OverviewData(ctx context.Context) (*Overview, error) {
today := now.Format("2006-01-02")
ovMu.Lock()
cached, day := ovCached, ovDay
cached, day, failAt, failErr := ovCached, ovDay, ovFailAt, ovFailErr
ovMu.Unlock()
if cached != nil && day == today {
// 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。
return withToday(ctx, cached, now), nil
}
if inCooldown(now, failAt) {
return degraded(cached, failErr)
}
ovRefresh.Lock()
defer ovRefresh.Unlock()
// 二次检查:等锁期间可能已有别的请求刷新过。
// 二次检查:等锁期间可能已有别的请求刷新过,或是刚失败过。
ovMu.Lock()
cached, day = ovCached, ovDay
cached, day, failAt, failErr = ovCached, ovDay, ovFailAt, ovFailErr
ovMu.Unlock()
if cached != nil && day == today {
return withToday(ctx, cached, now), nil
}
if inCooldown(now, failAt) {
return degraded(cached, failErr)
}
if db.Analytics == nil {
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
@@ -152,6 +166,9 @@ func OverviewData(ctx context.Context) (*Overview, error) {
raw, err := db.Analytics.SiteAnalytics(aggCtx, now)
if err != nil {
logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败")
ovMu.Lock()
ovFailAt, ovFailErr = now, err
ovMu.Unlock()
if cached != nil {
return withToday(ctx, cached, now), nil
}
@@ -165,10 +182,31 @@ func OverviewData(ctx context.Context) (*Overview, error) {
ovMu.Lock()
ovCached = v
ovDay = today
ovFailAt, ovFailErr = time.Time{}, nil
ovMu.Unlock()
return v, nil
}
// ovFailCooldown 聚合失败后多久内不再重试。
//
// 取 30 秒:够数据库扛过一次抖动,又能让恢复后的第一个请求在一分钟内拿到新数据。
const ovFailCooldown = 30 * time.Second
func inCooldown(now, failAt time.Time) bool {
return !failAt.IsZero() && now.Sub(failAt) < ovFailCooldown
}
// degraded 按「有旧快照就用旧的」降级:页面照常打开,只是数据停在上一份。
func degraded(cached *Overview, err error) (*Overview, error) {
if cached != nil {
return cached, nil
}
if err == nil {
err = errNoDatabase
}
return nil, err
}
// withToday 返回一个「今日」两项已更新到最新的副本。
//
// 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读,
@@ -205,9 +243,13 @@ func todayNumbers(ctx context.Context, now time.Time, fallbackQ, fallbackA int64
now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败")
// 失败也要盖时间戳:数据库故障时每个请求都打两条 COUNT 只会雪上加霜。
// 期间沿用同一天的旧值,跨日时用调用方给的回退值(昨天的数不是今天的数)。
if !at.IsZero() && d == date {
setTodayCache(q, a, now)
return q, a
}
setTodayCache(fallbackQ, fallbackA, now)
return fallbackQ, fallbackA
}
setTodayCache(nq, na, now)
@@ -225,6 +267,7 @@ func InvalidateOverview() {
ovMu.Lock()
ovCached = nil
ovDay = ""
ovFailAt, ovFailErr = time.Time{}, nil
ovMu.Unlock()
// 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。