接上一篇文章 Flink背压:原理、定位与解决,一文搞定!
有小伙伴私聊问说这个对象重用如何理解?本文将对此做个简要介绍
核心原理:JVM 垃圾回收(GC)的压力
在流处理系统中,数据记录(events/records)是以极高的速率流过的。如果一个算子(如 map)为每一条输入数据都创建一个新的对象,那么就会在极短的时间内产生海量的、短命的(short-lived)对象实例。
-
对象创建:
new MyOutputEvent(event)会在JVM的堆内存(Heap)的年轻代(Young Generation)中分配一块新内存。 -
垃圾产生:一旦这个对象被下游算子处理完毕(例如,被序列化通过网络发送出去),它就不再被引用,变成了垃圾。
-
GC触发:年轻代的内存空间是有限的。当海量的新对象迅速填满年轻代(Eden区)时,会频繁触发Minor Garbage Collection。Minor GC的目的是清理年轻代中的死对象。
-
性能瓶颈:虽然Minor GC速度通常很快,但如果发生的频率过高,其累积的开销会变得非常巨大。GC线程会暂停所有应用线程(Stop-The-World)来执行清理工作,这会导致:
- 处理延迟增加:算子实际处理数据的时间被GC挤占。
- 吞吐量下降:CPU时间被大量用于垃圾回收而非数据处理。
- 背压传播:当前算子的处理速度因GC而变慢,无法及时消费上游数据,导致背压向上游传播。
优化原理:对象复用(Object Reuse)
优化的核心思想是:将一个算子实例需要的内存分配从“每条记录”一次变为“每个实例”一次。
- 初始化分配:在算子的生命周期开始时(
open方法),预先初始化一个或多个可重用的对象。这些对象是算子的成员变量,生命周期与算子实例本身一样长。 - 更新而非创建:对于每一条输入数据,我们不再创建新对象,而是更新这个预先分配好的对象的状态(例如,修改它的字段值)。
- 传递引用:将这个更新后的对象的引用发送给下游。
- 零GC压力:由于没有持续的新对象创建,年轻代几乎不会因为此算子的操作而被填满,从而极大地减轻了GC压力,提升了吞吐量和稳定性。
重要警告:对象复用必须小心处理,因为Flink的运行时出于容错和分布式执行的目的,可能会缓存或拷贝记录。如果你在发出对象后,下一条记录到来时又修改了它的内容,而之前发出的对象还未被处理完,就会导致数据错乱。
深入实例:商品交易金额计算
假设我们有一个数据流 DataStream<Transaction>,我们要将每笔交易转换成一种简洁的 TransactionSummary 格式。
1. 糟糕的做法(GC压力大)
DataStream<Transaction> transactions = ...;
DataStream<TransactionSummary> summaries = transactions.map(
transaction -> { // 为每条数据执行一次Lambda
// 每次调用都new一个新对象!GC噩梦!
return new TransactionSummary(
transaction.getTransactionId(),
transaction.getAmount() * transaction.getQuantity(),
transaction.getTimestamp()
);
}
);
2. 正确的优化做法(对象复用)
DataStream<Transaction> transactions = ...;
DataStream<TransactionSummary> summaries = transactions.map(
new RichMapFunction<Transaction, TransactionSummary>() {
// 1. 声明一个可复用的对象引用
private transient TransactionSummary reuseSummary;
@Override
public void open(Configuration parameters) throws Exception {
// 2. 在算子初始化时预先创建对象(每个并行实例只执行一次)
reuseSummary = new TransactionSummary();
}
@Override
public TransactionSummary map(Transaction transaction) throws Exception {
// 3. 更新复用对象的状态,而不是创建新对象
reuseSummary.setTransactionId(transaction.getTransactionId());
reuseSummary.setTotalAmount(transaction.getAmount() * transaction.getQuantity());
reuseSummary.setTimestamp(transaction.getTimestamp());
// 4. 返回这个更新后的对象的引用
return reuseSummary;
}
}
).setParallelism(4); // 假设并行度为4
这个过程发生了什么?
- 如果你的作业并行度是4,那么会有4个
RichMapFunction的实例在不同的TaskManager上运行。 - 每个实例在启动时,会在其
open方法中只创建1个TransactionSummary对象。 - 之后,这个实例处理的所有
Transaction记录(可能是百万条/秒),都共用这1个TransactionSummary对象。每来一条新数据,就更新其内部字段,然后发送出去。 - 从创建对象的角度看,4个并行实例 x 1个对象 = 4个对象,完美避免了海量的对象分配。
注意事项和最佳实践
transient关键字:在RichFunction中,非序列化的成员变量应该标记为transient。因为Flink可能会对算子逻辑进行序列化以发送到各个节点。在open中初始化这些变量是标准模式。- 线程安全:每个算子实例在其自己的线程中运行,所以每个实例独享自己的
reuseSummary对象,不存在多线程竞争问题,是线程安全的。 - 下游兼容性:你必须信任Flink的运行时。当你将同一个对象引用发出后,Flink的序列化器会负责在需要时(如网络传输、 checkpointing)深拷贝(deep copy)对象内容。因此,在你更新复用对象之前,之前发出的数据已经被安全地处理了,不会出现数据覆盖的问题。这是由Flink的可变对象序列化器保障的。
- 适用场景:这种模式在所有无状态算子(如
Map、FlatMap、Filter)中都极其有效。对于有状态算子(如ProcessFunction),状态本身通常由Flink管理,但输出结果仍然可以通过此方式优化。
总结来说,通过RichFunction和成员变量在 open 中初始化对象,在 map 中更新状态并复用,是减少Flink作业GC压力、提升性能的最有效手段之一。
关注我们--跑享网,获取更多大数据技术干货!
加群交流学习,群里有一线大厂的大数据/Java专家、国内流行开源大数据组件和数据库大佬、高层管理等牛逼大佬坐镇,欢迎扫码进群交流学习哈~~
与高手过招,总有棋逢对手的快意,更能激发突破自我的动力。即便此刻你尚未登顶,与强者同行——接触、了解、深耕,终有一日,你也会成为下一个高手。
作者:跑享网
链接:juejin.cn/post/754062…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。