核心结论:Savepoint 保存的是"Operator ID → State"的映射。UID 的职责是稳定表达算子的逻辑身份,而不是让所有代码版本无条件共用同一份状态。
一、为什么改几行代码也可能恢复失败
Flink 恢复时需要回答:
text
旧任务中的 Source State
Keyed State
Operator State
Sink State
分别应该交给新 Job Graph 中的哪个算子?
如果没有显式 UID,Flink 会根据拓扑生成标识。插入一个 Map、调整 chaining、改变算子顺序或更换 API,都可能让生成标识变化。
于是发生:
text
Savepoint 中存在旧 Operator State
↓
新 Job Graph 找不到对应 Operator ID
↓
恢复失败
更危险的不是失败,而是把状态映射给逻辑上不同的算子。因此 UID 设计必须表达真实业务身份。
二、name、uid 和 uidHash 不是一回事
name()
主要用于 Web UI、日志和可读性。名称改变不应该被当作可靠的状态迁移方案。
uid()
用户提供的稳定逻辑标识,Flink 用它生成 Operator ID 并映射状态。
java
stream
.process(new OrderFunction())
.name("sqlserver-order")
.uid("erp-sqlserver-order-v1");
setUidHash()
直接指定生成后的 UID Hash,通常只在兼容已有状态且已明确旧 Hash 时使用。它更底层,也更容易误用,普通项目优先使用可解释的 uid()。
三、什么叫"逻辑身份稳定"
以下两次发布,算子逻辑身份通常仍相同:
- 修复同一个保序算子的空值处理;
- 优化同一个 Sink 的批量执行;
- 增加指标和日志;
- 调整不改变状态含义的内部实现。
以下变化则可能改变逻辑身份或状态语义:
- Source 监听的表集合发生变化;
- 一个 Source Group 拆成两个;
- Kafka Topic 或 Consumer Group 语义改变;
- KeySelector 从订单 ID 改成客户 ID;
- 状态从"最后 LSN"改成"每事务所有 LSN 集合";
- 算子从过滤旧事件改成允许迟到窗口重排。
不能为了让 Savepoint 强行恢复,就对逻辑上已经不同的算子继续使用旧 UID。
四、为什么 source-group-0 不够稳定
假设第一版分组:
text
group-0 = [order, order_item]
group-1 = [customer, product]
新增一张按字母排序靠前的表后:
text
group-0 = [account, customer]
group-1 = [order, order_item]
group-2 = [product]
如果 UID 仍是 source-group-0,Flink 可能把原来 order/order_item 的 Source State 交给 account/customer 组。
项目的公共工具已经提供以下身份模型:
text
job/system + operator role + sorted identities hash
CdcJobSupport.operatorUid() 会:
- 规范化 Job 名与角色名;
- 对表清单、Topic 等身份字段排序;
- 使用 SHA-256 生成稳定后缀;
- 让配置顺序变化不改变 UID;
- 让实际监听内容变化时 UID 明确变化。
示意:
text
oa-mysql-source-3f4a...
erp-sqlserver-order-postgres-97b2...
这不是为了"任何变化都能恢复",而是防止错误状态被静默映射。
不过,当前专用 ERP/OA Job 和部分手工测试入口仍能看到基于 groupIndex 的 UID。它们在表分组内容变化时仍有状态错配风险。因此这里描述的是项目应统一采用的安全模型,而不是声称所有入口已经完成迁移。正式发布前应导出全部有状态算子的 UID,逐个核对是否仍依赖数字下标。
五、Savepoint 中究竟有什么
可以把 Savepoint 简化为:
text
Operator ID A
├─ Source Split/Offset State
└─ Enumerator State
Operator ID B
└─ Keyed State:key → last LSN
Operator ID C
└─ Sink/Writer State(如果实现保存)
除此之外还有状态文件及元数据。恢复时,新 Job Graph 根据 Operator ID 找回对应状态,再按照并行度将 Key Group 或 Operator State 重新分配。
当前 JDBC Sink 的内存 Buffer 没有进入 Operator State,因此它靠 Checkpoint 回调前 Flush,而不是靠 Savepoint 保存 Buffer。
六、扩缩容时状态怎样迁移
Keyed State
Keyed State 不是按 Subtask 编号永久绑定,而是通过 Key Group 分配。恢复时可以根据新并行度重新分配,但受 Max Parallelism 上限约束。
text
旧并行度 2:subtask 0/1
↓ Savepoint
新并行度 4:subtask 0/1/2/3
↓
Key Group 重新分布
如果 KeySelector 语义变化,即使技术上恢复成功,旧状态也可能失去业务意义。
Operator State
Operator State 的重分配取决于 List/Union/Broadcast 等状态模式。Source Split 通常由 Connector Enumerator/Reader 协调,不能把它等同于普通 Keyed State。
外部系统
并行度变化还会影响:
- MySQL CDC Reader 的 Server ID 范围;
- Kafka Transactional ID 的唯一性;
- Kafka Partition 与消费并行度;
- JDBC 连接总数;
- 每个 Subtask 的 Batch 与内存占用。
Savepoint 能重分状态,不代表外围容量自动匹配。
七、增加、删除和重排算子分别会怎样
增加新有状态算子
旧 Savepoint 没有它的状态,新算子通常从空状态开始。必须确认从空状态开始是否符合业务语义。
删除有状态算子
默认恢复会发现 Savepoint 中存在无人接收的状态并失败。--allowNonRestoredState 可以跳过,但这相当于明确丢弃状态,必须证明该状态已不再需要。
重排算子
显式 UID 稳定时,单纯拓扑顺序变化不一定阻止状态恢复;没有显式 UID 时,自动生成标识很可能变化。
改状态类型
UID 相同也不代表序列化兼容。POJO 字段、Serializer、State Descriptor 和数据结构变化都可能影响状态 Schema Evolution。
八、Checkpoint 与 Savepoint 的升级职责不同
| 维度 | Checkpoint | Savepoint |
|---|---|---|
| 主要目的 | 自动故障恢复 | 人工运维、升级、迁移、扩缩容 |
| 生命周期 | 系统管理,可能自动清理 | 用户显式管理 |
| 可移植性 | 取决于格式和 Claim Mode | 更强调可控恢复与迁移 |
| 典型操作 | TaskManager 故障恢复 | 停服升级、新版本回滚、并行度调整 |
发布新版本时,不能因为有 Externalized Checkpoint 就省略 Savepoint 策略。还应记录版本、Job 参数、配置快照、目标并行度和回滚路径。
九、当前项目的 UID 设计清单
Source
建议身份字段:
text
systemName
sourceDbType
规范化后的真实表清单
Source 角色
Kafka Source
建议身份字段:
text
systemName
topic
downstream role
Consumer Group 决定外部消费进度语义,也应进入发布审查,但不一定直接拼进每个 UID。
SQLServer 保序算子
建议身份字段:
text
systemName
sqlserver-cdc-order
downstreamName
topic
因为它保存同一业务 Key 的最新顺序状态。
Sink
普通无状态 RichSink 是否必须固定 UID,要根据是否参与 Checkpoint 回调和未来演进判断。项目实践上统一显式 UID 更易审计,尤其不要假设框架内置算子一定无状态。
十、一次安全升级流程
text
1. 固化旧版本代码、配置和运行参数
2. 检查最近 Checkpoint 正常
3. 触发并记录 Savepoint 路径
4. 生成新旧 Job Graph / UID 清单
5. 对比新增、删除、变化的有状态算子
6. 检查 State Serializer 与 KeySelector 兼容性
7. 检查新并行度、Max Parallelism 和外围资源
8. 在隔离环境从 Savepoint 恢复
9. 验证 Source 位点、状态数量和业务结果
10. 上线并保留旧版本回滚条件
任何 UID 变化都应该能解释为:
text
逻辑身份确实变化
或
这是未经设计的兼容性风险
十一、必须验证的升级场景
- 只改日志,旧 Savepoint 能恢复
- 表清单顺序变化但集合不变,UID 保持稳定
- 表集合变化后 UID 变化并阻止错误映射
- 保序算子并行度调整后 Keyed State 正确重分配
- 删除有状态算子时默认恢复明确失败
- 使用
--allowNonRestoredState前有状态丢弃评审 - KeySelector 变化不直接复用旧状态
- MySQL Reader 扩容后 Server ID 不冲突
- Kafka Exactly Once Sink 的 Transactional ID 仍唯一
- 新版本失败时能够回滚到旧版本和对应 Savepoint
十二、这套机制不能保证什么
- 相同 UID 不能保证状态 Serializer 一定兼容;
- Savepoint 恢复成功不能证明业务状态语义正确;
- UID Hash 不能替代变更评审;
--allowNonRestoredState不能被当成通用修复参数;- 扩缩容恢复成功不能保证 Kafka、数据库连接和 Source Server ID 容量合理。
十三、一句话总结
text
UID 绑定逻辑身份
Savepoint 保存 Operator ID 到 State 的映射
表组 Hash 防止配置变化造成错误恢复
Keyed State 可以随并行度重分配
状态语义变化必须显式迁移或重新初始化
