Skip to content

perf(ops-report): eliminate logs full-table scans from the report path - #793

Open
mguozhen wants to merge 2 commits into
mainfrom
codex/ops-report-perf-p0
Open

perf(ops-report): eliminate logs full-table scans from the report path#793
mguozhen wants to merge 2 commits into
mainfrom
codex/ops-report-perf-p0

Conversation

@mguozhen

Copy link
Copy Markdown

Problem / Background

/ops-report (运营日报) 在每次 10 分钟缓存过期后重建报告时,会对 logs 表(约 45M 行)做全表/全历史扫描,页面加载因此达到数十秒到 100s+;数据持续增长下风险会进一步放大。

Evidence / Reproduction

  • model/ops_report.go:GetOpsKeyDailyUsage(plg DAU 数据源)的注释自证: "the optimizer full-scans ~45M rows there (measured 100s+ on prod)" —— 30 天窗口几乎覆盖整张 logs 表。
  • model/ops_report.go:GetOpsUserLogStats 无时间窗口,每次重建都扫描整个 logs 历史: "scans the whole logs history for the plg cohort no matter which day range is selected"
  • GetOpsUsersLastIP(stripe 报告)同样 MAX(id) 全历史扫描 logs。
  • 这三处都在 /api/data/ops_report/api/data/ops_report_stripe 的请求路径上,且重建发生在 opsReportMutex 全局锁内(锁内还可能在 opsSyncAdsSpend 中发起 Google Ads 网络调用),期间所有 ops 报告请求排队。

Root Cause / Hypothesis

报告需要的是"全历史 per-user 统计 + 窗口内 per-user-per-day 统计",但实现方式是每次重建时直接从大表实时聚合。logs 是只增的大表,任何"全历史/宽窗口 + GROUP BY"查询的成本都随数据量线性增长;缓存只推迟了问题,没有消除扫描本身。

Scope / Design

只改动 console 管理端报告路径,不触碰 relay/计费/鉴权

  1. plg DAU 改走 quota_data(GetOpsKeyDailyUsage):复用 dau_scope=all 已验证的路径(小时级 rollup,~500 行/天,user_id 有索引),plg 用户过滤在内存完成。Trade-off 与 GetOpsAllKeyDailyUsage 一致:quota_data 包含 playground 消耗(不再仅 token_id>0 的 API-key 调用),已在注释中说明。
  2. 新增 ops_user_log_stats 预聚合表(model/ops_user_log_stats.go):per-user 全历史统计(首次 playground/api-key 时间、计数、最后请求时间),由 master 节点后台任务(5 分钟间隔)增量折叠新 consume 日志,首次运行自动全量回填;游标存 ops_user_log_stats_meta,崩溃可恢复。GetOpsUserLogStats 改读该表,回填完成前自动 fallback 到原查询,部署过渡期报告不为空。
  3. GetOpsUsersLastIP 加报告窗口时间下限,MAX(id) 不再扫最老日志。
  4. 新表注册进 LOG_DB.AutoMigrate;main.go 启动 StartOpsUserLogStatsSyncTask()(仅 master 节点,Rule 11 单写者,upsert 幂等)。

Impact / Risks

  • 收益:请求路径上的 logs 全表/全历史扫描全部消除;DAU 与 per-user 统计从 100s+ 降到毫秒级(小表 + 索引)。
  • 口径变化:DAU 由"仅 API-key 请求"变为"含 playground 的全部消耗",与 dau_scope=all 口径对齐(注释已注明)。
  • 首次回填:新表部署后首轮后台回填会扫一遍现存 logs(一次性,后台,不阻塞请求);期间报告走 fallback 原查询,数字正确只是慢。
  • 失败安全:聚合表不可用时自动 fallback;后台任务失败仅记录日志,不影响请求。

Validation / Acceptance

  • go build ./... 通过。
  • go test ./model/... 全绿,新增 3 个测试覆盖:playground/API-key 判定与 first_* 语义、增量累加不覆盖历史、回填前 fallback。
  • controller ops 相关测试全过;controller 包内 4 个既有失败(TestModelAPISeedanceAssetTaskWorker* / TestValidateChannelRejectsModelAPISeedanceProxy)在干净的 origin/main worktree 上同样复现,与本次改动无关。
  • 部署:Router deploy not required;需部署 newapi-console(migrateLOGDB 自动建新表)。部署后观察首轮回填完成后接口响应时间。

The ops daily report rebuilt on every 10-minute cache expiry by scanning the
whole logs history (~45M rows, measured 100s+ on prod) plus unbounded scans
of tokens/top_ups/subscription_orders. This change removes the logs scans
from the request path:

- GetOpsKeyDailyUsage (plg DAU) now aggregates quota_data hourly rollups
  (~500 rows/day) instead of raw logs; same trade-off GetOpsAllKeyDailyUsage
  already documents (counts playground too). Users are still filtered to the
  plg cohort in memory.
- New ops_user_log_stats pre-aggregated table replaces the per-user
  playground/API-key scan of the full logs history. A background task
  (master node, 5-min interval) incrementally folds new consume logs in,
  backfilling on first run; GetOpsUserLogStats reads the table and falls
  back to the direct scan only until the first backfill completes.
- GetOpsUsersLastIP is bounded by the report window so the MAX(id) pass
  never walks the oldest logs.

No relay/router paths touched; console-only admin report change.

Tests: model suite green (3 new tests for the aggregation); controller ops
tests green. Pre-existing controller failures (seedance asset worker,
channel validation) reproduce on origin/main.
@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit 4f4fa321 · 共 3 条

model/ops_report.go

  • L214-220: [阻塞] 这里把 GetOpsKeyDailyUsagelogs + opsExternalAPIKeyLogPredicate 改成了直接汇总 quota_data,但 quota_data 自身会把 playground 等非 API-key 消耗也算进去。这样会把“key daily usage / key used”口径静默改大,导致报表数值失真,属于线上数据错误。建议保留原有 API-key 过滤口径,或拆出一个明确的新指标供 quota_data 版本使用。
SELECT user_id,
			       %s AS day_ts,
			       COUNT(*) AS req_count,
			       COALESCE(SUM(quota), 0) AS quota
			FROM logs%s
			WHERE type = ? AND %s AND created_at >= ? AND user_id IN ?
			GROUP BY user_id, %s`, dayExpr, logsForceIndexHint(), opsExternalAPIKeyLogPredicate, dayExpr)
			if err := LOG_DB.Raw(sql, LogTypeConsume, startTs, chunk).Scan(&batch).Error; err != nil {
				return nil, err
			}
  • L0: [阻塞] 这里先 upsert 聚合行再单独更新 cursor,两个写入没有放在同一个事务里;如果 upsert 成功后、cursor 落库前进程崩溃或连接中断,下一次会从旧 cursor 重放同一批日志,而代码又会把 ops_user_log_stats 里已有累计值再加一遍,造成计数翻倍并永久污染报表。建议把“写聚合表 + 推进 cursor”放进同一事务,或改成天然幂等的增量写法。
if err := LOG_DB.Transaction(func(tx *gorm.DB) error {
			if err := tx.Clauses(clause.OnConflict{
				Columns: []clause.Column{{Name: "user_id"}},
				DoUpdates: clause.AssignmentColumns([]string{
					"first_playground_at", "playground_count", "first_api_key_at",
					"api_key_count", "last_request_at", "updated_at",
				}),
			}).Create(&rows).Error; err != nil {
				return err
			}
			return tx.Model(&OpsUserLogStatsMeta{}).Where("id = 1").
				Updates(map[string]interface{}{"last_log_id": cursor, "updated_at": now}).Error
		}); err != nil {
			return err
		}

model/ops_user_log_stats.go

  • L177-191: [严重] 这里把统计表 upsert 和 meta.last_log_id 推进拆成了两次独立写入,而且没有放在同一事务里;如果进程在写完统计行后、更新游标前崩溃/重启,下一轮会再次扫描同一批日志并把计数再累加一次,造成报表数据重复。建议把“写统计结果 + 推进游标”包进同一个事务,或改成按 log_id 幂等去重后再前进游标。
if err := LOG_DB.Transaction(func(tx *gorm.DB) error {
			if err := tx.Clauses(clause.OnConflict{
				Columns: []clause.Column{{Name: "user_id"}},
				DoUpdates: clause.AssignmentColumns([]string{
					"first_playground_at", "playground_count", "first_api_key_at",
					"api_key_count", "last_request_at", "updated_at",
				}),
			}).Create(&rows).Error; err != nil {
				return err
			}
			cursor = int64(logs[len(logs)-1].Id)
			return tx.Model(&OpsUserLogStatsMeta{}).Where("id = 1").
				Updates(map[string]interface{}{"last_log_id": cursor, "updated_at": now}).Error
		}); err != nil {
			return err
		}

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 [4f4fa32..594164f] · OCR 共产生 73 条意见,超过 30 条上限(通常是大合并/rebase 带入大量改动),已跳过逐条评论,建议人工 review。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants