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:
@@ -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 秒内继续看到旧值。
|
||||
|
||||
Reference in New Issue
Block a user