# 运营航班状态设计 状态:PostgreSQL 实现基线,2026-09-08 核对。未完成项在各节明确标注;文件名沿用 v2,兼容历史引用。 配套文档:完成度证据见 [ACM2-29 核查与简化计划](acm2-29-audit-and-simplification.md),字段级行为见 [语义矩阵](flight-state-semantics.md),决策理由见 [决策摘要](decision-flight-state.md)。 ## 1. 系统做什么 系统从共享 MySQL 信箱接收 SIS 报文,把报文合并成本系统的"航班当前状态",再把变化以事件发出。 三条底线: - **一套权威状态。** 读写都指向本地数据库;共享 MySQL 和 Kafka 是提交之后的外部边界,失败各自重试,不会让已提交的状态回滚或重做。 - **一个活动主泵。** 生产同时只有一个实例在处理;事务行锁只保护本地提交,不是多实例协调机制。 - **一条提交事务。** 状态、outbox、处理终态在同一个本地事务里落库,要么全成功要么全回滚。 处理顺序严格按信箱 FIFO,按业务身份去重,失败按退避重试。 数据库:现场提供库时目标是 Oracle 11g;当前实际接通的只有 PostgreSQL,本文描述 PG 实现。 ## 2. 存储:一张主表加九张明细表 ### 为什么不用固定槽位 航班资源是变长集合:登机门通常一两个、值机柜台一到三个、转盘一个,报文规范理论上限 99。ACM2-29 曾定过"全标量宽表、零子表"方案(GATE1/GATE2 式固定槽位),当天撤销——槽位会截断数据,而设计要求保留重复资源的全部属性、输入顺序和源序号。结论:标量进主表,集合进明细表。 ### 表清单 | 表 | 作用 | |---|---| | `FLIGHT_SCHD` | 主表,一行一个 FLID:标量字段、异常前缀列、SRVT/VIPF/MAFL_TEXT、FDAY、STATE_VERSION、LAST_MESSAGE_ID、时间戳。 | | 8 张明细表 | gate、checkin、belt、stand_plan、chute、delay、bridge_op、chock_op;主键 (FLID, ORDINAL)。 | | `FLIGHT_ROUTE_POINT` | 第 9 张明细表,ROUT/ERUT 两类路线共用,ROUTE_KIND 区分。 | | `SCHD_GEN` / `SCHD_GEN_FLID` | 日代版本、最后消息身份、当日成员集合;成员表带外键级联删除。 | | `PIPELINE_LOCK` | 单写者行锁,每个处理事务先锁 FLIGHT_SCHD_WRITER 行。 | | `BACKFILL_TODO` | 回填待办,见第 4 节。 | | `FLIGHT_SCHD_DISPLAY` | PG 展示视图(前两个登机门/柜台、首个转盘/延误、资源数),只是投影,不是状态来源。 | 9 张明细表承载 10 类集合——路线表一张顶两类。 三个要知道的事实: 1. **明细表没有声明外键**,清理由仓储显式执行,不靠数据库级联;SOURCE_SEQ 可空且不唯一,只是记录——逐条定位更新的 Apply 协议因此不铺开(无生产调用方,定位键不可靠)。 2. **差删按日期圈定范围**:先选出目标 FDAY 的实际成员,再删这些成员的明细和主行;跨日迁移和 FDAY 为空的航班不动。(这里曾有 bug:先删明细再圈主行,导致保留航班的集合被误删;已修复并有真实 PG 回归。) 3. **演进纪律**:不为尚无消费者的查询加类型化时间列、摘要、额外载荷表或索引;时间先保留原始字符串,字段与长度以已应用迁移为准。 ### 已知待修 路线表主键仍是 (FLID, ORDINAL):同一航班 ROUT 和 ERUT 各自从序号 1 写起,共存即冲突。计划用新迁移改成 (FLID, ROUTE_KIND, ORDINAL),见 ACM2-30。 ## 3. 航班状态的两条来路:请求/下载与动态数据 状态只有两个来源,各成一条线: - **请求/下载线(基准)**:建立某天航班的基准全量。本系统向 AODB 发日计划请求,AODB 回 SCHD 报文(一整天全部航班),快照整体落库;当天没再出现的航班被差删。 - **动态线(运行事件)**:跟踪运行中的持续变化——实际时间、登机门开关、值机柜台、行李转盘、延误、靠撤桥、轮挡等。FLOP 类事件规范有 29 类,当前已接 GTDT 一类;一条报文只改一个航班的一类资源,其余字段不动。 两条线共用同一个入口和同一套事务/失败规则:进事务后都是"锁内读态 → 合并 → 写库 → 事件入 outbox → 终态 → 提交 → 回填"。 ```text 信箱队头 → 解码 → 绑定身份(去重)→ 按 Handler 路由 ├─ SCHD-DNLD(已接通)────────→ 请求/下载线 ├─ SCHD-RESP / ADFT(未接通)─→ 请求/下载线 └─ FLOP 事件(已接 GTDT)────→ 动态线 ``` ### 公共入口(所有消息都走) 1. **排队**:信箱消息和定时任务共用一个 FIFO 队列,主泵只处理队头;队头失败按退避等待,重试超限或队头滞留超过时限 → DEAD。 2. **解码**:报文非法 → DEAD 不重试;解码器缺陷类错误 → FAILED 退避重试。 3. **绑定身份**:幂等键 = 发送方|类型|子类型|序号,仅首次处理时绑定;已被其他消息占用 → SKIPPED(重复),自身照常回填共享信箱。 4. **路由**:按报文类型找 Handler;没有注册的 Handler → FAILED(可重放,不写终态)。 ### 请求/下载线:基准全量怎么来 SIS 循环:本系统发日计划请求 → AODB 回 SCHD。SCHD 三个子类型载荷结构相同: - **DNLD**:AODB 定时下发的时刻表下载(当前唯一接通的)。 - **RESP**:应答本系统的日计划请求。 - **ADFT**:临时/加班航班,单条航班记录。 请求登记在 REQ_TRACK(同类并发=1,新请求把旧请求强制置 EXPIRED),出站经 COUTMSGS outbox——发送与超时重发尚未接线。 收到下载后的落库步骤: 1. 解析整包并校验,单包上限 10000 个航班(超限 DEAD);真实解析未接通,测试靠注入 staging 数据。 2. 进事务(先锁 PIPELINE_LOCK 行): - **重放短路**:该消息已提交过(日代 LAST_MESSAGE_ID 相同)→ 直接 SUCCEEDED,不加版本、不发事件。 - **算成员差**:新快照里消失的航班进差删名单。 - **合并写入**:锁内逐航班读当前态 → 合并 → 整集合替换,FDAY 置为快照日期。 - **差删**:删差删名单航班的明细和主行(规则见第 2 节)。 - **CAS 推进日代版本**:版本不符回滚 → FAILED(INFRA) 退避。 - **应答匹配**:有待答的 SCHD 请求则标记完成。 - 每个航班一条 KAFKA_SCHD 事件入 outbox;PROC_STATE → SUCCEEDED;提交。 3. 提交后回填共享信箱(失败落 BACKFILL_TODO,见第 4 节);重放短路同样要回填。 现状:只有 DNLD 被路由进这条流程;RESP 和 ADFT 还没有 Handler,进来会记 FAILED(UNSUPPORTED)——"请求 → 应答 → 标记完成"的闭环待接通。参考数据请求(静态主数据 21 类)用同一张 REQ_TRACK,但应答落参考库、由 RequestCoordinator 处理,与航班状态无关。 ### 动态线:运行事件怎么改状态(以 GTDT 为例) 1. **Handler 纯函数决策**:输入 = 受影响航班的当前态 + 报文,输出 = 字段变更和待发事件;这一步不算账、不落库。每类资源的语义在 Handler 里固定,如 GTDT 是登机门集合整体替换(GTNO=0 表示清除)。 2. 进事务(先锁 PIPELINE_LOCK 行): - 每个受影响航班:锁内重读最新态 → 合并字段变更 → 整集合替换写入,保留已有 FDAY。 - 事件(KAFKA_MSG 通知 + KAFKA_SCHD 状态推送)入 outbox;PROC_STATE → SUCCEEDED;提交。 3. 提交后回填共享信箱(同上)。 ### 状态变更的实现(两条线共用) 所有状态变更都走同一套加工链:**报文 → 命令 → 合并出新状态 → 写库**。 #### 数据的统一形态 内存里一个航班的状态就是一个模型(FlightNextState):一组标量键值 + 10 类集合(每类是一组条目,每条目一组键值)+ 版本/消息追踪字段。主表存标量,明细表存集合,列映射固定(如 GTDT 的 GATE/GOTM/GCTM → flight_gate 对应列);读回时按 ORDINAL 组装集合,DB 权威态 = 主行 + 明细行。 #### 第一步:报文变成命令 `commandsFromFields` 把 Handler 解出的字段集翻成命令,规则: - **字段没出现 = 不动**。增量报文只带变化的部分,其余不碰。 - **标量出现 = Set**(覆盖)。异常键(FDIV/FRET/FLAB)载荷为空串、null、空对象时 = Clear。 - **集合出现 = Replace**(整组替换为报文给的条目);整组条目序号属性为 0(如 GTNO=0)= Clear(显式清除);清除标记和正常条目混在一组直接拒绝,不猜。 - DELY 没有协议序号属性,不支持逐条 Apply;清除走空数组替换。 #### 第二步:命令合并进当前态(纯函数) `FlightStateEngine.apply` 从当前态拷贝一份,逐条应用命令:Set 覆盖键值,Clear 删键并记入 clearedKeys;Replace 整组换,Clear 删组。然后版本 +1、记 LAST_MESSAGE_ID,产出 FlightNextState。纯函数:不碰库、不发事件,同样输入永远同样输出。 #### 第三步:新状态写进库(唯一写入口 persistNextStates) - **主表标量**:快照线整行替换(FDAY=快照日期);动态线只 UPDATE 本次出现的列,clearedKeys 对应的物理列显式置 NULL,新航班先插一行(FDAY 为空)。 - **明细集合**:每次写航班,9 张明细表对该 FLID 先 DELETE 再按合并后的集合整组 INSERT——即使某个集合本次没变也重写,保证库里与内存状态严格一致。这就是"整集合替换"的落地:ORDINAL 按输入顺序 1..N,SOURCE_SEQ 存协议序号,RECORD_VERSION 记写入时的版本。 - last_message_id / state_version / updated_at 一并更新。 #### 事件从状态序列化而来 KAFKA_SCHD 载荷 = FlightNextState 序列化(FlightFieldsJson):键按字典序输出保证同状态字节级稳定;集合键输出为真 JSON 数组/对象,不做双重字符串编码。快照线的事件与落库用同一份事务内状态;动态线的事件目前来自 Handler 锁外预览(见已知偏差)。 ### 失败规则(两条线共用) 事务内任何一步失败 → 整体回滚,消息记 FAILED 退避重试。已提交的成功不受后续失败影响:回填和投递失败只落补偿待办,不降级 SUCCEEDED,不重放业务。 ### 不变量(两条线共用) - **状态、事件、终态同事务**:库里能看到的必是完整提交结果。 - **计算是合并不是覆盖**:报文没提的字段一律不动(加工链见上节)。快照更新 FDAY,动态线保留 FDAY,新行为空。 - **快照重放只有一道防线**:LAST_MESSAGE_ID 识别最近一次快照的重放;更早的历史快照重入是否安全,靠人工重放的版本/业务日期策略。 - 字段缺失、清除、空数组、异常对象的精确行为见语义矩阵。 ### 已知偏差(待收敛) GTDT Handler 在事务外算一遍预览并构造事件,主泵在事务里再算一遍。目标是 Handler 只表达业务变化,事务内算出唯一一份 nextState,落库和 KAFKA_SCHD 共用——尚未实现。 ## 4. 提交之后:回填与事件 提交成功后有两件外部事:回填共享 MySQL 信箱、异步投递 Kafka,各自独立重试。 回填恢复靠一条持久待办: - 现状:只在回填失败后写 `BACKFILL_TODO`;提交后立即崩溃的窗口没有待办,恢复不了。 - 目标:在业务终态同一事务里登记回填意图,提交后回填成功再删待办。崩溃自然有账可查,不需要额外的"重启对账框架"。 - 红线:登记待办再失败,不能把已提交的 SUCCEEDED 降级为 FAILED。 ## 5. 怎么读 现在读一个航班 = 主行 1 次 + 10 类集合各 1 次,全量约 1+10N 次查询;多次读不在同一数据库快照,主子状态不保证一致。 后续在仓储边界按 FLID 批次加载明细,并明确一致性读事务;不引入缓存权威或通用查询框架。`GET /all/flights` 仍是 Controller TODO——仓储方法和展示视图存在不等于接口已交付。 KAFKA_SCHD 输出结构化 JSON,集合禁止双重编码成字符串。清除的 wire 表达、空数组、未知属性、异常局部替换仍有验收缺口,见核查报告;一个全字段样例通过不等于整个 SIS 协议无损。 ## 6. Oracle:一整个工作项,不是"等环境" PG 是唯一实现:SqlDialect 只覆盖两条主行 upsert;Oracle11gDialect 是没接线的模板(绑定顺序不兼容、MERGE 列数和占位符对不上);迁移目录只有 README,驱动、其余 SQL、视图、目标库测试都没写。 口径因此是"完整适配并验收":以一条跑通的仓储纵向链定接口,处理 DDL、绑定、空字符串、长文本,再上目标 11.2 实测。入口见 [Oracle 迁移说明](../src/main/resources/db/migration/oracle11g/README.md)。 ## 7. 老数据升级:脚本能跑不等于数据无损 V1.1→V1.4:V1.2 删 *_TXT,V1.3 建空明细表,V1.4 删旧槽位列——旧集合数据没有任何自动恢复链路。带数据的实例升级前必须:导出/备份 → 选完整 DNLD 或可信原文恢复 → 逐字段核对后再切换。已发布迁移不改(不靠改历史隐藏损失),也别指望从已删槽位找回数据。 ## 8. 现状 已验证:PG 上的事务、部分集合往返、CAS、行锁、脚本升级,101 个测试全绿。 未完成:真实 DNLD/RESP 解析与生产路由、`/all/flights`、全字段与空值无损往返、提交后崩溃恢复、带数据无损迁移、查询规模证据、Oracle 实测、现场对拍。分项证据在核查报告与 Plane,本文不复述工单流水账。