Files
junhong_cmp_fiber/openspec/changes/archive/2026-03-30-refactor-traffic-system/design.md
huang cebcada950
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
核心改造:
- 增量算法:流量计算从覆盖式改为增量累加(gateway - lastReading),支持上游运营商重置检测
- 日流量缓冲:insertDataUsageRecord 改为 Redis INCRBYFLOAT,每日凌晨落盘到 tb_card_daily_usage
- 运营商:新增 data_reset_day 字段(联通=27,其余=1)
- IoT卡:新增 last_gateway_reading_mb 字段存储上次网关读数
- 查询层:新建 TrafficQueryService 合并 Redis(今日)+ DB(历史)数据源
- 清理:删除 DataUsageRecord model/store,移除 polling_handler 旧引用

迁移:000094-000097(carrier字段、iot_card字段、数据初始化、日流量表)
2026-03-30 09:59:30 +08:00

8.5 KiB
Raw Blame History

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_mbdata_usage_mb 等字段
  • calculateFlowIncrement() → 算增量给 DeductDataUsage()(将增量记录到套餐已用量)

改造后两者使用同一个增量公式(gatewayFlowMB - card.LastGatewayReadingMB),维护两份重复逻辑有不一致风险。合并后调用方式:

updates, increment := h.calculateFlowUpdates(card, gatewayFlowMB, now)
// updates 用于更新卡字段
// increment 用于套餐已用量记录

3. 运营商重置日检测算法(上游重置)

data_reset_day运营商级别字段(tb_carrier.data_reset_day),标识上游运营商每月清零网关计数器的日期。这跟我们系统套餐的 data_reset_cycledaily/monthly/yearly是两个完全独立的维度。

// 增量 = 当前读数 - 上次读数
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 精确定义

// 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卡级字段,记录这张卡本自然月的系统累计用量。跨自然月时需要重置。

改造后的跨月处理:

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 完全独立——套餐的已用量重置由 ResetServicereset_service.go)负责,重置的是 PackageUsage.DataUsageMB,不涉及卡级字段。

5. Redis 缓冲策略

  • Key 格式traffic:daily:{card_id}:{YYYY-MM-DD}
  • 操作INCRBYFLOAT(原子增量)
  • TTL48 小时(给落盘任务足够消费时间)
  • 落盘频率:每天凌晨 2 点Asynq SchedulerSCAN 昨日 key → UPSERT → DELETE

为什么不用 Sorted Set 每张卡每天只有一个值,简单 KV + INCRBYFLOAT 最高效。

6. 落盘 UPSERT 语义:覆盖(幂等安全)

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. 数据初始化脚本

上线前必须运行:

-- 将所有卡的 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.goDataUsageRecord model
  • internal/store/postgres/data_usage_record_store.goStore
  • 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. 迁移 1ALTER TABLE tb_carrier ADD COLUMN data_reset_dayUPDATE 已有运营商初始值
  2. 迁移 2ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb
  3. 迁移 3CREATE TABLE tb_card_daily_usageusage_mb 使用 NUMERIC(12,2) 类型)
  4. 数据初始化:执行 UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb
  5. 部署代码:低峰期发布
  6. 回滚策略:如果增量算法异常,可临时切回覆盖写(保留旧代码为注释或 feature flag