- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点 - 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点 - 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值 - 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警 - 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过 - 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置 Change: add-card-traffic-queue-and-observed-deduction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.3 KiB
Context
完整推导、例子与决策编号(T1–T25)见 docs/卡流量采集写入与扣减技术方案.md。本文只记录实现层的关键决策。
现状(测试环境,Iteration/8-11):
rebuild-card-traffic-metering-and-stop-resume已实现日快照(tb_card_flow_day)、自然月换算与重置日防旧值(internal/domain/cardflow/rules.go)、按月对账、切换水位与灰度名单(traffic_calc_policy.go);测试环境JUNHONG_TRAFFIC_CALC_MODE=gray、名单device:10。cardflow.ValidReading要求读数 > 0,测试库全部轮询卡读数为 0,tb_card_flow_day与tb_card_flow_switch均为 0 行,扣费实际一直走卡表基线算法。internal/gateway/models.go的FlexFloat把 null、空、无法解析都转成 0。DeductDataUsage(internal/service/package/usage_service.go)按扣费时刻status = 生效中查套餐,没有套餐时返回错误由 Asynq 重试;updateDailyRecord先查后插、日期取time.Now();循环内会把余量 ≤ 0 的套餐改为已用完。hasActivePackageForCard/countActivePackages(traffic_calc_policy.go:135-171)只认生效中、不看expires_at。- 测试环境启用中的轮询配置只有 id 29(流量采集 60 秒),Redis 为
noeviction、未设maxmemory,数据库与容器时区为上海;只有实名回调,没有运营商流量推送。 - 约束:自动化测试为 N/A,验证在维护者指定的测试环境进行;不改既有迁移;不改对接日志与审计写入(本次不动)。
Goals / Non-Goals
Goals:
- 单次异常读数(中间点暴涨、清零前后来回跳)在写入前拦下,不进快照、不扣费、不触发通道阈值。
- 测试环境只剩一条计量路径,切换不重复扣。
- 采集侧与扣费侧"有没有套餐"一致,套餐用完到停机之间的用量能扣到。
- 每次采集可追溯,扣错可人工冲正。
Non-Goals:
- 自动对账(运营商 T+1 日用量或月账单)、月末尾巴补算。
- 最后一个点暴涨、运营商连续返回递增错误值、运营商晚清零的自动识别。
- 跨套餐重置时由写入间隔造成的用量归属偏移(避免不了,不处理)。
- 对接日志、审计的瘦身;复机无效重试问题。
- 生产环境发布、迁移与回滚。
Decisions
D1 队列放 Redis、Lua 原子更新,不放数据库。 队列只是写入前的过滤器,不是账:丢了只是晚写 N−1 次,不多扣不少扣。放数据库会让每次采集都多一次行写,与 polling-load-control 的低写入目标冲突。Lua 内只做插入、去重、裁剪、跨天清空与过期丢弃,返回队列与"新点是否在队尾";上海日期由 Go 侧算好随点传入,Lua 不做时区换算。写入判断(不下降 + 容差)在 Go 侧做,便于记录原因。
D2 写最后一个点,新点必须在队尾。 写最后一个点让增量最及时;新点不在队尾说明有更晚的点已判断过,再写会让采集记录看起来写了自己的读数,排查时误导,所以记为乱序不写。容差只用于"不下降"判断,日快照取最大值、增量 = 最大值 − 已结算,小幅回退的点写进去不产生增量,不会多扣。替代方案"写倒数第二个点"能挡住最后一个点暴涨,但每次写入都晚一个采集周期,已否决。
D3 采集间隔上限按 N+2 次。 理论最低是窗口内采到 N 次,但一次失败当天就写不进去,告警会常态触发;留 2 次冗余。只校验启用中的轮询配置,停用配置(测试库 id 25,86400 秒)不阻止保存,启用时再校验。
D4 全量切换用"待建起点"标记,不保留切换水位。 切换前扣费走卡表基线算法,日快照上没有本月已结算;不打标记,切换后第一次写入的 Δ 会是整月用量,而这部分老路径已扣过。标记与换卡共用同一字段(tb_iot_card 新增),随迁移把全部卡置位;建起点也过队列,避免异常读数当起点。迁移执行时老代码仍在跑也安全:老代码不认标记照常按老路径扣,新代码第一次写入只建起点。代价是老路径最后一次扣费到建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。
D5 "有套餐"抽成一个共用判断,采集与扣费同时调用。 由 package 服务提供按 (卡, 采集时间) 查有效套餐的方法;观测事务(写入范围、是否发事件、是否写采集记录、日用量 0 行)与 DeductDataUsage 都调用它。状态纳入已过期,是因为状态可能在采集之后才变;是否在采集时刻有效由 expires_at 判断。
D6 读数 0 由写入范围决定写不写,不由读数校验拦。 ValidReading 放宽为"有限且 ≥ 0";有套餐的卡 0 也写快照与日用量 0 行,用于"每天都有记录"的排查需求;没套餐的卡读数 0 不写。isStale 对 ≤ 0 直接返回否,换算结果不变。
D7 重置日照搬老系统,只认配置的重置日;比较基准为 0 统一照常防旧值。 老系统移动(YidongDayFlowService)、联通(PtLiantongDayFlowService)都是重置日当天全天 0.9 判断、不按小时、尾巴不补。两套代码对基准为 0 的处理不同(移动照常判断、联通不判断),统一为照常判断:isStale 去掉 ThisMonthBeforeResetMax > 0 条件,最坏是晚一天扣,不会多扣。不设多日警惕期、不把 1 点前的读数记回前一天。
D8 日用量 0 行在观测事务里写,扣减消费者只累加。 消费者只在 Δ > 0 时运行,没用流量的卡走不到那里。观测事务调用 package 服务的"确保当天日用量行"(INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING),不在 cardobservation 里直接写套餐表;updateDailyRecord 改为 upsert 累加。
D9 扣费查不到套餐按成功跳过。 原来返回错误会让 Asynq 一直重试,而退款、作废后不可能再查到;改为写跳过审计、事件成功结束,需要时人工冲正。
D10 采集记录单独建表、按月分区,不复用对接日志或审计。 只记有套餐的卡;保留 90 天需要同时存在 4 个月分区,过期直接删整月分区,不用 DELETE ... LIMIT。observation_id 串起采集记录、流量事件、扣费审计。不进 Audit Event 与 Integration Log,与 polling-load-control 的"无变化观测不写审计"不冲突。
D11 冲正锁套餐行、按当前值加减,提交后走停复机判断。 与扣减同一把行锁,避免并发互相覆盖;不允许负数,避免掩盖数据问题。卡可能同时因实名、沉默期、人工等原因停着,冲正只改了"套餐用完"一个条件,交给 evaluator.EvaluateAndAct 综合判断。冲正不改日快照与已结算。
D12 日期统一上海日期。 套餐日用量日期与消费者日流量缓冲 key 都用 cardflow.ShanghaiDate(observed_at);测试环境时区本就是上海,这是防止时间值为 UTC 时 0~8 点落到前一天。
Risks / Trade-offs
- [最后一个点暴涨挡不住] → 靠人工冲正;告警与采集记录便于发现。
- [运营商晚一天清零、清零后返回缓存旧数会多扣] → 与老系统一致;重置日防旧值命中数用于核对,多扣走冲正。
- [要攒满 N 个点才写,扣费与停机延后;跨天清空开启时当天第一次写入在第一次采集后 (N−1) 个间隔] → 测试环境间隔 60 秒,影响约 2 分钟;需要更及时时缩短间隔。
- [全量切换、换卡后到建起点之间的用量不扣] → 约 N−1 个采集周期,测试环境可接受。
- [采集后套餐被重置的扣在重置后周期] → 审计记录采集时间与重置时间,可追溯。
- [采集后退款、作废的用量不扣] → 跳过审计 + 人工冲正。
- [采集记录写入量 ≈ 有套餐卡数 × 每天采集次数,测试环境 60 秒间隔下单卡每天 1440 行] → 测试环境卡少;分区按月删除。
- [Redis 内存随卡数增长,每卡约 150 字节] → 测试环境
noeviction无上限,随卡数观察。 - [
DeductDataUsage查询范围扩大后,旧循环会把已过期套餐改成已用完] → 状态迁移只作用于当前生效中套餐(规格已明确)。
Migration Plan
- 前置:维护者确认后归档
rebuild-card-traffic-metering-and-stop-resume(灰度、切换水位相关验证 9.2–9.5 作废,停复机部分已由finalize-unified-stop-resume-rule覆盖)与add-tiered-polling-and-pre-activation-settlement,使card-traffic-metering主规格存在。 - 迁移(成对):采集记录分区表、冲正流水表、
tb_iot_card待建起点标记(迁移内对全部卡置位)、套餐日用量(package_usage_id, date)唯一索引(建前核对并合并重复行)。 - 注册系统配置项默认值;删除计量模式与灰度名单配置及测试环境部署变量。
- 部署测试环境后按 tasks 验证;回滚只在测试环境:回退代码与迁移 down,队列 key 自然过期。
Open Questions
- 重置日读取失败时仍按运营商类型兜底(
CUCC取 27、其余取 1,rebuild-...的 D6)。这与"只看配置的重置日"不完全一致,本 Change 保持不改,是否改为"读取失败本次不写入并告警"待定。