16、Flink通过重用对象缓解背压的核心原理介绍

135 阅读5分钟

接上一篇文章 Flink背压:原理、定位与解决,一文搞定!

有小伙伴私聊问说这个对象重用如何理解?本文将对此做个简要介绍

image.png

核心原理:JVM 垃圾回收(GC)的压力

在流处理系统中,数据记录(events/records)是以极高的速率流过的。如果一个算子(如 map)为每一条输入数据都创建一个新的对象,那么就会在极短的时间内产生海量的、短命的(short-lived)对象实例。

  1. 对象创建new MyOutputEvent(event) 会在JVM的堆内存(Heap)的年轻代(Young Generation)中分配一块新内存。

  2. 垃圾产生:一旦这个对象被下游算子处理完毕(例如,被序列化通过网络发送出去),它就不再被引用,变成了垃圾。

  3. GC触发:年轻代的内存空间是有限的。当海量的新对象迅速填满年轻代(Eden区)时,会频繁触发Minor Garbage Collection。Minor GC的目的是清理年轻代中的死对象。

  4. 性能瓶颈:虽然Minor GC速度通常很快,但如果发生的频率过高,其累积的开销会变得非常巨大。GC线程会暂停所有应用线程(Stop-The-World)来执行清理工作,这会导致:

    • 处理延迟增加:算子实际处理数据的时间被GC挤占。
    • 吞吐量下降:CPU时间被大量用于垃圾回收而非数据处理。
    • 背压传播:当前算子的处理速度因GC而变慢,无法及时消费上游数据,导致背压向上游传播。

优化原理:对象复用(Object Reuse)

优化的核心思想是:将一个算子实例需要的内存分配从“每条记录”一次变为“每个实例”一次

  1. 初始化分配:在算子的生命周期开始时(open 方法),预先初始化一个或多个可重用的对象。这些对象是算子的成员变量,生命周期与算子实例本身一样长。
  2. 更新而非创建:对于每一条输入数据,我们不再创建新对象,而是更新这个预先分配好的对象的状态(例如,修改它的字段值)。
  3. 传递引用:将这个更新后的对象的引用发送给下游。
  4. 零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个对象,完美避免了海量的对象分配。

注意事项和最佳实践

  1. transient 关键字:在RichFunction中,非序列化的成员变量应该标记为 transient。因为Flink可能会对算子逻辑进行序列化以发送到各个节点。在 open 中初始化这些变量是标准模式。
  2. 线程安全:每个算子实例在其自己的线程中运行,所以每个实例独享自己的 reuseSummary 对象,不存在多线程竞争问题,是线程安全的。
  3. 下游兼容性:你必须信任Flink的运行时。当你将同一个对象引用发出后,Flink的序列化器会负责在需要时(如网络传输、 checkpointing)深拷贝(deep copy)对象内容。因此,在你更新复用对象之前,之前发出的数据已经被安全地处理了,不会出现数据覆盖的问题。这是由Flink的可变对象序列化器保障的。
  4. 适用场景:这种模式在所有无状态算子(如 MapFlatMapFilter)中都极其有效。对于有状态算子(如 ProcessFunction),状态本身通常由Flink管理,但输出结果仍然可以通过此方式优化。

总结来说,通过RichFunction和成员变量在 open 中初始化对象,在 map 中更新状态并复用,是减少Flink作业GC压力、提升性能的最有效手段之一。

关注我们--跑享网,获取更多大数据技术干货!

加群交流学习,群里有一线大厂的大数据/Java专家、国内流行开源大数据组件和数据库大佬、高层管理等牛逼大佬坐镇,欢迎扫码进群交流学习哈~~

与高手过招,总有棋逢对手的快意,更能激发突破自我的动力。即便此刻你尚未登顶,与强者同行——接触、了解、深耕,终有一日,你也会成为下一个高手。

交流群logo.jpg

作者:跑享网
链接:juejin.cn/post/754062…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。