sub2api/backend/internal/service/user_platform_quota_flusher.go
DaydreamCoding f7f5e33830 feat(quota): user×platform 配额 DB 写聚合 flusher
Redis 同步权威 + DB 镜像,不在进程内维护 delta:
- 写入点 HasUserPlatformQuotaLimit 守卫:无 limit 跳过 Redis 写与持久化
- 累加 usage 的 Lua 在 flusher_enabled 时 SADD 脏集 billing:upq:dirty
- UserPlatformQuotaUsageFlusher 定时 SPOP 脏集 → 批量 HGETALL 读当前窗口 usage 快照
  → BatchSnapshotUsage 绝对值 UPSERT 覆盖 DB(去 SELECT FOR UPDATE 行锁)
  → 失败 SADD 回 / FK(23503)整批丢弃
- flusher 单批 clamp 到 ≤6000,保证一次 flush 只生成一条 UPSERT(单事务原子)
- flusher_enabled 默认 false(降级=旧异步直写 DB)

效果:DB 写连接从 O(QPS) 收敛到 O(副本)。
循环依赖:service 层独立 Snapshot/FK 类型,repository adapter 转换 + %w 映射 FK error。
admin reset/upsert 后失效 cache(脏残留被 flusher 当 MISS 跳过)。

健壮性与可观测性:
- flusher_enabled=false 时 Start 不注册定时器;flush_interval_ms 非法回退 2s
- Readd 回填失败单独计 dirty_lost(不再误记 dirty_readd)并 ALERT;脏集 Readd 补兜底 TTL
- 单 tick 达 max batches 上限仍有积压时记 log
- admin 失效 cache 失败升级为 ALERT(提示 enforcement 可能延迟至 sentinel TTL)
- BatchGet 单条命令失败 / usage 字段损坏均记 log,避免静默以 0 覆写 DB

三态 go vet + 单测/集成测全绿。
已知取舍(默认 flusher_enabled=false 不触发):
- FK 整批丢弃牵连同批正常 key(活跃 key 靠下次 SADD+绝对值快照自愈;Redis 仍权威)
- admin reset/upsert 直写 DB 与 flusher 异步刷存在覆盖竞态:flusher 持旧快照在途时可能覆盖
  admin 刚写值(limit 列不受影响;usage 有 preflight windowExpired 兜底;低频)。彻底消除需 version OCC。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:19:15 +08:00

268 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"errors"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// quotaDirtyCache 是 flusher 依赖的窄接口(来自 BillingCache
type quotaDirtyCache interface {
PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error)
ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error
BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error)
}
// quotaSnapshotWriter 是 flusher 依赖的 DB 写入窄接口。
// 使用 service 层的 UserPlatformQuotaSnapshot避免与 repository 包形成循环依赖;
// 实际实现由 repository adapter 在 B7 注入。
type quotaSnapshotWriter interface {
BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
}
// FlusherMetrics 记录 flusher 运行时指标(原子量,零值可用)。
type FlusherMetrics struct {
FlushSuccessTotal atomic.Int64
FlushErrorTotal atomic.Int64
FlushBatchSizeTotal atomic.Int64
FlushLatencyMsMax atomic.Int64
DirtyReaddTotal atomic.Int64
// DirtyLostTotalReadd 失败导致脏 key 丢失——已 SPOP+主操作失败+Readd 也失败;
// Redis 仍权威,活跃 key 下次 SADD 自愈。
DirtyLostTotal atomic.Int64
FlushFKViolationTotal atomic.Int64
}
// flusherMaxBatchesPerTick 单次 tick 最多消费的批数,防止 tick 执行时间过长。
const flusherMaxBatchesPerTick = 16
// maxFlushBatchSize 限制单批行数,必须 ≤ repository.BatchSnapshotUsage 的 batchRows(6000),
// 以保证单次 flush 的 snapshots 仅生成一条 UPSERT(单事务原子)。两处需手动保持一致。
const maxFlushBatchSize = 6000
// defaultFlushBatchSize 是配置 flush_batch_size 非法(≤0)时的回退值。
const defaultFlushBatchSize = 1000
// UserPlatformQuotaUsageFlusher 将 Redis 脏集快照定期批量写入 DB。
// 不维护任何 delta/in-process 状态;每批读取 Redis 当前绝对值覆盖写入。
type UserPlatformQuotaUsageFlusher struct {
cache quotaDirtyCache
quotaRepo quotaSnapshotWriter
timingWheel *TimingWheelService
// enabled 对应 flusher_enabled 配置false 时 Start() 不注册定时器。
enabled bool
interval time.Duration
batchSize int
flushTimeout time.Duration
metrics *FlusherMetrics
stopped atomic.Bool
}
// NewUserPlatformQuotaUsageFlusher 创建 UserPlatformQuotaUsageFlusher。
// cache(BillingCache) 隐式满足 quotaDirtyCachequotaRepo(UserPlatformQuotaRepository) 隐式满足 quotaSnapshotWriter。
func NewUserPlatformQuotaUsageFlusher(cfg *config.Config, cache BillingCache, quotaRepo UserPlatformQuotaRepository, tw *TimingWheelService) *UserPlatformQuotaUsageFlusher {
batchSize := cfg.Database.UserPlatformQuotaFlushBatchSize
if batchSize <= 0 {
batchSize = defaultFlushBatchSize
}
if batchSize > maxFlushBatchSize {
logger.LegacyPrintf("quota_flusher",
"[QuotaFlusher] flush_batch_size %d 超过上限 %d,已 clamp(避免 BatchSnapshotUsage 多子批非原子)",
cfg.Database.UserPlatformQuotaFlushBatchSize, maxFlushBatchSize)
batchSize = maxFlushBatchSize
}
interval := time.Duration(cfg.Database.UserPlatformQuotaFlushIntervalMs) * time.Millisecond
if interval <= 0 {
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] flush_interval_ms %d 非法,回退 2000ms", cfg.Database.UserPlatformQuotaFlushIntervalMs)
interval = 2 * time.Second
}
return &UserPlatformQuotaUsageFlusher{
cache: cache,
quotaRepo: quotaRepo,
timingWheel: tw,
enabled: cfg.Database.UserPlatformQuotaFlusherEnabled,
interval: interval,
batchSize: batchSize,
flushTimeout: 3 * time.Second,
metrics: &FlusherMetrics{},
}
}
// updateLatencyMax 用 CAS 单调更新最大延迟。
func (s *UserPlatformQuotaUsageFlusher) updateLatencyMax(ms int64) {
for {
old := s.metrics.FlushLatencyMsMax.Load()
if ms <= old {
return
}
if s.metrics.FlushLatencyMsMax.CompareAndSwap(old, ms) {
return
}
}
}
// readdOrCountLost 尝试把 keys 回填脏集:成功计 DirtyReaddTotal失败计 DirtyLostTotal 并 ALERT。
func (s *UserPlatformQuotaUsageFlusher) readdOrCountLost(ctx context.Context, keys []UserPlatformQuotaKey, stage string) {
if err := s.cache.ReaddDirtyUserPlatformQuotaKeys(ctx, keys); err != nil {
s.metrics.DirtyLostTotal.Add(int64(len(keys)))
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] ALERT: Readd after %s failed, %d keys 丢出脏集(DB 镜像缺这批,Redis 仍权威,活跃 key 下次 SADD 自愈): %v", stage, len(keys), err)
return
}
s.metrics.DirtyReaddTotal.Add(int64(len(keys)))
}
// flushOneBatch 处理单批Pop → BatchGet → 组装 snaps → BatchSnapshotUsage。
// 返回 (shouldContinue bool)false 表示本轮循环应停止(空集/错误/最后一批)。
// 每次调用独立创建带 timeout 的 ctx 并 defer cancel不会在循环中累积泄漏。
func (s *UserPlatformQuotaUsageFlusher) flushOneBatch(parentCtx context.Context) bool {
ctx, cancel := context.WithTimeout(parentCtx, s.flushTimeout)
defer cancel()
// 1. Pop 脏集
keys, err := s.cache.PopDirtyUserPlatformQuotaKeys(ctx, s.batchSize)
if err != nil {
s.metrics.FlushErrorTotal.Add(1)
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] PopDirty error: %v", err)
return false
}
if len(keys) == 0 {
// 脏集已空
return false
}
// 2. 批量读 Redis 快照
entries, err := s.cache.BatchGetUserPlatformQuotaCache(ctx, keys)
if err != nil {
s.metrics.FlushErrorTotal.Add(1)
s.readdOrCountLost(ctx, keys, "BatchGet")
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchGet error: %v", err)
return false
}
// 3. 组装 snapshotsMISS 或任一 WindowStart==nil → 跳过)
snaps := make([]UserPlatformQuotaSnapshot, 0, len(keys))
for i, key := range keys {
e := entries[i]
if e == nil {
continue
}
if e.DailyWindowStart == nil || e.WeeklyWindowStart == nil || e.MonthlyWindowStart == nil {
continue
}
snaps = append(snaps, UserPlatformQuotaSnapshot{
UserID: key.UserID,
Platform: key.Platform,
DailyUsageUSD: e.DailyUsageUSD,
WeeklyUsageUSD: e.WeeklyUsageUSD,
MonthlyUsageUSD: e.MonthlyUsageUSD,
DailyWindowStart: *e.DailyWindowStart,
WeeklyWindowStart: *e.WeeklyWindowStart,
MonthlyWindowStart: *e.MonthlyWindowStart,
})
}
// 4. 全部 MISS/异常跳过时
if len(snaps) == 0 {
// 若 Pop 数量已不满一批,表示脏集将空,停止
if len(keys) < s.batchSize {
return false
}
// 否则继续下一批(可能还有更多脏 key
return true
}
// 已知竞态(admin 写 × flusher 刷,仅 flusher_enabled=true 时存在):
// admin ResetExpiredWindow/UpsertForUser 是"先写 DB 再 DeleteCache"。若本批已 SPOP + BatchGet
// 读到旧 usage 快照(此刻 member 已离开脏集),而 admin 随后写 DB、本行 UPSERT 又在 admin 写之后落库,
// 则旧快照会覆盖 admin 刚写入的值;DeleteCache 后 Redis MISS,下次 preflight 从 DB 重载被覆盖的旧值。
// 因 member 已被 SPOP,admin 侧 SREM/清脏标记无法拦截本批(故未做)。影响有限,暂列为已知取舍:
// - UpsertForUser 改 limit,而本 UPSERT 不写 limit 列 → limit 配置不受影响;
// - ResetExpiredWindow 改 usage,但 preflight windowExpired 会在窗口真正过期时自愈重置,
// 仅"强制重置未过期窗口"且与本批精确交错时短暂失效;
// - 低频 admin 操作 + 默认 flusher_enabled=false。彻底消除需 version OCC(DB 加 version 列条件 UPSERT),
// 成本高;启用 flusher 后如需强一致再评估。
// 5. 写入 DB
start := time.Now()
writeErr := s.quotaRepo.BatchSnapshotUsage(ctx, snaps, time.Now().UTC())
s.updateLatencyMax(time.Since(start).Milliseconds())
if writeErr != nil {
if errors.Is(writeErr, ErrUserPlatformQuotaFKViolation) {
// 注意:PG FK violation 是整条 INSERT 回滚 → 整批(含同批正常用户)均未写入 DB,
// 且这些 key 已被 SPOP 出脏集、此处不 Readd。活跃 key 会在下次请求重新 SADD,
// flusher 读 Redis 当前累计绝对值刷库即自愈;低活跃 key 这轮 DB usage 偏低
// (Redis 仍是 enforcement 权威,不受影响;DB 仅展示)。已删用户边角的接受取舍,不做逐行重试。
// FK 违反:用户已被删除,直接丢弃不 Readd
s.metrics.FlushFKViolationTotal.Add(1)
s.metrics.FlushErrorTotal.Add(1)
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] FK violation (dropped %d snaps): %v", len(snaps), writeErr)
} else {
// 其他错误:回填脏集,保留下次重试
s.metrics.FlushErrorTotal.Add(1)
s.readdOrCountLost(ctx, keys, "BatchSnapshotUsage")
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchSnapshotUsage error: %v", writeErr)
}
return false
}
// 6. 成功
s.metrics.FlushSuccessTotal.Add(1)
s.metrics.FlushBatchSizeTotal.Add(int64(len(snaps)))
// 若 Pop 数量不满一批,脏集已空,停止
if len(keys) < s.batchSize {
return false
}
return true
}
// flush 执行一次完整的 flush循环消费至脏集空或达到 maxBatchesPerTick。
func (s *UserPlatformQuotaUsageFlusher) flush() {
if s == nil {
return
}
parentCtx := context.Background()
for b := 0; b < flusherMaxBatchesPerTick; b++ {
if !s.flushOneBatch(parentCtx) {
return
}
}
// 连续消费满 flusherMaxBatchesPerTick 批仍未取空脏集:本 tick 主动让出,剩余积压留待下一 tick。
// 记一条 log 便于 oncall 发现 distinct 活跃 key 远超 maxBatchesPerTick×batchSize(DB 镜像延迟上升);
// 可配合 Redis SCARD billing:upq:dirty 观察脏集存量。
logger.LegacyPrintf("quota_flusher",
"[QuotaFlusher] 单 tick 达到 max batches 上限(%d × batchSize=%d),脏集仍非空,积压顺延至下一 tick",
flusherMaxBatchesPerTick, s.batchSize)
}
// tick 是 TimingWheel 回调。若 flusher 已停止则直接返回。
func (s *UserPlatformQuotaUsageFlusher) tick() {
if s == nil || s.stopped.Load() {
return
}
s.flush()
}
// Start 注册定时 tick。flusher_enabled=false 时直接返回,不注册定时器。
func (s *UserPlatformQuotaUsageFlusher) Start() {
if s == nil || !s.enabled {
return
}
s.timingWheel.ScheduleRecurring("deferred:platform_quota", s.interval, s.tick)
}
// Stop 停止 flusher标记 stopped → Cancel 定时器 → 执行最后一次 flush。
func (s *UserPlatformQuotaUsageFlusher) Stop() {
if s == nil {
return
}
s.stopped.Store(true)
s.timingWheel.Cancel("deferred:platform_quota")
s.flush()
}