摘要:上一篇讲函数类全景时,RichFunction 只是"第二家族";这篇把它单独挖到底。文章从四个维度拆解富函数:接口与继承结构、从实例化到 close 的完整生命周期时序(状态恢复先于 open、链内上游先 open)、RuntimeContext 六类能力(状态/指标/分布式缓存/算子状态/配置/累加器),以及一组真实的序列化与执行陷阱------含非静态匿名类隐式捕获外部 this 导致序列化爆炸、异步 I/O 必须用连接池等。看完能回答"为什么构造器拿不到 RuntimeContext""open 里到底能做什么不能做什么""operator state 和 keyed state 有什么区别"这类进阶问题。
关键词:Flink 富函数、RichFunction、RichMapFunction、生命周期、open/close、RuntimeContext、分布式缓存、operator state、CheckpointedFunction、RichAsyncFunction、序列化陷阱、异步IO
一、从"会用"到"懂执行时机"
大部分 Flink 开发者都会写 RichMapFunction:open 里建连接池,map 里干活,close 里释放。但下面这几个问题,能答上来的就少了一半:
- 为什么连接池必须放在 open() 里建,构造器里建不行?
- open() 被调用时,状态恢复完了吗?从 checkpoint 重启后,open 里看到的是空状态还是恢复好的状态?
- 算子链上有多个 Rich 函数,open 的执行顺序是什么?
new RichMapFunction<>() {...}写在类的实例方法里,为什么作业提交时突然报序列化异常?
这些问题的答案都藏在富函数的执行时序 和上下文注入机制里。上一篇我们看的是函数类全景(四大家族的划分),这篇下沉到 RichFunction 内部,把生命周期、RuntimeContext、序列化机制一次讲透。
二、富函数家族结构:接口四方法 + 模板基类 + 算子变体

富函数不是一个类,而是一族。结构分三层:
第一层:RichFunction 接口 (org.apache.flink.api.common.functions)。定义四个方法:open(Configuration)、close()、setRuntimeContext(RuntimeContext)、getRuntimeContext()。前两个是生命周期钩子,由框架回调;后两个是上下文注入与读取,注意 setRuntimeContext 是框架内部调的,你只调 getRuntimeContext。
第二层:AbstractRichFunction 模板基类 。持有 runtimeContext 字段,open/close 给空实现------所有算子的 Rich 变体都继承它,你写的时候只需要覆盖 open/close 里需要的那部分。
第三层:各算子的 Rich 变体 。RichMapFunction / RichFlatMapFunction / RichFilterFunction / RichCoFlatMapFunction(connect 双流)/ RichSinkFunction / RichSourceFunction / RichAsyncFunction(异步 I/O 专用)。变体只改处理方法的签名(map/flatMap/filter),生命周期三件套完全一致------所以学会一个,全都会了。
还有一个容易漏掉的组合:CheckpointedFunction 接口 。它和 RichFunction 是正交的,可以同时实现:RichFunction 管生命周期和上下文,CheckpointedFunction 管非 keyed 的算子状态 (initializeState / snapshotState 两个方法)。记一个口诀:keyed state 用 getRuntimeContext().getState(),operator state 用 CheckpointedFunction。
三、生命周期时序:状态恢复先于 open,这是最容易被忽略的细节

单个并行子任务的函数实例,完整时序是七步:
- 实例化 :Task 启动,算子链内的函数反序列化,构造器执行。此刻 RuntimeContext 还不存在 ------构造器里调
getRuntimeContext()直接 NPE; - setRuntimeContext 注入:框架内部完成,发生在 open 之前;
- initializeState 状态恢复 :keyed state、operator state、定时器从 StateBackend 恢复;先于 open 完成;
- open(Configuration) :每个并行子任务各执行一次;算子链内按上游 → 下游顺序逐个 open;
- processElement 循环:单线程串行处理;
- checkpoint 周期快照 :贯穿处理期,状态持久化,函数实例本身不参与快照;
- close():任务正常结束时释放资源(故障/强制 cancel 不保证执行)。
用一段带日志的代码验证这个时序(生产排查时这也是标准手法):
java
// 验证:构造 → 注入 → 恢复 → open → 处理 → close 的顺序
DataStream<String> out = source.flatMap(new RichFlatMapFunction<String, String>() {
@Override
public void open(Configuration parameters) throws Exception {
// 此时状态已恢复:从 checkpoint 重启,这里读到的就是恢复好的值
Long restored = state.value(); // state: ValueState<Long>
LOG.info("open: restored={}, subtask={}", restored,
getRuntimeContext().getIndexOfThisSubtask());
// 能拿到并行度、任务名、全局参数、缓存文件------上下文已完整注入
}
@Override
public void flatMap(String value, Collector<String> out) {
// 每条记录调用;单线程,无需加锁
state.update(System.currentTimeMillis());
out.collect(value);
}
@Override
public void close() throws Exception {
LOG.info("close: subtask={}", getRuntimeContext().getIndexOfThisSubtask());
}
});
对应到生产实践,就是三个"一定":
- 连接池、HTTP client、加载的缓存文件,一定放 open()。它们是"每个子任务一份"的,正好和 open 的执行粒度对齐;
- 状态句柄一定在 open() 里注册一次、存成员变量 。每次处理都调
getState()会重复创建句柄对象,纯开销; - 一定不要用构造器做初始化。除了拿不到上下文,还有个隐蔽问题:函数实例要序列化分发,构造器里建好的连接在反序列化后已经无效(连接绑定的是原进程的资源)。
四、RuntimeContext 六类能力:状态、指标、缓存、算子状态、配置

getRuntimeContext() 返回的接口,能力可以分成六类,前四类生产最常用:
4.1 keyed state 句柄(最常见)
ValueState / ListState / MapState / ReducingState / AggregatingState,按 key 隔离,落 StateBackend,随 checkpoint 持久化。前提是 keyBy 之后------非 keyed 流上用会直接报错。
4.2 指标 MetricGroup(可观测性)
java
// open() 里注册,processElement 里打点------比日志统计可靠得多
Counter cnt = getRuntimeContext().getMetricGroup()
.counter("hit_count"); // 计数:累计值
Meter meter = getRuntimeContext().getMetricGroup()
.meter("hit_rate", new MeterView(cnt)); // 速率:每秒增量
Gauge<Long> gauge = getRuntimeContext().getMetricGroup()
.gauge("pending", () -> pendingSize); // 瞬时值:lambda 读取
指标会聚合到 JobManager,可接 Prometheus/Grafana 出面板。计数类指标优先用 MetricGroup.counter,别用累加器 Accumulator------累加器与 checkpoint 快照存在兼容性问题,官方也推荐 metric 优先;累加器只留给"作业结束时拿结果"的少数场景。
4.3 分布式缓存 DistributedCache(词表/规则/模型文件)
java
// 驱动端注册(提交前):
env.registerCachedFile("hdfs://namenode:8020/data/blacklist.txt", "blacklist");
// 函数端 open() 里读取:
File file = getRuntimeContext().getDistributedCache().getFile("blacklist");
文件会被分发到所有 TaskManager 的本地磁盘,每个子任务本地一份,open 里加载进内存即可------避免了每条记录远程读的灾难。注意两点:文件更新要重启作业才生效;超大文件(几百 MB 以上)别走缓存,用外部存储 + 惰性加载。
4.4 operator state(非 keyed 状态)与 CheckpointedFunction
这是很多文章一笔带过、但生产极有用的能力。非 keyed 流(比如 Source、无 keyBy 的算子)没有 key 概念,但可以持有按算子实例分片的算子状态,配合 CheckpointedFunction 实现"跨并行度可恢复的计数/位点":
java
// 示例:全局事件计数(非 keyed 流也能做、能随 checkpoint 恢复)
public class GlobalCounter extends RichFlatMapFunction<String, Long>
implements CheckpointedFunction {
private long localCount = 0; // 普通成员:本实例的计数
private ListState<Long> checkpointed; // 算子状态:checkpoint 用
@Override
public void initializeState(FunctionInitializationContext ctx) throws Exception {
// 恢复/初始化算子状态;isRestored 区分首次启动还是从 checkpoint 恢复
checkpointed = ctx.getOperatorStateStore().getListState(
new ListStateDescriptor<>("count", Long.class));
if (ctx.isRestored()) {
for (Long v : checkpointed.get()) localCount = v;
}
}
@Override
public void snapshotState(FunctionSnapshotContext ctx) throws Exception {
checkpointed.clear();
checkpointed.add(localCount); // 快照时把当前值写入算子状态
}
@Override
public void flatMap(String value, Collector<Long> out) {
localCount++;
out.collect(localCount);
}
}
和 keyed state 的差异要记清楚:keyed state 按 key 分片 (key 分布到哪个子任务,状态就在哪),operator state 按算子实例分片 (每个并行子任务一份,重启时可选择 list 均分或 union 合并再分配);另外 operator state 不支持 TTL,需要自己管理清理。典型应用:Kafka 自定义 Source 的 offset 位点记录、跨并行度的全局计数。
4.5 配置与任务信息
getRuntimeContext().getExecutionConfig().getGlobalJobParameters() 读提交时注入的全局参数(改参数需重启);getNumberOfParallelSubtasks() / getIndexOfThisSubtask() 拿到分片信息,可以做"按子任务编号分目录落盘"这类操作;getTaskName() / getAttemptNumber() 用于日志里定位。
4.6 累加器(历史遗留)
getAccumulator(name) / addAccumulator():作业结束后结果聚合回驱动端。前面说过,计数优先用 metric;别拿累加器当状态用------它既不参与 checkpoint,语义上也不是为跨记录累积设计的。
五、序列化陷阱:为什么作业提交时突然炸了
富函数实例要跨 JobManager → TaskManager 序列化分发,这带来一类高频坑,都围绕"什么被序列化了"。
陷阱 1:非静态匿名类隐式捕获外部 this 🔥
最常见的翻车现场:在某个类的实例方法里写
java
public class OrderProcessor {
private RedisClient redisClient; // 不可序列化的成员
public void run(DataStream<String> s) {
s.map(new RichMapFunction<String, String>() { // ⚠️ 匿名类
@Override
public String map(String v) { return enrich(v); }
});
}
}
匿名类隐式持有外部实例 OrderProcessor.this 的引用 。作业提交时 Flink 序列化这个匿名类,会把 OrderProcessor 整个实例一起序列化------redisClient 不可序列化,直接 NotSerializableException;就算外部类恰好可序列化,也会把一大棵对象树复制到每个 TaskManager,静默拖慢提交甚至 OOM。
修复 :要么把匿名类改成 static 修饰的具名内部类(static 内部类不持有外部 this),要么把函数类独立成文件,要么把外部类实现 Serializable 并确保成员都可序列化。写富函数前先问一句:这个类是不是 static / 独立类?
陷阱 2:transient 资源三件套
连接池、线程池、HTTP client 一律 private transient + open 里建 + close 里关。漏标 transient 会在提交时炸;标了但没在 open 里重建,运行期 NPE------两个错误成对出现,检查顺序:先看声明,再看 open。
陷阱 3:lambda 捕获外部非序列化变量
map(x -> doSth(externalClient, x)) 里捕获了 externalClient,lambda 序列化时同样要序列化它。规则和陷阱 1 一致:函数内引用的外部对象,全都会被拖进序列化。
六、RichAsyncFunction:异步 I/O 的线程模型与连接池
异步 I/O 是富函数家族里特殊的一员------它是唯一回调并发执行的富函数,线程模型和其他成员完全不同,坑也最典型:
java
public class AsyncRedisLookup extends RichAsyncFunction<String, Enriched> {
private transient JedisPool pool; // ⚠️ 必须连接池:asyncInvoke 并发调用
@Override
public void open(Configuration parameters) {
pool = new JedisPool(new JedisPoolConfig(), "redis-1", 6379);
// 初始化连接池发生在 open(主线程);asyncInvoke 在 I/O 线程池并发执行
}
@Override
public void asyncInvoke(String key, ResultFuture<Enriched> resultFuture) {
try {
try (Jedis jedis = pool.getResource()) { // 每次从池里借,用完归还
resultFuture.complete(Collections.singleton(enrich(key, jedis)));
}
} catch (Exception e) {
resultFuture.completeExceptionally(e); // 失败要显式回调,否则任务悬挂
}
}
@Override
public void close() throws Exception {
if (pool != null) pool.close();
}
}
三个要点:连接必须从连接池获取 ------asyncInvoke 被多个线程并发调用,单个 Jedis 实例非线程安全;失败必须 completeExceptionally ------不回调 ResultFuture,这一批数据会一直悬挂,最终触发超时/背压;open 里初始化一次,close 里释放 ------生命周期三件套对异步函数同样成立。异步 I/O 的本质收益是:单条数据的等待时间被并发请求覆盖,吞吐提升一个量级,但代价是顺序性丢失------需要保序的场景要设置 AsyncDataStream.OutputMode.ORDERED。
七、选型阶梯:什么时候从富函数升级到 ProcessFunction

富函数不是终点,它和普通函数、ProcessFunction 构成三级能力阶梯:
- 第 1 级 普通函数:纯转换/过滤,零样板,可链化。不能:生命周期、状态、指标、缓存;
- 第 2 级 富函数 :+ open/close 生命周期、+ RuntimeContext 全能力。覆盖线上 80% 的算子------连接池、词表、指标、状态读写,Rich 版本都够;
- 第 3 级 ProcessFunction :富函数能力之上,再 + 定时器、侧输出、事件时间访问。注意 ProcessFunction 本身继承自 AbstractRichFunction------它是富函数的超集,不是另一个东西。
升级触发点只有三个:要等 watermark 做超时判断 (定时器)、要把迟到/异常数据旁路 (侧输出)、要按 key 定时触发。没有这三个需求,Rich 函数就是性价比最高的选择------ProcessFunction 的样板代码和心智负担并不低,滥用会让代码可读性下降。
八、总结:我的判断
富函数的本质,是 Flink 把"初始化和处理分离"这个工程原则做成了框架级能力:open 是资源挂载点,close 是资源回收点,RuntimeContext 是唯一的外部能力出口。理解它的关键是执行时序------状态恢复先于 open、链内上游先 open、每个子任务独立一份,这三条解释了这个家族 80% 的行为。
给三条实操建议:
- 默认用 Rich 版本:线上 map/flatMap/sink 一律考虑 Rich 变体,哪怕暂时用不到 open------需求来了不用改结构;
- 序列化意识前置:函数类写成 static 或独立类,资源字段 transient,检查清单在写代码时过一遍,而不是等提交报错;
- 按需升级到 ProcessFunction:只有定时器/侧输出/事件时间三个需求值得升级,别为了"显得高级"把简单的转换写成 ProcessFunction。