10 KiB
PR Report: DB 写入热点与后台查询拥塞排查
背景
线上在高峰期出现了几类明显症状:
- 管理后台仪表盘接口经常超时,
/api/v1/admin/dashboard/snapshot-v2一度达到 50s 以上 - 管理后台充值接口
/api/v1/admin/users/:id/balance出现 15s 以上超时 - 登录态刷新、扣费、错误记录在高峰期出现大量
context deadline exceeded - PostgreSQL 曾出现连接打满,后续回退连接池后,主问题转为 WAL/刷盘拥塞
本报告基于 /home/ius/sub2api 当前源码,目标是给出一份可直接拆成 PR 的修复方案。
结论
这次故障的主因不是单一“慢 SQL”,而是请求成功路径上的同步写库次数过多,叠加部分后台查询仍直接扫 usage_logs,最终把 PostgreSQL 的 WAL 刷盘、热点行更新和 outbox 重建链路一起放大。
代码层面的核心问题有 6 个。
1. 成功请求路径同步写库过多
backend/internal/service/gateway_service.go:6594 的 postUsageBilling 在单次请求成功后,可能同步触发以下写操作:
userRepo.DeductBalanceAPIKeyService.UpdateQuotaUsedAPIKeyService.UpdateRateLimitUsageaccountRepo.IncrementQuotaUseddeferredService.ScheduleLastUsedUpdate(这一项已经做了延迟批量,是正确方向)
也就是说,一次成功请求不是 1 次落库,而是 3 到 5 次写入。
这和线上看到的现象是吻合的:
UPDATE accounts SET extra = ...INSERT INTO usage_logs ...INSERT INTO ops_error_logs ...scheduler_outboxbacklog
2. API Key 配额更新存在额外读写放大
backend/internal/service/api_key_service.go:815 的 UpdateQuotaUsed 当前流程是:
IncrementQuotaUsedGetByID- 如超限再
Update
对应仓储实现:
backend/internal/repository/api_key_repo.go:441只做自增- 然后 service 再回表读取完整 API Key
- 之后可能再整行更新状态
这让“每次扣费后更新 API Key 配额”从 1 条 SQL 变成了最多 3 次数据库交互。
3. accounts.extra 被当成高频热写字段使用
两个最重的热点都落在 accounts.extra:
backend/internal/repository/account_repo.go:1159UpdateExtrabackend/internal/repository/account_repo.go:1683IncrementQuotaUsed
问题有两个:
- 两者都会重写整块 JSONB,并更新
updated_at UpdateExtra每次写完都会额外插入一条scheduler_outbox
尤其 UpdateExtra 现在被多处高频调用:
backend/internal/service/openai_gateway_service.go:4039持久化 Codex rate-limit snapshotbackend/internal/service/ratelimit_service.go:903持久化 OpenAI Codex snapshotbackend/internal/service/ratelimit_service.go:1013/1025更新 session window utilization
这类“监控/额度快照”并不会改变账号是否可调度,却仍然走了:
- JSONB 更新
updated_atscheduler_outbox
这是明显的写放大。
4. scheduler_outbox 设计偏向“每次状态变更都写一条”,高峰期会反压调度器
backend/internal/repository/scheduler_outbox_repo.go:79 的 enqueueSchedulerOutbox 非常轻,但它被大量调用。
例如:
UpdateExtra每次都 enqueueAccountChangedBatchUpdateLastUsed也会 enqueue 一条AccountLastUsed- 各类账号限流、过载、错误状态切换也都会 enqueue
对应的 outbox worker 在:
backend/internal/service/scheduler_snapshot_service.go:199backend/internal/service/scheduler_snapshot_service.go:219
它会不断拉取 outbox,再触发 GetByID、rebuildBucket、loadAccountsFromDB。
所以当高频写入导致 outbox 增长时,系统不仅多了写,还会反向带出更多读和缓存重建。
5. 仪表盘只有一部分走了预聚合,models/groups/users-trend 仍然直接扫 usage_logs
好消息是,dashboard stats 本身已经接了预聚合表:
backend/internal/repository/usage_log_repo.go:306backend/internal/repository/usage_log_repo.go:420- 预聚合表定义在
backend/migrations/034_usage_dashboard_aggregation_tables.sql:1
但后台慢的不是只有 stats。
snapshot-v2 默认会同时拉:
- stats
- trend
- model stats
见:
backend/internal/handler/admin/dashboard_snapshot_v2_handler.go:68
其中:
GetUsageTrendWithFilters只有“无过滤、day/hour”时才走预聚合,见usage_log_repo.go:1657GetModelStatsWithFilters直接扫usage_logs,见usage_log_repo.go:1805GetGroupStatsWithFilters直接扫usage_logs,见usage_log_repo.go:1872GetUserUsageTrend直接扫usage_logs,见usage_log_repo.go:1101GetAPIKeyUsageTrend直接扫usage_logs,见usage_log_repo.go:1046
所以线上会出现:
- stats 快
- 但
snapshot-v2仍然慢 /admin/dashboard/users-trend单独也慢
这和你线上看到的日志完全一致。
6. 管理后台充值是“读用户 -> 整体更新用户 -> 插审计记录”
backend/internal/service/admin_service.go:694 的 UpdateUserBalance 当前流程:
GetByID- 在内存里改 balance
userRepo.UpdateredeemCodeRepo.Create记录 admin 调账历史
而 userRepo.Update 是整用户对象更新,并同步 allowed groups 事务处理:
backend/internal/repository/user_repo.go:118
这个接口平时不一定重,但在数据库已经抖动时,会比一个原子 UPDATE users SET balance = balance + $1 更脆弱。
额外观察
ops_error_logs 虽然已异步化,但单条写入仍然很重
错误日志中间件已经做了队列削峰:
backend/internal/handler/ops_error_logger.go:69backend/internal/handler/ops_error_logger.go:106
这点方向是对的。
但落库表本身很重:
backend/internal/repository/ops_repo.go:23backend/migrations/033_ops_monitoring_vnext.sql:69backend/migrations/033_ops_monitoring_vnext.sql:470
ops_error_logs 不仅列很多,还带了多组 B-Tree 和 trigram 索引。高错误率时,即使改成异步,也还是会把 WAL 和 I/O 压上去。
建议的 PR 拆分
建议拆成 4 个 PR,不要在一个 PR 里同时改数据库模型、后台查询和管理接口。
PR 1: 收缩成功请求路径的同步写库次数
目标:把一次成功请求的同步写次数从 3 到 5 次,尽量压到 1 到 2 次。
建议改动:
-
把
APIKeyService.UpdateQuotaUsed改为单 SQL- 新增 repo 方法,例如
IncrementQuotaUsedAndMaybeExhaust - 在 SQL 里同时完成
quota_used += ?和status = quota_exhausted - 返回
key/status/quota/quota_used最小字段,直接失效缓存 - 删掉当前的
Increment -> GetByID -> Update
- 新增 repo 方法,例如
-
把账号 quota 计数从
accounts.extra拆出去- 最理想:新增结构化列或独立
account_quota_counters表 - 次优:至少把
quota_used/quota_daily_used/quota_weekly_used从 JSONB 中剥离
- 最理想:新增结构化列或独立
-
对“纯监控型 extra 字段”禁止 enqueue outbox
- 例如 codex snapshot、session_window_utilization
- 这些字段不影响调度,不应该触发
SchedulerOutboxEventAccountChanged
-
复用现有
DeferredService思路last_used已经是批量刷盘,见deferred_service.go:41- 可继续扩展
deferred quota snapshot flush
预期收益:
- 直接减少 WAL 写入量
- 降低
accounts热点行锁竞争 - 降低 outbox 增长速度
PR 2: 给 dashboard 补齐预聚合/缓存,避免继续扫 usage_logs
目标:后台仪表盘接口不再直接扫描大表。
建议改动:
- 为
users-trend/api-keys-trend增加小时/天级预聚合表 - 为
model stats/group stats增加日级聚合表 snapshot-v2增加分段缓存statstrendmodelsgroupsusers_trend避免一个 section miss 导致整份 snapshot 重新扫库
- 可选:把
include_model_stats默认值从true改成false- 至少让默认仪表盘先恢复可用,再按需加载重模块
预期收益:
snapshot-v2/admin/dashboard/users-trend/admin/dashboard/api-keys-trend
这几类接口会从“随数据量线性恶化”变成“近似固定成本”。
PR 3: 简化管理后台充值链路
目标:管理充值/扣余额不再依赖整用户对象更新。
建议改动:
-
新增 repo 原子方法
SetBalance(userID, amount)AddBalance(userID, delta)SubtractBalance(userID, delta)
-
UpdateUserBalance改为:- 先执行原子 SQL
- 再读一次最小必要字段返回
- 审计记录改为异步或降级写
-
审计记录建议改名或独立表
- 现在把后台调账记录塞进
redeem_codes,语义上不干净
- 现在把后台调账记录塞进
预期收益:
/api/v1/admin/users/:id/balance在库抖时更稳- 失败面缩小,不再被 allowed groups 同步事务拖累
PR 4: 为重写路径增加“丢弃策略”和“熔断指标”
目标:高峰期先保护主链路,不让非核心写入拖死数据库。
建议改动:
-
ops_error_logs- 增加采样或分级开关
- 对重复 429/5xx 做聚合计数而不是逐条插入
- 对 request body / headers 存储加更严格开关
-
scheduler_outbox- 增加 coalesce/merge 机制
- 同一账号短时间内多次
AccountChanged合并为一条
-
指标补齐
- outbox backlog
- ops error queue dropped
- deferred flush lag
- account extra write QPS
推荐实施顺序
- 先做 PR 1
- 这是这次线上故障的主链路
- 再做 PR 2
- 解决后台仪表盘慢
- 再做 PR 3
- 解决后台充值接口脆弱
- 最后做 PR 4
- 做长期保护
验证方案
每个 PR 合并前都建议做同一组验证:
- 压测成功请求链路,记录单请求 SQL 次数
- 观测 PostgreSQL:
pg_stat_activitypg_stat_statementsWALWrite/WalSync- 每分钟 WAL 增量
- 观测接口:
/api/v1/auth/refresh/api/v1/admin/dashboard/snapshot-v2/api/v1/admin/dashboard/users-trend/api/v1/admin/users/:id/balance
- 观测队列:
ops_error_logsqueue length / droppedscheduler_outboxbacklog
可直接作为 PR 描述的摘要
This PR reduces database write amplification on the request success path and removes several hot-path writes from accounts.extra + scheduler_outbox. It also prepares dashboard endpoints to rely on pre-aggregated data instead of scanning usage_logs under load. The goal is to keep admin dashboard, balance update, auth refresh, and billing-related paths stable under sustained 500+ RPS traffic.