# 上游消息生命周期设计 ## 本文范围与读者 本文定义 msgexchange-v2(下称"本系统")对共享 MySQL 信箱中报文的完整生命周期:从上游写入、本系统处理、处理标记回信箱,到信箱数据最终清除。各阶段的输入输出、幂等方式、故障恢复方法,以及本系统与信箱库管理方之间的分工,都在本文界定。 - 模块协作、状态机与配置参数见 [design.md](design.md);系统边界与"不建表、不改结构"的红线见 [architecture.md](architecture.md);验收口径见 [user-stories.md](user-stories.md)。符号(`W` / `holeSince` / `R` / `R_keep` / `head-deadline` 等)与配置键的对应关系统一见 [design.md](design.md) §1.1。 - 读者包括本系统开发与运维人员、以及负责共享 MySQL 的库方接口人;**§5–§7 中标注 Q 编号的条款**即需要与库方书面确认的开放项。 - 状态标记沿用 [design.md](design.md) §1:【目标】/【现状】/【缺口】/【待确认】。正文描述目标设计,本文范围内的缺口清单集中在 §12;跨文档引用一律带文档名。 **角色与术语约定**(全文沿用): - **上游**:向信箱写入报文的源头系统(CIIMS、AODB 等)。 - **信箱**:共享 MySQL 中的入站信箱表 `CMINMSGS`;出站方向为 `COUTMSGS`(§7)。 - **库方**:共享 MySQL 的管理方;表结构变更与数据清除只能由库方执行或书面授权。 - **处理标记**:信箱行上表示"本系统已处理"的约定字段;逻辑名 `DATE_PROCESSED` / `STATUS`,实际列名以库方契约为准(legacy 为 `CMINMSGS_DATE_PROCESSED` 等真实列)。正文统一用"处理标记",不再混用列名。 - **自有 PG**:本系统唯一的业务数据库 PostgreSQL;信箱与自有 PG 之间不存在跨库事务。 ## 1. 五个独立事实 消息的认定分为五个环节,每个环节都有独立的证据,互不替代:上游写入了不等于本系统接手了,本系统处理完了也不等于信箱标记已写、下游已收到。 | 环节 | 认定依据 | 执行方 | |---|---|---| | 落信:报文进入信箱 | `CMINMSGS` 中存在该行 | 上游 | | 入队:本系统开始处理 | 自有 PG 建立 `PROC_STATE` 记录 | `InboxPoller` | | 处理完成:业务处理完成 | `PROC_STATE` 到达终态(SUCCEEDED / SKIPPED / DEAD) | 主泵 | | 已回填:信箱写入处理标记 | 信箱行持有处理标记 | 回填与补偿通道 | | 投递确认:投递目标已接受 | 目标端返回成功且 `MSG_EVENT` 置 `SENT` | `delivery`(Dispatcher / flushSchd) | 分环节的原因是存储边界:信箱与自有 PG 之间的写入不共享事务。“落信”以共享 MySQL 为准,“入队”和“处理完成”以自有 PG 为准;“已回填”与“投递确认”各以外部操作成功和本地确认事实共同判定。后两个环节各自独立重试,都可能单独失败。“投递确认”只表示 Kafka Broker 或其他投递目标已接受,不表示业务消费者已消费。 “入队”这一环还包含一个容易被忽略的前提:**发现是完整的**。它不由本系统单独保证,而依赖信箱 ID 单调与最大提交时延两条上游承诺(Q2);承诺不成立时,本系统只能看到"读过的区间",不能证明"读全了"(§5.1)。 ## 2. 状态总纲 ```text 信箱行(共享 MySQL) 未标记 ──[本系统写处理标记]──→ 已处理标记 ──[库方按保留期执行]──→ 已归档 ──→ 已清除 ↑ §6(本系统不执行 DDL) └── 回填与超期补写(§5.2) PROC_STATE(自有 PG) PENDING ──处理成功────────→ SUCCEEDED │ ↑ ─→ SKIPPED(业务重复,非错误) │ └── 人工重放(白名单错误类) └─ 处理失败 → FAILED ──退避到期──→ PENDING └── 次数耗尽 / 队头滞留超限 ──→ DEAD(人工处置) MSG_EVENT(自有 PG,投递侧,见 design.md §5) PENDING ──投递确认──→ SENT └── 失败退避 ──→ DEAD(DLQ) ``` - `PENDING` 与 `FAILED` 是处理中的状态,`FAILED` 继续退避重试并占用 FIFO 队头;`SUCCEEDED / SKIPPED / DEAD` 是终态,到达后队列方可推进。 - `DEAD` 与 `FAILED` 不是不可逆:经人工批准,指定错误类别的记录可重新置回 `PENDING` 处理(放行范围见 §9 / design.md §6.1)。重放处理的是当前状态,不恢复历史处理顺序。 - **写事务边界**:只有处理器产出的业务型终态才与航班变更、待发事件、回填意图同处一个 PG 事务;非业务型终态(`MALFORMED / PROTOCOL / SKIPPED / EXHAUSTED`)与毒丸升级只写 `PROC_STATE` 一条记录,终态与回填意图由同一条语句写入。完整边界见 [design.md](design.md) §3.3 表 1。 - 信箱处理标记写在该事务**之后**,由与终态同行的回填意图驱动重试与超期补写。 ## 3. 处理阶段 **表 1 取报与处理**(收报、主泵、解码与身份): | 阶段 | 输入 | 输出 | 幂等依据 | 失败处理 | |---|---|---|---|---| | 收报(发现 + 入队) | `ID > W` 的升序信箱行(谓词见 §5.1) | `PROC_STATE(PENDING)` 与推进后的 `(W, holeSince)` | `MSG_ID` 主键(冲突即已入队) | 下轮扫描补入队;整轮失败不动水位 | | 取队头 | 最小未完成 `MSG_ID`(`PENDING` 与 `FAILED` 都占位) | 本轮唯一处理对象 | 表状态即队列 | 无队头则等待;`FAILED` 未到期则等待 | | 解码与身份 | 信箱原文 | `DecodedMessage`、`IDENTITY_KEY` | 身份唯一约束(仅首次绑定) | 报文非法→`DEAD`;解码能力不足→`FAILED` 退避 | | 事务处理 | 当前完整状态 + 报文载荷 | 航班变更、终态、事件、回填意图 | 消息 ID + 业务身份 | 业务型事务整体回滚重试 | **表 2 回填**(终态之后的信箱标记): | 阶段 | 输入 | 输出 | 幂等依据 | 失败处理 | |---|---|---|---|---| | 回填扫描(每 30 秒,**调度周期 ≠ 完成时限**) | 终态 ∧ 未确认标记 ∧ 未放弃 ∧(退避到期 ∨ 已达 `NOW − R`) | 追平标记,或判定放弃自动重试 | 消息 ID(仅空标生效) | 退避 30 秒起步、封顶 15 分钟;越过 `R` 后每轮都试;**确认行不存在**或达尝试上限则停止自动重试(可人工恢复) | 主泵**不做**回填:终态与回填意图由同一条 UPDATE 落库,回填一律由扫描驱动(跨库写不能占用 FIFO 关键路径)。 **处理链路流程**(`├─` 括起的是同一个 PG 事务): ```text ① 取报(InboxPoller,默认每秒一轮) 读游标 (W, holeSince) → 取 ID > W 的升序前 N 行 → 求连续上界 → 空洞判定 ├─ PG 事务:insertIfAbsent(MSG_ID, RECEIVED_AT) × k + 写回 (W, holeSince) → 提交 └─ 未入队的行留到下一轮(每轮最多解决一个空洞) ② 调度(Pump,单线程) headUnfinished()(最小未完成 MSG_ID) ├─ 队头为 FAILED 且已毒丸 → DEAD(EXHAUSTED)(单语句,不取锁) ├─ FAILED 未到期 → 等待 └─ 可执行 → 记 PROCESSING_STARTED_AT(仅首次)→ processOne() ③ 处理(MessageProcessor) 读原文(共享 MySQL)→ 解码 → 身份绑定(独立单语句) └─ PG 事务(持 PIPELINE_LOCK):航班变更 + 待发事件 + 终态 + 回填意图 → 提交 ④ 回填(终态之后,跨库两次单写,无事务) 写信箱处理标记 → 记 BACKFILL_AT;失败则回填意图留在该行上 └─ 回填扫描(每 30 秒)按表 2 的谓词重试,越过 R 后强制补写 ``` 领域决策逻辑不执行 I/O。Processor 作为事务协调器:业务型终态在持有 `PIPELINE_LOCK` 的事务内写入自有 PG;非业务型终态与毒丸升级只写 `PROC_STATE` 单条记录。所有外部副作用(读信箱原文、写信箱标记)都发生在 PG 事务边界之外,跨库不共享事务。 ## 4. 故障恢复 恢复的唯一依据是各存储中已持久化的记录,不依赖任何进程内存中的状态: | 中断位置 | 重启后的判定 | 恢复动作 | |---|---|---| | 已落信、未入队 | 信箱行位于应扫描的 ID 范围且 PG 无记录(不以处理标记为判据) | 重扫补建入队记录 | | 水位卡在空洞 | `INBOX_CURSOR.HOLE_SINCE` 有值且未超过 `max-commit-delay` | 等待;超期后放行空洞本身并继续推进(§5.1) | | 事务执行中 | PG 无该消息终态 | 事务整体回滚,按 `PENDING` 重新处理 | | 事务已提交、标记未写 | 终态行仍持有回填意图(`BACKFILL_NEXT_AT` 非空) | 仅补写标记;业务处理结果保持不变 | | 回填意图登记失败(与业务同事务) | 事务未提交 | 同"事务执行中",不构成独立窗口 | | 标记写入中途 | 标记仍为空 | 重新写入;重复写入同一值无副作用 | | 兼容入口已入队、水位未追平 | PG 已有该 ID 的记录 | 轮询读到该行时主键幂等,水位照常推进;顺序风险见 §5.1/§12 | | 回填时信箱行已不存在 | 写入 0 行且信箱行不存在 | **立即放弃自动重试**(原因 `MISSING_ROW`)并告警;**放弃 ≠ 标记已确认**,仍需人工对账(§5.2) | | `RECEIVED_AT` 为 NULL | 终态行无标记且无超期判据 | 仅由退避重试保证;【目标】以入队时间兜底(§5.2/§12) | | 投递目标已接受、`SENT` 未置 | 事件仍 `PENDING` | 允许重发,消费方按事件身份去重 | 两个窗口已随"终态与回填意图同体同行"消除(口径与 design.md §10「事务与外部副作用」一致),不再是缺口: 1. **提交后回填前崩溃**:回填意图与处理终态是同一条记录的同一次写入、同一个事务;重启后扫描按该意图继续补写。 2. **回填意图二次落账失败**:不存在第二处落账——意图就在终态行上。 仍属未闭环的有两处:回填本身在跨库单写期间的持续失败,由退避重试与 §5.2 的超期期限 `R` 兜底;以及 §5.2 的"信箱行已不存在"没有终态(登记于 §12)。两者均登记在 design.md §10 与 user-stories.md US-09。 ## 5. 消费水位、超期补写与历史积压 ### 5.1 消费水位 W 与空洞 **定义**:水位 `W` 是信箱 ID 的连续上界——从最小 ID 到 `W` 的区间已全部读入自有 PG,无空洞;遇到空洞即停止推进。`W` 只随新 ID 的成功入队推进(永久空洞放行是唯一例外),不依赖处理完成或标记回写。 **收报流程**(每轮,谓词不以处理标记为条件): 1. 读取游标 `(W, holeSince)`;信箱不可读时记日志、等下一轮,**不动水位**——这属于基础设施失败,不能当成"没有新消息"。 2. 取 `CMINMSGS_ID > W` 的升序前 `claim-batch` 行(`ORDER BY ID ASC LIMIT N`)。 3. 从 `W+1` 起逐 1 数,求连续上界;遇到第一个缺号即停止计数。 4. 空洞判定(仅当本批存在"缺号之后的行"时才可能成立): - 缺号首次被观测到 → `holeSince = now`; - `now − holeSince < max-commit-delay` → 水位停在缺号前,**本批缺号之后的行一律不入队**。没有这条规则,晚提交的较小 ID 会排到它们后面,破坏 FIFO; - `now − holeSince ≥ max-commit-delay` → 判定为永久空洞,水位放行到"缺号后第一行 − 1",`holeSince` 清空。放行**只跳过空洞本身,不越过任何已存在的行**。 5. 在同一个 PG 事务内:对水位以内的每一行 `insertIfAbsent(MSG_ID, RECEIVED_AT)`,并写回 `(W, holeSince)`;主键冲突表示已入队(重复扫描与兼容入口并发都安全),不计入也不报错。 6. 提交。本批中因空洞或批次上限未入队的行留待下一轮——**每轮最多解决一个空洞**。 **空洞计时跨重启保留**:`holeSince` 落在 `INBOX_CURSOR.HOLE_SINCE`,进程重启不丢失。旧空洞补齐后出现的新空洞,从新观测时刻重新计时,不继承旧等待时间。 **代价(必须接受并观测)**:水位遇空洞即停意味着**该空洞之后的所有消息最多要等 `max-commit-delay` 才能入队**。自增回滚等会在 ID 序列中留下永久空位,因此每出现一个永久空位就是一次等长的入队停摆;空位频繁时有效吞吐按比例下降。运行期必须观测永久空洞计数与水位滞后(§5.3 验收口径、OPS-2)。 **水位的有效性依赖 Q2 的两条上游承诺**: 1. **ID 单调**:信箱 ID 按提交顺序分配,晚提交的较小 ID 不参与。承诺缺失时,`W` 只能作为快路径的提示,不能证明该区间收齐。 2. **ID 分配 → 事务可见时延上界**:从"库方为该报文分配 ID"到"该 ID 对其他事务可见"的最长时间。它同时决定空洞老化阈值与补偿扫描窗口宽度。 **这条时延必须由库方直接给出,不能由 SIS 的报文有效期推导。** SIS 的 `Expiry` = 480 分钟、断连 120–480 分钟按 Level 2 处理(`SIS_AODB_RMS-V0.1.md` §2.4.2.3.3、§3.16、§3.17、§5.2)描述的是**报文保留与传输恢复**,与"共享 MySQL 里 ID 分配后多久对读事务可见"不是同一个量,二者之间没有推导关系。因此当前默认的 `max-commit-delay = 5 分钟`**缺少依据**,只是占位假定值,属于上线门槛([design.md](design.md) §7/§10)。报文有效期只能作为**原文保留期 / 重放窗口**(§6、§9)的参照,不能反过来给本阈值背书。 **两条扫描路径(发现机制)**: | 路径 | 目的 | 谓词 | 状态 | |---|---|---|---| | 快路径(日常) | 发现水位之后的新消息 | `ID > W ORDER BY ID ASC LIMIT claim-batch` | 【现状】已实现 | | 迟到检测(只读,阶段 0) | 复查**被放行的空洞 ID** 是否后来真的出现在信箱 | 进程内监视队列 + `existingIds` 批量存在性检查(`late-detect-period`) | 【现状】已实现:只计数与告警,**不补入队** | | 补偿扫描(阶段 1) | 发现"提交晚于水位推进"的迟到行并**安全补入队** | 按周期重扫 `W` 之前一个窗口(宽度由最大提交时延决定)内的 ID 区间 | 【缺口】未实现 | 【缺口】在补偿扫描交付之前,"较小 ID 迟提交"**没有补入队机制**:快路径只读 `ID > W`,一旦水位越过某个 ID,该 ID 之后到达的消息永远不会被发现。阶段 0 的**只读迟到检测**已实现——监视"被放行的空洞 ID"是否后来真的出现,命中即计数(`msgx.pipeline.late_arrival.detected.total`)并告警,把原先的**静默丢失**变成可观测事实;但它**不补入队**,因此当前设计仍**不能对外声明"迟到不越序"**([design.md](design.md) §8/§10)。检测是进程内尽力而为(重启丢失监视),且只覆盖"曾被放行过的空洞 ID"——这已足够:水位只会越过连续存在的行与被判定为空洞的 ID。 **水位与其余两条写路径的关系**: - **兼容 HTTP 入口**(`POST /cminmsgs/send`):它直接写 `PROC_STATE`、不读不推水位,登记的行因此**超出水位**;主泵只领 `msgId ≤ W`,所以在水位追平前不会被处理——顺序不受影响,代价是延迟到追平,且**必须让收报轮询运行**(收报侧事实由 `InboxPollerTest` 固定,端到端由 `PipelineSmokeTest` 守住)。 - **回填**:不回写水位。水位不是处理标记,两者互不替代。 **单实例前提**:信箱读取不加锁,水位是单行覆盖写。本设计只在单活动实例下成立([architecture.md](architecture.md) §5、D2);多实例并发收报会让水位互相覆盖(覆盖回退只会造成重复扫描,不会丢消息,但空洞计时会失真),必须先有实例级排他。 **首次启动**:游标初值为 `(W=0, holeSince=NULL)`,第一轮会重读信箱中全部现存行;重复登记由主键幂等挡住,因此历史上"已入队但未回填"造成的漏读会一并补齐。 **切流播种(显式、一次性)**:若信箱已有存量(典型情况是最老分区已被清除、`MIN(ID)` 远大于 1),从 `W=0` 启动会先把 `ID=1` 判成空洞、白等一个老化窗口,同时也意味着要重新处理保留期内的全部存量。是否跳过存量属于**切流决策**,因此代码不做默认选择:只有显式配置 `msgx.pipeline.cutover-watermark` 才播种,取值 `min`(读现存全部)/ `zero`(从 0 按空洞规则)/ `max`(跳过可见存量)/ 具体 ID。升级实例(已有水位或已有处理记录)**拒绝重新播种**,重新切流必须是显式操作;播种事实与水位同语句落库(`INBOX_CURSOR.SEEDED_AT`),且该列为 NULL **不等于**"从未消费"(已有库新增列后同样为 NULL)。 ### 5.2 超期标记补写 所有处理终态都只依赖消息 ID 执行回填;报文残缺或缺少 META 不妨碍正常回填。本节规定回填的三种结果与"长期失败"时的超期兜底。 **处理规则**:已达 `PROC_STATE` 终态、且接收时间超期(信箱 `DATE_RECEIVED`,落库为 `PROC_STATE.RECEIVED_AT`;判据 `RECEIVED_AT < NOW − R`)仍无标记的信箱行,由回填通道补写一个库方认可的"已处理"类标记。 **扫描谓词**(与实现一一对应): ```text STATE ∈ {SUCCEEDED, SKIPPED, DEAD} -- 终态 AND BACKFILL_AT IS NULL -- 标记尚未确认 AND ( BACKFILL_NEXT_AT IS NULL -- 异常兜底:终态行没有退避时间 OR BACKFILL_NEXT_AT ≤ NOW -- 退避到期 OR ( RECEIVED_AT IS NOT NULL -- 接收时间可用 AND RECEIVED_AT < NOW − R ) ) -- 已达超期期限,覆盖退避 ORDER BY MSG_ID ASC LIMIT backfill-batch ``` **回填的可能结果**: | 结果 | 判定 | 处置 | |---|---|---| | 写入成功 | 标记为空、写入 1 行 | 记 `BACKFILL_AT`,不再重试 | | 早已有标记 | 写入 0 行且信箱行存在 | **视为成功**,不覆盖已有值,记 `BACKFILL_AT` | | 信箱行不存在 | 写入 0 行且信箱行不存在 | **立即放弃自动重试**(原因 `MISSING_ROW`)并告警。这是确定性结论,重试不会改变结果 | | 暂时性故障达上限 | 超时/连接失败累计达到 `backfill-max-attempts` | **停止自动重试**(原因 `MAX_ATTEMPTS`)并告警;保留人工恢复能力 | **放弃 ≠ 标记已确认**:放弃行不写 `BACKFILL_AT`,因此**不满足** §6"边界内全部行已打标"的清除前提,库方不得据此清除。放弃行可经人工恢复(清标记后重排一次回填)。 **约束**: - 期限 `R` 的语义是"回填长期失败时的强制补写上限"。**`R` 在任何清除语义下都不保护重放窗口**——因为回填不等 `R`:终态落库后由扫描尽快补写(§3 表 2),`R` 只在"回填持续失败"时把补写**提前**,从不推迟补写。 - 重放窗口的保护只能来自下面两条路之一,且 Q7/Q9 的确认是**阻塞性前提**,不是"调大 `R`"就能覆盖的参数问题: - **约定保留期(目标前提)**:库方按"标记 + 保留期 `R_keep`"清除(打标本身不触发清除,§6),且 `R_keep ≥ 人工重放期限 + 人工处置期限`。此时 `R` 只需满足 `R ≤ R_keep`,与重放窗口无关。 - **另设原文保留机制**:若库方的清除语义是"一旦打标即可清除"(未确认),则**增大 `R` 无效**——当天进入终态、当天打标的消息会被当天清除,即使 `R = 30 天`。此时必须另行约定保留期,或引入独立的原文保留通道(例如由库方把原文归档到 `CMINMSGS_HST` 后供重放读取);该通道**尚未设计**(§12 G10)。 - 撤回原先"打标即清除语义下需 `R ≥ 重放期限 + 处置期限`"的表述:它是错误推论,因为**回填由扫描驱动、不等 `R`**——打标时刻与 `R` 本来就是解耦的,`R` 无法推迟打标。 - 中间态(`PENDING` / `FAILED`)不适用本规则:处理未完成时不打标,也不允许被清除。 - 补写值仅限于库方认可的 legacy 值集(Q7);死信、业务重复等内部原因记录在 `PROC_STATE` 与审计日志,不在信箱新增枚举。 - 补写只针对空标记;已有值不回撤、不覆盖,重复执行无副作用。 - `RECEIVED_AT` 为 NULL(上游未写 `DATE_RECEIVED`)时,超期分支不成立,`R` 兜底**不生效**;该行只能靠退避(封顶 15 分钟)反复重试。【目标】接收时间缺失时以入队时间兜底。 - 判据不依赖独立待办表:回填意图与处理终态同行(§2/§4)。 - **越过 `R` 之后**:超期分支一旦成立便恒成立,该行**每轮扫描都会被重试**,退避被覆盖。不过永久失败不会无限重试:**确认行不存在**立即放弃,暂时性故障到 `backfill-max-attempts` 后也停止自动重试(两类都告警,且可人工恢复)。 - **扫描周期 ≠ 完成时限**:30 秒只是**调度周期**。`JobRunner` 串行执行 `backfill.sweep` 与历史作业后才等待 30 秒,批次积压、单行调用超时与历史作业耗时长都会延长实际回填延迟;只有在"正常无积压"时实际延迟才近似等于扫描周期。因此任何"标记延迟 ≤ 30 秒"的表述都不成立,**回填完成目标是独立指标**(§5.3 验收口径、OPS-2),需要硬时限时必须另行补齐容量、作业隔离与故障条件。 - **回填饥饿(已修)**:扫描改为**公平轮转**(`ORDER BY BACKFILL_ATTEMPTS ASC, MSG_ID ASC`)并排除已放弃的行,因此"最旧的一批永久失败行占满批次、后面的记录永远轮不到"不再成立。历史缺口见 §12 G9(已关闭)。 该规则保证两件事成立:收报表不被永不回填的记录占满(Q2 中"积压挡批"场景)、`R` 与 `R_keep` 的约束关系可核验(§5.2/§6/§9)。**它仍不保证"§6 的清除边界能在有限时间内达到"**:放弃行永远不满足"边界内全部行已打标",而"打标即清除"语义(§12 G10)会让该前提根本不成立。 ### 5.3 历史积压消息的处理方案 "历史积压"指信箱中成规模的未处理存量:上线前遗留、停机期间累积、或消量未完成的批次。处理方案分三步,并约束四条行为红线。 **第一步:摸底。** 处理动作开始前,先确定积压的范围与构成:ID 区间、条数、时间跨度、报文类型分布(`SCHD-DNLD/RESP/ADFT`、`FLOP-*`、`FDEL` 等,逐类清单依据见 Q8),并与库方确认其中哪些仍需业务处理、哪些按约定跳过(跳过基调只在该批确认时成立,授权与留痕见 Q12,标记值集见 Q7)。 **第二步:入队与处理的顺序关系。** 顺序由 `MSG_ID` 决定,**不由"先入队后处理"这种执行方式决定**: 1. 入队:`InboxPoller` 按升序有限批次将历史行全部建为 `PROC_STATE(PENDING)`,水位随之推到积压末端(连续推进以 §5.1 的 Q2 承诺为前提)。此阶段只写自有 PG,不触碰信箱标记。 2. 处理:主泵按最小未完成 ID 顺序消化。顺序、阈限与日常完全相同:不加速、不分流、不走旁路。积压期间到达的实时消息 ID 更大,自然排在积压之后。 **入队与处理可以并发**:两者由不同线程驱动,FIFO 由"队头取最小未完成 `MSG_ID`"保证,并发不会破坏顺序。因此"先入队后处理"是**可选的运维规程**(例如先手动跑一段只收报的窗口,便于提前把水位推到完整上界),**不是正确性前提**,系统也不需要提供"只入队"模式。 这样安排的收益:入队廉价、处理昂贵;即使并发,入队阶段的中断恢复也只涉及重扫(§4 第一行),水位能尽早到达完整上界,快路径与补偿扫描窗口立即生效。 **第三步:等待过程中的四个边界。** - 不插队:主泵按 FIFO 消化,积压期间到达的实时消息排在积压之后。本设计不允许并行队头,也不允许实时通道跳过积压。 - 不失控:队头滞留上限(`msgx.pipeline.head-deadline`,当前 10 分钟)对积压同样生效;队头长期失败按既有规则转 `DEAD(EXHAUSTED)` 并告警,不会因积压而延长容忍。判据以首次处理时写入的 `PROCESSING_STARTED_AT` 为稳定起点、经可注入 `Clock` 判定(design.md §3.2、§10);长期积压与人工重放的期限口径仍由 Q6 定案。 - 报文类型的处理方式不变:旧的全量日计划(`SCHD-DNLD`)按最新一份**合并**即可收敛(缺席不删、缺失字段保留;字段级冲突见 flight-state.md §3.1 与 Q13),但仍逐条执行;动态增量(`FLOP-*` / `FDEL` / `ADFT`)持有时序语义,必须逐条。 - 可放弃但必须留痕:摸底批中经库方确认"不再处理"的行,处置方式为——`PROC_STATE` 置 `SKIPPED` 并记录跳过原因,到达终态后走 §5.2 通道补写标记;本方案不支持任何"整段 DELETE"的快速通道。 **验收口径**:积压消化期间持续输出三项指标——剩余积压条数(`unfinished`)、最老未处理信龄(`oldestUnprocessedSeconds`)、**预计消化时长**(定义:`剩余积压条数 ÷ 近 N 分钟实际处理速率`,速率样本窗口 N 需在实现时固定;若实现不提供该估算,本条降级为"由运维按前两项指标自行推算")。另需观测水位滞后与永久空洞计数(§5.1)。期间不允许出现 FIFO 越序、身份去重失效或头行滞留超时未告警。红线依据:architecture.md §5(消息严格 FIFO)、单写者约束。以上指标并入 user-stories.md OPS-2 的可观测性验收。 ## 6. 信箱数据清除 表结构变更与物理清除由库方执行或书面授权执行;本系统对共享 MySQL 不建表、不改结构(红线见 architecture.md §6)。本系统不执行 DDL、不写共享历史表:`CMINMSGS_HST` 的历史归档写入由库方执行,属 Q9 授权范围。本系统在清除事务中的义务只有一项:为处理完成的行及时写入处理标记,使可清除范围存在明确边界。 具体方案由库方选择(Q9)。以下两种方案均基于 MySQL 自身能力:支持 `RANGE` 分区与 `TRUNCATE / DROP PARTITION`,不支持 `EXCHANGE PARTITION`(能力与现场版本待库方书面确认,Q9)。 **方案 A:按日分区(首选)**,适用于库方可以为表增加分区的场合。 1. `CMINMSGS` 按 `DATE_RECEIVED` 建立日粒度 RANGE 分区; 2. 某分区到达保留期时,确认该分区全部行已持有处理标记(§5.2 只在回填可持续成功时保证该条件;存在回填饥饿或"打标即清除"语义时见 §12 G9/G10); 3. 将该分区复制入历史表:`INSERT INTO CMINMSGS_HST SELECT`(`NOT EXISTS` 判重); 4. `TRUNCATE / DROP PARTITION` 执行清除:DDL 级操作,无行锁竞争,页外大字段(`CMINMSGS_CLOB_MSG` 列)整块释放;碎片与 binlog 影响待现场验证。 前提:库方确认现场 MySQL 版本支持分区 DDL,并已授权执行。 **方案 B:整表轮换**,适用于库方拒绝增加分区的场合。 1. `CREATE TABLE CMINMSGS_NEW LIKE CMINMSGS`,并将 `AUTO_INCREMENT` 种子设为 `max(ID) + 1`; 2. 将保留窗内行(`DATE_RECEIVED ≥ NOW − R_keep`)复制至新表; 3. 以单语句原子 `RENAME TABLE` 完成换名; 4. 对账:换名与复制之间新写入及新标记的行,从旧表补回;只补新表缺失的行,已存在行按标记单调取并集,不回退、不覆盖(§11); 5. 旧表中早于保留窗的部分追加写入 `CMINMSGS_HST`(`NOT EXISTS` 判重); 6. `DROP TABLE` 旧表:秒级完成,碎片与大字段一并释放。 两种中断均安全:换名前失败则废弃新表重新执行;换名后失败则旧表仍完整,对账与归档语句均可重跑。 方案共同前提: - **方案要求库方按"标记 + 保留期"清除**(打标本身不触发清除):处理标记只是可清除的**必要条件**,触发条件是"到达保留期 `R_keep`" ∧ "边界内全部行已持有处理标记"。该语义属 Q7/Q9 确认范围;若确认结果是"打标即可清除",则**本节前提不成立,且增大 `R` 无法补救**(原因见 §5.2:回填不等 `R`,打标时刻与 `R` 解耦),必须另行约定保留期或引入独立原文保留通道(§12 G10)。 - 执行清除时,边界内不存在未打标记的行;未达终态的行顺延至处理完成后清除(§5.2 仅对终态行补标)。 - 时间比较与换算统一采用机场时区 Asia/Shanghai 及明确的类型转换(口径同 user-stories.md §6)。 - 保留期 `R_keep` 的下界(**本节是唯一表述处**):`R_keep ≥ max(人工重放期限 + 人工处置期限, 审计期限, 回填重试上限)`——**仅在"标记 + 保留期"清除语义下**,这是"重放窗口内原文仍在"的**唯一保证来源**。报文在 CIIMS 的 `Expiry`(480 分钟量级,`SIS_AODB_RMS-V0.1.md` §3.16)可作为原文保留期的参照,但它是报文有效期,不等于本处所需的保留期,也不能用于推导 §5.1 的可见性时延。 - 超期补写期限 `R` 的约束(仅 `R ≤ R_keep`)**唯一见 §5.2**;`R` 不参与重放窗口保护,本节不重复。 - legacy 现役按接收时间超过 1 天即归档并删除 `CMINMSGS`(legacy 行为基线 §3.6);若沿用该窗口,则与上一行的保留期下限冲突,须在 Q9 中与库方一并确认; - 信箱 ID 全程不断链:方案 A 天然满足;方案 B 依赖 `AUTO_INCREMENT` 种子,种子缺失时新 ID 与旧记录主键冲突,水位随之失效。 ## 7. 出站信箱(COUTMSGS) 本系统一侧的规则如下(出站适配与请求协调**尚未实现**;请求生命周期的机制目标见 design.md §4.2,本节不重复): 出站前在 `REQ_TRACK` 登记 `REGISTERED`;写入 `COUTMSGS` 并确认落信后置 `SENT` 并关联出站记录 ID。"落信"指写入信箱成功,不等于下游已读取或已发送,交付承诺仅到落信为止(US-08)。 主/共享删除顺序违例时 SIS 要求向 AODB 回发 EROR(`SIS_AODB_RMS-V0.1.md` §1.6.1-1.d,事件定义见 SIS §4.8);当前没有该出站路径,取舍见 Q14。 本系统写入的行由谁消费、按什么顺序消费,**当前未知**。以下事项在 Q10 关闭前按未知处理,不得作为已具备能力描述: - 消费方与消费顺序; - `COUTMSGS_ACK_DATE_RECV`、`COUTMSGS_ACK_RESEND_TIMES`、`COUTMSGS_DATE_SENT`、`COUTMSGS_ERROR` 各列的语义与写入责任; - 出站行的清除责任与保留期; - 落信成功但本地未置 `SENT` 时的重复写入风险及下游去重契约。 参考事实:legacy 的 SIS 接口规范记载各系统持有各自的 `COUTMSGS` 副本,由 JDBC Adapter 读取后发送至 CIIMS;同时记载从 RMS 发出的报文不执行 CIIMS 确认(`SIS_AODB_RMS-V0.1.md` §2.4.2.2.2、§2.4.2.2.4)。这两点是否在现场沿用、与 `COUTMSGS_ACK_*` 列的关系如何,均属 Q10 的确认范围,不作为现状依据。 ## 8. 自有记录归档 - `PROC_STATE`:终态记录归档至 `PROC_STATE_HST`(尚未建表)。未达终态的记录不归档;归档不得使"同一业务身份只能绑定一条有效处理记录"的去重能力失效(US-11)。 - `SCHD_SNAP_LOG`:保留 90 天,清理窗口与判据见 design.md §6.2。 - 航班当前态的清理规则唯一归属 [flight-state.md](flight-state.md) §6;其判据不依赖信箱原文。信箱原文的可用性仅影响重放能力(§9)。 ## 9. 重放与原文可用性 - 可重放的错误类别为 `CODEC_ERROR / UNSUPPORTED / INFRA / EXHAUSTED` 四类(以 design.md §6.1 为准);重放处理当前状态,不恢复历史顺序。 - 重放的前提是信箱原文仍可读取。**该前提只在"标记 + 保留期"清除语义下才能由配置满足**:`R_keep` 必须覆盖(人工重放期限 + 人工处置期限)(§6)。若库方是"打标即清除",则需独立的原文保留通道(§5.2、§12 G10)——**增大 `R` 无效**。 - 确认信箱行或原文已缺失时,按 `MALFORMED → DEAD` 处理;共享库超时、连接失败等暂时性读取异常按 `INFRA` 退避重试,不得伪装成原文缺失。若原文缺失源于库方违反保留契约提前清除,按契约违例走运维追责通道(契约条款见 Q7/Q9);该消息的死信处置本身不变。 - **回填阶段的"信箱行缺失"是另一回事**(§5.2):它表示"本系统已经处理完、却找不到要标记的那一行"。此时不改变处理终态,也不重新处理业务;需要区分"该 ID 本就属于被判定的永久空洞"与"行被提前清除"。【缺口】当前无终态与告警,见 §12。 ## 10. 开放问题索引 | 编号 | 待确认事项 | 当前假定值 | 阻塞谁 | 风险 | 本文相关章节 | |---|---|---|---|---|---| | Q2 | 信箱 ID 单调承诺;**ID 分配 → 事务可见时延上界**;空洞与迟到处理 | 时延按 5 分钟(`max-commit-delay`,**缺少依据的占位值**;不可由 SIS 报文 `Expiry` 推导) | 快路径的发现完整性声明、空洞老化阈值、补偿扫描窗口 | 迟到小 ID 永久不被发现;空洞被误判为永久 | §5.1、§6 | | Q6 | 重放 deadline 与人工处置期限的取值(唯一作用是决定 `R_keep` 下界) | R = 30 天;重放/处置期限未定 | `R_keep` 的最终取值 | `R_keep` 不足导致重放窗口内原文被清除;**若清除语义为"打标即清除",则任何取值都无效**(§5.2) | §5.2、§6、§9 | | Q7 | 处理标记值集与写权限;原文保留期;处理时间语义 | 写入 `PROCESSED` | 回填值集、原文保留期下限 | 值集不被库方接受;保留期短于重放窗口 | §5.2、§6 | | Q8 | 逐类覆盖清单(积压摸底的类型分布依据) | — | 积压摸底与逐类矩阵 | 类型漏项导致积压处置误判 | §5.3 | | Q9 | 清除执行方与 DDL 授权;方案 A / B 选型 | 首选方案 A | `R_keep` 与清除边界 | 方案不可执行;ID 断链 | §6 | | Q10 | 出站消费方、ACK 列语义、出站清理与去重契约 | — | 出站信箱 | 重复写入、无法清理 | §7 | | Q11 | 上游 `SEQN` 重置周期与业务身份的日期边界 | 不含日期边界 | 身份算法 | 跨周期误判重复 | design.md §2.2 | | Q12 | 历史积压批次“不再处理”的确认主体、审批留痕与跳过值集 | — | 积压跳过处置 | 无授权跳过或删除 | §5.3 | | Q13 | 日计划缺失可选字段的删除语义(与 SIS §3.16 注释 4 冲突) | 保留未携带字段 | 快照合并 | 与 SIS 语义冲突 | §5.3、flight-state.md §3.1 | | Q14 | 主/共享删除顺序与 EROR 回报义务 | 自动幂等级联 | 出站事件类型 | 上游状态不一致 | §7、flight-state.md §3.3 | Q2、Q6–Q14 的完整登记见 [user-stories.md](user-stories.md) §6 的 Q 表。 ## 11. 不变量 - 五个事实互不替代:入队不引用信箱标记,回填不引用投递,投递不引用回填。 - 发现与处理互不阻塞:收报只看 `ID > W`,终态而未回填的行不阻断后续消息的发现;处理状态不改变扫描谓词。 - 队头唯一:任一时刻只有一个可执行队头,`FAILED` 未退避到期时后续消息不得越过。 - 只领取已发现的行:主泵只领 `MSG_ID ≤ W`;水位之外的行只可能来自兼容入口,必须等水位追平后按序处理(否则会越过尚未入队的较小 ID)。 - 处理终态不可逆:已提交的 `SUCCEEDED` 不因回填或投递失败回改。 - 处理标记单调:任何路径只将空标写为已处理,不回撤、不覆盖。 - 回填只针对终态:`PENDING / FAILED` 永不写信箱标记。 - 一信一行:每条信箱行在 `PROC_STATE` 至多一条记录(`MSG_ID` 主键);同一业务身份至多绑定一条有效处理记录。 - 水位不越过任何已存在的行;遇空洞即停,只有 §5.1 判定为永久空洞时才放行。 - 写入水位与入队同事务:不允许出现"水位已推进、消息未入队"的持久化状态。 - 执行清除前,边界内全部行已持有处理标记;物理删除仅发生在归档成功之后(追加写入与分区留档均构成归档成功)。 - 信箱 ID 全局单调、不断链:以 Q2 的 ID 单调承诺与 §6 各方案前提成立为条件(方案 B 依赖 `AUTO_INCREMENT` 种子)。 - 对外投递按至少一次设计;端到端恰好一次不在交付范围内。 ## 12. 与当前实现的差异 本节是本文范围内的缺口清单,与 [design.md](design.md) §10 对应。正文描述目标设计;下列条目尚未交付,不得作为已具备能力引用。 | 编号 | 缺口 | 影响 | 相关章节 | |---|---|---|---| | G1 | **窗口补偿扫描未实现**(阶段 0 已交付) | 快路径只读 `ID > W`;水位越过某个 ID 后,该 ID 之后到达的迟到消息永远不会被补入队。**已实现只读迟到检测**(`msgx.pipeline.late_arrival.detected.total` + 告警),把静默丢失变为可观测;**补入队(阶段 1)仍未实现**,因此不能声明"迟到不越序" | §5.1、§4 | | G2 | ~~**兼容入口不参与水位**~~(**已修**) | 兼容入口登记的行超出水位,主泵只领 `msgId ≤ W`,因此不会越过尚未入队的较小 ID;代价是需等水位追平(且收报轮询必须运行)。收报侧事实与端到端验收均有用例 | §5.1、§4 | | G3 | ~~**回填"信箱行缺失"无终态**~~(**已修 · V4**) | 运行时确认 `MISSING` 即放弃自动重试(原因 `MISSING_ROW`)并告警;暂时性故障达 `backfill-max-attempts` 后也停止自动重试;两者均可人工恢复,且**不等于**标记已确认 | §5.2、§4、§9 | | G4 | **`RECEIVED_AT` 为 NULL 时 R 兜底失效** | 该行只能靠退避重试;V2 只对存量行用 `UPDATED_AT` 兜底,新行没有 | §5.2、§4 | | G5 | **毒丸升级不在 `MessageLifecycleGate` 内** | 与人工重放并发时,存在"先标 DEAD 并写标记、再被重放拨回 `PENDING`"的窗口。**注意**:回填与重放**共用同一个 gate 单例**这一点已由装配断言守住(`PipelineSmokeTest`),但毒丸路径仍在 gate 之外 | §2、§5.3;design.md §3.2/§6.1 | | G6 | **"预计消化时长"指标无实现定义** | §5.3 验收口径的第三项指标尚无算法与实现(已降级为可推算项) | §5.3 | | G7 | **`max-commit-delay` 默认值缺少依据** | 库方尚未给出"ID 分配 → 事务可见"的时延上界;5 分钟只是占位假定值,**不能由 SIS 报文 `Expiry` 推导**(§5.1)。取值无依据时可能大量误判永久空洞 | §5.1、§6 | | G8 | **Q 编号条款尚未与库方书面确认** | 当前取值均为假定,见 §10 | §10 | | G9 | ~~**回填扫描无公平轮转、无永久失败隔离**~~(**已修 · V4**) | 改用 `ORDER BY BACKFILL_ATTEMPTS, MSG_ID` 公平轮转并排除已放弃行,新行不会再被最旧的一批永久失败行饿死 | §5.2、§3、§6 | | G10 | **"打标即清除"语义下没有原文保留通道** | 若库方清除由打标触发,则**回填成功后当天打标**会让原文当天即可被清除,增大 `R` 无效 → 重放窗口失去保护。需另行约定保留期,或引入独立原文保留通道(如库方归档 `CMINMSGS_HST` 供重放读取);该通道尚未设计 | §5.2、§6、§9 |