16 KiB
系统改造方向
本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。 用于在开发过程中明确"干什么、为什么干、干的好处"。
一、现状
1.1 项目规模
- 当前在线卡数:约 3 万张(生产)
- 待迁移卡数:约 67 万张(来自旧系统)
- 核心业务链路:代理购卡 → 销售分配 → 套餐购买 → 流量同步 → 停复机
1.2 轮询系统现状
轮询系统的作用是定期向运营商查询卡的状态(实名状态、流量用量、卡网络状态等),保持系统数据与运营商同步。
当前架构:
全量卡 ID → Redis Sorted Set(分片,score = 下次检查时间)
→ Scheduler 每秒扫描到期卡
→ Asynq 队列
→ Worker 执行检查(实名/流量/卡状态/保护期/套餐,共 5 种类型)
问题清单:
| # | 问题 | 具体表现 |
|---|---|---|
| 1 | 看不见,无法排查 | 业务反馈"某张卡一小时了还没同步实名",没有任何结构化记录可以查,只能翻日志文件靠关键词过滤 |
| 2 | 配置改了不知道有没有用 | 轮询配置对哪些卡生效完全黑盒,改完后没有验证手段,全靠等和猜 |
| 3 | 大量无意义轮询 | 全量卡按固定频率轮询,大多数卡状态根本没有变化,这些查询全是浪费;67 万张卡迁移后压力更大 |
| 4 | 业务事件不触发检查 | 代理通过开放接口买了套餐,系统不知道,要等下次定时轮询碰到这张卡(可能要等几分钟到几十分钟) |
| 5 | 两种不同职责混在一个调度器 | 卡状态同步(每 1 秒触发)和业务流程调度(套餐过期处理、流量重置,每 10 秒触发)混在同一个 Scheduler,出问题难以定位是哪一层 |
| 6 | 调度状态全在 Redis,没有持久化 | Redis 重启或清空后,全量卡的调度状态丢失,需要重跑全量初始化才能恢复;67 万张卡的初始化需要相当长时间,期间所有卡停止轮询 |
| 7 | 单卡状态散在四处 | 上次检查时间在 IotCard 表;下次检查时间在 Redis Sorted Set;卡状态快照在 Redis Hash;执行结果在日志文件。没有一个地方能给完整答案 |
1.3 流量数据现状
运营商接口返回的是当前账期内的累计已用流量(不是增量,是总量)。 运营商账期重置日因运营商而异,在创建运营商时配置。 套餐周期可以是自然月或固定日期,在创建套餐时决定。
当前 IotCard 表上与流量相关的字段:
DataUsageMB 卡生命周期总用量(永不归零)
CurrentMonthUsageMB 系统自然月累计用量(展示用)
LastMonthTotalMB 上月结束时的流量总量(用于跨月计算)
CurrentMonthStartDate 系统自然月起始日期
LastGatewayReadingMB 上次轮询时运营商返回的累计读数
PackageUsage 表上:
DataUsageMB 套餐已用量(套餐周期到期时归零,用于判断是否耗尽)
问题清单:
| # | 问题 | 具体表现 |
|---|---|---|
| 1 | 运营商重置靠猜,有漏检风险 | 只存上一次读数(LastGatewayReadingMB),靠"本次读数比上次小"来猜测运营商是否重置了账期。若轮询有空窗(例如系统故障几小时),重置后的新用量超过了上次读数,则增量为正,系统判断为"没有重置",漏掉了那段用量 |
| 2 | 无原始读数,无法审计 | 运营商告诉了什么、什么时候告诉的、算出了多少增量——计算完成后全部丢弃。流量统计不对时,没有任何数据可以追溯原因 |
| 3 | 重置边界的流量归属不准 | 套餐在午夜重置,但上次轮询在 23:59,下次轮询在 00:01。这两分钟的增量(包含昨天剩余用量)全部算进今天,日流量套餐受影响明显 |
| 4 | 三套"月"的概念同时存在 | 运营商账期(各自定义重置日)+ 套餐周期(自然月或固定日)+ 系统自然月(展示用)。三者不对齐,边界处理散落在代码各处,逻辑复杂且脆弱 |
| 5 | CurrentMonthUsageMB 不按时重置 |
这个字段不由重置任务触发,而是在下次轮询时内联检测跨月并重置。1 号凌晨用户看到的数据是上月的,要等到下次轮询才更新 |
| 6 | 两套日流量记录并存 | CardDailyUsage(按卡)和 PackageUsageDailyRecord(按套餐)同时存在,来源不同,权威性不明确,出现差异时不知道信谁 |
1.4 整体架构现状
当前分层:Handler → Service → Store → Model,分层存在但执行不彻底。
核心问题:贫血模型 + 上帝 Service
- Model 只是数据库映射,没有任何业务行为
- 所有业务逻辑全部堆在 Service,导致 Service 极度臃肿
| Service | 行数 | 混杂的业务域 |
|---|---|---|
| order/service.go | 3031 行 | 订单创建、支付、佣金计算、代购、库存扣减 |
| iot_card/service.go | 2310 行 | 卡管理、网关调用、停复机、生命周期、流量同步 |
| device/service.go | 2078 行 | 设备管理、绑定、批量导入 |
没有领域事件:业务事件(套餐支付完成、卡实名成功)不产生任何可订阅的事件,所有下游动作全部通过直接方法调用耦合,新增联动逻辑必须修改原有代码。
二、改造方向(优先级一):轮询系统 + 流量数据
2.1 轮询系统改造
改造 A:建立单卡可查询的轮询状态
要干什么: 新增一张表 card_polling_state,每张卡一行,只更新不追加,记录每种检查类型的上次执行时间、结果、命中配置、下次预计时间。
为什么干: 现在"这张卡的轮询状态"散在四个地方,没有一处能完整回答问题。这张表就是单一的事实来源。
好处:
- 业务反馈某张卡没同步 → 查这张表,30 秒给出答案(上次检查时间、结果是什么、下次什么时候)
- 不影响任何现有轮询逻辑,只需在执行检查后多写一次 UPDATE
同时新增两个接口:
GET /admin/polling/cards/:id/state
→ 返回这张卡的完整轮询状态
GET /admin/polling/preview?card_id=12345
→ 返回这张卡当前命中哪条配置、各检查类型的间隔是多少、下次检查时间
第二个接口解决"改了配置不知道有没有用"的问题——改完配置后,查任意一张卡,立刻知道新配置有没有生效。
改造 B:引入事件触发层
要干什么: 在业务动作完成时,主动安排针对性的延迟检查,而不是等定时轮询碰到这张卡。
为什么干: 现在买了套餐,系统要等下次轮询才知道套餐有没有激活。运营商激活需要时间,但我们不知道什么时候激活完成,只能等碰到。
好处:
- 套餐购买 → 5 分钟后主动检查 → 激活状态及时同步,不用等下次定时轮询
- 轮询有了"意义"——有事件发生才检查,而不是盲目扫全量
触发规则:
| 业务事件 | 触发检查 | 延迟 |
|---|---|---|
| 套餐订单支付完成 | 检查套餐激活状态 | 5 分钟 |
| 代理开放接口购套餐 | 检查套餐激活状态 | 5 分钟 |
| 卡首次分配给店铺 | 检查实名状态 | 30 分钟 |
| 手动触发刷新 | 立即检查 | 0 |
实现方式:利用 Asynq 的定时任务功能(asynq.ProcessAt),在现有 Worker 框架内完成,不需要新增基础设施。
改造 C:按卡状态分级调度
要干什么: 不同状态的卡,轮询频率不同,而不是所有卡一视同仁。
为什么干: 稳定运行了半年、没有套餐变动的卡,每 2 分钟轮询一次是纯粹的浪费。等实名的卡、刚购套餐的卡才需要频繁检查。
好处:
- 67 万张卡迁移后,无意义轮询量大幅下降(估计减少 60%-80%)
- 轮询资源集中在真正需要关注的卡
分级规则(通过现有 PollingConfig 机制配置):
| 卡状态 | 实名检查间隔 | 流量检查间隔 |
|---|---|---|
| 等待实名 | 10 分钟 | 30 分钟 |
| 活跃中(近期有套餐变动) | 15 分钟 | 30 分钟 |
| 稳定运行 | 2 小时 | 2 小时 |
| 停机 / 停用 | 每日一次 | 每日一次 |
改造 D:分离业务调度
要干什么: 把套餐过期处理、流量重置从 Scheduler 里拆出去,用独立的定时任务管理。
为什么干: 现在卡状态同步(每 1 秒)和业务逻辑调度(每 10 秒)混在同一个 Scheduler 里。出了问题不知道是哪一层,改动也容易互相影响。
好处:
- 各司其职,互不干扰
- 业务调度(套餐过期)出问题不影响卡状态同步
- 代码更清晰,新增调度任务不需要修改 Scheduler
2.2 流量数据改造
改造 A:保留原始读数
要干什么: 新增 carrier_readings 表,每次从运营商拿到读数都记录下来,永不修改。
carrier_readings:
card_id 卡ID
reading_mb 运营商返回的当期累计值
read_at 读取时间
cycle_start_date 本条读数所属的运营商账期开始日期
source 来源(polling / manual / triggered)
为什么干: 现在计算完增量后,原始读数就丢弃了。流量统计不对时,没有任何数据可以追溯。
好处:
- 流量统计有问题 → 查这张表,还原每次拿到了什么数据、算出了多少增量
- 不再是"只有最终结果,不知道过程"
- 可以离线重算历史数据,修复过去的错误
存储量控制: 保留 30 天原始记录,超过 30 天只保留"状态变化"事件(数量极少)。分区表实现,不影响查询性能。
改造 B:基于账期的重置检测
要干什么: 用 cycle_start_date 字段判断运营商是否重置了账期,替代现在靠"增量为负"来猜测的方式。
为什么干: 现在的猜测方式有漏检风险——如果轮询空窗期间运营商重置且新用量超过了旧读数,增量为正,系统无法检测到重置,漏计那段用量。
好处:
- 重置检测从"猜"变成"判断",准确率 100%
- 彻底消除因轮询空窗导致的流量漏计
- 代码逻辑更简单,不再需要"重置窗口"时间判断
// 改前:靠负增量猜测,有漏检风险
if increment < 0 && isRefreshResetWindow(now, resetDay) { ... }
// 改后:直接判断账期,准确无误
if prev.CycleStartDate != curr.CycleStartDate {
increment = curr.ReadingMB // 不同账期 = 运营商重置,当前读数即为增量
}
改造 C:清理 IotCard 上的流量计算字段
要干什么: 将 LastGatewayReadingMB、CurrentMonthStartDate、LastMonthTotalMB 从 IotCard 表移除,这些信息改由 carrier_readings 表承载。
IotCard 只保留两个对外展示的汇总值:
CurrentMonthUsageMB:展示用,由定时任务按自然月计算并主动更新(不再等轮询时内联检测)DataUsageMB:生命周期总量,由增量事件累加
为什么干: 现在 IotCard 同时承担了三个角色:卡的身份信息、流量计数器、流量计算中间状态。职责太多,一张表改动牵一发动全身。
好处:
- IotCard 回归本职:描述这张卡是谁、状态是什么
- 流量计算有了独立的数据层,不污染主体数据
CurrentMonthUsageMB在月初会被主动更新,不再有"1号凌晨数据是上月的"问题
三、改造方向(优先级二):DDD 整体方向
这部分是更长期的方向,在优先级一完成后推进。
3.1 核心思想
积木本身是安全的,拼积木组成业务流程。
- 积木 = 领域对象(
IotCard、Order、Wallet):自己知道自己的规则,外部只能通过方法操作,不能随意改字段 - 拼积木 = 应用服务(用例):只负责"拿数据 → 调对象方法 → 保存 → 发事件",不含业务判断逻辑
AI 辅助开发的约束价值: 有了这个结构,AI 生成代码时只能往"一个用例一个文件"的模式里填,不会生成新的上帝 Service;业务规则在领域对象里,AI 调用对象方法时规则自动生效。
3.2 目标目录结构
internal/
├── domain/ 领域层(纯业务逻辑,不依赖任何框架)
│ ├── order/
│ │ ├── order.go 聚合根(Order 实体,有方法:Pay, Cancel, Reject)
│ │ ├── events.go 领域事件(OrderPaid, OrderCancelled)
│ │ ├── repository.go 仓储接口(interface,不含实现)
│ │ └── values.go 值对象(OrderStatus, Amount)
│ ├── asset/
│ │ ├── iot_card.go IotCard 聚合根
│ │ └── repository.go
│ └── wallet/
│ └── wallet.go Wallet 聚合根(Debit, Credit)
│
├── application/ 应用层(用例,只做编排)
│ ├── order/
│ │ ├── create_order.go 一个文件一个用例
│ │ ├── pay_order.go
│ │ └── cancel_order.go
│ └── asset/
│
├── infrastructure/ 基础设施层(实现接口)
│ ├── persistence/ 原来的 store/postgres/
│ └── event/ 事件总线
│
└── interface/ 接口层
├── http/ 原来的 handler/ + routes/
└── task/ Asynq task handlers
3.3 迁移策略(绞杀者模式)
不全量重写,新旧并行,逐步替换:
- 建好
domain/、application/目录和规范文件 - 所有新功能按新结构写,不往旧 Service 里加代码
- 最痛的模块优先迁移:
order(3031 行)和iot_card(2310 行) - 每次迁移一个用例,验证跑通后再迁移下一个
四、实施顺序
第一阶段(当前)
├── 新增 card_polling_state 表(轮询状态持久化)
├── 新增轮询状态查询接口 + 配置预览接口(可观测性)
├── 新增 carrier_readings 表(保留原始读数)
├── 改造增量计算逻辑(基于账期判断重置)
└── 事件触发层(套餐购买 → 延迟检查)
第二阶段
├── 分级调度(按卡状态动态调整轮询频率)
├── 分离业务调度(套餐过期/流量重置从 Scheduler 独立)
└── 清理 IotCard 流量计算字段
第三阶段(DDD 改造)
├── 建立目录骨架和规范示例
├── 新功能按新结构写
└── 逐步迁移最痛的模块
五、预期收益
轮询 + 流量改造后
| 场景 | 改造前 | 改造后 |
|---|---|---|
| 业务反馈"某张卡没同步" | 翻日志,靠运气,可能花几十分钟 | 查接口,30 秒内给出答案 |
| 验证配置是否生效 | 完全黑盒,改完只能等和猜 | 配置预览接口,改完立即验证 |
| 流量统计不对 | 无法追溯,无从下手 | 查原始读数,还原每次增量计算 |
| 运营商重置漏计 | 有风险,轮询空窗时必现 | 账期判断,100% 准确 |
| 套餐购买后同步延迟 | 等下次定时轮询,最多几十分钟 | 5 分钟内主动检查 |
| 67 万卡迁移后轮询压力 | 全量扫描,压力线性增长 | 分级 + 事件触发,压力可控 |
DDD 改造后
| 场景 | 改造前 | 改造后 |
|---|---|---|
| AI 辅助开发新功能 | 容易生成新的上帝 Service | 遵循用例模式,每次只加一个文件 |
| 新增业务联动逻辑 | 必须修改原有 Service 代码 | 新增事件监听器,原有代码不动 |
| 定位业务规则在哪里 | 散在几千行 Service 里找 | 在对应的领域对象方法里 |
| 排查某个流程的问题 | 跨多个 Service 追调用链 | 用例文件即流程,一目了然 |