Files
junhong_cmp_fiber/internal/polling/scheduler.go
huang 434a8b0349
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
【核心变更】

1. 停复机逻辑统一(StopResumeService)
   - 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
   - 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
   - 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
   - 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
   - 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway

2. Redis 分片队列(PollingQueueManager)
   - 新建 queue_manager.go,封装所有轮询 Redis 操作
   - 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
   - Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
   - 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
   - RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)

3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
   - polling_base.go:共享基类(并发控制/卡缓存/重入队)
   - polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
   - polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
   - polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
   - polling_protect_handler.go:保护期一致性检查,保护期内强制修正

4. 配置管理(PollingConfigManager)
   - 新建 config_manager.go,从 scheduler.go 提取配置职责
   - 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
   - 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置

5. 渐进式初始化(CardInitializer)
   - 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
   - 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队

6. 卡生命周期服务(PollingLifecycleService)
   - 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
   - OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling

7. Scheduler 精简(1000+行 → 227行)
   - 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
   - 保留每 10 秒触发套餐过期检测和流量重置
   - 移除所有 DB 操作、配置加载、卡初始化逻辑

8. 轮询管控 API(enable_polling)
   - 新增 PUT /api/admin/assets/:id/polling-status 接口
   - 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除

9. 数据库迁移
   - 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
   - 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval

【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-07 12:27:04 +08:00

249 lines
7.5 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 polling
import (
"context"
"sync"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// InitProgress 已迁移到 initializer.go
// Scheduler 轮询调度器
// 职责:读取分片 Sorted Set 中到期的卡,生成 Asynq 任务
// 不再负责:配置加载、卡初始化(分别由 PollingConfigManager、PollingInitializer 负责)
type Scheduler struct {
redis *redis.Client
queueClient *asynq.Client
logger *zap.Logger
queueMgr *PollingQueueManager
configMgr *PollingConfigManager
cfg *SchedulerConfig // 启动时固定,避免每次调度重新创建
packageActivationHandler *PackageActivationHandler
dataResetHandler *DataResetHandler
stopChan chan struct{}
wg sync.WaitGroup
}
// SchedulerConfig 调度器配置
type SchedulerConfig struct {
ScheduleInterval time.Duration
MaxManualBatchSize int
ScheduleBatchSize int
}
// DefaultSchedulerConfig 默认调度器配置
func DefaultSchedulerConfig() *SchedulerConfig {
return &SchedulerConfig{
ScheduleInterval: 1 * time.Second,
MaxManualBatchSize: 1000,
ScheduleBatchSize: constants.PollingDequeueMaxBatchSize,
}
}
// NewScheduler 创建调度器
func NewScheduler(
redisClient *redis.Client,
queueClient *asynq.Client,
queueMgr *PollingQueueManager,
configMgr *PollingConfigManager,
logger *zap.Logger,
packageActivationHandler *PackageActivationHandler,
dataResetHandler *DataResetHandler,
) *Scheduler {
return &Scheduler{
redis: redisClient,
queueClient: queueClient,
queueMgr: queueMgr,
configMgr: configMgr,
logger: logger,
cfg: DefaultSchedulerConfig(),
stopChan: make(chan struct{}),
packageActivationHandler: packageActivationHandler,
dataResetHandler: dataResetHandler,
}
}
// Start 启动调度循环(快速启动,配置加载和初始化由外部完成)
func (s *Scheduler) Start(ctx context.Context) error {
s.wg.Add(1)
go s.scheduleLoop(ctx)
s.logger.Info("轮询调度器已启动")
return nil
}
// Stop 停止调度器
func (s *Scheduler) Stop() {
s.logger.Info("正在停止轮询调度器...")
close(s.stopChan)
s.wg.Wait()
s.logger.Info("轮询调度器已停止")
}
// RefreshConfigs 刷新配置缓存
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
if s.configMgr == nil {
return nil
}
return s.configMgr.Load(ctx)
}
// SetStopResumeCallback 注入停复机回调(在 Start 前调用)
func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) {
if s.packageActivationHandler != nil {
s.packageActivationHandler.stopResumeCallback = callback
}
}
// scheduleLoop 调度循环
func (s *Scheduler) scheduleLoop(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(s.cfg.ScheduleInterval)
activationTicker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
defer activationTicker.Stop()
s.logger.Info("调度循环已启动", zap.Duration("interval", s.cfg.ScheduleInterval))
for {
select {
case <-s.stopChan:
s.logger.Info("调度循环收到停止信号")
return
case <-ctx.Done():
s.logger.Info("调度循环收到 ctx 取消信号")
return
case <-ticker.C:
s.processShardSchedule(ctx)
case <-activationTicker.C:
s.processActivationTasks(ctx)
}
}
}
// processShardSchedule 处理手动队列和分片定时队列(每 1 秒触发)
// 使用 90% 的 tick 间隔作为超时,确保单分片 Redis 挂起时不阻塞下一个 tick
func (s *Scheduler) processShardSchedule(ctx context.Context) {
for _, taskType := range allTaskTypes {
s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
}
if s.queueMgr == nil {
return
}
timeout := s.cfg.ScheduleInterval * 9 / 10
tickCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var wg sync.WaitGroup
for shardID := 0; shardID < s.queueMgr.shardCount; shardID++ {
wg.Add(1)
go func(sid int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
s.logger.Error("分片处理 panic已恢复",
zap.Int("shard_id", sid), zap.Any("panic", r))
}
}()
s.processOneShard(tickCtx, sid)
}(shardID)
}
wg.Wait()
}
// processOneShard 处理单个分片的所有任务类型出队并推入 Asynq
func (s *Scheduler) processOneShard(ctx context.Context, shardID int) {
for _, taskType := range allTaskTypes {
depth, err := s.queueMgr.GetQueueDepth(ctx, shardID, taskType)
if err != nil {
s.logger.Warn("获取分片队列深度失败",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
} else if depth > constants.PollingBackpressureThreshold {
s.logger.Debug("背压:分片队列积压过深,跳过本轮出队",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Int64("depth", depth))
continue
}
entries, err := s.queueMgr.DequeueReady(ctx, shardID, taskType, s.cfg.ScheduleBatchSize)
if err != nil {
s.logger.Error("分片出队失败",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
continue
}
if len(entries) > 0 {
cardIDs := make([]string, len(entries))
for i, e := range entries {
cardIDs[i] = formatUint(e.CardID)
}
s.enqueueBatch(ctx, taskType, cardIDs)
}
}
}
// processActivationTasks 套餐激活检查和流量重置调度(每 10 秒触发)
func (s *Scheduler) processActivationTasks(ctx context.Context) {
if s.packageActivationHandler != nil {
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
s.logger.Warn("套餐激活检查失败", zap.Error(err))
}
}
if s.dataResetHandler != nil {
if err := s.dataResetHandler.HandleDataReset(ctx); err != nil {
s.logger.Warn("流量重置调度失败", zap.Error(err))
}
}
}
// processManualQueue 处理手动触发队列
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string, maxBatch int) {
key := constants.RedisPollingManualQueueKey(taskType)
cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result()
if err != nil || len(cardIDs) == 0 {
return
}
s.enqueueBatch(ctx, taskType, cardIDs)
}
// enqueueBatch 批量提交任务到 Asynq 队列;入队失败时回退至分片队列防止卡永久丢失
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string) {
for _, cardID := range cardIDs {
payload := map[string]interface{}{
"card_id": cardID,
"is_manual": false,
"timestamp": time.Now().Unix(),
}
payloadBytes, marshalErr := marshalJSON(payload)
if marshalErr != nil {
s.logger.Error("序列化任务载荷失败,跳过该卡",
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(marshalErr))
continue
}
task := asynq.NewTask(taskType, payloadBytes,
asynq.MaxRetry(0),
asynq.Timeout(30*time.Second),
asynq.Queue(constants.QueueDefault),
)
if _, err := s.queueClient.Enqueue(task); err != nil {
s.logger.Error("提交任务失败,回退至分片队列防止卡永久丢失",
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(err))
if id, parseErr := parseUint(cardID); parseErr == nil {
if reqErr := s.queueMgr.Requeue(ctx, id, taskType, time.Now()); reqErr != nil {
s.logger.Error("回退入队失败,卡可能永久丢失",
zap.String("card_id", cardID), zap.Error(reqErr))
}
}
}
}
}