6.3 KiB
Context
轮询停复机系统(stop_resume_service.go + 三个轮询 Handler)存在三个已在生产环境触发的缺陷,均已通过日志 + DB 分析定位到根因:
- 连坐停机(已复现):设备 862639076038233 下 card_id=5(未实名)触发
checkStopReasons → not_realname → stopDeviceCards,将同设备已实名的 card_id=2、card_id=4 一并停机。 - 无防抖误停(潜在风险):
polling_realname_handler单次检测到实名逆转即触发停机,上游运营商同步延迟时单次 false 可导致误停。 - carrier 停机状态不完整(已复现):
polling_cardstatus_handler检测到网关侧停机时仅更新network_status=0,stop_reason留空,导致卡进入无法被自动复机的死锁状态。
涉及文件:
internal/service/iot_card/stop_resume_service.go(EvaluateAndAct)internal/task/polling_realname_handler.gointernal/task/polling_cardstatus_handler.gopkg/constants/iot.gopkg/constants/redis.go
Goals / Non-Goals
Goals:
not_realname停机仅影响被评估的单卡,不扩散至设备维度- 实名逆转需连续 3 次才触发停机,消除上游单次误报
- 网关侧停机事件写入明确的
stop_reason,确保卡状态完整可追踪
Non-Goals:
- 不修改
no_package/traffic_exhausted的设备维度停机逻辑(仍保持连带停机) - 不引入数据库 schema 变更
- 不回填存量
stop_reason=''的历史数据 carrier_stopped不纳入isPollingStopReason(不自动复机,运营商停机需人工确认)
Decisions
决策 1:isDeviceScopeReason——停机范围判断
背景:not_realname 是单卡属性(每张卡独立实名),no_package / traffic_exhausted 是设备共享资源,两者停机范围语义完全不同。
方案:新增 isDeviceScopeReason(reason string) bool,仅 no_package / traffic_exhausted 返回 true。EvaluateAndAct 中,只有当 isDeviceScopeReason(primaryReason) 为 true 时才调用 stopDeviceCards;否则走 stopCardWithRetry(单卡)。
// 停机范围为设备维度的原因(套餐/流量是设备共享资源)
func isDeviceScopeReason(reason string) bool {
return reason == constants.StopReasonNoPackage ||
reason == constants.StopReasonTrafficExhausted
}
// EvaluateAndAct 修改后的在线分支
primaryReason := reasons[0]
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr == nil && bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
边界确认:若卡同时满足 no_package + not_realname,primaryReason = "no_package"(优先级更高),走设备维度——符合预期(设备整体无套餐)。
决策 2:实名逆转防抖——Redis 滑动计数器
背景:运营商实名接口存在同步延迟,单次返回 false 不足以确认实名已失效。
方案:在 polling_realname_handler 检测到 1→0 逆转时,写入 Redis 计数器(INCR + 独立 TTL),仅当计数 >= 3 时才触发 EvaluateAndAct;检测到 true 时清除计数器。不修改 DB 写入逻辑(real_name_status 仍实时同步,只延迟停机决策)。
Redis Key: polling:card:realname:reversal:{card_id}
TTL: 10 分钟(若 10 分钟内未连续触发,计数归零)
阈值: 3 次(常量 PollingRealnameReversalThreshold = 3)
流程:
gateway 返回 false(已更新 DB real_name_status=0)
↓
INCR polling:card:realname:reversal:{card_id}(TTL=10min)
├─ count < 3:仅记录,不触发停机,返回
└─ count >= 3:DEL key → EvaluateAndAct(触发停机)
gateway 返回 true(已更新 DB real_name_status=1 或无变化)
↓
DEL polling:card:realname:reversal:{card_id}
若 DB 为 0→1:触发 EvaluateAndAct(复机评估,逻辑不变)
注意:INCR 仅在当前 newStatus == RealNameStatusNotVerified 时执行,不影响 true 的处理路径。
决策 3:carrier_stopped——网关侧停机原因
背景:polling_cardstatus_handler 通过轮询 Gateway 卡状态(非主动停机操作)检测到在线→停机变化,当前未写入 stop_reason,导致 isPollingStopReason('')=false,卡永久无法自动复机。
方案:
- 新增常量
StopReasonCarrierStopped = "carrier_stopped" polling_cardstatus_handler在检测到在线→停机变化时,在fields中补写stop_reason = carrier_stoppedcarrier_stopped不加入isPollingStopReason(运营商停机原因不明,不自动复机)carrier_stopped作为合法状态存储,管理员可在后台查看并手动决策
// polling_cardstatus_handler.go 修改处
if statusChanged {
fields["network_status"] = newNetworkStatus
if newNetworkStatus == constants.NetworkStatusOffline {
fields["stop_reason"] = constants.StopReasonCarrierStopped
}
}
关于存量数据:已处于 stop_reason='' 的停机卡不回填,需运维人工处理(可通过 SQL 批量检查后决策)。
关于 resumeDeviceCards 的兼容性
现有 resumeDeviceCards 在遍历停机卡时已对 isRealnameOK 做过滤:
for _, card := range cards {
if !s.isRealnameOK(card) {
s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname)
continue
}
s.resumeSingleCard(ctx, card.ID)
}
修复后,not_realname 只停单卡,触发 resumeDeviceCards 的场景(no_package / traffic_exhausted 复机)不会遇到 not_realname 卡,此处逻辑仍保留作为防御性检查,无需修改。
Risks / Trade-offs
| 风险 | 评估 | 缓解 |
|---|---|---|
| 防抖计数 3 次意味着最多 ~3 分钟延迟停机 | 低风险,实名验证丢失通常是持续状态 | 阈值做成常量,可快速调整 |
| carrier_stopped 卡无法自动复机,需人工处理 | 已接受(运营商停机原因不明,保守处理) | 后台日志/状态可见,管理员可手动触发复机 |
| 不回填存量 stop_reason='' 数据 | 存量问题继续存在 | 运维可执行一次性 SQL 修复,超出本次 change 范围 |