user_platform_quota_flusher.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync/atomic"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  9. )
  10. // quotaDirtyCache 是 flusher 依赖的窄接口(来自 BillingCache)。
  11. type quotaDirtyCache interface {
  12. PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error)
  13. ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error
  14. BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error)
  15. }
  16. // quotaSnapshotWriter 是 flusher 依赖的 DB 写入窄接口。
  17. // 使用 service 层的 UserPlatformQuotaSnapshot,避免与 repository 包形成循环依赖;
  18. // 实际实现由 repository adapter 在 B7 注入。
  19. type quotaSnapshotWriter interface {
  20. BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
  21. }
  22. // FlusherMetrics 记录 flusher 运行时指标(原子量,零值可用)。
  23. type FlusherMetrics struct {
  24. FlushSuccessTotal atomic.Int64
  25. FlushErrorTotal atomic.Int64
  26. FlushBatchSizeTotal atomic.Int64
  27. FlushLatencyMsMax atomic.Int64
  28. DirtyReaddTotal atomic.Int64
  29. // DirtyLostTotal:Readd 失败导致脏 key 丢失——已 SPOP+主操作失败+Readd 也失败;
  30. // Redis 仍权威,活跃 key 下次 SADD 自愈。
  31. DirtyLostTotal atomic.Int64
  32. FlushFKViolationTotal atomic.Int64
  33. }
  34. // flusherMaxBatchesPerTick 单次 tick 最多消费的批数,防止 tick 执行时间过长。
  35. const flusherMaxBatchesPerTick = 16
  36. // maxFlushBatchSize 限制单批行数,必须 ≤ repository.BatchSnapshotUsage 的 batchRows(6000),
  37. // 以保证单次 flush 的 snapshots 仅生成一条 UPSERT(单事务原子)。两处需手动保持一致。
  38. const maxFlushBatchSize = 6000
  39. // defaultFlushBatchSize 是配置 flush_batch_size 非法(≤0)时的回退值。
  40. const defaultFlushBatchSize = 1000
  41. // UserPlatformQuotaUsageFlusher 将 Redis 脏集快照定期批量写入 DB。
  42. // 不维护任何 delta/in-process 状态;每批读取 Redis 当前绝对值覆盖写入。
  43. type UserPlatformQuotaUsageFlusher struct {
  44. cache quotaDirtyCache
  45. quotaRepo quotaSnapshotWriter
  46. timingWheel *TimingWheelService
  47. // enabled 对应 flusher_enabled 配置;false 时 Start() 不注册定时器。
  48. enabled bool
  49. interval time.Duration
  50. batchSize int
  51. flushTimeout time.Duration
  52. metrics *FlusherMetrics
  53. stopped atomic.Bool
  54. }
  55. // NewUserPlatformQuotaUsageFlusher 创建 UserPlatformQuotaUsageFlusher。
  56. // cache(BillingCache) 隐式满足 quotaDirtyCache;quotaRepo(UserPlatformQuotaRepository) 隐式满足 quotaSnapshotWriter。
  57. func NewUserPlatformQuotaUsageFlusher(cfg *config.Config, cache BillingCache, quotaRepo UserPlatformQuotaRepository, tw *TimingWheelService) *UserPlatformQuotaUsageFlusher {
  58. batchSize := cfg.Database.UserPlatformQuotaFlushBatchSize
  59. if batchSize <= 0 {
  60. batchSize = defaultFlushBatchSize
  61. }
  62. if batchSize > maxFlushBatchSize {
  63. logger.LegacyPrintf("quota_flusher",
  64. "[QuotaFlusher] flush_batch_size %d 超过上限 %d,已 clamp(避免 BatchSnapshotUsage 多子批非原子)",
  65. cfg.Database.UserPlatformQuotaFlushBatchSize, maxFlushBatchSize)
  66. batchSize = maxFlushBatchSize
  67. }
  68. interval := time.Duration(cfg.Database.UserPlatformQuotaFlushIntervalMs) * time.Millisecond
  69. if interval <= 0 {
  70. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] flush_interval_ms %d 非法,回退 2000ms", cfg.Database.UserPlatformQuotaFlushIntervalMs)
  71. interval = 2 * time.Second
  72. }
  73. return &UserPlatformQuotaUsageFlusher{
  74. cache: cache,
  75. quotaRepo: quotaRepo,
  76. timingWheel: tw,
  77. enabled: cfg.Database.UserPlatformQuotaFlusherEnabled,
  78. interval: interval,
  79. batchSize: batchSize,
  80. flushTimeout: 3 * time.Second,
  81. metrics: &FlusherMetrics{},
  82. }
  83. }
  84. // updateLatencyMax 用 CAS 单调更新最大延迟。
  85. func (s *UserPlatformQuotaUsageFlusher) updateLatencyMax(ms int64) {
  86. for {
  87. old := s.metrics.FlushLatencyMsMax.Load()
  88. if ms <= old {
  89. return
  90. }
  91. if s.metrics.FlushLatencyMsMax.CompareAndSwap(old, ms) {
  92. return
  93. }
  94. }
  95. }
  96. // readdOrCountLost 尝试把 keys 回填脏集:成功计 DirtyReaddTotal,失败计 DirtyLostTotal 并 ALERT。
  97. func (s *UserPlatformQuotaUsageFlusher) readdOrCountLost(ctx context.Context, keys []UserPlatformQuotaKey, stage string) {
  98. if err := s.cache.ReaddDirtyUserPlatformQuotaKeys(ctx, keys); err != nil {
  99. s.metrics.DirtyLostTotal.Add(int64(len(keys)))
  100. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] ALERT: Readd after %s failed, %d keys 丢出脏集(DB 镜像缺这批,Redis 仍权威,活跃 key 下次 SADD 自愈): %v", stage, len(keys), err)
  101. return
  102. }
  103. s.metrics.DirtyReaddTotal.Add(int64(len(keys)))
  104. }
  105. // flushOneBatch 处理单批:Pop → BatchGet → 组装 snaps → BatchSnapshotUsage。
  106. // 返回 (shouldContinue bool):false 表示本轮循环应停止(空集/错误/最后一批)。
  107. // 每次调用独立创建带 timeout 的 ctx 并 defer cancel,不会在循环中累积泄漏。
  108. func (s *UserPlatformQuotaUsageFlusher) flushOneBatch(parentCtx context.Context) bool {
  109. ctx, cancel := context.WithTimeout(parentCtx, s.flushTimeout)
  110. defer cancel()
  111. // 1. Pop 脏集
  112. keys, err := s.cache.PopDirtyUserPlatformQuotaKeys(ctx, s.batchSize)
  113. if err != nil {
  114. s.metrics.FlushErrorTotal.Add(1)
  115. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] PopDirty error: %v", err)
  116. return false
  117. }
  118. if len(keys) == 0 {
  119. // 脏集已空
  120. return false
  121. }
  122. // 2. 批量读 Redis 快照
  123. entries, err := s.cache.BatchGetUserPlatformQuotaCache(ctx, keys)
  124. if err != nil {
  125. s.metrics.FlushErrorTotal.Add(1)
  126. s.readdOrCountLost(ctx, keys, "BatchGet")
  127. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchGet error: %v", err)
  128. return false
  129. }
  130. // 3. 组装 snapshots(MISS 或任一 WindowStart==nil → 跳过)
  131. snaps := make([]UserPlatformQuotaSnapshot, 0, len(keys))
  132. for i, key := range keys {
  133. e := entries[i]
  134. if e == nil {
  135. continue
  136. }
  137. if e.DailyWindowStart == nil || e.WeeklyWindowStart == nil || e.MonthlyWindowStart == nil {
  138. continue
  139. }
  140. snaps = append(snaps, UserPlatformQuotaSnapshot{
  141. UserID: key.UserID,
  142. Platform: key.Platform,
  143. DailyUsageUSD: e.DailyUsageUSD,
  144. WeeklyUsageUSD: e.WeeklyUsageUSD,
  145. MonthlyUsageUSD: e.MonthlyUsageUSD,
  146. DailyWindowStart: *e.DailyWindowStart,
  147. WeeklyWindowStart: *e.WeeklyWindowStart,
  148. MonthlyWindowStart: *e.MonthlyWindowStart,
  149. })
  150. }
  151. // 4. 全部 MISS/异常跳过时
  152. if len(snaps) == 0 {
  153. // 若 Pop 数量已不满一批,表示脏集将空,停止
  154. if len(keys) < s.batchSize {
  155. return false
  156. }
  157. // 否则继续下一批(可能还有更多脏 key)
  158. return true
  159. }
  160. // 已知竞态(admin 写 × flusher 刷,仅 flusher_enabled=true 时存在):
  161. // admin ResetExpiredWindow/UpsertForUser 是"先写 DB 再 DeleteCache"。若本批已 SPOP + BatchGet
  162. // 读到旧 usage 快照(此刻 member 已离开脏集),而 admin 随后写 DB、本行 UPSERT 又在 admin 写之后落库,
  163. // 则旧快照会覆盖 admin 刚写入的值;DeleteCache 后 Redis MISS,下次 preflight 从 DB 重载被覆盖的旧值。
  164. // 因 member 已被 SPOP,admin 侧 SREM/清脏标记无法拦截本批(故未做)。影响有限,暂列为已知取舍:
  165. // - UpsertForUser 改 limit,而本 UPSERT 不写 limit 列 → limit 配置不受影响;
  166. // - ResetExpiredWindow 改 usage,但 preflight windowExpired 会在窗口真正过期时自愈重置,
  167. // 仅"强制重置未过期窗口"且与本批精确交错时短暂失效;
  168. // - 低频 admin 操作 + 默认 flusher_enabled=false。彻底消除需 version OCC(DB 加 version 列条件 UPSERT),
  169. // 成本高;启用 flusher 后如需强一致再评估。
  170. // 5. 写入 DB
  171. start := time.Now()
  172. writeErr := s.quotaRepo.BatchSnapshotUsage(ctx, snaps, time.Now().UTC())
  173. s.updateLatencyMax(time.Since(start).Milliseconds())
  174. if writeErr != nil {
  175. if errors.Is(writeErr, ErrUserPlatformQuotaFKViolation) {
  176. // 注意:PG FK violation 是整条 INSERT 回滚 → 整批(含同批正常用户)均未写入 DB,
  177. // 且这些 key 已被 SPOP 出脏集、此处不 Readd。活跃 key 会在下次请求重新 SADD,
  178. // flusher 读 Redis 当前累计绝对值刷库即自愈;低活跃 key 这轮 DB usage 偏低
  179. // (Redis 仍是 enforcement 权威,不受影响;DB 仅展示)。已删用户边角的接受取舍,不做逐行重试。
  180. // FK 违反:用户已被删除,直接丢弃不 Readd
  181. s.metrics.FlushFKViolationTotal.Add(1)
  182. s.metrics.FlushErrorTotal.Add(1)
  183. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] FK violation (dropped %d snaps): %v", len(snaps), writeErr)
  184. } else {
  185. // 其他错误:回填脏集,保留下次重试
  186. s.metrics.FlushErrorTotal.Add(1)
  187. s.readdOrCountLost(ctx, keys, "BatchSnapshotUsage")
  188. logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchSnapshotUsage error: %v", writeErr)
  189. }
  190. return false
  191. }
  192. // 6. 成功
  193. s.metrics.FlushSuccessTotal.Add(1)
  194. s.metrics.FlushBatchSizeTotal.Add(int64(len(snaps)))
  195. // 若 Pop 数量不满一批,脏集已空,停止
  196. if len(keys) < s.batchSize {
  197. return false
  198. }
  199. return true
  200. }
  201. // flush 执行一次完整的 flush,循环消费至脏集空或达到 maxBatchesPerTick。
  202. func (s *UserPlatformQuotaUsageFlusher) flush() {
  203. if s == nil {
  204. return
  205. }
  206. parentCtx := context.Background()
  207. for b := 0; b < flusherMaxBatchesPerTick; b++ {
  208. if !s.flushOneBatch(parentCtx) {
  209. return
  210. }
  211. }
  212. // 连续消费满 flusherMaxBatchesPerTick 批仍未取空脏集:本 tick 主动让出,剩余积压留待下一 tick。
  213. // 记一条 log 便于 oncall 发现 distinct 活跃 key 远超 maxBatchesPerTick×batchSize(DB 镜像延迟上升);
  214. // 可配合 Redis SCARD billing:upq:dirty 观察脏集存量。
  215. logger.LegacyPrintf("quota_flusher",
  216. "[QuotaFlusher] 单 tick 达到 max batches 上限(%d × batchSize=%d),脏集仍非空,积压顺延至下一 tick",
  217. flusherMaxBatchesPerTick, s.batchSize)
  218. }
  219. // tick 是 TimingWheel 回调。若 flusher 已停止则直接返回。
  220. func (s *UserPlatformQuotaUsageFlusher) tick() {
  221. if s == nil || s.stopped.Load() {
  222. return
  223. }
  224. s.flush()
  225. }
  226. // Start 注册定时 tick。flusher_enabled=false 时直接返回,不注册定时器。
  227. func (s *UserPlatformQuotaUsageFlusher) Start() {
  228. if s == nil || !s.enabled {
  229. return
  230. }
  231. s.timingWheel.ScheduleRecurring("deferred:platform_quota", s.interval, s.tick)
  232. }
  233. // Stop 停止 flusher:标记 stopped → Cancel 定时器 → 执行最后一次 flush。
  234. func (s *UserPlatformQuotaUsageFlusher) Stop() {
  235. if s == nil {
  236. return
  237. }
  238. s.stopped.Store(true)
  239. s.timingWheel.Cancel("deferred:platform_quota")
  240. s.flush()
  241. }