diff --git a/cmd/worker/carrier_scheduler.go b/cmd/worker/carrier_scheduler.go index d146ebc1..86f895c0 100644 --- a/cmd/worker/carrier_scheduler.go +++ b/cmd/worker/carrier_scheduler.go @@ -42,9 +42,11 @@ type carrierSchedulerRuntime struct { srConsumers *nsqbus.ConsumerManager devConsumers *nsqbus.ConsumerManager srActive bool - singleton bool - instanceID string - logger *zap.Logger + // ingest 是轮询观测入库消费者与积压监控(add-polling-async-ingest);未配置 nsqd 时为 nil。 + ingest *pollingIngestRuntime + singleton bool + instanceID string + logger *zap.Logger mu sync.Mutex lastMode string @@ -104,6 +106,7 @@ func startCarrierScheduler(ctx context.Context, cfg *config.Config, runtime *wor MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts, }, constants.NSQChannelWorker, executor.Handle, appLogger) } + c.ingest = startPollingIngest(ctx, cfg, runtime, c.producer, singleton, c.grace(), appLogger) c.setupStopResume(carrierCfg, alerts) var budgets carrierqueue.BudgetProvider if runtime.gatewayLimit != nil && runtime.gatewayLimit.Limiter != nil { @@ -302,6 +305,8 @@ func (c *carrierSchedulerRuntime) shutdown() { }(manager) } wg.Wait() + // 卡任务消费者停止后不再产生入库消息,再停入库与采集记录消费者,最后停生产者。 + c.ingest.shutdown() if c.producer != nil { c.producer.Stop() } diff --git a/cmd/worker/polling_ingest.go b/cmd/worker/polling_ingest.go new file mode 100644 index 00000000..214ca0f4 --- /dev/null +++ b/cmd/worker/polling_ingest.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/task" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// pollingIngestRuntime 是轮询观测入库异步化在 Worker 内的运行组件(add-polling-async-ingest): +// 入库消费者、采集记录批量消费者、活动优先项索引重建与积压监控。入库消费者常驻订阅, +// 使切回同步模式后队列中已有的入库消息仍会被处理完。 +type pollingIngestRuntime struct { + runtime *workerRuntime + nsqdAddress string + executor *task.IngestExecutor + collector *task.CollectionBatchConsumer + ingest *nsqbus.ConsumerManager + collection *nsqbus.ConsumerManager + rebuilder *task.PriorityIndexRebuilder + stopCollect context.CancelFunc + grace time.Duration + logger *zap.Logger +} + +// startPollingIngest 注入入库发布器并启动入库与采集记录消费者;未配置 nsqd 时返回 nil,轮询保持同步入库。 +func startPollingIngest(ctx context.Context, cfg *config.Config, runtime *workerRuntime, producer *nsqbus.Producer, + singleton bool, grace time.Duration, logger *zap.Logger) *pollingIngestRuntime { + carrierCfg := cfg.Polling.Carrier + if producer == nil || carrierCfg.NSQDAddress == "" { + return nil + } + checkIngestConnBudget(cfg, logger) + publisher := task.NewIngestPublisher(producer) + runtime.pollingBase.SetIngest(publisher, func(ctx context.Context) bool { + return runtime.carrierSettings.Current(ctx).IngestAsync() + }) + observation := runtime.workerResult.Services.CardObservation + if observation != nil { + observation.SetCollectionSink(publisher) + } + r := &pollingIngestRuntime{ + runtime: runtime, nsqdAddress: carrierCfg.NSQDAddress, grace: grace, logger: logger, + executor: task.NewIngestExecutor(runtime.pollingBase, observation, cfg.Polling.Ingest.EffectiveTimeout()), + collector: task.NewCollectionBatchConsumer(runtime.db, cfg.Polling.Collection.EffectiveBatchSize(), logger), + rebuilder: task.NewPriorityIndexRebuilder(postgres.NewPollingPriorityItemStore(runtime.db), runtime.redisClient, logger), + } + collectCtx, stopCollect := context.WithCancel(context.Background()) + r.stopCollect = stopCollect + r.collector.Start(collectCtx) + consumerCfg := nsqbus.ConsumerConfig{ + NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses), + MsgTimeout: carrierCfg.MsgTimeout, UnlimitedAttempts: true, + } + r.ingest = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.executor.Handle, logger) + r.collection = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.collector.Handle, logger) + r.ingest.Sync(map[string]int{constants.NSQCardIngestTopic: cfg.Polling.Ingest.EffectiveConcurrency()}) + r.collection.Sync(map[string]int{constants.NSQCardCollectionTopic: constants.PollingCollectionMaxInFlight}) + logger.Info("轮询观测入库消费者已启动", zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()), + zap.Duration("ingest_timeout", cfg.Polling.Ingest.EffectiveTimeout()), + zap.Int("collection_batch_size", cfg.Polling.Collection.EffectiveBatchSize())) + if singleton { + go r.loop(ctx) + } + return r +} + +// checkIngestConnBudget 启动时检查入库并发与采集记录写并发不超过数据库连接数减去预留,超过只记 ERROR、不阻止启动。 +func checkIngestConnBudget(cfg *config.Config, logger *zap.Logger) { + maxOpen := cfg.Database.MaxOpenConns + if maxOpen <= 0 { + maxOpen = constants.DefaultMaxOpenConns + } + need := cfg.Polling.Ingest.EffectiveConcurrency() + constants.PollingIngestCollectionWriters + if budget := maxOpen - constants.PollingIngestConnReserve; need > budget { + logger.Error("入库并发超出数据库连接预算,请调小 polling.ingest.concurrency 或调大数据库连接数", + zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()), + zap.Int("collection_writers", constants.PollingIngestCollectionWriters), + zap.Int("max_open_conns", maxOpen), zap.Int("reserved", constants.PollingIngestConnReserve)) + } +} + +// loop 在 leader 上定期重建活动优先项索引并检查入库积压。 +func (r *pollingIngestRuntime) loop(ctx context.Context) { + r.rebuilder.Rebuild(ctx) + rebuild := time.NewTicker(constants.PollingPriorityIndexRebuildEvery) + monitor := time.NewTicker(constants.PollingIngestMonitorInterval) + defer rebuild.Stop() + defer monitor.Stop() + for { + select { + case <-ctx.Done(): + return + case <-rebuild.C: + r.rebuilder.Rebuild(ctx) + case <-monitor.C: + r.checkBacklog(ctx) + } + } +} + +// checkBacklog 记录入库与采集记录积压,超过告警阈值或等待过久记 ERROR;入库积压超过反压阈值时暂停派发新卡任务,降到一半以下恢复。 +func (r *pollingIngestRuntime) checkBacklog(ctx context.Context) { + defer func() { + if recovered := recover(); recovered != nil { + r.logger.Error("入库积压检查 panic,已恢复", zap.Any("panic", recovered)) + } + }() + settings := r.runtime.carrierSettings.Current(ctx) + ingestDepth, ingestErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardIngestTopic) + collectionDepth, collectionErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardCollectionTopic) + if ingestErr != nil || collectionErr != nil { + r.logger.Warn("读取入库积压失败", zap.NamedError("ingest_error", ingestErr), zap.NamedError("collection_error", collectionErr)) + return + } + lag := r.executor.LastLag() + fields := []zap.Field{zap.Int64("ingest_backlog", ingestDepth), zap.Int64("collection_backlog", collectionDepth), + zap.Duration("ingest_lag", lag), zap.String("ingest_mode", settings.IngestMode)} + r.logger.Info("轮询入库积压", append(fields, zap.String("metric", "polling.ingest.backlog"))...) + alertDepth := int64(settings.IngestAlertDepth) + if ingestDepth > alertDepth || collectionDepth > alertDepth || lag > constants.PollingIngestLagAlert { + r.logger.Error("轮询入库积压超过告警阈值", append(fields, zap.Int64("alert_depth", alertDepth))...) + } + r.applyBackpressure(ctx, ingestDepth, int64(settings.IngestPauseDepth)) +} + +// applyBackpressure 按积压置位或清除暂停标记;标记带 TTL,leader 失联时自动解除。 +func (r *pollingIngestRuntime) applyBackpressure(ctx context.Context, depth, pauseDepth int64) { + redisClient := r.runtime.redisClient + paused, _ := redisClient.Exists(ctx, constants.RedisPollingIngestPausedKey).Result() + ttl := 3 * constants.PollingIngestMonitorInterval + switch { + case depth > pauseDepth: + redisClient.Set(ctx, constants.RedisPollingIngestPausedKey, depth, ttl) + if paused == 0 { + r.logger.Error("入库积压超过反压阈值,暂停派发新卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth)) + } + case paused > 0 && depth > pauseDepth/2: + redisClient.Expire(ctx, constants.RedisPollingIngestPausedKey, ttl) + case paused > 0: + redisClient.Del(ctx, constants.RedisPollingIngestPausedKey) + r.logger.Info("入库积压已降到反压阈值一半以下,恢复派发卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth)) + } +} + +// shutdown 先停入库消费者(处理中的消息在宽限期内完成),再停采集记录消费者,最后停止攒批循环(写完已缓冲的一批)。 +func (r *pollingIngestRuntime) shutdown() { + if r == nil { + return + } + r.ingest.StopAll(r.grace) + r.collection.StopAll(r.grace) + r.stopCollect() +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ff31d163..b085a5ea 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -201,7 +201,7 @@ services: image: nsqio/nsq:v1.3.0 container_name: junhong-cmp-nsqd restart: unless-stopped - command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 --broadcast-address=nsqd --data-path=/data --mem-queue-size=10000 --max-msg-timeout=15m + command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 --broadcast-address=nsqd --data-path=/data --mem-queue-size=0 --max-msg-timeout=15m volumes: - ./nsq-data:/data networks: diff --git a/docs/2026-11-06-轮询入库异步化技术方案.md b/docs/2026-11-06-轮询入库异步化技术方案.md new file mode 100644 index 00000000..1edb7eee --- /dev/null +++ b/docs/2026-11-06-轮询入库异步化技术方案.md @@ -0,0 +1,230 @@ +# 轮询入库异步化技术方案 + +- 日期:2026-11-06 +- 分支:`Iteration/8-11`(只部署测试环境,不涉及生产配置与重启) +- 关联:`add-carrier-polling-scheduler`(按运营商账户调度)、`add-card-traffic-queue-and-observed-deduction`(采集队列与按观测扣减)、`refactor-audit-log-persistence`(采集记录分表) + +## 1. 背景 + +### 1.1 测试环境现状(2026-11-06 上午,只读统计) + +| 指标 | 数值 | +|---|---| +| worker 报 `context deadline exceeded` 的 SQL | 每分钟 90–220 次,平均约 130 次,即每小时约 8,000 次,部署审计改造前已存在 | +| 慢查询告警(> 500ms) | 每小时约 13.8 万条,占 worker 日志行数 53% | +| 按主键查单行 `tb_iot_card` | 经常 0.7–2.4 秒 | +| worker 日志量 | 每小时约 26 万行,18 分钟写满一个 100MB 文件,40 个备份只能留约 12 小时 | +| 数据库连接 | worker 连接池 `MAX_OPEN_CONNS=60`,测试库上 61 个连接,基本占满;有一次 30 个会话同时等锁 | +| 采集记录丢失 | 写采集成功表/失败表 3 秒超时,约每 16 分钟丢 60 条 | + +### 1.2 原因:消费并发没有总量限制 + +NSQ 只限制了每个运营商账户的并发(保护网关),没有限制全部账户加起来的并发(保护数据库): + +| 来源 | 规则 | 理论上限 | 实测 | +|---|---|---|---| +| 卡轮询 `polling.card.<账户>` | 每账户 20(`default_card_max_in_flight`,`tb_carrier_polling_param` 无记录) | 41 × 20 = 820 | 约 310 | +| 停复机 `polling.stop_resume.<账户>` | 每账户 10 | 41 × 10 = 410 | 约 22 | +| 设备评估 `polling.device_eval` | 10 | 10 | 10 | +| 异步任务队列 | `JUNHONG_QUEUE_CONCURRENCY=50` | 50 | — | +| 合计 | | 约 1,290 | 约 380 | + +`worker.polling_total_max_concurrency: 100` 只传给旧分片轮询(`cmd/worker/main.go:371`),按账户调度的 NSQ 消费者不受它约束。 + +每个轮询步骤在**同一个任务里**先调网关、再同步写库,所有写库都在步骤超时内完成。三百多个任务抢 60 个连接,等连接的时间计入超时,数据库稍有抖动就成批超时。 + +### 1.3 超时分布(按调用栈,约 16 分钟样本) + +| 占比 | 位置 | 说明 | +|---|---|---| +| 约 40% | 配置匹配查套餐状态(`TierResolver.Prefill`,`internal/polling/config_manager.go:191`,共用 3 秒) | 绑定设备的卡连查 3 次;失败后把"未知"结果写进缓存 | +| 约 20% | `writeTrafficFailureAudit` | 沿用已超时的上下文再查卡,属连带报错 | +| 约 20% | 写采集成功表、失败表(`collectionRecordTimeout = 3s`) | 记录直接丢失 | +| 其余 | 流量观测事务、网络状态观测、实名缓存失效等 | | + +## 2. 目标与原则 + +1. **调上游与入库分开控制**:调网关按账户并发(保持现有每账户上限),入库单独发消息、由单独的消费者处理。 +2. **数据库并发有总上限**:入库消费者的并发是一个固定、可配置的数,与账户数量无关;加账户不会挤爆数据库。 +3. **消费慢一点没关系**:消息在 NSQ 里排队,入库按数据库承受的速度处理;不丢、不重复算。 +4. **轮询步骤只做"调网关 + 发消息"**:调网关前必要的读查询走缓存,尽量不碰数据库。 +5. **可切回**:保留同步入库路径,用系统配置一键切换,异常时可回退。 +6. **范围**:只在 `Iteration/8-11` 上实现,只部署测试环境。 + +## 3. 现状链路(一次流量步骤) + +``` +NSQ 卡任务 ──► 步骤(15s 超时) + ├─ 读:优先轮询项 FindActive(tb_polling_priority_item) + ├─ 读:卡(先清缓存再查库 tb_iot_card) + ├─ 读:轮询范围校验(tb_carrier、tb_device、tb_device_sim_binding) + ├─ 调网关 QueryFlow + ├─ 写:ApplyTrafficObservation + │ ├─ Redis 采集队列 Push + │ ├─ 事务:锁卡行、查有效套餐、写日快照、更新卡、写流量事件 outbox、通道阈值评估、审计 + │ └─ 写采集成功表/失败表(3s) + ├─ 写:对接日志、优先项完成 + └─ 返回 TriggeredEvaluation(决定是否跳过本轮套餐步骤) +``` + +网络状态步骤(`ApplyNetworkObservation`)、实名步骤(`ApplyCardObservation`)结构相同;套餐步骤不调网关,只读写库。 + +## 4. 技术设计 + +### 4.1 两段式 + +``` +采集段(按账户并发,现有 NSQ 卡任务) 入库段(固定小并发) +┌────────────────────────────────┐ ┌──────────────────────────────┐ +│ 读缓存 → 调网关 → 发入库消息 │──NSQ──► │ card.ingest 消费者(并发 N) │ +│ 网关失败 → 发采集失败消息 │ │ Apply*Observation(事务) │ +└────────────────────────────────┘ │ → 发采集记录消息 │ + └──────────────┬───────────────┘ + │NSQ + ┌──────────────▼───────────────┐ + │ card.collection 消费者 │ + │ 攒批写成功表/失败表 │ + └──────────────────────────────┘ +``` + +- **采集段**:现有卡任务消费者,并发规则不变(每账户 `card_max_in_flight`)。步骤拿到网关结果后发一条入库消息就结束,步骤结果只反映"网关是否成功"。 +- **入库段**:新 topic `card.ingest`,单独消费者,`max_in_flight` = `polling.ingest.concurrency`(默认 30)。 +- **采集记录**:新 topic `card.collection`,单独的批量消费者(见 4.5)。 + +### 4.2 入库消息 + +topic `card.ingest`,JSON: + +| 字段 | 说明 | +|---|---| +| `kind` | `traffic` / `network` / `realname` | +| `card_id`、`carrier_id`、`iccid` | 卡标识 | +| `observation_id` | 现有 `attempt.IntegrationID`,幂等键 | +| `observed_at` | 网关返回时刻(不是入库时刻) | +| `source`、`scene`、`correlation_id` | 现有观测元数据 | +| `payload` | 流量:`reading_mb`、`reading_result`、`reset_day`;网络:`status`、`extend`、`imei`;实名:`verified` | +| `raw`、`failure` | 网关原始数据(截断规则同采集记录) | +| `priority_item_id` | 本轮认领的优先轮询项,入库成功后由入库段完成 | +| `published_at` | 发出时刻,用于积压与延迟统计 | + +### 4.3 入库消费者 + +处理一条消息: + +1. 幂等检查:Redis `ingest:done:` 存在则直接 FIN。 +2. 按 `kind` 调用现有 `ApplyTrafficObservation` / `ApplyNetworkObservation` / `ApplyCardObservation`,上下文超时 `polling.ingest.timeout`(默认 30s),从拿到消息开始计时。 +3. 成功后:写 `ingest:done:`(TTL 48h);流量有变化时写 Redis 标记 `flow_evaluated:`(见 4.6)。 + - 本轮认领了优先轮询项的观测不走消息,仍在卡任务内同步入库,优先项完成与对接日志沿用原逻辑。 + - 网络观测命中风险停机或已销户时,卡任务按网关结果直接停止该卡轮询,入库段写库时关闭轮询开关。 +4. 失败: + - 数据库超时、连接错误等可重试错误:NSQ 延迟重投,退避 5s、15s、30s、1m、2m,之后每 5 分钟一次,**不设次数上限、不丢弃**(入库消费者的 NSQ `max_attempts` 设为 0);第 10 次起每次重投记 ERROR,带 `observation_id`、卡号与错误,便于排查。 + - 业务性错误(卡不存在、数据校验失败):不重投,记 WARN 后 FIN。 + - 本期不做死信表。长时间重试的消息会体现在积压量与最老消息等待时间上(见 4.7)。 + +### 4.4 顺序与幂等 + +NSQ 不保证顺序,也可能重投。逐项说明现有逻辑能否承受: + +| 风险 | 处理 | +|---|---| +| 同一张卡两条流量消息乱序 | 采集队列按 `observed_at` 插入,"新点在队尾才写"——旧消息后到时不在队尾,不写快照,只记采集记录。现有规则已覆盖 | +| 同一张卡两条消息并发处理 | 事务内 `FOR UPDATE` 锁卡行,串行执行 | +| 同一条消息重投 | ① `ingest:done` 键挡掉已成功的;② 事务提交后、写键前崩溃:日快照取当日最大值、增量按"本月最大 − 已结算"计算,重放得到 0 增量;流量事件 outbox ID 由 `observation_id` 生成,重复写入被唯一键挡掉;③ 采集队列 Push 同一个点需改为按 `observed_at` 去重(Lua 内判断) | +| 网络/实名状态乱序 | 现有 `ApplyNetworkObservation`、`ApplyCardObservation` 需确认按 `observed_at` 比较"更新的才生效";没有的补上 | +| 采集记录重复 | 成功表/失败表按 `(observation_id, observed_at)` 建唯一索引(含分区键),批量写用 `ON CONFLICT DO NOTHING` | + +### 4.5 采集记录批量写 + +- 入库段写完事务后,把采集成功记录发到 `card.collection`;采集段网关失败、无读数时直接把失败记录发到 `card.collection`,不经过入库段。 +- `card.collection` 消费者 `max_in_flight` = 500,攒满 `polling.collection.batch_size`(默认 200)或等满 1 秒就批量写一次(多行 INSERT),成功后统一 FIN,失败统一重投。 +- 写库并发固定 1–2 个连接。按测试环境每小时约 2.1 万条计算,每秒约 6 条,一次批量写即可。 +- 现有 3 秒超时和"写失败只记日志"的逻辑删除,不再丢记录。 + +### 4.6 采集段剩余读查询 + +步骤里调网关前的读查询不能异步,改为走缓存: + +| 读查询 | 现状 | 改为 | +|---|---|---| +| 卡信息 | 每步先清缓存再查库 | 读 Redis 卡缓存;入库段写完后失效缓存 | +| 优先轮询项 `FindActive` | 每步每类型查一次库 | Redis 维护"有活动优先项的卡"集合,不在集合里不查库 | +| 运营商实名开关、重置日 | 每步查库 | 进程内缓存 1 分钟 | +| 设备轮询开关、绑定关系 | 每步查库 | 并入卡缓存 | +| 套餐状态(配置匹配) | 3 次查询共用 3 秒,失败缓存"未知" | 每次查询各自计时;失败不写缓存,下次重查 | + +套餐步骤跳过逻辑:现在根据同一任务里流量步骤的 `TriggeredEvaluation` 跳过。异步后改为读 Redis 标记 `flow_evaluated:`(入库段写,TTL = 套餐检查间隔)。 + +### 4.7 积压与反压 + +- 监控:每分钟读 nsqd `/stats`,记录 `card.ingest`、`card.collection` 的积压量和最老消息等待时间,写应用日志。 +- 告警:积压超过 `polling.ingest.alert_depth`(默认 200,000)或等待超过 10 分钟,记 ERROR(接现有告警通道)。 +- 反压:积压超过 `polling.ingest.pause_depth`(默认 500,000,须大于告警阈值)时,调度器暂停发新的卡任务;降到一半以下恢复。账户限流状态不受影响。 + +### 4.8 NSQ 持久化 + +nsqd 当前参数 `--mem-queue-size=10000`:消息先放内存,nsqd 意外退出会丢掉内存里的部分。 + +- 测试环境改为 `--mem-queue-size=0`:所有消息直接落盘(`./nsq-data` 已挂载),按现有量级(每秒几十条)性能没有影响。 +- 卡任务 topic 也会一起落盘,对现有调度无行为变化。 +- 磁盘占用:入库消息带网关原始数据,单条约 2KB;积压到反压阈值 50 万条约 1GB,测试服务器剩余 32G,足够。 + +### 4.9 不改的同步路径 + +以下入口量小、需要立即返回结果,保持同步入库:后台人工刷新、运营商回调(移动/联通/电信实名)、激活前结算、观测序列(series runner)。 + +### 4.10 配置项 + +| 配置 | 默认 | 说明 | +|---|---|---| +| `polling.ingest.mode` | `sync` | `sync` 为现在的同步入库;部署确认无误后切 `async` | +| `polling.ingest.concurrency` | 30 | 入库消费者并发 | +| `polling.ingest.timeout` | 30s | 单条入库超时 | +| `polling.collection.batch_size` | 200 | 采集记录批量大小 | +| `polling.ingest.alert_depth` / `pause_depth` | 200,000 / 500,000 | 告警与反压阈值 | + +`mode` 与阈值放系统配置(改了几秒内生效),并发放启动配置。启动时检查:入库并发 + 采集记录写并发(2)不超过 `MAX_OPEN_CONNS` − 20(给采集段读查询、异步任务队列与其他查询留出至少 20 个连接),超过记 ERROR。测试环境 `MAX_OPEN_CONNS=60`:入库 30 + 采集记录 2 = 32 ≤ 40,满足。 + +### 4.11 发布失败兜底 + +采集段发消息失败(nsqd 不可用)时,降级为当前的同步入库,并记 WARN;不因 nsqd 故障丢观测。 + +## 5. 影响与代价 + +- 入库延迟:正常几秒;积压时更长。卡流量、状态在后台的刷新会比现在晚几秒。 +- 停复机判断:超量停机、通道阈值停机依赖入库结果,跟着入库段触发,晚几秒。 +- 优先轮询(加急):完成时间从"网关返回"推迟到"入库成功"。 +- 同一张卡的数据库写入从"一个任务内"变成"跨两个消费者",排查时按 `observation_id` 串联日志。 + +## 6. 分支与范围 + +- 基于 `Iteration/8-11`,只部署测试环境;不写生产配置、不涉及生产重启。 +- 依赖 `add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction` 的现有实现,不改它们的业务规则。 + +## 7. 实施步骤 + +1. 采集队列 Push 按 `observed_at` 去重;网络/实名观测补"更新的才生效"判断(如缺)。 +2. 采集记录唯一索引迁移;`card.collection` 批量消费者;采集段失败记录改发消息。 +3. `card.ingest` 消息、入库消费者、幂等键、不限次数的退避重试。 +4. 三个轮询步骤改为发消息;优先项完成、对接日志、`StopPolling`、`flow_evaluated` 移到入库段;发布失败降级。 +5. 采集段读查询走缓存;套餐状态查询各自计时、失败不缓存。 +6. 积压监控、告警、反压;启动并发检查。 +7. 系统配置与回退开关;测试环境 nsqd 改 `--mem-queue-size=0`。 + +## 8. 测试与验证(测试环境) + +| 验证项 | 通过标准 | +|---|---| +| SQL 超时 | `context deadline exceeded` 从每小时约 8,000 次降到个位数 | +| 慢查询 | 每小时告警数明显下降,worker 日志留存能达到 14 天目标的量级 | +| 采集记录完整 | 每小时:入库消息数 = 成功表 + 失败表新增行数(按 `observation_id` 对账) | +| 计量正确 | 抽样卡日快照、套餐扣减与同步模式结果一致;重投、乱序不重复扣减 | +| 积压 | `card.ingest` 等待时间常态 < 30 秒 | +| 回退 | `polling.ingest.mode=sync` 后恢复现有行为 | +| 降级 | 停掉 nsqd 时采集段改为同步入库,不丢观测 | + +## 9. 已定事项(2026-11-06) + +1. 入库消费者默认并发 30。 +2. 测试环境 nsqd 改为 `--mem-queue-size=0`,所有消息直接落盘。 +3. 本期不做死信:可重试错误不限次数退避重试,不丢弃;业务性错误记 WARN 后丢弃。 +4. 积压告警阈值 200,000 条;反压(暂停派发新卡任务)阈值 500,000 条。 diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index 476f9518..76123a7c 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -82,6 +82,18 @@ type Service struct { logger *zap.Logger // commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。 commandGrace func(ctx context.Context) time.Duration + // collectionSink 把采集记录发布到批量写入消息;未注入或发布失败时同步写库。 + collectionSink CollectionSink +} + +// CollectionSink 发布一条采集记录(成功表或失败表二选一),由批量消费者写库。 +type CollectionSink interface { + PublishCollection(ctx context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error +} + +// SetCollectionSink 注入采集记录发布器(Worker 启用 card.collection 批量写入时使用)。 +func (s *Service) SetCollectionSink(sink CollectionSink) { + s.collectionSink = sink } // SetCommandGrace 注入停复机成功后的状态保护窗口(受控系统配置 polling.stop_resume.status_grace_seconds)。 @@ -144,6 +156,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R } var decision domain.RealnameDecision var auditedCard *model.IotCard + stale := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var card model.IotCard if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { @@ -153,6 +166,15 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败") } auditedCard = &card + if staleObservation(card.LastRealNameCheckAt, observation.Metadata.ObservedAt) { + // 异步入库可能乱序:早于已应用观测的实名结果不改卡事实,只记录。 + s.warnLogger().Info("实名观测早于卡上最近一次已应用的检查,忽略", + zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID), + zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastRealNameCheckAt), + zap.String("metric", "card_observation.realname_stale")) + stale = true + return nil + } nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{ CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt, ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt, @@ -275,6 +297,9 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R } return domain.RealnameDecision{}, err } + if stale { + return domain.RealnameDecision{}, nil + } if s.cache != nil { s.cache.Invalidate(ctx, observation.CardID) } diff --git a/internal/application/cardobservation/network.go b/internal/application/cardobservation/network.go index 0fbaea1d..7fc708d3 100644 --- a/internal/application/cardobservation/network.go +++ b/internal/application/cardobservation/network.go @@ -37,6 +37,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai } var decision domain.NetworkDecision var auditedCard *model.IotCard + stale := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var card model.IotCard if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { @@ -46,6 +47,15 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败") } auditedCard = &card + if staleObservation(card.LastCardStatusCheckAt, observation.Metadata.ObservedAt) { + // 异步入库可能乱序:早于已应用观测的网络状态不改卡事实,只记录。 + s.warnLogger().Info("网络状态观测早于卡上最近一次已应用的检查,忽略", + zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID), + zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastCardStatusCheckAt), + zap.String("metric", "card_observation.network_stale")) + stale = true + return nil + } nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{ CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason, EnablePolling: card.EnablePolling, StoppedAt: card.StoppedAt, ResumedAt: card.ResumedAt, @@ -176,8 +186,16 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai } return domain.NetworkDecision{}, err } + if stale { + return domain.NetworkDecision{}, nil + } if s.cache != nil { s.cache.Invalidate(ctx, observation.CardID) } return decision, nil } + +// staleObservation 判断观测时间是否早于卡上最近一次已应用的同类检查时间(异步入库乱序保护)。 +func staleObservation(lastChecked *time.Time, observedAt time.Time) bool { + return lastChecked != nil && !observedAt.IsZero() && observedAt.Before(*lastChecked) +} diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go index d1950dcf..d26b2182 100644 --- a/internal/application/cardobservation/traffic.go +++ b/internal/application/cardobservation/traffic.go @@ -231,6 +231,10 @@ func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation doma if !workerObservationAudited(ctx) || s.auditWriter == nil { return } + // 观测失败常因上下文已超时,失败审计改用独立短超时上下文,避免连带报错。 + auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + ctx = auditCtx var card model.IotCard if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil { return diff --git a/internal/application/cardobservation/traffic_collection.go b/internal/application/cardobservation/traffic_collection.go index d7a531ae..b62a1e11 100644 --- a/internal/application/cardobservation/traffic_collection.go +++ b/internal/application/cardobservation/traffic_collection.go @@ -16,9 +16,6 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" ) -// collectionRecordTimeout 是事务提交后写采集记录的超时;采集记录只用于排查,写失败只告警。 -const collectionRecordTimeout = 3 * time.Second - // rawResponseMaxBytes 是原始响应体保存上限,超过时截断并标记。 const rawResponseMaxBytes = 4096 @@ -27,6 +24,7 @@ type collectionRecord struct { model.CardTrafficCollection HasPackage bool failure *domain.CollectionFailure + iccid string } func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord { @@ -34,8 +32,8 @@ func newCollectionRecord(observation domain.TrafficObservation) *collectionRecor IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt, ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source, Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation), - CollectionRaw: buildCollectionRaw(observation.Raw), - }, failure: observation.Failure} + CollectionRaw: buildCollectionRaw(observation.Raw), CarrierID: observation.CarrierID, + }, failure: observation.Failure, iccid: observation.ICCID} if record.Result == constants.TrafficCollectionResultOK { reading := observation.GatewayReadingMB record.VendorUsedMB = &reading @@ -59,18 +57,28 @@ func (r *collectionRecord) applyOutcome(outcome *flowOutcome) { } // writeCollectionRecord 在观测事务之外写采集记录:成功读数只为采集时刻有套餐的卡写成功表; -// 失败读数不判断有无套餐,写失败表。 +// 失败读数不判断有无套餐,写失败表。注入发布器时发布到批量写入消息,由消费者攒批写库并失败重投; +// 未注入或发布失败时同步写库。 func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, _ time.Time) { - writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), collectionRecordTimeout) - defer cancel() if record.Result != constants.TrafficCollectionResultOK { - s.writeCollectionFailure(writeCtx, record) + s.writeCollectionFailure(ctx, record) return } if !record.HasPackage { return } - if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, &record.CardTrafficCollection); err != nil { + row := &record.CardTrafficCollection + if s.collectionSink != nil { + err := s.collectionSink.PublishCollection(ctx, row, nil) + if err == nil { + return + } + s.logTrafficWarn("发布卡流量采集记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID), + zap.String("observation_id", record.ObservationID), zap.Error(err)) + } + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout) + defer cancel() + if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, row); err != nil { s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID), zap.String("observation_id", record.ObservationID), zap.Error(err)) } @@ -80,16 +88,29 @@ func (s *Service) writeCollectionFailure(ctx context.Context, record *collection row := &model.CardTrafficCollectionFailure{ IotCardID: record.IotCardID, ObservedAt: record.ObservedAt, ObservationID: record.ObservationID, Source: record.Source, Scene: record.Scene, Result: record.Result, CollectionRaw: record.CollectionRaw, + ICCID: record.iccid, CarrierID: record.CarrierID, } if failure := record.failure; failure != nil { row.ErrorCategory, row.ProviderCode = failure.ErrorCategory, failure.ProviderCode row.ProviderMessage, row.DurationMS = truncateText(failure.ProviderMessage, 500), failure.DurationMS } - var card model.IotCard - if err := s.db.WithContext(ctx).Unscoped().Select("iccid", "carrier_id").Where("id = ?", record.IotCardID).Take(&card).Error; err == nil { - row.ICCID, row.CarrierID = card.ICCID, card.CarrierID + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout) + defer cancel() + if row.ICCID == "" || row.CarrierID == 0 { + var card model.IotCard + if err := s.db.WithContext(writeCtx).Unscoped().Select("iccid", "carrier_id").Where("id = ?", record.IotCardID).Take(&card).Error; err == nil { + row.ICCID, row.CarrierID = card.ICCID, card.CarrierID + } } - if err := postgres.NewCardTrafficCollectionFailureStore(s.db).Create(ctx, row); err != nil { + if s.collectionSink != nil { + err := s.collectionSink.PublishCollection(ctx, nil, row) + if err == nil { + return + } + s.logTrafficWarn("发布卡流量采集失败记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID), + zap.String("observation_id", record.ObservationID), zap.Error(err)) + } + if err := postgres.NewCardTrafficCollectionFailureStore(s.db).Create(writeCtx, row); err != nil { s.logTrafficWarn("写卡流量采集失败记录失败", zap.Uint("card_id", record.IotCardID), zap.String("observation_id", record.ObservationID), zap.Error(err)) } diff --git a/internal/domain/cardobservation/traffic.go b/internal/domain/cardobservation/traffic.go index 03a6e6db..f41a827b 100644 --- a/internal/domain/cardobservation/traffic.go +++ b/internal/domain/cardobservation/traffic.go @@ -20,6 +20,9 @@ type TrafficObservation struct { Raw *GatewayRaw // Failure 是读数结果非 ok 时的失败详情,未提供时为 nil。 Failure *CollectionFailure + // ICCID、CarrierID 是调用方已知的卡标识,写采集失败记录时直接使用;为空时由观测用例查库补齐。 + ICCID string + CarrierID uint } // GatewayRaw 是一次网关调用的原始数据(最后一次 HTTP 尝试),只含明文业务请求与网关响应。 diff --git a/internal/infrastructure/nsqbus/nsqbus.go b/internal/infrastructure/nsqbus/nsqbus.go index f4f7feab..0e5540f4 100644 --- a/internal/infrastructure/nsqbus/nsqbus.go +++ b/internal/infrastructure/nsqbus/nsqbus.go @@ -47,6 +47,10 @@ func (p *Producer) Stop() { // Handler 处理一条消息;返回后由管理器 FIN。消息处理期间可调用 touch 延长 NSQ 消息超时。 type Handler func(ctx context.Context, body []byte, touch func()) +// RetryHandler 处理一条消息并决定是否重投:返回 0 表示 FIN,返回正数表示按该延迟重投(REQ,不计 NSQ 退避)。 +// attempts 是 NSQ 记录的投递次数(含本次,从 1 起)。 +type RetryHandler func(ctx context.Context, body []byte, attempts uint16, touch func()) time.Duration + // ConsumerConfig 是消费者参数。 type ConsumerConfig struct { NSQDAddress string @@ -55,14 +59,17 @@ type ConsumerConfig struct { MaxAttempts uint16 // LookupdPollInterval 消费者向 nsqlookupd 查询 nsqd 的间隔;0 表示 15 秒。 LookupdPollInterval time.Duration + // UnlimitedAttempts 为 true 时不限投递次数(忽略 MaxAttempts),用于不允许丢弃的入库消息。 + UnlimitedAttempts bool } // ConsumerManager 按 topic 动态订阅与取消订阅;每个 topic 一个消费者,max_in_flight 控制本进程对该 topic 的并发。 type ConsumerManager struct { - cfg ConsumerConfig - channel string - handler Handler - logger *zap.Logger + cfg ConsumerConfig + channel string + handler Handler + retryHandler RetryHandler + logger *zap.Logger mu sync.Mutex consumers map[string]*subscription @@ -86,6 +93,13 @@ func NewConsumerManager(cfg ConsumerConfig, channel string, handler Handler, log consumers: map[string]*subscription{}, ctx: ctx, cancel: cancel} } +// NewRetryConsumerManager 创建由处理结果决定 FIN 或延迟重投的消费者管理器。 +func NewRetryConsumerManager(cfg ConsumerConfig, channel string, handler RetryHandler, logger *zap.Logger) *ConsumerManager { + manager := NewConsumerManager(cfg, channel, nil, logger) + manager.retryHandler = handler + return manager +} + // Sync 使订阅集合与 desired(topic → max_in_flight)一致:新增订阅、取消多余订阅、调整 max_in_flight。 func (m *ConsumerManager) Sync(desired map[string]int) { m.mu.Lock() @@ -97,7 +111,7 @@ func (m *ConsumerManager) Sync(desired map[string]int) { if _, keep := desired[topic]; !keep { sub.consumer.Stop() delete(m.consumers, topic) - m.logger.Info("取消订阅卡任务 topic", zap.String("topic", topic)) + m.logger.Info("取消订阅 NSQ topic", zap.String("topic", topic)) } } for topic, maxInFlight := range desired { @@ -113,11 +127,11 @@ func (m *ConsumerManager) Sync(desired map[string]int) { } consumer, err := m.subscribe(topic, maxInFlight) if err != nil { - m.logger.Warn("订阅卡任务 topic 失败,下一轮重试", zap.String("topic", topic), zap.Error(err)) + m.logger.Warn("订阅 NSQ topic 失败,下一轮重试", zap.String("topic", topic), zap.Error(err)) continue } m.consumers[topic] = &subscription{consumer: consumer, maxInFlight: maxInFlight} - m.logger.Info("订阅卡任务 topic", zap.String("topic", topic), zap.Int("max_in_flight", maxInFlight)) + m.logger.Info("订阅 NSQ topic", zap.String("topic", topic), zap.Int("max_in_flight", maxInFlight)) } } @@ -130,6 +144,9 @@ func (m *ConsumerManager) subscribe(topic string, maxInFlight int) (*nsq.Consume if m.cfg.MaxAttempts > 0 { cfg.MaxAttempts = m.cfg.MaxAttempts } + if m.cfg.UnlimitedAttempts { + cfg.MaxAttempts = 0 + } cfg.LookupdPollInterval = 15 * time.Second if m.cfg.LookupdPollInterval > 0 { cfg.LookupdPollInterval = m.cfg.LookupdPollInterval @@ -146,6 +163,14 @@ func (m *ConsumerManager) subscribe(topic string, maxInFlight int) (*nsq.Consume message.DisableAutoResponse() m.inflight.Add(1) defer m.inflight.Done() + if m.retryHandler != nil { + if delay := m.retryHandler(handlerCtx, message.Body, message.Attempts, message.Touch); delay > 0 { + message.RequeueWithoutBackoff(delay) + return nil + } + message.Finish() + return nil + } defer message.Finish() m.handler(handlerCtx, message.Body, message.Touch) return nil diff --git a/internal/infrastructure/nsqbus/stats.go b/internal/infrastructure/nsqbus/stats.go new file mode 100644 index 00000000..eb7d317a --- /dev/null +++ b/internal/infrastructure/nsqbus/stats.go @@ -0,0 +1,60 @@ +package nsqbus + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type nsqdStats struct { + Topics []struct { + TopicName string `json:"topic_name"` + Depth int64 `json:"depth"` + Channels []struct { + Depth int64 `json:"depth"` + DeferredCount int64 `json:"deferred_count"` + } `json:"channels"` + } `json:"topics"` +} + +// TopicBacklog 读取 nsqd 上某 topic 的积压量:topic 未分发量 + 各 channel 待消费量与延迟重投量(不含处理中)。 +// topic 不存在时返回 0。 +func TopicBacklog(ctx context.Context, nsqdTCPAddress, topic string) (int64, error) { + httpAddress := nsqdHTTPAddress(nsqdTCPAddress) + if httpAddress == "" { + return 0, fmt.Errorf("nsqd 地址无效:%s", nsqdTCPAddress) + } + requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + endpoint := "http://" + httpAddress + "/stats?format=json&topic=" + url.QueryEscape(topic) + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, err + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return 0, fmt.Errorf("读取 nsqd 统计失败,HTTP %d", response.StatusCode) + } + var stats nsqdStats + if err := json.NewDecoder(response.Body).Decode(&stats); err != nil { + return 0, err + } + var backlog int64 + for _, item := range stats.Topics { + if item.TopicName != topic { + continue + } + backlog += item.Depth + for _, channel := range item.Channels { + backlog += channel.Depth + channel.DeferredCount + } + } + return backlog, nil +} diff --git a/internal/polling/carrierqueue/dispatcher.go b/internal/polling/carrierqueue/dispatcher.go index cc05a395..5c2ba0e3 100644 --- a/internal/polling/carrierqueue/dispatcher.go +++ b/internal/polling/carrierqueue/dispatcher.go @@ -110,6 +110,12 @@ func (d *Dispatcher) Ready(ctx context.Context) bool { return err == nil && state == InitStateDone } +// ingestPaused 判断入库积压是否已触发反压:置位时暂停领取新卡任务(停复机指令照常分发)。 +func (d *Dispatcher) ingestPaused(ctx context.Context) bool { + exists, err := d.store.redis.Exists(ctx, constants.RedisPollingIngestPausedKey).Result() + return err == nil && exists > 0 +} + func (d *Dispatcher) tick(ctx context.Context, interval time.Duration) { defer func() { if r := recover(); r != nil { @@ -118,7 +124,7 @@ func (d *Dispatcher) tick(ctx context.Context, interval time.Duration) { }() // 停复机指令与设备判断的分发只看停复机出口开关,不受 scheduler_mode 限制;卡任务只在新调度就绪时领取。 stopResumeOn := d.settings.Current(ctx).StopResumeQueueEnabled - cardsReady := d.Ready(ctx) + cardsReady := d.Ready(ctx) && !d.ingestPaused(ctx) if !stopResumeOn && !cardsReady { return } diff --git a/internal/polling/carrierqueue/settings.go b/internal/polling/carrierqueue/settings.go index 9fe340ea..e8bd1c76 100644 --- a/internal/polling/carrierqueue/settings.go +++ b/internal/polling/carrierqueue/settings.go @@ -27,6 +27,7 @@ func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logg return } minZero, minOne, maxSeconds, maxCount := int64(0), int64(1), int64(86400*7), int64(100000) + maxDepth := int64(100000000) definitions := []systemconfig.Definition{ {Key: constants.SystemConfigPollingSchedulerMode, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.PollingSchedulerModeLegacy, Description: "卡轮询调度模式:legacy 旧分片调度,carrier 按运营商账户调度(整体切换)", @@ -61,6 +62,13 @@ func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logg DefaultValue: "5", Description: "停复机连续失败告警阈值", Min: &minOne, Max: &maxCount}, {Key: constants.SystemConfigStopResumeStatusGrace, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "300", Description: "停复机成功后的状态保护窗口(秒):窗口内查卡状态返回操作前的状态视为上游滞后,不回写;0 不保护", Min: &minZero, Max: &maxSeconds}, + {Key: constants.SystemConfigPollingIngestMode, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeString, + DefaultValue: constants.PollingIngestModeSync, Description: "轮询观测入库模式:sync 卡任务内同步入库,async 只调网关并发布入库消息", + EnumValues: []string{constants.PollingIngestModeSync, constants.PollingIngestModeAsync}}, + {Key: constants.SystemConfigPollingIngestAlertDepth, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, + DefaultValue: strconv.Itoa(constants.PollingIngestDefaultAlertDepth), Description: "入库消息积压告警阈值(条)", Min: &minOne, Max: &maxDepth}, + {Key: constants.SystemConfigPollingIngestPauseDepth, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, + DefaultValue: strconv.Itoa(constants.PollingIngestDefaultPauseDepth), Description: "入库消息积压反压阈值(条):超过时暂停派发新卡任务,降到一半以下恢复;须大于告警阈值", Min: &minOne, Max: &maxDepth}, } for _, definition := range definitions { if _, exists := registry.Get(definition.Key); exists { @@ -205,6 +213,16 @@ type Settings struct { SRBackoff []time.Duration SRAlertFailCount int SRStatusGrace time.Duration + + // 轮询观测入库(add-polling-async-ingest)。 + IngestMode string + IngestAlertDepth int + IngestPauseDepth int +} + +// IngestAsync 判断轮询观测是否按异步入库执行。 +func (s Settings) IngestAsync() bool { + return s.IngestMode == constants.PollingIngestModeAsync } // SRBackoffFor 返回停复机第 n 次连续失败(n 从 1 起)的退避间隔;超过序列长度取最后一项。 @@ -297,6 +315,9 @@ func DefaultSettings() Settings { SRBackoff: []time.Duration{10 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour}, SRAlertFailCount: 5, SRStatusGrace: 5 * time.Minute, + IngestMode: constants.PollingIngestModeSync, + IngestAlertDepth: constants.PollingIngestDefaultAlertDepth, + IngestPauseDepth: constants.PollingIngestDefaultPauseDepth, } } @@ -421,6 +442,8 @@ func (p *SettingsProvider) load(ctx context.Context) (Settings, error) { {constants.SystemConfigStopResumeUnknownAlert, func(v int) { s.SRUnknownAlert = v }}, {constants.SystemConfigStopResumeAlertFailCount, func(v int) { s.SRAlertFailCount = v }}, {constants.SystemConfigStopResumeStatusGrace, func(v int) { s.SRStatusGrace = time.Duration(v) * time.Second }}, + {constants.SystemConfigPollingIngestAlertDepth, func(v int) { s.IngestAlertDepth = v }}, + {constants.SystemConfigPollingIngestPauseDepth, func(v int) { s.IngestPauseDepth = v }}, } for _, item := range ints { raw, err := get(item.key) @@ -448,6 +471,16 @@ func (p *SettingsProvider) load(ctx context.Context) (Settings, error) { for _, value := range srBackoff { s.SRBackoff = append(s.SRBackoff, time.Duration(value)*time.Second) } + if raw, err = get(constants.SystemConfigPollingIngestMode); err != nil { + return s, err + } + s.IngestMode = raw + if s.IngestPauseDepth <= s.IngestAlertDepth { + // 反压阈值须大于告警阈值:配置不满足时按告警阈值的 2 倍执行并告警,避免还没告警就先停派发。 + p.logger.Warn("入库反压阈值不大于告警阈值,按告警阈值的 2 倍执行", + zap.Int("alert_depth", s.IngestAlertDepth), zap.Int("pause_depth", s.IngestPauseDepth)) + s.IngestPauseDepth = s.IngestAlertDepth * 2 + } return s, nil } diff --git a/internal/polling/config_manager.go b/internal/polling/config_manager.go index c242d7cc..90174ad2 100644 --- a/internal/polling/config_manager.go +++ b/internal/polling/config_manager.go @@ -188,9 +188,8 @@ func (m *PollingConfigManager) lazyTierFacts(card *model.IotCard) func() TierFac return func() TierFacts { once.Do(func() { if m.tier != nil { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - facts = m.tier.Resolve(ctx, card) + // 每次查询在解析器内单独计时,避免一张绑定设备的卡连查多次时共用一个超时。 + facts = m.tier.Resolve(context.Background(), card) } }) return facts diff --git a/internal/polling/queue_manager.go b/internal/polling/queue_manager.go index 8d65b511..d4f53404 100644 --- a/internal/polling/queue_manager.go +++ b/internal/polling/queue_manager.go @@ -156,6 +156,16 @@ func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, ta // 使用 RPUSH 与调度器的 LPopCount 组成先进先出;提示通道不是权威,丢失或缓存服务重启时 // 库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,只退化为延迟一个普通轮询周期。 func (m *PollingQueueManager) EnqueuePriority(ctx context.Context, cardID uint, taskType string) error { + // 同时登记活动优先项索引(轮询步骤据此跳过无优先项卡的查库);recent 集合供索引重建时并入,避免重建期间漏掉新项。 + member := constants.PollingPriorityIndexMember(cardID, taskType) + pipe := m.redis.Pipeline() + pipe.SAdd(ctx, constants.RedisPollingPriorityActiveKey, member) + pipe.SAdd(ctx, constants.RedisPollingPriorityRecentKey, member) + pipe.Expire(ctx, constants.RedisPollingPriorityRecentKey, constants.PollingPriorityIndexRecentTTL) + if _, err := pipe.Exec(ctx); err != nil { + m.logger.Warn("登记活动优先项索引失败,轮询步骤将在索引重建后发现该项", zap.Uint("card_id", cardID), + zap.String("task_type", taskType), zap.Error(err)) + } key := constants.RedisPollingPriorityQueueKey(taskType) return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err() } diff --git a/internal/polling/tier.go b/internal/polling/tier.go index 78ac7f0c..8174a058 100644 --- a/internal/polling/tier.go +++ b/internal/polling/tier.go @@ -76,7 +76,7 @@ func (r *TierResolver) Prefill(ctx context.Context, cards []*model.IotCard) { } results := make(map[uint]TierFacts, len(cards)) if len(standaloneIDs) > 0 { - states, err := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeIotCard, standaloneIDs) + states, err := r.packageStates(ctx, constants.AssetTypeIotCard, standaloneIDs) if err != nil { r.logger.Warn("批量查询卡套餐状态失败,相关卡本次不按套餐状态匹配", zap.Error(err)) } @@ -87,7 +87,9 @@ func (r *TierResolver) Prefill(ctx context.Context, cards []*model.IotCard) { } } if len(boundCardIDs) > 0 && r.bindings != nil { - bindings, err := r.bindings.GetActiveBindingsByCardIDs(ctx, boundCardIDs) + bindCtx, cancel := context.WithTimeout(ctx, constants.PollingTierQueryTimeout) + bindings, err := r.bindings.GetActiveBindingsByCardIDs(bindCtx, boundCardIDs) + cancel() if err != nil { r.logger.Warn("批量查询卡绑定设备失败,相关卡本次不按套餐状态匹配", zap.Error(err)) } else { @@ -97,8 +99,8 @@ func (r *TierResolver) Prefill(ctx context.Context, cards []*model.IotCard) { deviceOf[binding.IotCardID] = binding.DeviceID deviceIDs = append(deviceIDs, binding.DeviceID) } - deviceStates, devErr := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeDevice, deviceIDs) - cardStates, cardErr := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeIotCard, boundCardIDs) + deviceStates, devErr := r.packageStates(ctx, constants.AssetTypeDevice, deviceIDs) + cardStates, cardErr := r.packageStates(ctx, constants.AssetTypeIotCard, boundCardIDs) for _, id := range boundCardIDs { // 有有效设备绑定按设备计算;绑定已失效的卡按卡自身计算(与 resolvePackageCarrier 口径一致)。 if deviceID, bound := deviceOf[id]; bound { @@ -111,17 +113,22 @@ func (r *TierResolver) Prefill(ctx context.Context, cards []*model.IotCard) { } } } + // 只缓存查询成功的卡:查询失败的卡本次按未知处理,下次重新查询,不把"未知"留在缓存里。 now := time.Now() r.mu.Lock() - for _, card := range cards { - if card == nil { - continue - } - r.cache[card.ID] = tierCacheEntry{facts: results[card.ID], loadedAt: now} + for id, facts := range results { + r.cache[id] = tierCacheEntry{facts: facts, loadedAt: now} } r.mu.Unlock() } +// packageStates 以单独的超时查询一批载体的套餐状态,避免多次查询共用一个超时。 +func (r *TierResolver) packageStates(ctx context.Context, carrierType string, ids []uint) (map[uint]string, error) { + queryCtx, cancel := context.WithTimeout(ctx, constants.PollingTierQueryTimeout) + defer cancel() + return r.usage.PackageStatesByCarriers(queryCtx, carrierType, ids) +} + // Invalidate 清除某卡的分层事实缓存(套餐变化后可调用,未调用时最迟缓存过期后更新)。 func (r *TierResolver) Invalidate(cardID uint) { if r == nil { diff --git a/internal/store/postgres/polling_priority_item_store.go b/internal/store/postgres/polling_priority_item_store.go index 2afa7244..a6e43632 100644 --- a/internal/store/postgres/polling_priority_item_store.go +++ b/internal/store/postgres/polling_priority_item_store.go @@ -219,6 +219,23 @@ func (s *PollingPriorityItemStore) FindActive(ctx context.Context, cardID uint, return &item, nil } +// PollingPriorityActiveKey 是一个活动优先项的卡与任务类型。 +type PollingPriorityActiveKey struct { + CardID uint `gorm:"column:card_id"` + TaskType string `gorm:"column:task_type"` +} + +// ListActiveKeys 查询全部活动优先项的卡与任务类型,供轮询侧重建活动优先项索引。 +func (s *PollingPriorityItemStore) ListActiveKeys(ctx context.Context) ([]PollingPriorityActiveKey, error) { + var keys []PollingPriorityActiveKey + if err := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).Distinct("card_id", "task_type"). + Where("status IN ?", []string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}). + Scan(&keys).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动优先轮询项索引失败") + } + return keys, nil +} + // ListActiveByCards 批量查询指定卡在该任务类型下的活动优先项,供轮询执行前一次性探测。 func (s *PollingPriorityItemStore) ListActiveByCards(ctx context.Context, cardIDs []uint, taskType string) ([]*model.PollingPriorityItem, error) { if len(cardIDs) == 0 || strings.TrimSpace(taskType) == "" { diff --git a/internal/task/carrier_card_executor.go b/internal/task/carrier_card_executor.go index 2ff210b2..562b4eb2 100644 --- a/internal/task/carrier_card_executor.go +++ b/internal/task/carrier_card_executor.go @@ -124,7 +124,7 @@ func (e *CarrierCardExecutor) execute(ctx context.Context, msg carrierqueue.Card } return set, del } - if !card.EnablePolling || (e.base.scopeChecker != nil && !e.base.scopeChecker.ShouldEnqueue(ctx, card)) { + if !e.base.scopeAllowsCached(ctx, card) { e.removeCard(ctx, msg.CardID, msg.CarrierID) return set, del } @@ -143,7 +143,9 @@ func (e *CarrierCardExecutor) execute(ctx context.Context, msg carrierqueue.Card if lost.Load() || ctx.Err() != nil { break } - if checkType == constants.CheckTypePackage && settings.SkipPackageAfterFlow && flowEvaluated { + // 异步入库时流量评估发生在入库段,由其写入的评估标记表示套餐检查间隔内已触发过停复机评估。 + if checkType == constants.CheckTypePackage && settings.SkipPackageAfterFlow && + (flowEvaluated || e.base.flowEvaluatedRecently(ctx, msg.CardID)) { executed[checkType] = stepOK() continue } diff --git a/internal/task/collection_batch_consumer.go b/internal/task/collection_batch_consumer.go new file mode 100644 index 00000000..b48ac45a --- /dev/null +++ b/internal/task/collection_batch_consumer.go @@ -0,0 +1,154 @@ +package task + +import ( + "context" + "encoding/json" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// collectionWriteTimeout 是一批采集记录写库的超时。 +const collectionWriteTimeout = 30 * time.Second + +// CollectionBatchConsumer 消费采集记录消息(topic card.collection),攒满一批或等满 1 秒写一次库: +// 写成功后整批确认,写失败整批延迟重投;重复记录由唯一索引忽略。写库只占一个连接。 +type CollectionBatchConsumer struct { + db *gorm.DB + batchSize int + logger *zap.Logger + items chan *collectionItem +} + +type collectionItem struct { + msg CollectionMessage + done chan error +} + +// NewCollectionBatchConsumer 创建采集记录批量消费者。 +func NewCollectionBatchConsumer(db *gorm.DB, batchSize int, logger *zap.Logger) *CollectionBatchConsumer { + if batchSize <= 0 { + batchSize = constants.PollingCollectionDefaultBatchSize + } + if logger == nil { + logger = zap.NewNop() + } + return &CollectionBatchConsumer{db: db, batchSize: batchSize, logger: logger, + items: make(chan *collectionItem, constants.PollingCollectionMaxInFlight)} +} + +// Start 启动攒批写库循环,ctx 结束时写完已缓冲的一批后退出。 +func (c *CollectionBatchConsumer) Start(ctx context.Context) { + go c.run(ctx) +} + +// Handle 处理一条采集记录消息:交给攒批循环并等待本批写库结果。 +func (c *CollectionBatchConsumer) Handle(ctx context.Context, body []byte, attempts uint16, _ func()) time.Duration { + var msg CollectionMessage + if err := json.Unmarshal(body, &msg); err != nil || (msg.Success == nil && msg.Failure == nil) { + c.logger.Warn("采集记录消息无效,丢弃", zap.ByteString("body", body)) + return 0 + } + item := &collectionItem{msg: msg, done: make(chan error, 1)} + select { + case c.items <- item: + case <-ctx.Done(): + return ingestRetryDelay(attempts) + } + select { + case err := <-item.done: + if err == nil { + return 0 + } + delay := ingestRetryDelay(attempts) + if attempts >= constants.PollingIngestErrorLogFromAttempt { + c.logger.Error("写采集记录持续失败,继续重投", zap.Uint16("attempts", attempts), + zap.String("observation_id", collectionObservationID(msg)), zap.Duration("retry_after", delay), zap.Error(err)) + } + return delay + case <-ctx.Done(): + return ingestRetryDelay(attempts) + } +} + +func (c *CollectionBatchConsumer) run(ctx context.Context) { + ticker := time.NewTicker(constants.PollingCollectionFlushInterval) + defer ticker.Stop() + batch := make([]*collectionItem, 0, c.batchSize) + flush := func() { + if len(batch) == 0 { + return + } + c.flush(batch) + batch = make([]*collectionItem, 0, c.batchSize) + } + for { + select { + case <-ctx.Done(): + flush() + return + case item := <-c.items: + batch = append(batch, item) + if len(batch) >= c.batchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +// flush 一次写入一批成功记录与失败记录,并把结果通知本批每条消息。 +func (c *CollectionBatchConsumer) flush(batch []*collectionItem) { + var successes []*model.CardTrafficCollection + var failures []*model.CardTrafficCollectionFailure + for _, item := range batch { + if item.msg.Success != nil { + successes = append(successes, item.msg.Success) + } + if item.msg.Failure != nil { + failures = append(failures, item.msg.Failure) + } + } + ctx, cancel := context.WithTimeout(context.Background(), collectionWriteTimeout) + defer cancel() + err := c.write(ctx, successes, failures) + if err != nil { + c.logger.Warn("批量写采集记录失败,整批重投", zap.Int("success_rows", len(successes)), + zap.Int("failure_rows", len(failures)), zap.Error(err)) + } + for _, item := range batch { + item.done <- err + } +} + +func (c *CollectionBatchConsumer) write(ctx context.Context, successes []*model.CardTrafficCollection, failures []*model.CardTrafficCollectionFailure) error { + return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if len(successes) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(successes, len(successes)).Error; err != nil { + return err + } + } + if len(failures) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(failures, len(failures)).Error; err != nil { + return err + } + } + return nil + }) +} + +func collectionObservationID(msg CollectionMessage) string { + if msg.Success != nil { + return msg.Success.ObservationID + } + if msg.Failure != nil { + return msg.Failure.ObservationID + } + return "" +} diff --git a/internal/task/ingest_executor.go b/internal/task/ingest_executor.go new file mode 100644 index 00000000..6319ac47 --- /dev/null +++ b/internal/task/ingest_executor.go @@ -0,0 +1,177 @@ +package task + +import ( + "context" + "encoding/json" + stderrors "errors" + "sync/atomic" + "time" + + "go.uber.org/zap" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// IngestExecutor 消费轮询观测入库消息(topic card.ingest):复用卡观测应用服务写库,按采集 ID 去重; +// 可重试错误按退避延迟重投且不设次数上限,业务性错误记录后丢弃。 +type IngestExecutor struct { + base *PollingBase + observation *cardapp.Service + timeout time.Duration + // lastLagMS 是最近处理消息的发布到开始处理的等待时长,供积压监控读取。 + lastLagMS atomic.Int64 +} + +// NewIngestExecutor 创建入库消费者。 +func NewIngestExecutor(base *PollingBase, observation *cardapp.Service, timeout time.Duration) *IngestExecutor { + if timeout <= 0 { + timeout = constants.PollingIngestDefaultTimeout + } + return &IngestExecutor{base: base, observation: observation, timeout: timeout} +} + +// LastLag 返回最近处理的入库消息从发布到开始处理的等待时长。 +func (e *IngestExecutor) LastLag() time.Duration { + if e == nil { + return 0 + } + return time.Duration(e.lastLagMS.Load()) * time.Millisecond +} + +// Handle 处理一条入库消息,返回 0 表示确认,正数表示按该延迟重投。 +func (e *IngestExecutor) Handle(ctx context.Context, body []byte, attempts uint16, _ func()) time.Duration { + var msg IngestMessage + if err := json.Unmarshal(body, &msg); err != nil || !msg.valid() { + e.base.logger.Warn("入库消息无效,丢弃", zap.ByteString("body", body)) + return 0 + } + if !msg.PublishedAt.IsZero() { + e.lastLagMS.Store(time.Since(msg.PublishedAt).Milliseconds()) + } + if e.alreadyDone(ctx, msg.ObservationID) { + return 0 + } + taskCtx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + taskCtx = withPollingWorkerAuditContext(taskCtx, msg.TaskType, pollingPriorityTaskName(msg.TaskType), msg.ObservationID) + changed, err := e.apply(taskCtx, msg) + if err != nil { + return e.onFailure(msg, attempts, err) + } + e.markDone(msg.ObservationID) + if changed && msg.Kind == ingestKindTraffic { + e.markFlowEvaluated(msg.CardID) + } + return 0 +} + +// apply 按观测类型调用卡观测应用服务,返回流量是否产生增量或跨月。 +func (e *IngestExecutor) apply(ctx context.Context, msg IngestMessage) (bool, error) { + if e.observation == nil { + return false, errors.New(errors.CodeInternalError, "卡观测能力未配置") + } + switch msg.Kind { + case ingestKindTraffic: + decision, err := e.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ + CardID: msg.CardID, GatewayReadingMB: msg.Traffic.ReadingMB, ReadingResult: msg.Traffic.ReadingResult, + ResetDay: msg.Traffic.ResetDay, Raw: msg.Raw, ICCID: msg.ICCID, CarrierID: msg.CarrierID, + Failure: &carddomain.CollectionFailure{DurationMS: msg.Traffic.DurationMS}, + Metadata: msg.metadata(), + }) + return decision.IncrementMB > 0 || decision.CrossMonth, err + case ingestKindNetwork: + _, err := e.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ + CardID: msg.CardID, GatewayStatus: msg.Network.Status, GatewayExtend: msg.Network.Extend, + GatewayIMEI: msg.Network.IMEI, Metadata: msg.metadata(), + }) + return false, err + case ingestKindRealname: + _, err := e.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ + CardID: msg.CardID, Verified: msg.Realname.Verified, Metadata: msg.metadata(), + }) + return false, err + } + return false, errors.New(errors.CodeInvalidParam, "未知的入库观测类型") +} + +// onFailure 业务性错误丢弃,其余按退避重投;第 10 次起每次重投记 ERROR。 +func (e *IngestExecutor) onFailure(msg IngestMessage, attempts uint16, err error) time.Duration { + fields := []zap.Field{zap.String("kind", msg.Kind), zap.Uint("card_id", msg.CardID), + zap.String("observation_id", msg.ObservationID), zap.Uint16("attempts", attempts), zap.Error(err)} + if ingestBusinessError(err) { + e.base.logger.Warn("入库遇到业务性错误,丢弃消息", fields...) + return 0 + } + delay := ingestRetryDelay(attempts) + fields = append(fields, zap.Duration("retry_after", delay)) + if attempts >= constants.PollingIngestErrorLogFromAttempt { + e.base.logger.Error("入库持续失败,继续重投", fields...) + } else { + e.base.logger.Warn("入库失败,延迟重投", fields...) + } + return delay +} + +// ingestBusinessError 判断错误是否为重试也不会成功的业务性错误(卡不存在、观测数据校验失败)。 +func ingestBusinessError(err error) bool { + var appErr *errors.AppError + if !stderrors.As(err, &appErr) { + return false + } + switch appErr.Code { + case errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeInvalidStatus: + return true + } + return false +} + +func (e *IngestExecutor) alreadyDone(ctx context.Context, observationID string) bool { + if e.base.redis == nil { + return false + } + exists, err := e.base.redis.Exists(ctx, constants.RedisPollingIngestDoneKey(observationID)).Result() + return err == nil && exists > 0 +} + +func (e *IngestExecutor) markDone(observationID string) { + if e.base.redis == nil { + return + } + markCtx, cancel := pollingFallbackContext() + defer cancel() + if err := e.base.redis.Set(markCtx, constants.RedisPollingIngestDoneKey(observationID), "1", constants.PollingIngestDoneTTL).Err(); err != nil { + e.base.logger.Warn("写入库已处理标记失败,重投时由业务幂等兜底", zap.String("observation_id", observationID), zap.Error(err)) + } +} + +// markFlowEvaluated 流量入库产生增量或跨月时写评估标记,有效期取该卡套餐检查间隔;套餐步骤据此跳过。 +func (e *IngestExecutor) markFlowEvaluated(cardID uint) { + if e.base.redis == nil { + return + } + markCtx, cancel := pollingFallbackContext() + defer cancel() + ttl := constants.PollingFlowEvaluatedFallbackTTL + if card, err := e.base.getCardWithCache(markCtx, cardID); err == nil && e.base.configMgr != nil { + info, ok := e.base.configMgr.MergedTaskIntervals(card)[constants.TaskTypePollingPackage] + if !ok || info.Interval <= 0 { + return + } + ttl = time.Duration(info.Interval) * time.Second + } + if err := e.base.redis.Set(markCtx, constants.RedisPollingFlowEvaluatedKey(cardID), "1", ttl).Err(); err != nil { + e.base.logger.Debug("写流量评估标记失败", zap.Uint("card_id", cardID), zap.Error(err)) + } +} + +// flowEvaluatedRecently 判断入库段是否在套餐检查间隔内写过流量评估标记。 +func (b *PollingBase) flowEvaluatedRecently(ctx context.Context, cardID uint) bool { + if b == nil || b.redis == nil { + return false + } + exists, err := b.redis.Exists(ctx, constants.RedisPollingFlowEvaluatedKey(cardID)).Result() + return err == nil && exists > 0 +} diff --git a/internal/task/ingest_message.go b/internal/task/ingest_message.go new file mode 100644 index 00000000..1d81c709 --- /dev/null +++ b/internal/task/ingest_message.go @@ -0,0 +1,156 @@ +package task + +import ( + "context" + "encoding/json" + "time" + + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// 入库消息的观测类型。 +const ( + ingestKindTraffic = "traffic" + ingestKindNetwork = "network" + ingestKindRealname = "realname" +) + +// ingestRawResponseLimit 是入库消息携带原始响应的上限:比采集记录保存上限多 1 字节,使"超过 4KB 截断"判断不变,同时控制消息大小。 +const ingestRawResponseLimit = 4096 + 1 + +// IngestMessage 是一次轮询观测的入库消息(topic card.ingest)。采集时间取网关返回时刻,入库全程按它归属日期与判断新旧。 +type IngestMessage struct { + Kind string `json:"kind"` + CardID uint `json:"card_id"` + CarrierID uint `json:"carrier_id"` + ICCID string `json:"iccid"` + ObservationID string `json:"observation_id"` + ObservedAt time.Time `json:"observed_at"` + Source string `json:"source"` + Scene string `json:"scene"` + CorrelationID string `json:"correlation_id"` + // TaskType 是发起观测的轮询任务类型,入库时用于审计上下文与流量评估标记。 + TaskType string `json:"task_type"` + Traffic *IngestTrafficPayload `json:"traffic,omitempty"` + Network *IngestNetworkPayload `json:"network,omitempty"` + Realname *IngestRealnamePayload `json:"realname,omitempty"` + Raw *carddomain.GatewayRaw `json:"raw,omitempty"` + PublishedAt time.Time `json:"published_at"` +} + +// IngestTrafficPayload 是流量观测内容。 +type IngestTrafficPayload struct { + ReadingMB float64 `json:"reading_mb"` + ReadingResult string `json:"reading_result"` + ResetDay int `json:"reset_day"` + DurationMS int `json:"duration_ms"` +} + +// IngestNetworkPayload 是网络状态观测内容。 +type IngestNetworkPayload struct { + Status string `json:"status"` + Extend string `json:"extend"` + IMEI string `json:"imei"` +} + +// IngestRealnamePayload 是实名观测内容。 +type IngestRealnamePayload struct { + Verified bool `json:"verified"` +} + +// metadata 还原观测元数据。 +func (m IngestMessage) metadata() carddomain.ObservationMetadata { + return carddomain.ObservationMetadata{ + ObservationID: m.ObservationID, Source: m.Source, Scene: m.Scene, + ObservedAt: m.ObservedAt, CorrelationID: m.CorrelationID, + } +} + +// valid 校验消息必需字段。 +func (m IngestMessage) valid() bool { + if m.CardID == 0 || m.ObservationID == "" || m.ObservedAt.IsZero() { + return false + } + switch m.Kind { + case ingestKindTraffic: + return m.Traffic != nil + case ingestKindNetwork: + return m.Network != nil + case ingestKindRealname: + return m.Realname != nil + } + return false +} + +// CollectionMessage 是一条采集记录消息(topic card.collection),成功记录与失败记录二选一。 +type CollectionMessage struct { + Success *model.CardTrafficCollection `json:"success,omitempty"` + Failure *model.CardTrafficCollectionFailure `json:"failure,omitempty"` +} + +// MessagePublisher 发布 NSQ 消息。 +type MessagePublisher interface { + Publish(topic string, body []byte) error +} + +// IngestPublisher 发布入库消息与采集记录消息。 +type IngestPublisher struct { + producer MessagePublisher +} + +// NewIngestPublisher 创建入库消息发布器;producer 为 nil 时所有发布都返回错误,调用方降级同步入库。 +func NewIngestPublisher(producer MessagePublisher) *IngestPublisher { + return &IngestPublisher{producer: producer} +} + +// PublishIngest 发布一条入库消息。 +func (p *IngestPublisher) PublishIngest(_ context.Context, msg IngestMessage) error { + if p == nil || p.producer == nil { + return errors.New(errors.CodeInternalError, "入库消息发布器未配置") + } + if msg.Raw != nil && len(msg.Raw.Response) > ingestRawResponseLimit { + trimmed := *msg.Raw + trimmed.Response = trimmed.Response[:ingestRawResponseLimit] + msg.Raw = &trimmed + } + msg.PublishedAt = time.Now() + body, err := json.Marshal(msg) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "编码入库消息失败") + } + if err := p.producer.Publish(constants.NSQCardIngestTopic, body); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "发布入库消息失败") + } + return nil +} + +// PublishCollection 发布一条采集记录消息,实现卡观测服务的 CollectionSink。 +func (p *IngestPublisher) PublishCollection(_ context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error { + if p == nil || p.producer == nil { + return errors.New(errors.CodeInternalError, "采集记录消息发布器未配置") + } + body, err := json.Marshal(CollectionMessage{Success: success, Failure: failure}) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "编码采集记录消息失败") + } + if err := p.producer.Publish(constants.NSQCardCollectionTopic, body); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "发布采集记录消息失败") + } + return nil +} + +// ingestRetryDelay 返回第 attempts 次投递失败后的重投延迟;超过序列长度取最后一项,不设次数上限。 +func ingestRetryDelay(attempts uint16) time.Duration { + delays := constants.PollingIngestRetryDelays + index := int(attempts) - 1 + if index < 0 { + index = 0 + } + if index >= len(delays) { + index = len(delays) - 1 + } + return delays[index] +} diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index 4e347f7e..ad21170d 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -73,6 +73,10 @@ type PollingBase struct { rateLimitedDelay func() time.Duration // 卡网关调用被运营商账户限流后的固定重入队延迟 errorClassifier ErrorClassifier // 上游错误分类(按运营商账户调度的失败退避与告警使用) schedulerMode SchedulerModeReader // 当前调度模式;carrier 时旧 Asynq 轮询任务直接结束 + ingest *IngestPublisher // 入库消息发布器;为 nil 时轮询步骤同步入库 + ingestAsync func(ctx context.Context) bool + scopeCache scopeCache + resetDays resetDayCache } // SetRateLimitedDelay 设置被运营商账户限流后的重入队延迟来源(受控系统配置,即时生效)。 diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 135b39fc..d37304f8 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -69,8 +69,8 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc return stepDefer(0) } - h.base.invalidateCardCache(ctx, cardID) - card, err := h.base.iotCardStore.GetByID(ctx, cardID) + // 卡任务只用到 ICCID、运营商账户与轮询开关;读缓存即可,入库段写库后会失效缓存。 + card, err := h.base.getCardWithCache(ctx, cardID) if err != nil { if isNotFound(err) { priority.FailFinal(ctx, "卡不存在") @@ -95,6 +95,7 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc queryStartedAt := time.Now() result, err := h.gateway.QueryFlow(queryCtx, &gateway.FlowQueryReq{CardNo: card.ICCID}) unreadable := unreadableDetail{raw: domainGatewayRaw(rawCapture), failure: collectionFailure(err, time.Since(queryStartedAt))} + unreadable.iccid, unreadable.carrierID = card.ICCID, card.CarrierID if gateway.IsRateLimited(err) { h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultRateLimited, unreadable) priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试") @@ -120,10 +121,25 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc return h.fail(ctx, cardID, startedAt, "卡流量观测能力未配置", nil) } now := time.Now() + resetDay := h.base.carrierResetDay(ctx, card.CarrierID) + durationMS := int(time.Since(queryStartedAt).Milliseconds()) + if h.base.publishIngest(ctx, priority, IngestMessage{ + Kind: ingestKindTraffic, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID, + ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneTrafficPolling, CorrelationID: attempt.IntegrationID, + TaskType: constants.TaskTypePollingCarddata, Raw: unreadable.raw, + Traffic: &IngestTrafficPayload{ReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result), + ResetDay: resetDay, DurationMS: durationMS}, + }) { + // 异步入库:网关已成功,入库由入库消费者完成与重试,本步骤按成功结束。 + h.base.configMgr.RecordPreResetCheck(ctx, card, now) + h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt)) + return stepOK() + } decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ CardID: cardID, GatewayReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result), - ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID), Raw: unreadable.raw, - Failure: &carddomain.CollectionFailure{DurationMS: int(time.Since(queryStartedAt).Milliseconds())}, + ResetDay: resetDay, Raw: unreadable.raw, ICCID: card.ICCID, CarrierID: card.CarrierID, + Failure: &carddomain.CollectionFailure{DurationMS: durationMS}, Metadata: carddomain.ObservationMetadata{ ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, @@ -168,8 +184,10 @@ func (h *PollingCarddataHandler) fail(ctx context.Context, cardID uint, startedA // recordUnreadable 为没拿到有效读数的采集写采集记录(只记采集时刻有套餐的卡),失败只影响留痕。 // unreadableDetail 是读数不可用时随采集失败记录保存的原始数据与错误详情。 type unreadableDetail struct { - raw *carddomain.GatewayRaw - failure *carddomain.CollectionFailure + raw *carddomain.GatewayRaw + failure *carddomain.CollectionFailure + iccid string + carrierID uint } func (h *PollingCarddataHandler) recordUnreadable(ctx context.Context, cardID uint, observationID, result string, detail unreadableDetail) { @@ -178,6 +196,7 @@ func (h *PollingCarddataHandler) recordUnreadable(ctx context.Context, cardID ui } if _, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ CardID: cardID, ReadingResult: result, ResetDay: 1, Raw: detail.raw, Failure: detail.failure, + ICCID: detail.iccid, CarrierID: detail.carrierID, Metadata: carddomain.ObservationMetadata{ ObservationID: observationID, Source: constants.CardObservationSourcePolling, Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: time.Now(), CorrelationID: observationID, diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 1a1acdc0..81858a7f 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -118,6 +118,16 @@ func (h *PollingCardStatusHandler) Step(ctx context.Context, cardID uint) StepOu return h.fail(ctx, cardID, startedAt, "卡网络观测能力未配置", nil) } now := time.Now() + if h.base.publishIngest(ctx, priority, IngestMessage{ + Kind: ingestKindNetwork, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID, + ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneNetworkPolling, CorrelationID: attempt.IntegrationID, + TaskType: constants.TaskTypePollingCardStatus, + Network: &IngestNetworkPayload{Status: result.CardStatus, Extend: result.Extend, IMEI: result.IMEI}, + }) { + h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt)) + return asyncNetworkOutcome(card.EnablePolling, result.CardStatus, result.Extend) + } decision, err := h.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ CardID: cardID, GatewayStatus: result.CardStatus, GatewayExtend: result.Extend, GatewayIMEI: result.IMEI, Metadata: carddomain.ObservationMetadata{ @@ -170,3 +180,20 @@ func (h *PollingCardStatusHandler) fail(ctx context.Context, cardID uint, starte h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startedAt)) return h.base.stepFail(ctx, constants.GatewayOpQueryStatus, message, err) } + +// asyncNetworkOutcome 在异步入库时按网关结果直接得出步骤结果:命中风险停机或已销户时停止轮询(入库段同时关闭卡轮询开关), +// 观测到的网络状态按网关状态映射,供延迟确认比较。 +func asyncNetworkOutcome(enablePolling bool, gatewayStatus, gatewayExtend string) StepOutcome { + extend := strings.TrimSpace(gatewayExtend) + if enablePolling && carddomain.ShouldStopPollingForRisk(extend) { + return StepOutcome{Result: StepStopPolling} + } + outcome := stepOK() + if status, known := carddomain.MapGatewayNetworkStatus(gatewayStatus, extend); known { + outcome.Observed = ObservedOffline + if status == constants.NetworkStatusOnline { + outcome.Observed = ObservedOnline + } + } + return outcome +} diff --git a/internal/task/polling_ingest.go b/internal/task/polling_ingest.go new file mode 100644 index 00000000..71ef6e1c --- /dev/null +++ b/internal/task/polling_ingest.go @@ -0,0 +1,104 @@ +package task + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// SetIngest 注入入库消息发布器与入库模式读取(受控系统配置 polling.ingest.mode,数秒内生效)。 +func (b *PollingBase) SetIngest(publisher *IngestPublisher, async func(ctx context.Context) bool) { + b.ingest = publisher + b.ingestAsync = async +} + +// publishIngest 在异步入库模式下发布入库消息,返回是否已发布;返回 false 时调用方按原逻辑同步入库。 +// 本轮同时是优先执行时保持同步入库:加急结果需在本轮写库后才能完成优先项。 +func (b *PollingBase) publishIngest(ctx context.Context, round *pollingPriorityRound, msg IngestMessage) bool { + if round != nil || b.ingest == nil || b.ingestAsync == nil || !b.ingestAsync(ctx) { + return false + } + if err := b.ingest.PublishIngest(ctx, msg); err != nil { + b.logger.Warn("发布入库消息失败,本次改为同步入库", zap.String("kind", msg.Kind), zap.Uint("card_id", msg.CardID), + zap.String("observation_id", msg.ObservationID), zap.Error(err)) + return false + } + return true +} + +// scopeCache 是卡轮询范围校验结果的进程内短缓存,只供按运营商账户调度的卡任务使用,减少每个卡任务查绑定与设备。 +// 设备停用轮询最迟在缓存时长后生效;生命周期事件仍按实时校验移出队列。 +type scopeCache struct { + mu sync.Mutex + entries map[uint]scopeEntry +} + +type scopeEntry struct { + allowed bool + loadedAt time.Time +} + +// scopeAllowsCached 返回卡是否在轮询范围内(卡自身开关实时判断,设备开关按短缓存)。 +func (b *PollingBase) scopeAllowsCached(ctx context.Context, card *model.IotCard) bool { + if !card.EnablePolling { + return false + } + if b.scopeChecker == nil || card.IsStandalone { + return true + } + b.scopeCache.mu.Lock() + entry, ok := b.scopeCache.entries[card.ID] + b.scopeCache.mu.Unlock() + if ok && time.Since(entry.loadedAt) < constants.PollingScopeCacheTTL { + return entry.allowed + } + allowed := b.scopeChecker.ShouldEnqueue(ctx, card) + b.scopeCache.mu.Lock() + if b.scopeCache.entries == nil || len(b.scopeCache.entries) > 200000 { + b.scopeCache.entries = map[uint]scopeEntry{} + } + b.scopeCache.entries[card.ID] = scopeEntry{allowed: allowed, loadedAt: time.Now()} + b.scopeCache.mu.Unlock() + return allowed +} + +// resetDayCache 是运营商账户流量重置日的进程内缓存。 +type resetDayCache struct { + mu sync.Mutex + entries map[uint]resetDayEntry +} + +type resetDayEntry struct { + day int + loadedAt time.Time +} + +// carrierResetDay 返回运营商账户流量重置日(1-28),成功读取的值按 1 分钟缓存;读取失败或未配置时返回 1 且不缓存。 +func (b *PollingBase) carrierResetDay(ctx context.Context, carrierID uint) int { + b.resetDays.mu.Lock() + entry, ok := b.resetDays.entries[carrierID] + b.resetDays.mu.Unlock() + if ok && time.Since(entry.loadedAt) < constants.PollingCarrierResetDayCacheTTL { + return entry.day + } + if b.db == nil { + return 1 + } + var days []int + if err := b.db.WithContext(ctx).Unscoped().Model(&model.Carrier{}).Where("id = ?", carrierID).Limit(1). + Pluck("data_reset_day", &days).Error; err != nil || len(days) == 0 || days[0] == 0 { + return 1 + } + b.resetDays.mu.Lock() + if b.resetDays.entries == nil { + b.resetDays.entries = map[uint]resetDayEntry{} + } + b.resetDays.entries[carrierID] = resetDayEntry{day: days[0], loadedAt: time.Now()} + b.resetDays.mu.Unlock() + return days[0] +} diff --git a/internal/task/polling_priority_claim.go b/internal/task/polling_priority_claim.go index 863cfc59..4a086f95 100644 --- a/internal/task/polling_priority_claim.go +++ b/internal/task/polling_priority_claim.go @@ -53,6 +53,9 @@ func (b *PollingBase) beginPollingPriority(ctx context.Context, cardID uint, tas if b == nil || b.priorityStore == nil { return nil, false, nil } + if b.priorityIndexAbsent(ctx, cardID, taskType) { + return nil, false, nil + } item, err := b.priorityStore.FindActive(ctx, cardID, taskType) if err != nil { return nil, false, err diff --git a/internal/task/polling_priority_index.go b/internal/task/polling_priority_index.go new file mode 100644 index 00000000..544aaac5 --- /dev/null +++ b/internal/task/polling_priority_index.go @@ -0,0 +1,73 @@ +package task + +import ( + "context" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// priorityIndexAbsent 判断活动优先项索引能否确认该卡该任务类型没有活动优先项。 +// 索引未建立、读取失败或成员存在时返回 false,调用方照常查库。 +func (b *PollingBase) priorityIndexAbsent(ctx context.Context, cardID uint, taskType string) bool { + if b == nil || b.redis == nil { + return false + } + pipe := b.redis.Pipeline() + ready := pipe.Exists(ctx, constants.RedisPollingPriorityReadyKey) + member := pipe.SIsMember(ctx, constants.RedisPollingPriorityActiveKey, constants.PollingPriorityIndexMember(cardID, taskType)) + if _, err := pipe.Exec(ctx); err != nil { + return false + } + return ready.Val() > 0 && !member.Val() +} + +// PriorityIndexRebuilder 定期按库内活动优先项重建索引(并入最近入队的项),使已终态的项退出索引。 +type PriorityIndexRebuilder struct { + store *postgres.PollingPriorityItemStore + redis *redis.Client + logger *zap.Logger +} + +// NewPriorityIndexRebuilder 创建活动优先项索引重建器。 +func NewPriorityIndexRebuilder(store *postgres.PollingPriorityItemStore, redisClient *redis.Client, logger *zap.Logger) *PriorityIndexRebuilder { + if logger == nil { + logger = zap.NewNop() + } + return &PriorityIndexRebuilder{store: store, redis: redisClient, logger: logger} +} + +// Rebuild 重建一次索引;失败时清除就绪标记,轮询步骤回退为查库。 +func (r *PriorityIndexRebuilder) Rebuild(ctx context.Context) { + if r == nil || r.store == nil || r.redis == nil { + return + } + keys, err := r.store.ListActiveKeys(ctx) + if err != nil { + r.logger.Warn("重建活动优先项索引失败,轮询步骤回退查库", zap.Error(err)) + r.redis.Del(ctx, constants.RedisPollingPriorityReadyKey) + return + } + members := make([]any, 0, len(keys)) + for _, key := range keys { + members = append(members, constants.PollingPriorityIndexMember(key.CardID, key.TaskType)) + } + tmp := constants.RedisPollingPriorityRebuildKey + pipe := r.redis.TxPipeline() + pipe.Del(ctx, tmp) + if len(members) > 0 { + pipe.SAdd(ctx, tmp, members...) + } + pipe.SUnionStore(ctx, tmp, tmp, constants.RedisPollingPriorityRecentKey) + pipe.Del(ctx, constants.RedisPollingPriorityActiveKey) + pipe.SUnionStore(ctx, constants.RedisPollingPriorityActiveKey, tmp) + pipe.Del(ctx, tmp) + pipe.Set(ctx, constants.RedisPollingPriorityReadyKey, "1", constants.PollingPriorityIndexReadyTTL) + if _, err := pipe.Exec(ctx); err != nil { + r.logger.Warn("写入活动优先项索引失败,轮询步骤回退查库", zap.Error(err)) + r.redis.Del(ctx, constants.RedisPollingPriorityReadyKey) + } +} diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index 0485689a..ea96f5ec 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -112,6 +112,15 @@ func (h *PollingRealnameHandler) Step(ctx context.Context, cardID uint) StepOutc return h.fail(ctx, cardID, startedAt, "卡实名观测能力未配置", nil) } now := time.Now() + if h.base.publishIngest(ctx, priority, IngestMessage{ + Kind: ingestKindRealname, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID, + ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneRealnamePolling, CorrelationID: attempt.IntegrationID, + TaskType: constants.TaskTypePollingRealname, Realname: &IngestRealnamePayload{Verified: result.RealStatus}, + }) { + h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt)) + return stepOK() + } decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ CardID: cardID, Verified: result.RealStatus, Metadata: carddomain.ObservationMetadata{ diff --git a/migrations/000274_add_card_traffic_collection_unique.down.sql b/migrations/000274_add_card_traffic_collection_unique.down.sql new file mode 100644 index 00000000..a566585e --- /dev/null +++ b/migrations/000274_add_card_traffic_collection_unique.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS uk_card_traffic_failure_observation; +DROP INDEX IF EXISTS uk_card_traffic_collection_observation; diff --git a/migrations/000274_add_card_traffic_collection_unique.up.sql b/migrations/000274_add_card_traffic_collection_unique.up.sql new file mode 100644 index 00000000..4328dafe --- /dev/null +++ b/migrations/000274_add_card_traffic_collection_unique.up.sql @@ -0,0 +1,8 @@ +-- 采集记录经消息批量写入(add-polling-async-ingest):重投时同一采集只保留一条。 +-- 分区表的唯一索引必须包含分区键 observed_at;父表上建索引会自动作用到各分区,建索引期间锁写入,请在低峰执行。 + +CREATE UNIQUE INDEX IF NOT EXISTS uk_card_traffic_collection_observation + ON tb_card_traffic_collection (observation_id, observed_at); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_card_traffic_failure_observation + ON tb_card_traffic_collection_failure (observation_id, observed_at); diff --git a/openspec/changes/add-polling-async-ingest/.openspec.yaml b/openspec/changes/add-polling-async-ingest/.openspec.yaml new file mode 100644 index 00000000..8a94d769 --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/add-polling-async-ingest/design.md b/openspec/changes/add-polling-async-ingest/design.md new file mode 100644 index 00000000..1e8ecace --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/design.md @@ -0,0 +1,114 @@ +## Context + +依据 `docs/2026-11-06-轮询入库异步化技术方案.md`。按运营商账户调度上线后,测试环境 worker 实测同时处理约 380 个任务(理论上限约 1,290:卡轮询 41 × 20、停复机 41 × 10、设备评估 10、异步任务队列 50),共用 `MAX_OPEN_CONNS=60` 的连接池。每个轮询步骤在同一个任务里先调网关、再同步写库,写库计入步骤超时;结果是每小时约 8,000 次 SQL 超时、13.8 万条慢查询告警、采集记录因 3 秒写超时丢失。 + +现状(流量步骤):读优先项 → 清缓存后查卡 → 范围校验查库 → 调网关 → `ApplyTrafficObservation`(Redis 队列 Push + 事务:锁卡、查套餐、写快照、更新卡、outbox、通道阈值、审计)→ 写采集记录(3s)→ 对接日志、优先项完成 → 返回 `TriggeredEvaluation`。卡状态、实名步骤结构相同。 + +约束:基于 `Iteration/8-11`,只部署测试环境;测试环境网关只读开关保持开启;不改计量与扣减业务规则。 + +## Goals / Non-Goals + +**Goals:** +- 调网关的并发继续按账户控制;入库并发固定、可配置,与账户数无关。 +- 入库不丢、不重复算,乱序安全。 +- 轮询步骤调网关前的读查询基本不碰数据库。 +- 一键回退到同步入库;nsqd 故障时自动降级。 + +**Non-Goals:** +- 死信表与重放接口(本期不做)。 +- 调整按账户调度参数、网关限流规则。 +- 生产环境发布。 +- 人工刷新、运营商回调、激活前结算、观测序列的异步化。 + +## Decisions + +### D1 入库拆到单独的 NSQ topic,而不是给卡任务加全局并发上限 + +备选:在卡任务消费者上加一个全局信号量。拒绝原因:卡任务里既有网关等待又有写库,全局上限会同时压低网关吞吐;而网关并发本来就由账户限流控制。拆开后网关侧按账户并发,数据库侧固定 30 并发,两种瓶颈各自控制。 + +### D2 入库复用现有 `Apply*Observation` + +入库消费者把消息还原为 `domain.TrafficObservation` / `NetworkObservation` / `RealnameObservation` 后调用现有应用服务,计量、扣减、通道阈值、审计逻辑不复制、不改写。同步路径与异步路径共用一套业务代码,`polling.ingest.mode=sync` 时轮询步骤直接调用同一函数。 + +### D3 消息格式 + +`card.ingest` 一条消息一次观测,JSON,字段见规格。`observed_at` 取网关返回时刻,入库全程使用它,保证延迟入库不改变日期归属。网关原始数据按现有截断规则(4KB)在发布前截断,单条约 2KB。 + +### D4 重试用 NSQ 延迟重投、不限次数 + +入库失败按错误分类:可重试错误(数据库超时、连接错误、锁等待超时、`context deadline exceeded`)调用 `msg.RequeueWithoutBackoff(delay)`,延迟按 `attempts` 取 5s/15s/30s/1m/2m/5m…;消费者 `MaxAttempts=0`(不限)。nsqd `--max-msg-timeout=15m` 已满足最大 5 分钟延迟。业务性错误(`CodeNotFound`、校验失败)FIN 并记 WARN。第 10 次起每次重投记 ERROR。 + +卡任务的业务重试仍由 Redis score 表达(现有要求),入库 topic 与卡任务 topic 互不影响。 + +### D5 幂等 + +- 处理前查 Redis `ingest:done:`,存在即 FIN;事务提交后 `SET … EX 48h`。 +- 事务已提交但标记未写入时的重放:日快照取当日最大、增量按"本月最大 − 已结算"得 0;outbox 按 `event_id` `ON CONFLICT DO NOTHING`(已核对 `internal/infrastructure/messaging/outbox/repository.go:103`);采集记录唯一索引兜底。 +- 采集队列 Lua 增加同 `observed_at` 去重,防止重放时同一点进队两次影响判断。 + +### D6 网络、实名观测的乱序保护 + +现有 `ApplyNetworkObservation`、`ApplyCardObservation` 没有比较采集时间。新增:事务内锁卡后,若 `observed_at` 早于卡上 `last_card_status_check_at` / `last_real_name_check_at`,不改状态、不写事件,只确认消息并记 INFO。同步入口也走同一判断,行为一致。 + +### D7 采集记录走 `card.collection` 批量写 + +成功记录在入库事务提交后发布;网关失败、无读数的失败记录由轮询步骤直接发布。批量消费者 `max_in_flight=500`,内存缓冲按 `batch_size=200` 或 1 秒刷写,多行 `INSERT … ON CONFLICT DO NOTHING`,成功统一 FIN、失败统一 `Requeue`。写库并发 1。唯一索引 `(observation_id, observed_at)`,`observed_at` 为分区键,满足分区表唯一索引要求。删除 `collectionRecordTimeout` 与"写失败只记告警"。 + +### D8 收尾动作的归属 + +| 原位置(步骤内) | 异步后 | +|---|---| +| 优先项完成、对接日志"业务事实发生变化" | 本轮认领了优先项的观测保持同步入库,沿用原逻辑;普通轮询本来就不写对接日志 | +| `StopPolling` → `removeCard` | 步骤按网关结果(`ShouldStopPollingForRisk`)直接返回停止轮询;入库段写库时关闭卡轮询开关 | +| 延迟确认的观测值 | 步骤按网关状态映射(`MapGatewayNetworkStatus`)得出 | +| `TriggeredEvaluation` 跳过套餐步骤 | 入库段写 Redis `flow_evaluated:`,TTL = 套餐检查间隔;套餐步骤读取 | + +理由:优先项需要"本轮写库后完成"的语义,且量很小(测试环境十几分钟几条),保持同步比在入库段重建优先项轮次简单可靠。停止轮询与延迟确认只依赖网关结果,不需要等入库。 + +步骤结果在网关成功、消息发布成功时即为成功,写回下次到期时间;入库失败不影响该卡调度退避。 + +### D9 读查询缓存 + +- 卡信息:步骤不再"先清缓存再查库",改读 Redis 卡缓存;入库段事务提交后失效缓存(现有 `cache.Invalidate`)。 +- 优先项:Redis 集合 `polling:priority:active`(成员 `card_id:task_type`),优先项提示下发时加入;leader 每 30 秒按库内活动项重建(并入 2 分钟内新入队的项),就绪标记存在且不在集合中时不查库,否则回退查库。 +- 运营商实名开关(已有)、重置日:进程内缓存 1 分钟,重置日读取失败不缓存。 +- 设备轮询开关与绑定关系:卡任务的范围校验结果按卡进程内缓存 30 秒;卡自身轮询开关仍实时判断,生命周期事件仍实时校验。 +- `lazyTierFacts`:3 秒总超时改为每次查询单独 2 秒;`Prefill` 查询失败的卡不写缓存。 + +### D10 积压监控与反压 + +worker leader 每分钟请求 nsqd `/stats?format=json&topic=…`,取 topic 未分发量与 channel 待消费量、延迟重投量之和作为积压,等待时间取 leader 进程内最近处理消息的 `now − published_at`,写 INFO 指标;超过 `alert_depth`(默认 200,000)或等待 > 10 分钟写 ERROR。超过 `pause_depth`(默认 500,000)时在 Redis 置 `polling:ingest:paused`,分发器每轮检查,置位时不领取新卡;低于一半时清除。 + +### D11 同步回退与降级 + +`polling.ingest.mode` 放系统配置(5 秒进程缓存),默认 `sync`,部署后确认无误再切 `async`。采集记录不论模式都经 `card.collection` 写入(nsqd 不可用时同步写)。`sync` 时步骤直接调用 `Apply*Observation` 并同步写采集记录(采集记录仍可发 `card.collection`;nsqd 不可用时同步写)。异步模式发布失败时,该次观测降级为同步入库并记 WARN。 + +### D12 连接预算检查 + +启动时:`ingest.concurrency + 1(采集记录) ≤ MAX_OPEN_CONNS − 20`,否则 ERROR(不阻止启动)。测试环境 30 + 1 ≤ 40。 + +### D13 nsqd 落盘 + +测试环境 `docker-compose.prod.yml` nsqd 参数改 `--mem-queue-size=0`,所有 topic 写 `./nsq-data`。每秒几十条消息,磁盘写入可忽略;积压 50 万条约 1GB。 + +## Risks / Trade-offs + +- [入库延迟使后台数据晚几秒] → 常态目标等待 < 30 秒;积压告警覆盖异常。 +- [停复机、通道阈值判断晚几秒] → 判断逻辑不变,只是触发时间后移;与网关调用间隔相比可接受。 +- [不做死信,坏消息无限重试] → 业务性错误直接丢弃;可重试错误第 10 次起 ERROR 告警,积压告警兜底;需要时后续加死信。 +- [乱序保护改变同步入口行为] → 只拦"比已应用观测更旧"的状态变化,同步入口原本也不应接受更旧的观测。 +- [卡缓存陈旧导致轮询范围判断滞后] → 入库段与停复机、轮询开关修改均失效缓存;缓存 TTL 保持现有值。 +- [nsqd 全落盘性能] → 当前量级每秒几十条,远低于 nsqd 磁盘队列能力。 +- [一张卡的写入跨两个消费者,排查变复杂] → 日志统一带 `observation_id`、`card_id`。 + +## Migration Plan + +1. 部署含新代码的镜像,`polling.ingest.mode` 默认 `sync`,确认行为不变(采集记录已改经 `card.collection` 写入)。 +2. 执行采集记录唯一索引迁移(测试库,用户授权后执行)。 +3. nsqd 改 `--mem-queue-size=0` 并重启 nsqd。 +4. 系统配置切 `async`,观察 1 小时:SQL 超时数、慢查询数、积压、采集记录对账。 +5. 回退:系统配置切 `sync`,已在队列中的消息继续被消费完。 + +## Open Questions + +- 无。2026-11-06 已定:入库并发 30;nsqd `--mem-queue-size=0`;不做死信;告警 200,000 / 反压 500,000。 diff --git a/openspec/changes/add-polling-async-ingest/proposal.md b/openspec/changes/add-polling-async-ingest/proposal.md new file mode 100644 index 00000000..d0fd6920 --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/proposal.md @@ -0,0 +1,39 @@ +## Why + +测试环境 worker 每小时约 8,000 次 SQL `context deadline exceeded`、13.8 万条慢查询告警,采集记录因 3 秒写超时持续丢失。根因是按运营商账户调度的 NSQ 消费者只有"每账户上限"(41 个账户 × 20 = 820),没有总量上限,而每个轮询步骤在同一个任务里先调网关再同步写库,三百多个任务争抢 60 个数据库连接。依据 `docs/2026-11-06-轮询入库异步化技术方案.md`,把"调上游"与"入库"拆开:调网关继续按账户并发,入库单独发消息、由固定并发的消费者处理。 + +## What Changes + +- 轮询的卡状态、流量、实名步骤只做"读缓存 → 调网关 → 发入库消息",不在卡任务内写库;步骤结果只反映网关调用是否成功。 +- 新增 NSQ topic `card.ingest` 与入库消费者,并发固定(默认 30),与账户数量无关;入库复用现有 `ApplyTrafficObservation`、`ApplyNetworkObservation`、`ApplyCardObservation`。 +- 入库可重试错误按退避不限次数重试、不丢弃;业务性错误记 WARN 后丢弃;本期不做死信。 +- 入库幂等与顺序:按 `observation_id` 去重;采集队列按采集时间去重;网络、实名观测"更新的才生效";采集记录唯一索引。 +- 新增 NSQ topic `card.collection` 与批量消费者,攒批写采集成功表、失败表;删除采集记录 3 秒写超时与"写失败只记告警"。 +- 优先轮询项完成、对接日志"业务事实发生变化"、网络观测导致的停止轮询、套餐步骤跳过标记移到入库段。 +- 轮询步骤调网关前的读查询走缓存(卡信息、优先项、运营商开关、绑定关系);配置匹配查套餐状态每次查询单独计时,失败不缓存"未知"。 +- 入库积压监控:积压超过 20 万条告警,超过 50 万条调度器暂停派发新卡任务,降到一半以下恢复。 +- 系统配置 `polling.ingest.mode`(`async` / `sync`)一键回退;发消息失败时降级为同步入库。 +- 测试环境 nsqd 改为 `--mem-queue-size=0`,消息全部落盘。 +- 后台人工刷新、运营商回调、激活前结算、观测序列保持同步入库。 +- 不在本 Change:死信表与重放、生产环境发布、按账户调度参数调整。 + +## Capabilities + +### New Capabilities + +- `polling-async-ingest`:轮询观测入库消息、入库消费者并发与重试、幂等与顺序、采集记录批量写、积压告警与反压、同步回退与降级。 + +### Modified Capabilities + +- `carrier-polling-scheduler`:卡任务的检查步骤改为只调网关并发入库消息;套餐步骤跳过依据改为入库段写的流量评估标记。 +- `card-traffic-metering`:采集记录写入改为经消息批量写、失败重试不丢,替代"写入失败只记告警"。 + +## Impact + +- 前置:`add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction`、`refactor-audit-log-persistence` 须先归档(本 Change 修改它们新增或修改的要求)。 +- 代码:`internal/task/polling_carddata_handler.go`、`polling_cardstatus_handler.go`、`polling_realname_handler.go`、`carrier_card_executor.go`、`polling_priority_claim.go`;`internal/application/cardobservation/`(`traffic.go`、`traffic_collection.go`、`network.go`、`apply.go`);`internal/polling/config_manager.go`、`tier.go`;采集队列 Lua;新增入库消费者、采集记录批量消费者与积压监控;`cmd/worker/carrier_scheduler.go`、`internal/polling/carrierqueue/dispatcher.go`(反压)。 +- 数据库:采集成功表、失败表新增 `(observation_id, observed_at)` 唯一索引(成对迁移)。 +- 配置:系统配置 `polling.ingest.mode`、`polling.ingest.alert_depth`、`polling.ingest.pause_depth`;启动配置 `polling.ingest.concurrency`、`polling.ingest.timeout`、`polling.collection.batch_size`。 +- Redis:`ingest:done:`(TTL 48h)、`flow_evaluated:`、有活动优先项的卡集合。 +- 部署:`docker-compose.prod.yml` 中 nsqd 改 `--mem-queue-size=0`。 +- 范围:基于 `Iteration/8-11`,只部署测试环境。 diff --git a/openspec/changes/add-polling-async-ingest/specs/card-traffic-metering/spec.md b/openspec/changes/add-polling-async-ingest/specs/card-traffic-metering/spec.md new file mode 100644 index 00000000..bc577d15 --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/specs/card-traffic-metering/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: 采集记录 + +系统 SHALL 把每次流量采集按结果分别写入两张表。 + +**成功表 `tb_card_traffic_collection`** + +- 只写读数结果为有效的采集。 +- 记录内容:采集时间、采集 ID、来源、场景、运营商账户、运营商原始读数、入队后的队列、写入判断(已写入、建起点、队列未满、读数下降、乱序、激活前结算用已有快照)。 +- 写入成功时,还记录换算后的本月累计、是否触发防旧值、本次增量与流量事件 ID。 +- 只为采集时刻有套餐的卡写成功记录;没有套餐的卡 MUST NOT 写成功记录。 + +**失败表 `tb_card_traffic_collection_failure`** + +- 写读数结果为失败、限流、响应无效、无读数、读数无效的采集。 +- 记录内容:采集时间、采集 ID、来源、场景、运营商账户、ICCID、失败类型、错误类别、上游返回码、上游返回信息(截断)、耗时。 +- 失败记录 MUST 为所有被采集的卡写入,不判断卡是否有套餐。 + +**原始数据** + +- 两张表都 SHALL 保存本次网关调用的原始数据:加密前的明文业务请求、HTTP 状态码、网关响应的 `code`、`msg`、`trace_id`、原始响应体,以及网关客户端内部重试次数。 +- 原始响应能解析为 JSON 时按 JSON 保存,否则按文本保存。超过 4 KB 时截断,并标记已截断。 +- 网关客户端内部发生过重试时,保存最后一次的请求与响应。 +- 系统 MUST NOT 保存加密后的报文、签名、应用标识、密钥或请求头。 + +**通用规则** + +- 采集 ID 等于对接日志的 `integration_id`,MUST 随流量事件与扣费审计传递,使扣费可追溯到对应采集记录。 +- 采集记录 SHALL 经 `card.collection` 消息由批量消费者写入;写入失败 MUST 重投重试、不丢弃,且不影响扣减与卡状态。 +- 同一采集 ID 与采集时间的记录 MUST 只保留一条,重复写入被忽略。 + +#### Scenario: 被队列拦下的采集 +- **WHEN** 有套餐的卡本次读数使队列出现下降 +- **THEN** 系统在成功表写一条采集记录,判断为读数下降,记录队列内容与原始响应,不写快照 + +#### Scenario: 从扣费追到采集 +- **WHEN** 维护者从一笔扣费排查 +- **THEN** 可经流量事件的采集 ID 找到成功表中对应的原始读数、队列与原始响应 + +#### Scenario: 没套餐的卡采集成功 +- **WHEN** 没套餐的卡被采集且读数有效 +- **THEN** 系统不写成功记录 + +#### Scenario: 没套餐的卡采集失败 +- **WHEN** 没套餐的卡查流量超时 +- **THEN** 系统在失败表写一条记录,失败类型为失败,错误类别为超时,HTTP 状态码为空 + +#### Scenario: 上游返回业务错误 +- **WHEN** 网关返回非成功的业务码 +- **THEN** 失败表记录上游返回码、返回信息、`trace_id` 与原始响应体 + +#### Scenario: 原始响应过长 +- **WHEN** 网关原始响应体超过 4 KB +- **THEN** 系统保存前 4 KB,并标记已截断 + +#### Scenario: 不保存敏感报文 +- **WHEN** 任一采集记录写入原始数据 +- **THEN** 记录中不含加密报文、签名、应用标识与密钥 + +#### Scenario: 采集记录写库失败 +- **WHEN** 批量写采集记录时数据库超时 +- **THEN** 这一批消息重投后重新写入,记录不丢失,卡的扣减与状态不受影响 + +#### Scenario: 采集记录重复投递 +- **WHEN** 同一条采集记录消息被投递两次 +- **THEN** 表中只保留一条该采集 ID 的记录 diff --git a/openspec/changes/add-polling-async-ingest/specs/carrier-polling-scheduler/spec.md b/openspec/changes/add-polling-async-ingest/specs/carrier-polling-scheduler/spec.md new file mode 100644 index 00000000..0af15c69 --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/specs/carrier-polling-scheduler/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: 卡任务串行执行检查步骤 + +系统 SHALL 对一张卡的一个卡任务按配置的步骤顺序(默认卡状态 → 流量 → 实名 → 套餐)串行执行本次已到期的检查类型。每步 MUST 有单独超时;某步失败只影响该步,后续步骤照常执行;限流等待超时时该步按短延迟重排且不计失败。卡任务总时长 MUST 不超过各步超时之和加余量,到达后取消剩余步骤并按失败收尾。同一张卡的不同检查类型 MUST NOT 并行执行。 + +入库模式为异步时,卡状态、流量、实名步骤 SHALL 只读取缓存、调用网关并发布入库消息,MUST NOT 在卡任务内写观测结果;步骤结果只反映网关调用是否成功,入库成败不计入该步的失败与退避。网关未返回有效结果的采集,步骤 SHALL 直接发布采集失败记录消息。套餐步骤 SHALL 依据入库段写入的流量评估标记决定是否跳过:标记存在时本轮套餐步骤按成功跳过。 + +#### Scenario: 流量步骤失败 +- **WHEN** 卡任务中流量查询超时 +- **THEN** 实名、套餐步骤照常执行,流量按退避间隔重试 + +#### Scenario: 同卡不同类型 +- **WHEN** 卡的状态检查与流量检查同时到期 +- **THEN** 系统在同一个卡任务中先查状态再查流量,不并行发起 + +#### Scenario: 网关成功后只发消息 +- **WHEN** 入库模式为异步,流量步骤拿到有效网关响应 +- **THEN** 步骤发布一条流量入库消息后按成功结束,不在卡任务内写日快照与采集记录 + +#### Scenario: 入库失败不影响步骤退避 +- **WHEN** 网关调用成功,但该消息入库时数据库超时 +- **THEN** 该卡的流量检查按成功计算下次到期时间,入库由入库消费者重试 + +#### Scenario: 流量已触发评估时跳过套餐步骤 +- **WHEN** 入库段处理流量消息后产生了流量增量并写入流量评估标记,标记有效期内该卡套餐检查到期 +- **THEN** 本轮套餐步骤按成功跳过 diff --git a/openspec/changes/add-polling-async-ingest/specs/polling-async-ingest/spec.md b/openspec/changes/add-polling-async-ingest/specs/polling-async-ingest/spec.md new file mode 100644 index 00000000..484bf5e4 --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/specs/polling-async-ingest/spec.md @@ -0,0 +1,131 @@ +## ADDED Requirements + +### Requirement: 轮询观测经消息入库 + +入库模式为异步时,系统 SHALL 把轮询的卡状态、流量、实名观测发布到 NSQ topic `card.ingest`,由入库消费者调用现有观测应用逻辑写库。消息 MUST 包含观测类型、卡 ID、运营商账户、ICCID、采集 ID、采集时间(网关返回时刻)、来源、场景、关联 ID、观测内容、网关原始数据、本轮认领的优先轮询项与发布时间。后台人工刷新、运营商回调、激活前结算与观测序列 MUST 保持同步入库。 + +#### Scenario: 流量观测入库 +- **WHEN** 入库消费者收到一条流量入库消息 +- **THEN** 系统按消息中的采集时间执行采集队列判断、日快照、扣减事件与通道阈值判断,结果与同步入库一致 + +#### Scenario: 人工刷新保持同步 +- **WHEN** 后台人工刷新一张卡的流量 +- **THEN** 系统同步写库并在接口中返回本次读数与写入结果,不经过入库消息 + +### Requirement: 入库消费者并发上限 + +系统 SHALL 以单独的消费者处理 `card.ingest`,每进程并发由启动配置 `polling.ingest.concurrency` 决定,默认 30,与运营商账户数量无关。单条消息的入库超时 SHALL 从消费者开始处理该消息时计时,默认 30 秒。启动时,入库并发与采集记录写并发之和 MUST 不超过数据库最大连接数减 20,超过时记录 ERROR。 + +#### Scenario: 账户增加不增加入库并发 +- **WHEN** 运营商账户从 41 个增加到 60 个 +- **THEN** 入库消费者同时处理的消息数仍不超过 30 + +#### Scenario: 并发配置超出连接预算 +- **WHEN** 数据库最大连接数为 60,入库并发配置为 50 +- **THEN** worker 启动时记录 ERROR,指出入库并发超出连接预算 + +### Requirement: 入库失败重试 + +入库遇到可重试错误(数据库超时、连接错误、锁等待超时)时,系统 SHALL 让 NSQ 延迟重投,退避依次为 5 秒、15 秒、30 秒、1 分钟、2 分钟,之后每 5 分钟一次,MUST NOT 设次数上限或丢弃消息。第 10 次及以后的每次重投 SHALL 记录 ERROR,包含采集 ID、卡 ID 与错误。业务性错误(卡不存在、观测数据校验失败)MUST NOT 重投,记录 WARN 后确认消息。本期不提供死信表。 + +#### Scenario: 数据库超时后重试成功 +- **WHEN** 一条流量入库消息因数据库超时失败 +- **THEN** 该消息 5 秒后重投,再次处理成功后写入日快照 + +#### Scenario: 长时间失败 +- **WHEN** 一条消息连续失败到第 10 次 +- **THEN** 系统记录 ERROR 并继续每 5 分钟重投,不丢弃 + +#### Scenario: 卡已删除 +- **WHEN** 入库时卡不存在 +- **THEN** 系统记录 WARN 并确认消息,不再重投 + +### Requirement: 入库幂等与顺序 + +系统 SHALL 保证入库消息乱序、重复投递时结果正确: + +- 入库成功后 SHALL 写入以采集 ID 为键的已处理标记(保留 48 小时);处理前发现已处理标记 MUST 直接确认消息。 +- 采集队列 MUST 按采集时间去重,同一采集时间的点只保留一个。 +- 晚到的旧流量观测按采集队列规则不在队尾时 MUST NOT 写日快照。 +- 网络状态与实名观测 MUST 只在采集时间晚于卡上最近一次已应用的同类观测时才改变卡状态。 +- 流量事件按采集 ID 生成稳定事件 ID,重复写入 MUST 被忽略。 + +#### Scenario: 同一消息投递两次 +- **WHEN** 一条已入库成功的流量消息被再次投递 +- **THEN** 系统发现已处理标记,直接确认,不重复扣减 + +#### Scenario: 提交后未写标记即崩溃 +- **WHEN** 入库事务已提交、写已处理标记前进程退出,消息被重投 +- **THEN** 重放不产生新的流量增量,流量事件不重复,采集记录只保留一条 + +#### Scenario: 网络状态乱序 +- **WHEN** 采集时间 10:00 的"停机"观测晚于 10:05 的"正常"观测到达 +- **THEN** 卡状态保持 10:05 的"正常" + +### Requirement: 入库段收尾动作 + +入库成功后,流量入库产生增量或跨月时,系统 SHALL 写入该卡的流量评估标记,有效期等于该卡的套餐检查间隔。本轮同时是优先执行(认领了优先轮询项)的观测 MUST 保持在卡任务内同步入库,优先项的完成与对接日志沿用原逻辑。网络观测命中风险停机或已销户时,卡任务 SHALL 按网关结果直接停止该卡轮询,入库段写库时关闭卡的轮询开关。 + +#### Scenario: 加急轮询保持同步 +- **WHEN** 异步模式下一张卡的流量步骤认领了优先轮询项 +- **THEN** 该次观测在卡任务内同步入库,入库成功后优先项被标记为完成 + +#### Scenario: 卡销户停止轮询 +- **WHEN** 异步模式下网络步骤查到卡已销户 +- **THEN** 卡任务把该卡移出运营商账户调度队列,入库段写库时把卡的轮询开关置为关闭 + +### Requirement: 采集记录批量写入 + +Worker 配置了 nsqd 时,系统 SHALL 不论入库模式,把采集成功记录与采集失败记录发布到 NSQ topic `card.collection`,由批量消费者写入采集成功表与失败表。批量消费者 SHALL 攒满 `polling.collection.batch_size`(默认 200)条或等待满 1 秒时写一次,写成功后统一确认,写失败时整批重投。成功表与失败表 MUST 以采集 ID 与采集时间建唯一索引,重复记录忽略。 + +#### Scenario: 攒批写入 +- **WHEN** 1 秒内收到 150 条采集记录消息 +- **THEN** 系统在 1 秒到达时一次写入这 150 条 + +#### Scenario: 网关失败直接写失败记录 +- **WHEN** 轮询步骤查流量时网关超时 +- **THEN** 步骤直接发布采集失败记录消息,不经过入库消费者 + +### Requirement: 轮询读查询走缓存 + +入库模式为异步时,轮询步骤调网关前 SHALL 从缓存读取卡信息、运营商实名开关与重置日、设备轮询开关与绑定关系;优先轮询项 SHALL 先查"有活动优先项的卡"集合,不在集合中时 MUST NOT 查库。配置匹配查询套餐状态时,每次查询 SHALL 单独计时;查询失败 MUST NOT 把"未知"写入缓存。 + +#### Scenario: 无优先项的卡 +- **WHEN** 一张没有活动优先项的卡执行流量步骤 +- **THEN** 系统不查询优先轮询项表 + +#### Scenario: 套餐状态查询失败 +- **WHEN** 配置匹配查询某卡套餐状态超时 +- **THEN** 本次按未知处理,缓存中不写入该卡结果,下次重新查询 + +### Requirement: 入库积压告警与反压 + +系统 SHALL 每分钟读取 `card.ingest` 与 `card.collection` 的积压量与最老消息等待时间并记录。积压量超过系统配置 `polling.ingest.alert_depth`(默认 200,000)或最老消息等待超过 10 分钟时 SHALL 记录 ERROR 告警。`card.ingest` 积压量超过 `polling.ingest.pause_depth`(默认 500,000)时,调度器 SHALL 暂停派发新的卡任务,积压降到该阈值一半以下时恢复。反压阈值 MUST 大于告警阈值。 + +#### Scenario: 积压告警 +- **WHEN** `card.ingest` 积压达到 200,001 条 +- **THEN** 系统记录 ERROR 告警,包含积压量与最老消息等待时间 + +#### Scenario: 反压暂停与恢复 +- **WHEN** `card.ingest` 积压超过 500,000 条 +- **THEN** 调度器暂停派发新卡任务,积压降到 250,000 条以下后恢复派发 + +### Requirement: 同步回退与发布失败降级 + +系统 SHALL 提供系统配置 `polling.ingest.mode`,取值 `async` 或 `sync`,默认 `sync`,修改后数秒内生效;为 `sync` 时轮询步骤 MUST 恢复在卡任务内同步入库。异步模式下发布入库消息或采集记录消息失败时,轮询步骤 SHALL 改为同步入库并记录 WARN,MUST NOT 丢弃本次观测。 + +#### Scenario: 切回同步 +- **WHEN** 运维把 `polling.ingest.mode` 改为 `sync` +- **THEN** 之后的轮询步骤在卡任务内同步写库,已在队列中的入库消息继续由入库消费者处理 + +#### Scenario: nsqd 不可用 +- **WHEN** 发布流量入库消息时 nsqd 连接失败 +- **THEN** 该步骤同步执行入库并记录 WARN + +### Requirement: 入库消息持久化 + +测试环境 nsqd SHALL 以 `--mem-queue-size=0` 运行,所有 topic 的消息直接写入磁盘数据目录,nsqd 重启后未确认的消息 MUST 仍可被消费。 + +#### Scenario: nsqd 重启 +- **WHEN** `card.ingest` 有 1,000 条未处理消息时 nsqd 容器重启 +- **THEN** 重启后这 1,000 条消息继续被入库消费者处理 diff --git a/openspec/changes/add-polling-async-ingest/tasks.md b/openspec/changes/add-polling-async-ingest/tasks.md new file mode 100644 index 00000000..cf741d0a --- /dev/null +++ b/openspec/changes/add-polling-async-ingest/tasks.md @@ -0,0 +1,70 @@ +## 1. 幂等与乱序基础 + +- [x] 1.1 采集队列 Lua 按 `observed_at` 去重(现有 Lua 已按同一采集时间去重,无需改动) +- [x] 1.2 `ApplyNetworkObservation` 增加采集时间早于 `last_card_status_check_at` 时不改状态的保护 +- [x] 1.3 `ApplyCardObservation` 增加采集时间早于 `last_real_name_check_at` 时不改状态的保护 +- [x] 1.4 迁移:采集成功表、失败表新增 `(observation_id, observed_at)` 唯一索引(成对 up/down) + +## 2. 配置 + +- [x] 2.1 启动配置 `polling.ingest.concurrency`(30)、`polling.ingest.timeout`(30s)、`polling.collection.batch_size`(200),含默认值、环境变量与校验 +- [x] 2.2 系统配置 `polling.ingest.mode`(async/sync)、`polling.ingest.alert_depth`(200000)、`polling.ingest.pause_depth`(500000,须大于告警阈值) +- [x] 2.3 启动连接预算检查:入库并发 + 1 > `MAX_OPEN_CONNS` − 20 时记 ERROR + +## 3. 采集记录批量写 + +- [x] 3.1 定义 `card.collection` 消息与发布器(成功、失败两种记录) +- [x] 3.2 批量消费者:缓冲按 200 条或 1 秒刷写,多行 INSERT `ON CONFLICT DO NOTHING`,成功统一 FIN、失败统一重投 +- [x] 3.3 `ApplyTrafficObservation` 采集记录改为发布消息,发布失败同步写;删除 `collectionRecordTimeout` 与"写失败只记告警" +- [ ] 3.4 测试环境验证:攒批、整批失败重投、重复记录忽略(项目自动化测试为 N/A,按 ENG-TEST-001 在测试环境验证) + +## 4. 入库消息与消费者 + +- [x] 4.1 定义 `card.ingest` 消息(三种观测、原始数据、优先项、发布时间)与编解码,补测试 +- [x] 4.2 入库消费者:已处理标记检查、按类型调用 `Apply*Observation`、成功写标记(48h) +- [x] 4.3 错误分类与重投:可重试错误按 5s/15s/30s/1m/2m/5m 延迟重投、不限次数,第 10 次起记 ERROR;业务性错误记 WARN 后 FIN +- [x] 4.4 收尾归属:优先执行保持同步入库(优先项完成、对接日志沿用原逻辑);`StopPolling` 由步骤按网关结果移出调度;入库段写 `flow_evaluated:` +- [x] 4.5 在 `cmd/worker/carrier_scheduler.go` 注册入库与采集记录消费者(`MaxAttempts=0`),随 worker 优雅退出 +- [ ] 4.6 测试环境验证:重复投递、提交后未写标记重放、卡不存在、可重试错误重投 + +## 5. 轮询步骤改造 + +- [x] 5.1 流量步骤:异步模式下网关成功后发布入库消息即成功返回;网关失败直接发布采集失败记录 +- [x] 5.2 卡状态步骤、实名步骤:异步模式下发布入库消息 +- [x] 5.3 发布失败降级为同步入库并记 WARN;`sync` 模式走原逻辑 +- [x] 5.4 套餐步骤跳过依据改为读 `flow_evaluated:` +- [ ] 5.5 测试环境验证:异步成功、发布失败降级、同步模式行为不变、套餐步骤跳过 + +## 6. 读查询缓存 + +- [x] 6.1 步骤读卡改为读卡缓存(去掉每步先清缓存再查库);入库段提交后失效缓存 +- [x] 6.2 有活动优先项的卡集合:入队加、终态删、启动全量重建;步骤先查集合 +- [x] 6.3 运营商实名开关、重置日进程内缓存 1 分钟;设备轮询开关与绑定关系并入卡缓存 +- [x] 6.4 `lazyTierFacts` 每次查询单独计时;`Prefill` 失败的卡不写缓存 + +## 7. 积压监控与反压 + +- [x] 7.1 leader 每分钟读 nsqd stats,记录 `card.ingest`、`card.collection` 积压与最老等待时间 +- [x] 7.2 超过告警阈值或等待 > 10 分钟记 ERROR +- [x] 7.3 超过反压阈值置暂停标记,分发器暂停领取新卡;低于一半恢复 + +## 8. 部署(测试环境) + +- [x] 8.1 `docker-compose.prod.yml` nsqd 参数改 `--mem-queue-size=0` +- [ ] 8.2 `go build ./...`、相关包测试、OpenSpec `--strict` 校验通过 +- [ ] 8.3 提交并推送 `Iteration/8-11`(自动部署测试环境),`polling.ingest.mode` 先保持 `sync` +- [ ] 8.4 测试库执行唯一索引迁移(用户授权后) + +## 9. 测试环境验证 + +- [ ] 9.1 切 `async` 后 1 小时:`context deadline exceeded` 从每小时约 8,000 次降到个位数 +- [ ] 9.2 慢查询告警与 worker 日志量明显下降 +- [ ] 9.3 采集记录对账:入库消息数 = 成功表 + 失败表新增行数 +- [ ] 9.4 抽样卡日快照、扣减与同步模式一致;重投与乱序不重复扣减 +- [ ] 9.5 `card.ingest` 常态等待 < 30 秒 +- [ ] 9.6 切回 `sync` 行为恢复;停 nsqd 时步骤降级同步入库 +- [ ] 9.7 nsqd 重启后未处理消息继续消费 + +## 10. 归档 + +- [ ] 10.1 `add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction`、`refactor-audit-log-persistence` 归档后再归档本 Change diff --git a/pkg/config/config.go b/pkg/config/config.go index 05b33759..33a1fb29 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -204,6 +204,51 @@ type PollingConfig struct { VerboseLog bool `mapstructure:"verbose_log"` // Carrier 按运营商账户调度(add-carrier-polling-scheduler)的部署参数;调度模式本身放受控系统配置 polling.scheduler.mode。 Carrier CarrierSchedulerConfig `mapstructure:"carrier"` + // Ingest 轮询观测入库消费者参数(add-polling-async-ingest);入库模式本身放受控系统配置 polling.ingest.mode。 + Ingest PollingIngestConfig `mapstructure:"ingest"` + // Collection 流量采集记录批量写入参数。 + Collection PollingCollectionConfig `mapstructure:"collection"` +} + +// PollingIngestConfig 轮询观测入库消费者的启动参数。 +type PollingIngestConfig struct { + // Concurrency 每进程入库消费者并发(card.ingest 的 max_in_flight),默认 30。 + // 环境变量:JUNHONG_POLLING_INGEST_CONCURRENCY + Concurrency int `mapstructure:"concurrency"` + // Timeout 单条入库消息的处理超时,从消费者开始处理时计时,默认 30s。 + // 环境变量:JUNHONG_POLLING_INGEST_TIMEOUT + Timeout time.Duration `mapstructure:"timeout"` +} + +// PollingCollectionConfig 流量采集记录批量写入参数。 +type PollingCollectionConfig struct { + // BatchSize 每批写入的采集记录条数上限,默认 200;不足时每 1 秒写一次。 + // 环境变量:JUNHONG_POLLING_COLLECTION_BATCH_SIZE + BatchSize int `mapstructure:"batch_size"` +} + +// EffectiveConcurrency 返回入库消费者并发,未配置时取默认值。 +func (c PollingIngestConfig) EffectiveConcurrency() int { + if c.Concurrency > 0 { + return c.Concurrency + } + return constants.PollingIngestDefaultConcurrency +} + +// EffectiveTimeout 返回单条入库超时,未配置时取默认值。 +func (c PollingIngestConfig) EffectiveTimeout() time.Duration { + if c.Timeout > 0 { + return c.Timeout + } + return constants.PollingIngestDefaultTimeout +} + +// EffectiveBatchSize 返回采集记录每批写入条数,未配置时取默认值。 +func (c PollingCollectionConfig) EffectiveBatchSize() int { + if c.BatchSize > 0 { + return c.BatchSize + } + return constants.PollingCollectionDefaultBatchSize } // CarrierSchedulerConfig 按运营商账户调度的 yaml 参数。 @@ -430,6 +475,13 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid configuration: worker.polling_total_max_concurrency: must be 1-%d (current value: %d)", constants.PollingMaxConcurrencyLimit, c.Worker.PollingTotalMaxConcurrency) } + if c.Polling.Ingest.Concurrency < 0 || c.Polling.Ingest.Concurrency > constants.PollingMaxConcurrencyLimit { + return fmt.Errorf("invalid configuration: polling.ingest.concurrency: must be 0-%d (current value: %d)", constants.PollingMaxConcurrencyLimit, c.Polling.Ingest.Concurrency) + } + if c.Polling.Collection.BatchSize < 0 || c.Polling.Collection.BatchSize > constants.PollingCollectionMaxInFlight { + return fmt.Errorf("invalid configuration: polling.collection.batch_size: must be 0-%d (current value: %d)", constants.PollingCollectionMaxInFlight, c.Polling.Collection.BatchSize) + } + if !validWorkerRoles[c.Worker.Role] { return fmt.Errorf( "invalid configuration: worker.role: invalid worker role (current value: %s, expected: %s, %s, %s)", diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 06c0fa25..198a870c 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -146,6 +146,12 @@ polling: default_inflight_limit: 20 # 未配置账户的执行上限(跨实例) default_card_max_in_flight: 20 # 未配置账户的每进程 max_in_flight subscriber_processes: 1 # 订阅卡任务的 Worker 进程数(校验执行上限用) + # 轮询观测入库(入库模式在受控系统配置 polling.ingest.mode 切换:sync / async) + ingest: + concurrency: 30 # 每进程入库消费者并发(环境变量:JUNHONG_POLLING_INGEST_CONCURRENCY) + timeout: 30s # 单条入库超时(环境变量:JUNHONG_POLLING_INGEST_TIMEOUT) + collection: + batch_size: 200 # 采集记录每批写入条数(环境变量:JUNHONG_POLLING_COLLECTION_BATCH_SIZE) # 轮询自动触发配置 polling_auto_trigger: diff --git a/pkg/config/loader.go b/pkg/config/loader.go index b8ba91fc..141fd477 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -135,6 +135,9 @@ func bindEnvVariables(v *viper.Viper) { "polling.carrier.nsqd_address", "polling.carrier.nsqlookupd_addresses", "polling.carrier.subscriber_processes", + "polling.ingest.concurrency", + "polling.ingest.timeout", + "polling.collection.batch_size", "polling_auto_trigger.enable_auto_trigger", "polling_auto_trigger.auto_trigger_system_user_id", "worker.role", diff --git a/pkg/constants/polling_ingest.go b/pkg/constants/polling_ingest.go new file mode 100644 index 00000000..aadac637 --- /dev/null +++ b/pkg/constants/polling_ingest.go @@ -0,0 +1,89 @@ +package constants + +import ( + "fmt" + "time" +) + +// 轮询观测入库异步化(add-polling-async-ingest):调网关与入库分离,入库单独发消息、单独消费。 + +// 入库 NSQ topic。 +const ( + // NSQCardIngestTopic 轮询观测入库消息(卡状态、流量、实名)。 + NSQCardIngestTopic = "card.ingest" + // NSQCardCollectionTopic 流量采集记录消息(成功表与失败表),由批量消费者攒批写入。 + NSQCardCollectionTopic = "card.collection" +) + +// 入库模式(受控系统配置 polling.ingest.mode)。 +const ( + PollingIngestModeSync = "sync" // 轮询步骤在卡任务内同步入库(回退模式) + PollingIngestModeAsync = "async" // 轮询步骤只调网关并发布入库消息 +) + +// 入库相关受控系统配置键(module = polling)。 +const ( + SystemConfigPollingIngestMode = "polling.ingest.mode" + SystemConfigPollingIngestAlertDepth = "polling.ingest.alert_depth" + SystemConfigPollingIngestPauseDepth = "polling.ingest.pause_depth" +) + +// 入库运行默认值。 +const ( + PollingIngestDefaultConcurrency = 30 + PollingIngestDefaultTimeout = 30 * time.Second + PollingCollectionDefaultBatchSize = 200 + PollingCollectionFlushInterval = time.Second + PollingCollectionMaxInFlight = 500 + PollingIngestDefaultAlertDepth = 200000 + PollingIngestDefaultPauseDepth = 500000 + PollingIngestDoneTTL = 48 * time.Hour + PollingIngestErrorLogFromAttempt = 10 + PollingIngestLagAlert = 10 * time.Minute + PollingIngestMonitorInterval = time.Minute + PollingIngestConnReserve = 20 + PollingIngestCollectionWriters = 1 + PollingFlowEvaluatedFallbackTTL = 30 * time.Minute + PollingPriorityIndexRebuildEvery = 30 * time.Second + PollingPriorityIndexRecentTTL = 2 * time.Minute + PollingPriorityIndexReadyTTL = 2 * time.Minute + PollingScopeCacheTTL = 30 * time.Second + PollingCarrierResetDayCacheTTL = time.Minute + PollingTierQueryTimeout = 2 * time.Second + PollingIngestSyncCollectionTimeout = 10 * time.Second +) + +// PollingIngestRetryDelays 是入库可重试错误的延迟重投序列;超过序列长度后取最后一项,不设次数上限。 +var PollingIngestRetryDelays = []time.Duration{ + 5 * time.Second, 15 * time.Second, 30 * time.Second, time.Minute, 2 * time.Minute, 5 * time.Minute, +} + +// RedisPollingIngestDoneKey 入库已处理标记(String,TTL 48h),按采集 ID 去重。 +func RedisPollingIngestDoneKey(observationID string) string { + return "polling:ingest:done:" + observationID +} + +// RedisPollingFlowEvaluatedKey 流量入库产生增量或跨月后的评估标记(String,TTL=套餐检查间隔),套餐步骤据此跳过。 +func RedisPollingFlowEvaluatedKey(cardID uint) string { + return fmt.Sprintf("polling:flow_evaluated:%d", cardID) +} + +// RedisPollingIngestPausedKey 入库积压超过反压阈值时置位(String),分发器暂停领取新卡任务。 +const RedisPollingIngestPausedKey = "polling:ingest:paused" + +// 有活动优先轮询项的卡索引:member = "card_id:task_type"。 +const ( + // RedisPollingPriorityActiveKey 活动优先项索引(SET)。 + RedisPollingPriorityActiveKey = "polling:priority:active" + // RedisPollingPriorityRecentKey 最近入队的优先项(SET,短 TTL),重建索引时并入,避免重建期间漏掉新项。 + RedisPollingPriorityRecentKey = "polling:priority:recent" + // RedisPollingPriorityReadyKey 索引已由重建任务建立(String,TTL);不存在时轮询步骤回退查库。 + RedisPollingPriorityReadyKey = "polling:priority:ready" + // RedisPollingPriorityRebuildKey 重建过程使用的临时 key。 + RedisPollingPriorityRebuildKey = "polling:priority:active:rebuild" +) + +// PollingPriorityIndexMember 返回活动优先项索引成员。 +func PollingPriorityIndexMember(cardID uint, taskType string) string { + return fmt.Sprintf("%d:%s", cardID, taskType) +}