Files
junhong_cmp_fiber/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/design.md
break 395e5fb47c
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m13s
修复套餐接续停机竞态与轮询兜底
2026-08-29 16:27:48 +08:00

66 lines
5.1 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
见 proposal.md。当前到期处理在事务提交后投递异步套餐激活任务同时以 goroutine 发起停机检查;两条路径没有共同顺序或共享状态结论。生产中已观察到新套餐激活后 1.9 至 15.7 秒仍写入 `no_package` 停机。
现有 `ActivationService` 已负责待生效主套餐激活及激活后的复机回调;`StopResumeService` 已集中停复机条件。保留这两个职责边界,不增加新的状态表或外部依赖。
## Goals / Non-Goals
**Goals:**
- 同一载体到期接续期间不以过时权益快照停机。
- 没有后续可生效套餐时保留现有停机语义。
- 激活投递或执行暂时失败时宁可交由既有恢复/轮询兜底,不误判为无套餐。
- 给维护者提供基于当前事实的只读候选筛选与受控补偿流程。
**Non-Goals:**
- 不新增自动批量复机、管理接口或数据库迁移。
- 不把 Redis/Asynq 运行排障改造成业务状态机。
- 不改变手动停机、运营商风险停机或实名策略语义。
## Decisions
### 1. 将停机决策后移到套餐激活任务的最终重新评估
到期处理找到后续待生效主套餐时,只投递激活任务,不并发启动停机检查。激活任务完成后以该载体的最新套餐状态调用现有停机评估:成功激活时不触发无套餐停机;队首套餐因实名等业务条件不能激活时,才按当前事实评估停机。
这样复用现有激活锁、套餐条件和停复机服务,不让两个异步流程各自读取一次状态。
备选方案:在到期处理内同步完成套餐激活。未采用,因为到期扫描单轮最多处理大量套餐,同步串行激活会放大调度 Worker 的数据库和 Redis 锁持有时间。
### 2. 激活投递失败不是无套餐结论
仅在明确不存在后续主套餐时,允许到期处理立即发起停机评估。存在后续套餐但投递失败时记录错误并保留当前网络状态,由既有孤儿套餐恢复扫描和套餐轮询重试;不得回退到并发停机。
备选方案:投递失败立即停机。未采用,因为它重新引入“接续结果未知被当作无套餐”的错误路径。
### 3. 存量恢复与代码修复分离
代码发布后,维护者用只读查询筛选“有效主套餐、未耗尽、实名满足、`no_package` 停机”的候选卡;每张或受控小批次先查询运营商实时状态,再调用既有复机能力。补偿不直接更新数据库,也不自动对全部候选执行。
备选方案:发布时自动批量复机全部候选。未采用,因为本地状态不足以代替运营商风险、销户和人工业务判断。
### 4. 轮询只作兜底并单独核验运行覆盖
停机卡配置的 `polling:package` 仍是后备恢复机制,不能作为避免竞态的主路径。维护者分别核验调度 Worker 心跳、Redis 分片队列深度、Asynq 队列积压与消费者吞吐;这些运行指标只用于定位未补偿原因。
### 5. 卡状态轮询仅补齐缺失的套餐任务
生产日志确认存在卡状态和流量任务仍持续执行、但同一卡的 `polling:package` 队列项缺失的情形。卡状态轮询成功且未命中风险停机后,按数据库最新卡状态重新匹配轮询配置;若该卡应参与套餐检查,则以 Redis `ZADD NX` 仅在对应分片 Sorted Set 不存在该卡时立即补入套餐任务。已存在任务不得覆盖其 score也不新增重复任务。
该做法不将卡状态轮询变为停复机决策路径:它只修复缺失的套餐调度项,最终仍由既有 `PollingPackageHandler → EvaluateAndAct` 判断权益、实名、停机原因和运营商结果。
## Risks / Trade-offs
- [激活任务投递失败时卡暂不因套餐到期自动停机] → 保留孤儿恢复扫描和套餐轮询;记录失败并在运行排障中核验任务恢复。
- [激活任务内增加最终停机评估] → 保持幂等条件和现有 Redis 锁;同一载体状态变化必须重读数据库。
- [存量候选包含运营商侧不可复机卡] → 补偿前逐卡或受控小批查询运营商状态,拒绝风险停机、销户或其他非本地套餐原因。
- [轮询吞吐不足延迟兜底] → 发布前后分别记录队列深度、消费速率和最近检查时间分布,不以单次数据库字段判断 Worker 健康。
- [卡状态轮询补齐套餐任务] → 仅使用 `ZADD NX` 补入缺失成员,不改写已有任务的执行时间;最终复机仍受既有权益、实名、停机原因和运营商校验约束。
## Migration Plan
1. 在隔离环境验证“旧套餐到期 + 后续套餐可激活”不会产生停机调用,及“无后续/不可激活”仍进入停机评估。
2. 按生产运行说明发布 Worker不需要数据库迁移。
3. 维护者观察一轮套餐到期处理、激活任务与轮询队列,确认没有新的“有效套餐 + `no_package` 停机”记录。
4. 使用只读筛选得到存量候选,按受控批次核验运营商状态后调用既有复机;失败项保留失败原因并人工处理。
5. 若发布后出现意外停机语义,回滚 Worker 二进制;存量补偿不得直接写库。