355 lines
32 KiB
Markdown
355 lines
32 KiB
Markdown
# CMPP 发送链路重新设计方案
|
||
|
||
更新日期:2026-08-25
|
||
状态:已在测试环境完成实施与正价50 TPS验收
|
||
适用范围:耐久 Inbox 完成业务校验后,到 Gateway 供应商 Submit、结果回调、回执和计费结算的完整链路
|
||
|
||
## 1. 结论
|
||
|
||
现阶段不应继续在 BullMQ 发送 Worker 内做小幅并发、缓存或微批补丁。已完成的耐久 Inbox、入口幂等、路由/报备查询收敛、开放会话热点移除、无消费者 BullMQ 副本移除和计费锁缩短应保留。
|
||
|
||
若以后继续追求完整供应商提交 100~500 条/秒,建议作为独立项目,重新设计为四个隔离阶段:
|
||
|
||
1. Inbox 业务处理与计费预留;
|
||
2. 批量路由规划与提交事实持久化;
|
||
3. PostgreSQL Submit Outbox 批量发布;
|
||
4. 独立的提交结果、回执、计费结算与任务进度回调。
|
||
|
||
核心不是把若干 BullMQ Job 临时拼成数组,而是把“应提交什么”先作为 PostgreSQL 事实一次性落库,再由无业务判断的发布器向 Gateway 供给命令。这样才能同时减少数据库往返、消除数据库到 Redis 的崩溃窗口,并隔离供应商提交与回调写入的资源竞争。
|
||
|
||
## 2. 原方案中应保留的部分
|
||
|
||
- 正价测试使用 325 分而不是 0 单价,暴露了真实计费锁竞争;
|
||
- `pg_stat_statements` 和固定低基数阶段指标能区分入口、Worker、Gateway 与回调瓶颈;
|
||
- 路由、在线连接和签名报备合并查询,取消最终通道重复报备查询;
|
||
- CMPP 单号码内部任务不再逐消息执行整批状态 `GROUP BY`;
|
||
- 提交事务不再更新 `CmppSubmitSession.submitTotal` 热点行;
|
||
- 删除没有消费者的 `gateway.submit.queue` BullMQ 同步副本,保留 Gateway 实际消费的 Redis Stream;
|
||
- 账户 advisory lock 移到事务末尾账务段后,平均等待由约 443.963ms 降至约 5.420ms;
|
||
- 验收以完整供应商提交、队列排空、消息唯一性和账务恒等式为准,没有用入口 SubmitResp 冒充完整吞吐。
|
||
|
||
这些改动降低了单消息成本和锁等待,但没有改变发送链的逐消息架构,因此未把完整供应商吞吐提升到目标档位。
|
||
|
||
## 3. 原方案存在的问题
|
||
|
||
### 3.1 微批位置选错
|
||
|
||
原 P2 在 BullMQ Worker 已领取单个 Job 后,用 5ms 窗口聚合最多 20 条消息。这个位置太晚:任务领取、调度和并发槽已经发生,批次规模还受 Worker 并发上限约束,无法稳定形成 32/64 条批次。
|
||
|
||
批内慢消息还会形成最慢项栅栏,使本来可以独立完成的消息互相等待。实测微批完整供应商提交约 22.20 条/秒,未优于对照,证明这不是正确的批处理边界。
|
||
|
||
### 3.2 只批量加载,没有批量处理主成本
|
||
|
||
原微批只合并了消息读取,运营商识别、路由、在线/报备判断、`SmsSubmitRecord` 创建、消息更新、Stream 发布、结果/回执/计费和任务进度仍逐消息执行。省下一个 `WHERE id IN (...)` 查询不足以抵消批次协调开销。
|
||
|
||
### 3.3 PostgreSQL 与 Redis 仍不是原子边界
|
||
|
||
当前链路先提交 PostgreSQL,再发布 Redis Stream。进程若在两步之间退出,会留下数据库已进入待提交状态、但 Gateway 未收到命令的窗口。若 Redis 已成功而数据库未记录发布结果,重试又可能重复发布。
|
||
|
||
Redis pipeline 只能减少网络往返,不能解决跨介质一致性。必须增加 PostgreSQL Outbox,并以稳定 `submitId`、命令幂等键和 Gateway 去重实现至少一次发布。
|
||
|
||
### 3.4 提交供给与回调写入争用资源
|
||
|
||
供应商 Submit 结果、分片结果、最终回执、下游投递和计费结算会同时写 PostgreSQL。回调越快,数据库写入越密集,越容易抢占发送 Worker 的连接和 CPU。仅提高 Worker 并发只会把等待转移到数据库池。
|
||
|
||
### 3.5 实时业务事实不能用长期缓存替代
|
||
|
||
通道启停、真实连接、签名报备、应用/企业状态、余额和号码频控都可能变化。正确做法是批次内共享只读快照,并在提交事实落库前用数据库条件最终校验;不能建立跨批次长期缓存决定是否发送。
|
||
|
||
### 3.6 压测口径和夹具容易误导
|
||
|
||
入口接收、Inbox 完成、供应商首次 Submit 和最终回执是不同阶段。入口 100 条/秒成功,不代表完整供应商提交达到 100 条/秒。历史待投递回执、重复号码频控、缺失运营商规则,以及模拟器重启后数据库连接状态尚未恢复,都会污染结论。
|
||
|
||
## 4. 目标架构
|
||
|
||
### 4.1 阶段 A:Inbox 与业务预留
|
||
|
||
保留现有耐久 Inbox。业务 Worker 批量领取时继续执行应用、企业、签名、模板、黑名单、日限额、号码频控和正价余额预留。
|
||
|
||
- 使用 `FOR UPDATE SKIP LOCKED` 在短事务内领取 32 条,验证稳定后最多 64 条;
|
||
- 领取事务只更新租约,业务查询和外部操作不放在持锁事务内;
|
||
- 日限、频控和冻结继续保留逐消息稳定业务键及数据库唯一约束;
|
||
- 同企业账户锁只覆盖最终余额与流水写入。
|
||
|
||
### 4.2 阶段 B:批量路由规划与提交事实
|
||
|
||
按应用、运营商、签名组合批量读取候选,在一个短事务内为每条可发送消息持久化:
|
||
|
||
- 独立 `SmsSubmitRecord`;
|
||
- 目标通道、`submitId` 和消息 `submit_queued` 状态;
|
||
- 一条 `GatewaySubmitOutbox` 命令事实。
|
||
|
||
事务提交前,SQL 再次约束应用/企业 active、通道 active、连接 connected、签名报备 approved。任一消息失效只拒绝该消息,不回滚整批其他消息。供应商网络调用、Redis 写入和任务进度聚合都不进入该事务。
|
||
|
||
### 4.3 阶段 C:PostgreSQL Submit Outbox
|
||
|
||
建议新增表:
|
||
|
||
```text
|
||
GatewaySubmitOutbox
|
||
- id / submitId(唯一)
|
||
- messageRecordId / channelId
|
||
- payload / schemaVersion
|
||
- status: pending | publishing | published | dead
|
||
- attemptCount / nextAttemptAt
|
||
- leaseOwner / leaseExpiresAt
|
||
- streamEntryId / publishedAt / lastError
|
||
- createdAt / updatedAt
|
||
```
|
||
|
||
发布器只做:
|
||
|
||
1. 用 `FOR UPDATE SKIP LOCKED` 原子领取一批 pending 行;
|
||
2. 使用 Redis pipeline `XADD`,每条命令携带唯一 `submitId`;
|
||
3. 批量回写 published 或可重试失败。
|
||
|
||
“Redis 已成功、PostgreSQL 回写前崩溃”会产生重复发布,因此 Gateway 必须按 `submitId` 去重,结果事件继续按 `resultEventId` 幂等。Outbox 扫描器负责恢复数据库已提交但未发布的命令。
|
||
|
||
### 4.4 阶段 D:回调与计费隔离
|
||
|
||
提交结果、回执和下游投递使用独立回调进程/连接池:
|
||
|
||
- 单分片只处理聚合结果,多分片保留逐片事实与最终聚合;
|
||
- accepted 结算复用冻结事实,保持 released/charged 幂等且净余额正确;
|
||
- rejected/timeout 以唯一 `retryOfSubmitRecordId` 认领组内补发;
|
||
- 最终失败退款使用稳定幂等键;
|
||
- 任务进度从消息事实异步刷新,CMPP 单消息任务继续直接写计数;
|
||
- 回调积压不得阻塞 Outbox 向 Gateway 供给命令。
|
||
|
||
### 4.5 连接池预算
|
||
|
||
| 角色 | 建议初始数据库槽 | 说明 |
|
||
| --- | ---: | --- |
|
||
| API/入口 | 16~24 | 保证客户 Submit 和查询接口可用 |
|
||
| Inbox/路由规划 | 16~24 | 批量查询和短事务持久化 |
|
||
| 结果/回执/计费 | 12~16 | 吸收供应商回调峰值 |
|
||
| Outbox 发布器 | 4~8 | 只领取、发布和回写 |
|
||
| 运维/迁移保留 | 不少于 8 | 健康检查、诊断和恢复 |
|
||
|
||
实际值必须结合 PostgreSQL CPU、`max_connections`、PgBouncer 模式和 `pg_stat_statements` 复测,不能把表中数字直接当生产配置。
|
||
|
||
## 5. 实施步骤
|
||
|
||
### 第 0 步:固定基线和测试夹具
|
||
|
||
固定专用号段、三运营商比例、隔离应用和六供应商账号;每次故障注入后同时确认模拟器 6/6 和数据库六通道 connected;建立 PostgreSQL、运行源码和环境/systemd 恢复资产。
|
||
|
||
### 第 1 步:Outbox 迁移与影子写入
|
||
|
||
只新增表、索引和指标;原 Stream 发布仍生效,同时影子写 Outbox 但不发布;对比每个 `submitId` 的原命令与影子 payload。回退只需关闭影子写,不删除历史表。
|
||
|
||
### 第 2 步:Outbox 发布器影子验证
|
||
|
||
发布到不连接真实 Gateway 的影子 Stream,验证领取、租约恢复、重复发布、死信和批量大小;做发布前/后崩溃注入,证明可恢复且幂等。
|
||
|
||
### 第 3 步:单应用灰度切换
|
||
|
||
一个隔离应用切换到正式 Outbox;保留旧直接发布回退开关,但同一消息任何时刻只能有一个发布路径;先做正价 smoke 和全部发送拦截/补发回归。
|
||
|
||
### 第 4 步:批量路由与持久化
|
||
|
||
初始批量 32,按应用、运营商、签名共享查询;批量插入 Submit/Outbox,逐消息保留唯一键和失败结果;使用 `EXPLAIN (ANALYZE, BUFFERS)` 与 `pg_stat_statements` 验证真实 SQL。
|
||
|
||
### 第 5 步:回调与连接池隔离
|
||
|
||
拆分 Submit/Receipt 回调连接池;集合式处理可安全合并的流水、账单和任务进度,同时保留逐消息幂等键和确定账务顺序。
|
||
|
||
### 第 6 步:容量验收
|
||
|
||
按 `smoke → 20 → 30 → 50 → 100 → 200 → 300 → 500` 逐档执行。每档完全排空后再升档;只有完整供应商提交达到目标、无丢重、账务一致且数据库稳定才继续。
|
||
|
||
## 6. 功能开关与回退
|
||
|
||
建议提供:
|
||
|
||
- `SEND_SUBMIT_OUTBOX_SHADOW_ENABLED`;
|
||
- `SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED`;
|
||
- `SEND_ROUTE_BATCH_ENABLED`;
|
||
- `SEND_ROUTE_BATCH_SIZE`;
|
||
- `SEND_CALLBACK_POOL_ENABLED`。
|
||
|
||
回退时先停止新领取,等待在途事务完成,关闭正式 Outbox 发布并恢复旧直接发布;按 `submitId` 对账 Outbox 与 Stream PEL,不能删除 Outbox 行或清空 Redis 来回退。
|
||
|
||
## 7. 验收标准
|
||
|
||
- 客户 SubmitResp 零丢失,MessageId 与业务号码唯一;
|
||
- Inbox、Submit Outbox、Gateway/结果 Stream 和 BullMQ 最终排空;
|
||
- 每条消息最多一个有效首次 Submit,补发必须关联原提交;
|
||
- 供应商完整提交速率达到该档目标,不只看入口速率;
|
||
- 签名、模板、余额、应用/企业状态、号码频次、报备和通道停用继续实时拦截;
|
||
- 冻结、释放、扣费、退款和 `SmsBillingRecord` 净额一致;
|
||
- 无持续锁等待、无 idle in transaction、连接数不超过预算;
|
||
- 服务重启和 Outbox 重放不重复提交、不重复计费。
|
||
|
||
## 8. 风险与工作量判断
|
||
|
||
这是跨 Prisma migration、发送状态机、Gateway 命令协议、回调处理、部署配置和压测工具的架构改造,不适合作为当前阶段的继续小修。主要风险是双发布、Outbox 重放重复提交、补发认领冲突、实时通道状态过期和账务顺序错误。
|
||
|
||
建议以后单独立项,先完成 Outbox 影子对账和崩溃恢复,再进入批量路由。没有完成影子验证前,不应直接替换现有正式发布路径。
|
||
|
||
## 9. 2026-08-25 完整实施结果
|
||
|
||
- `GatewaySubmitOutbox`、独立发布器和正式发布路径已完成;发送Worker进一步实现最大32条、3ms聚合窗的有界批次。批内一次加载消息、号段、路由/连接及签名报备事实,按通道执行限速,再以一个短事务`createMany`写Submit、集合式更新消息并`createMany`写Outbox。重试及异常分支保留逐消息状态机和幂等键。
|
||
- Submit结果、分片结果、回执、上行、受限协议日志和死信改由仅绑定`127.0.0.1:3001`的`cmpp-gateway-callback`进程接收,使用独立12槽PostgreSQL连接池;指标绑定`127.0.0.1:9468`。客户HTTP Webhook仍由主API的独立BullMQ Worker处理,避免慢客户回调占用Gateway事实回写池。连接状态控制仍写主API,供应商事件才写回调进程。
|
||
- 初次发布smoke暴露并修复了回调URL职责过宽问题:连接状态误发到回调端点导致数据库显示`connecting`、路由失败。修复为控制面/事件面双URL后,六连接均为`connected`;该失败样本未发生供应商提交或计费,不纳入性能结果。
|
||
- 修复后正价smoke为9/9受理,账单`9×325=2925`;20 TPS为199/199、账单64675;30 TPS为299/299、账单97175;50 TPS为499/499、账单162175。四个成功窗口共1006条,账单1006笔、单价均325、合计326950。
|
||
- 50 TPS档499条非补发首次供应商Submit覆盖9.930秒,即`50.25条/秒`;相对同日改造前Outbox阶段的`33.07条/秒`提高约52%。该档客户端P50/P95/P99为35/76/135ms;首提499条唯一,全部尝试及Outbox各542条唯一,补发均关联原提交。
|
||
- 压测结束Inbox、Submit Outbox和两条Redis Stream全部排空,数据库无重复Submit ID、无等待锁、无idle in transaction;回调池`max=12,total=1,idle=1,waiting=0`。隔离应用单价已恢复0,三条临时运营商规则已删除,真实账务事实保留审计。100/200/300/500未继续执行,当前验收结论限定为50 TPS档通过。
|
||
|
||
## 10. 长短信分段回执并发整改方案(2026-09-16,待实施)
|
||
|
||
### 10.1 目的、证据和适用范围
|
||
|
||
业务解释:一条长短信有多段回执,多个处理流程同时发现整条短信已经有结果,重复安排通知或补发。现有数据库唯一键能阻止重复创建,但不能避免此前重复查询、选路和事务开销。整改目标是“先认领处理资格,再做业务动作;中途退出后可以安全接续”。
|
||
|
||
依据:[预生产只读诊断](cpu-diagnosis-20260916.md)。2026-09-16 09:20~09:40,12682次下游回执唯一键冲突关联6335条长短信;1862次补发唯一键冲突关联1859个长短信原始提交。下游冲突关联消息的13373条Inbox均matched且attemptCount=1;补发6组抽样显示不同分段失败回执同时到达并争抢创建补发。核心回执、补发、提交文件在d13ca07至cbc4a03之间无变化。
|
||
|
||
缺陷有两层:单个receiptKey去重不能覆盖不同分段;补发“先查询、后选路、再创建”的流程存在并发窗口。CPU峰值包含I/O等待,缺少业务进程历史CPU与慢SQL,不能承诺消除此缺陷即可消除全部CPU高峰。
|
||
|
||
本节是现有发送链路的专项补充,作为本次并发整改的设计依据;细化第4节结果处理及第7节不重不漏验收要求,不替代路由、计费、长短信协议或历史容量结论。不扩展到批次编号冲突、通道容量改造或历史短信重发。用户本轮仅要求方案文档,以下模型、状态、参数均是拟实施设计。
|
||
|
||
### 10.2 必须保持的业务规则
|
||
|
||
1. 原始回执逐事件持久化,同一事件幂等;不同分段的合法回执不能当成重复而丢弃。
|
||
2. 成功依现有通道回执模式汇总:per_segment须齐段成功,message_level保持既有整条成功语义。不得为降低冲突把第一段成功视为整条成功。
|
||
3. 同一次有效发送的失败只能触发一份补发决策;是否允许补发仍检查当前应用、通道组、报备、风控、时间上限等既有规则。unknown不触发补发,72小时及组内时限规则保持。
|
||
4. 旧尝试的迟到回执保留审计,不覆盖后续有效尝试的最终成功,不再次触发旧尝试补发或退款。矛盾回执按既有规则记录异常,不采用“谁抢到锁谁决定事实”。
|
||
5. 补发不是消息最终失败;仍有有效补发时不得提前生成业务最终失败通知或最终退款。
|
||
6. HTTP回调保持业务消息维度;CMPP回执按客户原始分段及Registered_Delivery要求生成。内部上游分段与客户分段不能混用。
|
||
7. 现有提交、回执、补发、投递和账务唯一约束全部保留;计费单位、价格、扣退费条件及租户权限不变。
|
||
8. 对外投递仍是可重试的至少一次交付,不能承诺网络侧绝对只收到一次;整改保证同一逻辑通知仅创建一份耐久投递事实,重试沿用其身份。
|
||
|
||
### 10.3 影响模块与最小实施边界
|
||
|
||
| 所有者 | 拟修改内容 |
|
||
|---|---|
|
||
| send-receipt.service.ts、send-chain.helpers.ts | 保存分段后提交汇总工作;按当前持久化事实重新汇总和校验有效尝试,移出重复终态副作用 |
|
||
| send-retry.service.ts、send-gateway-submit.service.ts | 统一补发处理资格,认领后选路,事务内验证资格并创建Submit与GatewaySubmitOutbox |
|
||
| send-gateway-result.service.ts、send-timeout.service.ts、send-downstream-delivery.service.ts | 清查提交失败、超时、平台失败等旁路,接入同一业务消息终态协调,不留下与回执处理竞争的独立写入口 |
|
||
| send-accounting.service.ts、send-completion.service.ts | 核实稳定账务键和事务接口;重复恢复不重复扣退,不能用已完成标记替代实际账务事实 |
|
||
| downstream-receipt-targets.ts、HTTP Webhook生产端 | 保持目标分段契约,幂等落库后再独立投递,重复命中不直接再次发送 |
|
||
| Prisma、回调进程恢复扫描、定向与集成测试 | 增量状态模型、认领/恢复索引、有限批次消费与故障注入 |
|
||
|
||
预期主要为API发送链路和数据库迁移;不新增页面、不改变客户HTTP参数、签名算法、错误码和CMPP协议。Gateway命令契约预期不变,若实现必须变更,先补契约及Go测试,不能隐式扩大范围。需检查全部调用者而非只在回执方法外套进程内锁。
|
||
|
||
### 10.4 拟增数据模型与认领规则
|
||
|
||
采用PostgreSQL耐久工作记录和短事务条件更新,避免仅靠进程内锁或Redis短期锁。拟新增 `SmsAttemptCompletionWork`,每个sourceSubmitRecordId唯一;该名称及字段在实施迁移前与现有模型再次核对。无供应商Submit的业务拒绝使用独立的稳定事件键,不伪造Submit ID。
|
||
|
||
| 字段 | 含义及约束 |
|
||
|---|---|
|
||
| id、workKey | 主键及唯一工作键;有Submit时固定为attempt:<sourceSubmitRecordId> |
|
||
| tenantId、messageRecordId、sourceSubmitRecordId | 与真实记录归属一致;不信任外部回调传入的租户或消息归属 |
|
||
| revision、processedRevision | 收到新的有效事实递增revision;完成时只确认已处理版本,避免并发新回执被覆盖 |
|
||
| state | pending、processing、retry_wait、idle、needs_review;idle表示当前事实已处理,不等于永不再接收新事实 |
|
||
| leaseOwner、leaseUntil、fenceVersion | 租约及递增认领版本;旧持有者不能提交后续业务动作 |
|
||
| decision、retrySubmitRecordId | 本次已提交决策及已有补发引用;补发一旦创建,不因恢复或后续分段重新选路创建 |
|
||
| attempts、nextAttemptAt、lastError、createdAt、updatedAt | 恢复、退避和诊断信息;错误脱敏,不复制短信正文或凭据 |
|
||
|
||
索引:唯一workKey、非空sourceSubmitRecordId唯一;按state/nextAttemptAt及state/leaseUntil支持有界扫描;messageRecordId支持对账。不按手机号或高基数业务ID创建监控标签。
|
||
|
||
认领以数据库时间为准:pending、到期retry_wait或租约过期processing可由条件更新取得;认领成功递增fenceVersion。建议初始租约60秒、每20秒续租、每次扫描不超过32条,作为测试起点而非容量结论。连接池预算、扫描间隔及退避上限须通过隔离环境实测定稿。
|
||
|
||
每次持久化副作用都在短事务中锁定工作记录并核对认领版本、租约及当前有效尝试,同时锁定对应业务消息,使不同尝试/超时入口也不能交错覆盖最终状态。锁顺序统一为工作记录→业务消息→既有账务锁;禁止反向调用形成死锁。不得持有事务等待限速、网络或Redis。
|
||
|
||
### 10.5 事实保存、业务收尾和补发流程
|
||
|
||
**A. 接收与汇总**
|
||
|
||
1. 维持耐久Inbox接收。处理单个回执时,将回执事实、分段审计和工作记录唤醒放入同一短事务;唯一事件已存在时,也须核实存在对应工作,不直接忽略未完成收尾。
|
||
2. Inbox的matched表示关联事实已可靠保存,业务收尾由工作记录跟踪;两者状态不能混为一个“完成”。在事务提交前退出,由Inbox恢复;提交后退出,由工作扫描恢复。
|
||
3. 工作消费者认领后,重新读取当前Submit、所有有效分段及业务最终状态。未齐段成功则结束本轮为idle,保留processedRevision;后续新事实使其再次pending。unknown也不能被永久锁死,按既有状态机处理后续明确结果或超时。
|
||
4. 确认最终结果时,事务内重新核验revision、当前尝试和认领版本。有新事实或尝试已变更,重新汇总或记历史,不使用认领前的旧message对象直接更新。
|
||
|
||
**B. 允许补发**
|
||
|
||
1. Submit失败入口与回执失败入口都唤醒同一sourceSubmitRecordId的工作;取得资格后才进行选路等高成本操作。
|
||
2. 优先查询已有retryOfSubmitRecordId对应的Submit/Outbox。存在则复用,不再次选路、扣费或创建;不存在才评估业务规则并选择候选。
|
||
3. 选路和限速等待不持数据库锁,长等待续租;失去租约即停止。最终短事务核验资格、当前尝试及必要的实时启用/报备条件,原子提交补发Submit、消息当前尝试更新、GatewaySubmitOutbox和工作决策。
|
||
4. 事务失败全部回滚;成功后由原有Outbox发布器发送,不由认领流程直接调用供应商。不可把“处理中”当成“补发已成功创建”,也不可因此提前退款。
|
||
5. 临时数据库/连接错误进入retry_wait,不能误判为“无通道、最终失败”。确定不允许补发后,才走既有最终失败规则。
|
||
|
||
**C. 最终业务结果与客户通知**
|
||
|
||
最终消息状态、必要账务变化和全部应生成的耐久通知事实,应在同一短事务中提交。需让相关服务接受事务客户端;HTTP使用既有HttpWebhookEvent/Delivery模型,CMPP使用CmppDownstreamDelivery,不在事务内向客户发网络请求。
|
||
|
||
如实际账务接口无法共用事务,实施必须先补充稳定业务键的耐久分步恢复设计并验证全部崩溃点,不能先标工作完成再异步裸调用退款/通知。整条业务完成与单次发送尝试结束须分开:已安排补发只结束旧尝试,不给客户发最终失败。
|
||
|
||
正常幂等命中使用针对已知唯一键的无异常插入(例如参数化INSERT ON CONFLICT ... DO NOTHING RETURNING)并回读核实归属和内容;不能把所有数据库错误当重复成功。补发/回执唯一约束仍是最后防线,非预期冲突保留告警。
|
||
|
||
### 10.6 故障恢复与历史兼容
|
||
|
||
| 中断点 | 恢复预期 |
|
||
|---|---|
|
||
| 回执事实事务提交前 | 事务回滚,Inbox按既有机制再处理 |
|
||
| 事实已保存、工作尚未被领取 | 耐久pending扫描继续;不得依赖setImmediate一定执行 |
|
||
| 认领后、选路中进程退出 | 租约到期由新消费者接续,旧版本写入被fence拒绝 |
|
||
| Submit/Outbox事务提交结果不确定 | 按原始提交唯一键回读;已有记录继续原Outbox,不创建新Submit |
|
||
| 最终状态/账务/通知事务中退出 | 全部回滚或全部提交;恢复查稳定业务键,账务不重复 |
|
||
| 通知已入库、网络投递失败 | 原有投递记录重试;不重新运行短信补发决策 |
|
||
| 消费者持续失败 | 有限指数退避,达到配置上限进入needs_review并告警;不得伪造业务成功或静默丢弃 |
|
||
| 同时有新分段或迟到事实 | revision差异驱动再次汇总;旧尝试只留审计,已完成副作用不重复 |
|
||
|
||
迁移只增表/索引,不删除历史回执、补发、账单或唯一约束。启用前暂停受影响消费者并完成现有任务排空,不能让旧版直接处理与新版协调器长期并行。
|
||
|
||
切换时建立准确切换时间及待处理清单:未完成Inbox、正在处理回执以及尚未完成收尾的业务记录进入受控衔接;有最终通知/账务/补发事实的历史记录以事实作为已完成依据。不能仅因新工作表为空扫描全部历史短信安排补发。历史异常修复另行设计、授权和审计。
|
||
|
||
上线前必须验证回执先于Submit结果、历史缺失Submit关联、无租户通道测试短信和平台业务拒绝等路径。无法可靠关联的记录保留待匹配/人工排查,不推测来源、不误收尾。
|
||
|
||
### 10.7 观测、性能与验收证据
|
||
|
||
增加低基数指标:工作认领成功/竞争未获得、租约接管、旧版本拒绝、恢复次数、needs_review数量、最老未完成时长、最终动作数量。重复命中记录计数和必要采样,不让正常幂等持续生成ERROR堆栈;真实失败仍明确告警。
|
||
|
||
业务验证必须对账:每个源Submit最多一个有效后继补发;每个客户目标最多一份逻辑通知;每个账务业务键最多一笔;最终状态与分段事实/最新有效尝试一致;Inbox、工作表、Submit Outbox、Redis和客户投递都有可解释的完成或等待原因。
|
||
|
||
性能对比使用同环境、同版本以外条件、同长短信比例、同成功失败分布和同负载,分别记录入口、首次Submit、总Submit、最终送达、分段回执速率及窗口。记录CPU用户态/内核态/I/O wait、数据库写入与锁等待、队列积压及回执完成延迟。新增进程CPU/SQL采样需低开销评估,不擅自在线开启全量SQL日志。
|
||
|
||
确定性并发用例要求不再出现上述两类预期竞争导致的23505异常,正常情况下每次有效尝试仅一份已提交决策;故障恢复可重复计算,但不能重复业务副作用。性能无倒退且队列按批准的排空时限清空;执行负载前固定对比基线和延迟阈值,不事后选窗口宣称提升。不得承诺未经测量的CPU降幅或TPS。
|
||
|
||
### 10.8 实施顺序、成本和发布恢复
|
||
|
||
1. 补定向失败用例复现两段同时成功/失败;盘点所有终态和补发调用路径及账务事务能力,形成事务边界清单。
|
||
2. 实施增量模型、原子认领、revision/fence和扫描恢复;先验证多进程争抢及旧持有者失效。
|
||
3. 接入回执、提交失败、超时及平台失败路径;事务化Submit/Outbox与最终通知/账务。保持外部协议和业务规则。
|
||
4. 执行定向测试、API全量回归、类型检查、构建和现有质量门禁;真实PostgreSQL并发及迁移测试必须通过。Gateway有修改再执行Go测试及vet。
|
||
5. 在另行明确的隔离模拟范围内,以真实API、PostgreSQL、Redis、Gateway和Webhook接收端验收,包含非零计费、重启故障、回执乱序及排空对账;页面检查发送详情和批次最终状态。mock只能证明隔离逻辑。
|
||
6. 真实回归通过后,按明确提交/推送/目标环境授权交付;部署使用标准release流程和独立恢复资产。测试与预生产分别验收,预生产不得以历史授权直接补发或压测。
|
||
|
||
这是跨回执、补发、账务和耐久任务恢复的修复,成本主要在事务改造及故障验收,不能仅以修改几行或压低日志级别交付。完成第1步盘点后再给出实现工时;若需改变计费、状态优先级或Gateway契约,先记录范围扩展。
|
||
|
||
恢复策略:迁移保留增量表;发布失败先停止新旧受影响消费者交叉执行,盘点未完成工作、已生成Submit/Outbox/账务/投递事实,再选择经过兼容验证的应用版本恢复。旧版本不理解新工作表,不能直接回退并声称全部恢复;必须证明待处理工作已排空或存在经过验证的接续路径,否则保持暂停并修复。禁止删除工作记录、回滚业务数据或重新入队来掩盖异常。
|
||
|
||
上线停止条件:发现重复供应商Submit、重复扣退费、终态错误覆盖、持续积压、无法接管或恢复缺口立即停止放量,保留现场;不通过手工补发完成验收。
|
||
|
||
### 10.9 完成标准与本轮状态
|
||
|
||
验收用例见 [系统功能测试用例](system-functional-test-cases.md) 的TC-RC-20260916-01~12;原TC-SEND-018/019/020继续执行。记录见 [测试进度](testing-progress.md)。
|
||
|
||
只有代码、真实并发/故障恢复、账务与队列对账均通过,并完成对应环境独立验收,才能标为该环境已修复。2026-09-16本轮只编写整改方案与用例,未实现模型或业务代码,未运行发送、迁移、提交、推送、测试部署或预生产部署。
|
||
|
||
### 10.10 2026-09-16 实施细化(开发中,尚未验收)
|
||
|
||
用户已明确授权实施第10节、提交、推送及测试环境部署;第10.1、10.9的“仅方案”状态是此前记录,当前开始实施,不能据此宣称已上线。
|
||
|
||
- 增加 `SmsCompletionEvent` 耐久事件日志,与工作 revision 在同一接收事务提交;Inbox matched 表示原始事件已存入该日志。规范化 Receipt/Segment 事实、汇总和副作用由收尾事务一并提交。此细化替代10.5 A1中先写规范化表的顺序,避免规范化事实落库后收尾未接续的窗口。日志保留原始事件,不能将未处理日志计作已完成业务回执。
|
||
- 回执、整条提交结果、分段提交结果、超时与平台拒绝通过统一入口入日志;先按可靠关联核验消息及租户。无可靠Submit关联的供应商回执继续留在Inbox待匹配;无供应商Submit的平台拒绝使用message工作键。迁移不扫描历史消息重发。
|
||
- 所有收尾协作者通过仅限发送链路的事务适配器加入当前短事务,账务内层事务加入同一事务。锁顺序为工作→消息→账务。HTTP通知生产端显式接收事务客户端,CMPP通知无异常幂等创建;事务内仅写耐久通知,不执行网络投递。
|
||
- 补发首先认领。首次尝试到达选路点时回滚探测事务,在事务外重新选路和限速;最终事务重新读事件、revision、有效尝试与当前路由条件,原子创建Submit/Outbox。回滚产生的会话ID不得进入跨事务缓存。临时故障必须抛出并退避,不能被吞成最终失败。
|
||
- 恢复扫描初始5秒/32条,租约60秒、20秒续期、事务20秒上限。最多12次失败进入needs_review;这些是待真实环境验证的初值。异步通知仍由原恢复消费者投递。
|
||
- 超时与迟到结果保持已退款状态,不重复扣退;每个历史业务键依据实际账务和通知事实恢复。上线前必须以故障与乱序测试核实,不把代码编译当作证明。
|
||
|
||
待补证据:真实PostgreSQL并发、fence过期、事务中断、队列排空、长短信CMPP与HTTP通知以及账务对账。当前仍为开发中。
|
||
|
||
### 10.11 本次实现边界与发布依赖(2026-09-16)
|
||
|
||
- 恢复扫描在worker/callback/all角色运行;正式运行必须启用SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED及对应发布进程。测试环境18:20只读核验两项Outbox开关为true、独立服务active。关闭发布器不属于受支持上线组合,不能把pending当成已发送。
|
||
- 收尾needs_review及等待超过300秒由监控采集器直接查询耐久工作/事件表,写入同一告警事件库,恢复仍保留、仅人工清除;不依赖应用发布工具安装Prometheus规则。过程计数仍提供低基数metrics,采集进程口径需区分。
|
||
- 两项迁移仅新增四张表及索引,无历史回填、无删除、无自动重发;全量106项已在新隔离数据库重放。工作时间使用UTC表达式,避免数据库会话时区与Prisma时间不一致。应用回退前必须盘点和接续未完成工作,旧代码不能消费这些新表。
|
||
- 本地验证包括真实PostgreSQL、两个独立OS进程、事务回滚、旧消费者挂起后接管、三段通知目标、非零账务和重试上限可见告警;网络投递与目标环境的重启/排空验收独立记录,不将路由隔离测试冒称整链路通过。
|
||
|
||
### 10.12 测试环境发现的快速SubmitResp竞态(2026-09-16)
|
||
|
||
a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4、1/3段,Gateway在已收到即时应答时仍等待60秒后误判SUBMIT_TIMEOUT;补发及最终账务/通知收尾正常,但不能据此视作无异常验收。只读代码证据:submitPart在SendReqPkt、异步日志启动之后才登记pending[seq],readLoop可能提前消费应答并因不存在等待者丢弃。新增真实TCP回归在修改前分别于CMPP2.0第206次、3.0第7次复现。
|
||
|
||
最小修复保持现有协议、接口、存储和补发策略:使用与heartbeat一致的mu→sendMu锁序,将连接有效性检查、写包和登记pending置于同一临界区,响应读取须等登记完成。网络失败仍走原连接关闭/失败处理。锁内不得执行日志、业务回调或数据库操作。验证两种协议各500次即时应答、Gateway全量test/vet及测试环境新的长短信样本;原异常证据保留,不将旧样本改成无补发成功。
|