## Context 轮询系统每 5-15 分钟调用网关获取所有卡的流量数据,写入 DB。当前问题: 1. 每次轮询无条件写详单记录,增量=0 也写,DB 膨胀严重(P2-26) 2. `current_month_usage_mb` 直接用网关值覆盖,上游自然月重置时我们的值也归零(P2-27) 核心改造对象:`internal/task/polling_handler.go` 中的 `calculateFlowUpdates()`、`calculateFlowIncrement()` 和 `insertDataUsageRecord()`。 ## Goals / Non-Goals **Goals:** - 月流量改为增量累加,不受上游自然月重置影响(E-2) - 流量详单减少 90%+ 的无效写入(E-1) - 查询层无缝兼容新数据源(E-3) - 清理旧流量详单代码路径(`tb_data_usage_record` 相关代码) - 提供数据初始化脚本保证上线安全 **Non-Goals:** - 不修改轮询调度逻辑(频率、并发控制不变) - 不新增自动化测试 - 不修改套餐级重置逻辑(`ResetService` 不受影响,套餐的 `data_reset_cycle` 机制独立运行) ## Decisions ### 1. E-2 先于 E-1 的原因 **决策**:先改增量算法(E-2),再改存储介质(E-1)。 **原因**:E-2 修的是"数据正确性"(覆盖 vs 累加),E-1 修的是"写入效率"(DB vs Redis)。正确性比效率更紧迫。且 E-2 的 `increment` 变量是 E-1 Redis 写入的前提。 ### 2. 合并 `calculateFlowUpdates()` 和 `calculateFlowIncrement()` **决策**:合并为一个函数,返回 `(updates map[string]any, increment float64)`。 **原因**:当前两个函数各自独立计算增量,用途不同: - `calculateFlowUpdates()` → 更新卡的 `current_month_usage_mb`、`data_usage_mb` 等字段 - `calculateFlowIncrement()` → 算增量给 `DeductDataUsage()`(将增量记录到套餐已用量) 改造后两者使用同一个增量公式(`gatewayFlowMB - card.LastGatewayReadingMB`),维护两份重复逻辑有不一致风险。合并后调用方式: ```go updates, increment := h.calculateFlowUpdates(card, gatewayFlowMB, now) // updates 用于更新卡字段 // increment 用于套餐已用量记录 ``` ### 3. 运营商重置日检测算法(上游重置) `data_reset_day` 是**运营商级别**字段(`tb_carrier.data_reset_day`),标识上游运营商每月清零网关计数器的日期。这跟我们系统套餐的 `data_reset_cycle`(daily/monthly/yearly)是两个完全独立的维度。 ```go // 增量 = 当前读数 - 上次读数 increment := gatewayFlowMB - card.LastGatewayReadingMB if increment < 0 { // 当前值比上次小 → 可能是上游运营商重置了 resetDay := getCarrierResetDay(card.CarrierID) if isResetWindow(time.Now().Day(), resetDay) { // 在重置日窗口内,视为正常上游重置 increment = gatewayFlowMB // 重置后原始值就是增量 } else { // 非重置日出现值下降,异常,不计入 logger.Warn("流量异常:非重置日出现值下降") increment = 0 } } ``` **`isResetWindow` 精确定义**: ```go // isResetWindow 判断今天是否在运营商重置日窗口内 // 窗口 = 重置日当天 + 前一天(容错网关数据延迟) // 例:resetDay=27 → 窗口包含 26、27 // 例:resetDay=1 → 窗口包含上月最后一天、1号 func isResetWindow(now time.Time, resetDay int) bool { today := now.Day() if today == resetDay { return true } // 前一天容错:计算 resetDay 的前一天 resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location()) prevDay := resetDate.AddDate(0, 0, -1).Day() return today == prevDay } ``` ### 4. 跨自然月重置逻辑 `current_month_usage_mb` 是**卡级字段**,记录这张卡本自然月的系统累计用量。跨自然月时需要重置。 改造后的跨月处理: ```go if isCrossMonth { updates["last_month_total_mb"] = card.CurrentMonthUsageMB updates["current_month_start_date"] = currentMonthStart updates["current_month_usage_mb"] = 0 // 新月从 0 开始累计(不再 = gatewayFlowMB) // increment 仍然正常计算(由 LastGatewayReadingMB 决定) } ``` **注意**:这里的"自然月重置"是我们**系统级别**对 `current_month_usage_mb` 的重置。跟套餐级别的 `data_reset_cycle` 完全独立——套餐的已用量重置由 `ResetService`(`reset_service.go`)负责,重置的是 `PackageUsage.DataUsageMB`,不涉及卡级字段。 ### 5. Redis 缓冲策略 - **Key 格式**:`traffic:daily:{card_id}:{YYYY-MM-DD}` - **操作**:`INCRBYFLOAT`(原子增量) - **TTL**:48 小时(给落盘任务足够消费时间) - **落盘频率**:每天凌晨 2 点(Asynq Scheduler),SCAN 昨日 key → UPSERT → DELETE **为什么不用 Sorted Set?** 每张卡每天只有一个值,简单 KV + INCRBYFLOAT 最高效。 ### 6. 落盘 UPSERT 语义:覆盖(幂等安全) ```sql INSERT INTO tb_card_daily_usage (iot_card_id, date, usage_mb) VALUES ($1, $2, $3) ON CONFLICT (iot_card_id, date) DO UPDATE SET usage_mb = EXCLUDED.usage_mb, updated_at = NOW(); ``` 选择**覆盖**而非**累加**:Redis `INCRBYFLOAT` 保证值是正确的累积值,落盘只是"从 Redis 搬到 DB"。重试时覆盖写结果不变,天然幂等。如果用累加,任务重试会导致数据翻倍。 ### 7. 落盘分批策略 10 万张活跃卡 ≈ 10 万个 Redis key。分批处理避免单次操作过大: 1. **SCAN 阶段**:`SCAN cursor pattern COUNT 500`,分批收集所有昨日 key 2. **UPSERT 阶段**:每批 200 条,批量 UPSERT 到 DB 3. **DELETE 阶段**:每批 200 条,Pipeline 批量删除已落盘 key 预估耗时:10 万 key ≈ 30-60 秒。Asynq 任务超时设为 5 分钟。 ### 8. 数据初始化脚本 上线前必须运行: ```sql -- 将所有卡的 last_gateway_reading_mb 初始化为当前月用量 -- 避免上线后第一次轮询把整个已用量当作增量 UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0; ``` 此脚本放在迁移文件中但标注为"上线前人工确认执行"。 **新卡首次轮询**:`last_gateway_reading_mb` 默认 0,首次轮询增量 = 全量(网关返回值)。对于新入库的卡(从没用过)这是正确的。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb = current_month_usage_mb`。 ### 9. 查询层兼容策略 `TrafficQueryService.GetDailyUsage()` 合并两段数据: - 今天 → 从 Redis 读(可能还没落盘) - 历史 → 从 `tb_card_daily_usage` 读 - 排序合并后返回 ### 10. 旧流量详单代码清理 `tb_data_usage_record` 不再写入后,相关代码路径全部删除: - `internal/model/data_usage.go`(DataUsageRecord model) - `internal/store/postgres/data_usage_record_store.go`(Store) - `internal/bootstrap/` 中的引用 - `scripts/cleanup/main.go` 中的引用 旧表数据暂保留在 DB(后续可通过数据清理功能清除),但代码层完全去除依赖。 ### 11. 运营商管理接口变更 `tb_carrier` 新增 `data_reset_day INT NOT NULL DEFAULT 1`。创建/编辑运营商时新增此字段(1-28),前端显示为"每月 N 日重置流量"。 建议初始值:联通=27,移动/电信/广电=1。可通过迁移 SQL 直接 UPDATE 已有运营商记录。 ## Risks / Trade-offs - **[Redis 丢数据]** Redis 重启导致今日缓冲丢失 → TTL 48h + 落盘任务可补偿;极端情况丢一天数据,业务可接受 - **[首次轮询增量暴增]** `last_gateway_reading_mb` 未初始化时第一次轮询增量=全量 → 通过初始化脚本消除 - **[重置日误判]** 运营商非预期时间重置 → 前一天容错 + Warn 日志,人工可介入 - **[落盘任务失败]** Redis key 在 48h TTL 内未消费 → 数据丢失。Asynq 有重试机制(MaxRetry=3),且可手动触发 - **[跨自然月边界]** 轮询恰好在 0 点附近:先检测跨月 → 重置 `current_month_usage_mb = 0` → 再计算增量。两步原子完成 ## Migration Plan 1. **迁移 1**:`ALTER TABLE tb_carrier ADD COLUMN data_reset_day`,UPDATE 已有运营商初始值 2. **迁移 2**:`ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb` 3. **迁移 3**:`CREATE TABLE tb_card_daily_usage`(`usage_mb` 使用 `NUMERIC(12,2)` 类型) 4. **数据初始化**:执行 `UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb` 5. **部署代码**:低峰期发布 6. **回滚策略**:如果增量算法异常,可临时切回覆盖写(保留旧代码为注释或 feature flag)