Files
junhong_cmp_fiber/openspec/changes/archive/2026-04-16-fix-polling-config-multi-match/design.md
huang 5d9be1d7e4
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m27s
fix: 修正轮询配置多匹配逻辑,支持同卡匹配多个配置并按 priority 合并 interval
核心变更:
- MatchConfig 改为 MatchConfigs,返回所有匹配配置
- MergedTaskIntervals 按 task type 合并各配置,选取最高优先级(非 nil 且最小 priority 值)
- hasAnyEnabledInterval 过滤所有 interval 均为 NULL 的配置
- calcInitialDelay 重构为纯函数,接收 interval 参数
- 移除 getEnabledTaskTypes 和 getIntervalByTaskType(被 MergedTaskIntervals 替代)
- scheduler.go 新增心跳 key + 顶层 panic recovery + Init 完成守卫
- initializer.go 批量失败日志升级为 Error,逐条检查 Pipeline 命令错误
- 数据迁移:禁用 id=29 的轮询配置(所有 interval 均为 NULL)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 14:27:47 +08:00

13 KiB
Raw Blame History

Context

当前 PollingConfigManager.MatchConfig(card) 使用独占式单匹配:遍历所有配置(按 priority ASC第一个满足条件的配置直接返回,后续配置全部忽略。

这个设计在以下场景会出问题:

  • id=29 (priority=1, card_condition="", 所有 interval 全为 NULL):匹配所有卡,什么轮询都没配,像黑洞一样废掉整个轮询系统
  • id=28 (priority=25, card_condition="suspended", card_status_check_interval=600):永远不会有机会被评估,因为被 id=29 截断

现有 polling-config-manager/spec.md 描述了"按优先级返回第一个匹配",这是需求规格说明,而非实现细节 bug。

Goals / Non-Goals

Goals:

  • 支持一张卡匹配多个配置,每个配置贡献自己非 NULL 的 interval
  • 相同 task type 从多个匹配的 config 中,按 priority 数值小(优先级高)选取
  • 跳过所有 interval 均为 NULL 的配置(这些配置不应参与轮询匹配)
  • 向后兼容:外部已有调用 MatchConfig 的地方不受影响

Non-Goals:

  • 不改变配置优先级机制priority ASC 不变)
  • 不改变 DB schema
  • 不引入新的外部依赖

Decisions

Decision 1: MatchConfigMatchConfigs,返回全部匹配配置

选择:新增 MatchConfigs(card) []*model.PollingConfig,返回所有满足条件的配置(已过滤 NULL-interval已按 priority ASC 排序),保留原 MatchConfig 作为其特例(前向兼容)。

替代方案 A直接改 MatchConfig 返回合并结果):改动最小,但破坏了 MatchConfig 的语义(原 spec 描述为"返回第一个匹配"),且外部若有调用方依赖此行为会无声失效。

替代方案 B新增 MatchConfigs + 废弃 MatchConfigAPI 更干净,但需要迁移所有调用方,成本高。

结论:采用替代方案 C——新增 MatchConfigsMatchConfig 保持不变(返回 MatchConfigs()[0]),后续调用方逐步迁移。


Decision 2: 过滤所有 interval 均为 NULL 的配置

选择:在 matchConfigConditions 之后,新增 hasAnyEnabledInterval(cfg) 检查,跳过所有 interval 字段全为 NULL 的配置。

理由id=29 这种"所有 interval 全为 NULL"的配置在业务上等效于"不参与轮询",但当前 matchConfigConditions 只检查 card 条件,不检查 interval 是否有效。增加这个过滤可以在配置层面彻底堵住"黑洞"问题。

// hasAnyEnabledInterval 检查配置是否有至少一个非 NULL 的轮询间隔
func hasAnyEnabledInterval(cfg *model.PollingConfig) bool {
    return (cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0) ||
           (cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0) ||
           (cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0) ||
           (cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) ||
           (cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0)
}

Decision 3: 多配置 interval 合并策略

选择:新增 MergedTaskIntervals(card) map[string]int 方法(在 config_manager.go),对所有匹配配置按 priority 遍历,对每种 task type 选取第一个非 NULL 的 interval。

合并逻辑

所有匹配 configs按 priority ASC:
  for each config:
    for each taskType in allTaskTypes:
      if merged[taskType] == nil && config[taskType] != nil:
        merged[taskType] = config[taskType]

理由initBatchenqueueCard 都需要这个合并逻辑,集中在 PollingConfigManager 中实现可以保持两处调用的一致性。

新增类型

// TaskTypeInterval 某任务类型的轮询间隔信息
type TaskTypeInterval struct {
    Interval int // 秒
    Priority int  // 来源配置的 priority
}

Decision 4: initBatch 中的 interval 选取更新

当前initBatch 调用 cfg := p.configMgr.MatchConfig(card),然后分别读取 cfg.CardStatusCheckIntervalcfg.PackageCheckInterval 等字段。

修改后

intervals := p.configMgr.MergedTaskIntervals(card)
for taskType, info := range intervals {
    nextCheck := calculateNextCheckTime(/* 对应 last 字段 */, info.Interval, now)
    // ZADD 到对应分片队列
}

每种 task type 对应哪个 Last*CheckAt 字段:

taskType card 字段
realname LastRealNameCheckAt
carddata LastDataCheckAt
package LastDataCheckAt
protect LastProtectCheckAt
card_status LastCardStatusCheckAt

Decision 5: enqueueCard 中的 interval 选取更新

当前getEnabledTaskTypes(cfg) 返回 []stringcalcInitialDelay 分别处理每种 task type。

修改后:复用 MergedTaskIntervals,逻辑与 initBatch 一致。calcInitialDelay 签名改为 calcInitialDelay(interval int) time.Time,接收合并后的 interval 值,内联 jitter 计算。

getEnabledTaskTypes 移除——MergedTaskIntervals 的 key set 天然就是启用的 task type 列表。


Decision 6: requeueCardinternal/task/polling_base.go)迁移

发现MatchConfig 有第三个调用方 PollingBase.requeueCard()internal/task/polling_base.go:105),位于 internal/task/ 包而非 internal/polling/。这是稳态路径——每次轮询任务完成后的重入队操作,被 5 个 handler 共 26 处调用,是执行频率最高的匹配路径。

当前

cfg := b.configMgr.MatchConfig(card)
interval := getIntervalByTaskType(cfg, taskType)

修改后

intervals := b.configMgr.MergedTaskIntervals(card)
info, ok := intervals[taskType]
if !ok || info.Interval <= 0 {
    return nil // 该 task type 无有效配置,不入队
}
nextCheckAt := time.Now().Add(time.Duration(info.Interval) * time.Second)
return b.queueMgr.Requeue(ctx, cardID, taskType, nextCheckAt)

getIntervalByTaskType 移除MergedTaskIntervals 已提供按 task type 查 interval 的能力。requeueCardgetIntervalByTaskType 的唯一调用方,迁移后该函数不再需要。

注意requeueCard 只需要单个 taskType 的 intervalMergedTaskIntervals 返回全量 map。这在功能上正确性能开销可忽略配置通常 ≤10 条task type 仅 5 种)。若后续需要优化,可新增 MergedIntervalForTaskType(card, taskType) 方法按需停止遍历。



Decision 7: scheduleLoop 顶层 panic recovery + 心跳机制

问题scheduleLoop 的主 for-select 循环没有 recover()processOneShard 里的子 goroutine 有 recoverprocessManualQueueprocessActivationTasks 等直接在主循环调用panic 会导致整个调度 goroutine 终止——所有分片队列积压,无任何告警。

修改

func (s *Scheduler) scheduleLoop(ctx context.Context) {
    defer s.wg.Done()
    defer func() {
        if r := recover(); r != nil {
            s.logger.Error("调度主循环发生 panic调度已停止需重启 Worker",
                zap.Any("panic", r))
        }
    }()
    // ... 原有逻辑
}

心跳机制:每次 tick 向 Redis 写入心跳 keypolling:scheduler:heartbeatTTL 设为 2 倍 ScheduleInterval2 秒)。告警规则通过检查该 key 是否存在判断调度器存活。

case <-ticker.C:
    // 写心跳TTL=2s2×1s tick超过 2s 无更新说明调度器挂了
    _ = s.redis.Set(ctx, constants.RedisPollingSchedulerHeartbeatKey(),
        time.Now().Unix(), 2*s.cfg.ScheduleInterval)
    s.processShardSchedule(ctx)

新增常量pkg/constants/redis.go 新增 RedisPollingSchedulerHeartbeatKey() string,返回 "polling:scheduler:heartbeat"

不新增 AlertRule:心跳 key 是字符串 keyGET 检查 TTL当前 AlertService 的告警指标是队列深度/成功率,告警规则的具体配置由运维通过 API 创建,不在代码中硬编码。


Decision 8: Scheduler 注入 initializerInit 完成前守卫

问题Scheduler 和 Initializer 同时启动Init 需要数十分钟加载全量卡。这期间 Scheduler 每秒 tick 出队到空队列,产生无意义的 Redis 读请求和日志噪音。更严重的是Init 期间手动触发队列的卡会被正常出队,但 MatchConfig 此时已可用ConfigManager 先加载),所以手动触发是正常的——只需跳过定时出队的空分片扫描

修改

Scheduler struct 新增可选 initializer *PollingInitializer 字段,通过 SetInitializer(init *PollingInitializer)Start 前注入:

func (s *Scheduler) processShardSchedule(ctx context.Context) {
    // 仍然处理手动队列(允许启动期手动触发)
    for _, taskType := range allTaskTypes {
        s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
    }

    // Init 未完成时跳过分片扫描
    if s.initializer != nil && !s.initializer.IsCompleted() {
        return
    }
    // ... 原有分片出队逻辑
}

为何不在 Start 前 wait:同步等待 Init 完成会延迟 Worker 启动Init 可能需要 30 分钟不可接受。Skip 方案既保留了手动触发能力,又消除了空扫描噪音。


Decision 9: initBatch 失败日志级别 Warn→Error

问题initBatch 失败时当前记录 Warn 并继续。但 initBatch 失败意味着该批次的卡未进入任何轮询队列,除非下次重启重新 Init这些卡永久丢失调度是应该触发告警的严重问题。

修改

if initErr := p.initBatch(ctx, cards); initErr != nil {
    // Warn → ErrorinitBatch 失败 = 该批卡未进入轮询队列
    p.logger.Error("批量初始化失败,该批次卡未入队,需关注",
        zap.Int("batch_start_id", int(lastID)), zap.Error(initErr))
}

不做:不重试(批次失败通常是 Redis 连接问题,重试可能级联)。重启 Worker 会触发完整 Init 重跑。


Decision 10: Pipeline 逐条错误检查

问题initBatchflushPipe() 调用 pipe.Exec(ctx) 只检查整体 error。go-redis 的 Pipeline 在部分命令失败时,整体 error 可能是 MultiError,也可能是 nil(取决于 go-redis 版本和 Redis 模式)。不逐条检查 cmd.Err(),会导致部分 ZADD 失败静默丢失。

修改

flushPipe := func() {
    if cmdCount == 0 {
        return
    }
    cmds, execErr := pipe.Exec(ctx)
    if execErr != nil {
        p.logger.Error("Pipeline flush 失败", zap.Error(execErr))
    }
    // 逐条检查,记录具体失败的命令
    for _, cmd := range cmds {
        if cmd.Err() != nil && cmd.Err() != redis.Nil {
            p.logger.Warn("Pipeline 单条命令失败",
                zap.String("cmd", cmd.Name()), zap.Error(cmd.Err()))
        }
    }
    pipe = p.redis.Pipeline()
    cmdCount = 0
}

注意逐条检查仅记录日志不中断批次。ZADD 失败率极低(仅在 Redis 数据类型冲突等异常情况),单条失败不应阻塞整个批次。


Risks / Trade-offs

[Risk] MatchConfig 的前向兼容:外部若有代码调用 MatchConfig 并期望"独占式匹配",改为返回第一个后可能产生语义变化。 → MitigationMatchConfig 行为不变,只是内部调用 MatchConfigs()[0],前向兼容。

[Risk] 临时修复 id=29 的副作用:用户提到 id=29 当前 status=1,所有 interval 全为 NULL。修复后 MatchConfigs 会跳过它(因为 hasAnyEnabledInterval 返回 false相当于它从轮询系统"退出"。这是预期行为,但可能用户原本希望它作为 catch-all 兜底配置。 → Mitigation:已在 design 中明确说明,临时方案是用户手动 UPDATE tb_polling_config SET status=0 WHERE id=29

[Risk] 相同 task type 不同 interval 的优先级覆盖:极端场景下,配置 A (priority=5, card_status=300) 和配置 B (priority=10, card_status=600) 同时匹配一张卡,按设计会选 A 的 300 秒。但用户可能期望更特定的配置(如 carrier_id 匹配的)优先级高于泛化配置(如 card_condition。 → 当前设计已满足priority ASC = 优先级高优先。配置 A 如果 priority 更小(更高优先级),它应该更具体才合理。如果出现"更具体的配置 priority 反而更大"的情况,那是配置管理员的问题,不是代码问题。

[Trade-off] 临时修复 vs 根本修复:本 design 是根本修复,但需要代码改动 + 部署。临时修复(禁用 id=29可以立即生效用户可自行选择。

Open Questions已闭环

  1. 是否有其他调用方依赖 MatchConfig 的独占语义? 已确认:有。 internal/task/polling_base.go:105requeueCard() 是第三个调用方,位于 internal/task/ 包。这是稳态路径(每次轮询任务完成后执行),被 5 个 handler 共 26 处调用。已在 Decision 6 中覆盖迁移方案。
  2. id=29 是否应该被删除而不是禁用? 决定:添加数据迁移 SQL 禁用。 id=29 作为历史配置,禁用(status=0)而非删除——保留审计可追溯性。在 tasks.md 中添加迁移任务。
  3. calcInitialDelay 需要改造 已决定:修改签名为 calcInitialDelay(interval int) time.Time 详见 Decision 5。同时移除 getIntervalByTaskType(详见 Decision 6