185 lines
14 KiB
Markdown
185 lines
14 KiB
Markdown
# 运营航班状态设计
|
||
|
||
状态: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,本文不复述工单流水账。
|