Files
sub2api/backend/internal/service/user_platform_quota_flusher.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +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()
}