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

275 lines
13 KiB
Markdown
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.
## 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 倍 ScheduleInterval2 秒)。告警规则通过检查该 key 是否存在判断调度器存活。
```go
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 是字符串 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 → ErrorinitBatch 失败 = 该批卡未进入轮询队列
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