Files
junhong_cmp_fiber/internal/polling/initializer.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

298 lines
8.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"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// initProgress 初始化进度内部状态(字段通过 GetProgress 快照对外,避免暴露锁)
type initProgress struct {
mu sync.RWMutex
totalCards int64
loadedCards int64
startTime time.Time
lastBatchTime time.Time
status string
errorMessage string
}
// InitProgress 初始化进度快照GetProgress 返回的值拷贝,调用方无需加锁)
type InitProgress struct {
TotalCards int64 `json:"total_cards"`
LoadedCards int64 `json:"loaded_cards"`
StartTime time.Time `json:"start_time"`
LastBatchTime time.Time `json:"last_batch_time"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
}
// pipelineFlushSize 单次 Pipeline Exec 最大命令数(每张卡最多 6 条)
// 10000 条约 2MB 内存峰值;千万级卡下约 6000 次 ExecRTT 总开销可忽略
const pipelineFlushSize = 10000
// PollingInitializer 分片渐进式初始化器
// 启动时从 DB 分批加载全量卡数据到分片 Sorted Set
// 使用 card_id % shardCount 分片,每 pipelineFlushSize 条命令 flush 一次 Pipeline
type PollingInitializer struct {
iotCardStore *postgres.IotCardStore
redis *redis.Client
configMgr *PollingConfigManager
queueMgr *PollingQueueManager
logger *zap.Logger
progress initProgress
initCompleted atomic.Bool
stopChan chan struct{}
wg sync.WaitGroup
}
// NewPollingInitializer 创建初始化器
func NewPollingInitializer(
iotCardStore *postgres.IotCardStore,
redisClient *redis.Client,
configMgr *PollingConfigManager,
queueMgr *PollingQueueManager,
logger *zap.Logger,
) *PollingInitializer {
p := &PollingInitializer{
iotCardStore: iotCardStore,
redis: redisClient,
configMgr: configMgr,
queueMgr: queueMgr,
logger: logger,
stopChan: make(chan struct{}),
}
p.progress.status = "pending"
return p
}
// StartBackground 启动后台渐进式初始化(非阻塞)
func (p *PollingInitializer) StartBackground(ctx context.Context) {
p.wg.Add(1)
go p.run(ctx)
}
// Stop 停止初始化
func (p *PollingInitializer) Stop() {
close(p.stopChan)
p.wg.Wait()
}
// IsCompleted 检查初始化是否完成
func (p *PollingInitializer) IsCompleted() bool {
return p.initCompleted.Load()
}
// GetProgress 返回当前初始化进度快照(加锁读取,返回值拷贝)
func (p *PollingInitializer) GetProgress() InitProgress {
p.progress.mu.RLock()
defer p.progress.mu.RUnlock()
return InitProgress{
TotalCards: p.progress.totalCards,
LoadedCards: p.progress.loadedCards,
StartTime: p.progress.startTime,
LastBatchTime: p.progress.lastBatchTime,
Status: p.progress.status,
ErrorMessage: p.progress.errorMessage,
}
}
// run 执行渐进式初始化
func (p *PollingInitializer) run(ctx context.Context) {
defer p.wg.Done()
p.setStatus("running", "")
p.progress.mu.Lock()
p.progress.startTime = time.Now()
p.progress.mu.Unlock()
p.logger.Info("开始分片渐进式初始化...")
totalCards, err := p.iotCardStore.CountForPolling(ctx)
if err != nil {
p.logger.Error("获取卡总数失败", zap.Error(err))
p.setStatus("failed", err.Error())
return
}
p.progress.mu.Lock()
p.progress.totalCards = totalCards
p.progress.mu.Unlock()
p.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
const batchSize = 100000
const batchSleep = 500 * time.Millisecond
var lastID uint
batchCount := 0
for {
select {
case <-p.stopChan:
p.logger.Info("渐进式初始化被中断")
return
default:
}
cards, fetchErr := p.iotCardStore.ListForPollingBatch(ctx, lastID, batchSize)
if fetchErr != nil {
p.logger.Error("加载卡数据失败", zap.Error(fetchErr))
p.setStatus("failed", fetchErr.Error())
return
}
if len(cards) == 0 {
break
}
if initErr := p.initBatch(ctx, cards); initErr != nil {
p.logger.Warn("批量初始化失败", zap.Error(initErr))
}
lastID = cards[len(cards)-1].ID
batchCount++
p.progress.mu.Lock()
p.progress.loadedCards += int64(len(cards))
p.progress.lastBatchTime = time.Now()
loaded := p.progress.loadedCards
p.progress.mu.Unlock()
if batchCount%10 == 0 || len(cards) < batchSize {
p.logger.Info("初始化进度",
zap.Int("batch", batchCount),
zap.Int64("loaded", loaded),
zap.Int64("total", totalCards))
}
time.Sleep(batchSleep)
}
p.setStatus("completed", "")
p.initCompleted.Store(true)
snapshot := p.GetProgress()
p.logger.Info("分片渐进式初始化完成",
zap.Int64("total_loaded", snapshot.LoadedCards),
zap.Duration("duration", time.Since(snapshot.StartTime)))
}
// initBatch 使用 Pipeline 将一批卡写入分片队列和缓存
// 每 pipelineFlushSize 条命令 Exec 一次,控制内存峰值并降低单次失败损失
func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCard) error {
if len(cards) == 0 {
return nil
}
now := time.Now()
cardCacheTTL := 7 * 24 * time.Hour
pipe := p.redis.Pipeline()
cmdCount := 0
flushPipe := func() {
if cmdCount == 0 {
return
}
if _, execErr := pipe.Exec(ctx); execErr != nil {
p.logger.Warn("Pipeline flush 失败,继续下一批", zap.Error(execErr))
}
pipe = p.redis.Pipeline()
cmdCount = 0
}
for _, card := range cards {
cfg := p.configMgr.MatchConfig(card)
if cfg == nil {
continue
}
shardID := int(card.ID) % p.queueMgr.shardCount
cardIDStr := fmt.Sprintf("%d", card.ID)
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingRealname), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingCarddata), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingPackage), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingProtect), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{
"id": card.ID, "iccid": card.ICCID,
"card_category": card.CardCategory, "real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus, "carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
"data_usage_mb": card.DataUsageMB,
"stop_reason": card.StopReason, "is_standalone": boolToStr(card.IsStandalone),
"cached_at": now.Unix(),
}
if card.CurrentMonthStartDate != nil {
cacheData["current_month_start_date"] = card.CurrentMonthStartDate.Unix()
}
pipe.HSet(ctx, cacheKey, cacheData)
pipe.Expire(ctx, cacheKey, cardCacheTTL)
cmdCount += 2
if cmdCount >= pipelineFlushSize {
flushPipe()
}
}
flushPipe()
return nil
}
// calculateNextCheckTime 计算下次检查时间
func calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int, now time.Time) time.Time {
if lastCheckAt == nil {
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
return now.Add(jitter)
}
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
if nextCheck.Before(now) {
return now
}
return nextCheck
}
func (p *PollingInitializer) setStatus(status, errMsg string) {
p.progress.mu.Lock()
p.progress.status = status
p.progress.errorMessage = errMsg
p.progress.mu.Unlock()
}