## 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: `MatchConfig` → `MatchConfigs`,返回全部匹配配置 **选择**:新增 `MatchConfigs(card) []*model.PollingConfig`,返回所有满足条件的配置(已过滤 NULL-interval,已按 priority ASC 排序),保留原 `MatchConfig` 作为其特例(前向兼容)。 **替代方案 A(直接改 `MatchConfig` 返回合并结果)**:改动最小,但破坏了 `MatchConfig` 的语义(原 spec 描述为"返回第一个匹配"),且外部若有调用方依赖此行为会无声失效。 **替代方案 B(新增 `MatchConfigs` + 废弃 `MatchConfig`)**:API 更干净,但需要迁移所有调用方,成本高。 **结论**:采用替代方案 C——新增 `MatchConfigs`,`MatchConfig` 保持不变(返回 `MatchConfigs()[0]`),后续调用方逐步迁移。 --- ### Decision 2: 过滤所有 interval 均为 NULL 的配置 **选择**:在 `matchConfigConditions` 之后,新增 `hasAnyEnabledInterval(cfg)` 检查,跳过所有 interval 字段全为 NULL 的配置。 **理由**:id=29 这种"所有 interval 全为 NULL"的配置在业务上等效于"不参与轮询",但当前 `matchConfigConditions` 只检查 card 条件,不检查 interval 是否有效。增加这个过滤可以在配置层面彻底堵住"黑洞"问题。 ```go // 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] ``` **理由**:`initBatch` 和 `enqueueCard` 都需要这个合并逻辑,集中在 `PollingConfigManager` 中实现可以保持两处调用的一致性。 **新增类型**: ```go // TaskTypeInterval 某任务类型的轮询间隔信息 type TaskTypeInterval struct { Interval int // 秒 Priority int // 来源配置的 priority } ``` --- ### Decision 4: `initBatch` 中的 interval 选取更新 **当前**:`initBatch` 调用 `cfg := p.configMgr.MatchConfig(card)`,然后分别读取 `cfg.CardStatusCheckInterval`、`cfg.PackageCheckInterval` 等字段。 **修改后**: ```go 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)` 返回 `[]string`,`calcInitialDelay` 分别处理每种 task type。 **修改后**:复用 `MergedTaskIntervals`,逻辑与 `initBatch` 一致。`calcInitialDelay` 签名改为 `calcInitialDelay(interval int) time.Time`,接收合并后的 interval 值,内联 jitter 计算。 `getEnabledTaskTypes` 移除——`MergedTaskIntervals` 的 key set 天然就是启用的 task type 列表。 --- ### Decision 6: `requeueCard`(`internal/task/polling_base.go`)迁移 **发现**:`MatchConfig` 有第三个调用方 `PollingBase.requeueCard()`(`internal/task/polling_base.go:105`),位于 `internal/task/` 包而非 `internal/polling/`。这是**稳态路径**——每次轮询任务完成后的重入队操作,被 5 个 handler 共 26 处调用,是执行频率最高的匹配路径。 **当前**: ```go cfg := b.configMgr.MatchConfig(card) interval := getIntervalByTaskType(cfg, taskType) ``` **修改后**: ```go 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 的能力。`requeueCard` 是 `getIntervalByTaskType` 的唯一调用方,迁移后该函数不再需要。 **注意**:`requeueCard` 只需要单个 taskType 的 interval,而 `MergedTaskIntervals` 返回全量 map。这在功能上正确,性能开销可忽略(配置通常 ≤10 条,task type 仅 5 种)。若后续需要优化,可新增 `MergedIntervalForTaskType(card, taskType)` 方法按需停止遍历。 --- --- ### Decision 7: `scheduleLoop` 顶层 panic recovery + 心跳机制 **问题**:`scheduleLoop` 的主 for-select 循环没有 `recover()`。`processOneShard` 里的子 goroutine 有 recover,但 `processManualQueue`、`processActivationTasks` 等直接在主循环调用,panic 会导致整个调度 goroutine 终止——所有分片队列积压,无任何告警。 **修改**: ```go 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 写入心跳 key(`polling:scheduler:heartbeat`),TTL 设为 2 倍 ScheduleInterval(2 秒)。告警规则通过检查该 key 是否存在判断调度器存活。 ```go case <-ticker.C: // 写心跳,TTL=2s(2×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 是字符串 key(`GET` 检查 TTL),当前 AlertService 的告警指标是队列深度/成功率,告警规则的具体配置由运维通过 API 创建,不在代码中硬编码。 --- ### Decision 8: Scheduler 注入 `initializer`,Init 完成前守卫 **问题**:Scheduler 和 Initializer 同时启动,Init 需要数十分钟加载全量卡。这期间 Scheduler 每秒 tick 出队到空队列,产生无意义的 Redis 读请求和日志噪音。更严重的是,Init 期间手动触发队列的卡会被正常出队,但 `MatchConfig` 此时已可用(ConfigManager 先加载),所以手动触发是正常的——**只需跳过定时出队的空分片扫描**。 **修改**: `Scheduler` struct 新增可选 `initializer *PollingInitializer` 字段,通过 `SetInitializer(init *PollingInitializer)` 在 `Start` 前注入: ```go 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,这些卡永久丢失调度,是应该触发告警的严重问题。 **修改**: ```go if initErr := p.initBatch(ctx, cards); initErr != nil { // Warn → Error:initBatch 失败 = 该批卡未进入轮询队列 p.logger.Error("批量初始化失败,该批次卡未入队,需关注", zap.Int("batch_start_id", int(lastID)), zap.Error(initErr)) } ``` **不做**:不重试(批次失败通常是 Redis 连接问题,重试可能级联)。重启 Worker 会触发完整 Init 重跑。 --- ### Decision 10: Pipeline 逐条错误检查 **问题**:`initBatch` 中 `flushPipe()` 调用 `pipe.Exec(ctx)` 只检查整体 error。go-redis 的 Pipeline 在部分命令失败时,整体 error 可能是 `MultiError`,也可能是 `nil`(取决于 go-redis 版本和 Redis 模式)。不逐条检查 `cmd.Err()`,会导致部分 ZADD 失败静默丢失。 **修改**: ```go 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` 并期望"独占式匹配",改为返回第一个后可能产生语义变化。 → **Mitigation**:`MatchConfig` 行为不变,只是内部调用 `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:105` 的 `requeueCard()` 是第三个调用方,位于 `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)。