ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spark Streaming中foreachRDD的避坑指南:连接管理与性能调优

Spark Streaming中foreachRDD的避坑指南:连接管理与性能调优 1. 为什么foreachRDD是Spark Streaming里最容易翻车的算子刚接触Spark Streaming那会儿我对foreachRDD的理解就是把每个批次的数据拿出来处理一下看起来跟RDD的foreach差不多能有多难结果第一次在生产环境用它往外部存储写数据就踩了个大坑——每个批次都新建一个连接跑了几分钟直接把连接池打满了任务全线卡死。后来复盘才发现问题根本不在业务逻辑而在于我对foreachRDD的执行位置和生命周期完全没有概念。foreachRDD是Spark Streaming中唯一一个逃出DStream抽象、直接操作底层RDD的算子。它的定位非常特殊DStream本身是一系列RDD的序列而foreachRDD让你在每个批次生成的RDD上执行任意操作。这个任意就是双刃剑——它给了你最大的灵活性也把资源管理的责任全部甩给了你。你可以用它写数据库、写消息队列、写文件系统、调用外部接口但每一个操作都需要你自己考虑连接复用、序列化、幂等性、失败重试这些事。这篇文章适合已经写过基础Spark Streaming任务、但对foreachRDD的内部机制和最佳实践还不够清晰的开发者。我会从执行架构讲起把连接管理的坑、序列化的坑、幂等性的坑一个个拆开配上可以直接抄的代码模板。如果你正在用foreachRDD往MySQL、HBase、Kafka或者Redis写数据这篇内容应该能帮你少走不少弯路。2. foreachRDD在Spark Streaming执行链路中的真实位置2.1 DStream的惰性本质与foreachRDD的触发角色要理解foreachRDD得先搞清楚DStream到底是什么。DStream并不是一个装着数据的容器它更像一份生产计划书——描述了数据从哪里来、经过哪些变换、最终要到哪里去。真正承载数据的是每个批次间隔batch interval生成的一个个RDD。这些RDD按时间顺序排列就构成了DStream的底层结构。Spark Streaming的调度器会按照batch interval周期性地触发作业。每个周期内DStream Graph中所有的转换操作会被编译成一个Job交给底层的Spark引擎执行。而foreachRDD就是这个Graph的终点站——它是一个Output Operation只有Output Operation才会真正触发Job的提交。没有Output Operation的DStream Graph就是一张废纸什么都不会执行。这一点跟RDD的惰性求值是一个道理。你写了一堆map、filter、reduceByKey只要没有actionSpark什么都不会做。DStream也一样foreachRDD就是那个action级别的存在。区别在于RDD的action触发一次计算就结束了而foreachRDD会在每个batch interval被反复触发直到StreamingContext停止。2.2 Driver端与Executor端的执行边界foreachRDD最容易被误解的地方就是它的代码到底在哪里执行。很多人以为foreachRDD里面的代码都在Executor上跑其实不是。foreachRDD的方法体本身是在Driver端执行的它接收一个函数func: RDD[T] Unit这个函数在每个批次被调用一次调用发生在Driver上。但函数内部对RDD的操作就不一样了。如果你在foreachRDD里面写了rdd.foreach(...)那么rdd.foreach里面的代码才会被分发到Executor上执行。如果你写的是rdd.collect()数据会被拉到Driver端。如果你写的是rdd.saveAsTextFile(...)写操作由Executor完成。这个边界搞不清楚就会犯一个经典错误在foreachRDD的方法体里直接创建数据库连接。因为方法体在Driver上执行你创建的连接对象在Driver上然后你试图在rdd.foreach里用它——这个连接对象需要被序列化后发送到Executor而数据库连接根本不可序列化直接报NotSerializableException。就算某些连接碰巧能序列化每个Executor上拿到的也是一个反序列化后的副本跟Driver上的原始连接没有任何关系用完了也不会关闭连接泄漏是必然的。2.3 批次间隔与foreachRDD调用频率的关系foreachRDD的调用频率等于batch interval的倒数。如果你设置的batch interval是5秒那么foreachRDD的方法体每5秒被执行一次里面的RDD操作也会每5秒触发一次。这个频率直接决定了你对外部系统的写入压力。我见过有同学把batch interval设成1秒然后在foreachRDD里往MySQL写数据每个批次建一次连接、关一次连接。1秒一次连接创建和销毁MySQL的wait_timeout还没到连接数就已经爆了。这不是MySQL的问题是batch interval和连接管理策略没有匹配好。一般来说batch interval的设置要综合考虑数据量、处理延迟要求和外部系统的承载能力。如果外部系统对连接创建比较敏感要么把batch interval调大要么在foreachRDD内部做连接池复用。这两个方向后面都会详细讲。3. 连接管理的三种写法与它们的性能差距3.1 最直觉但最致命的写法每个RDD建一次连接先看一段很多人一开始都会写的代码dstream.foreachRDD { rdd val connection createConnection() // 在Driver上执行 rdd.foreach { record connection.write(record) // 连接被序列化到Executor必然失败 } connection.close() }这段代码有两个致命问题。第一createConnection()在Driver上执行得到的连接对象要随rdd.foreach的闭包一起序列化发送到Executor绝大多数数据库连接不支持序列化直接抛异常。第二即使某个连接库支持序列化每个Executor上反序列化出来的连接是独立的副本connection.close()在Driver上调用关的是Driver上的那个连接Executor上的连接永远不会被关闭。正确的做法是把连接的创建和关闭都放到Executor端dstream.foreachRDD { rdd rdd.foreachPartition { partition val connection createConnection() // 在Executor上执行 partition.foreach { record connection.write(record) } connection.close() // 在Executor上关闭 } }用foreachPartition代替foreach连接的创建和关闭就都发生在Executor端了。每个分区一个连接而不是每条记录一个连接性能提升是数量级的。但即便如此如果分区数很多每个批次仍然会创建大量连接。假设你的RDD有200个分区batch interval是10秒那么每10秒就会创建和销毁200个连接一分钟就是1200个。对于连接创建开销大的数据库来说这个压力依然不小。3.2 连接池方案用单例对象管理连接生命周期连接池的核心思路是让连接在Executor的JVM生命周期内复用而不是每个批次都重新创建。实现方式是利用Scala的object单例特性——每个Executor的JVM里这个object只会被初始化一次里面的连接池也就只创建一次。object ConnectionPoolManager extends Serializable { transient private var pool: HikariDataSource _ def getPool(): HikariDataSource { if (pool null) { synchronized { if (pool null) { val config new HikariConfig() config.setJdbcUrl(jdbc:mysql://host:3306/db) config.setUsername(user) config.setPassword(pass) config.setMaximumPoolSize(10) config.setMinimumIdle(2) pool new HikariDataSource(config) } } } pool } }然后在foreachRDD里这样用dstream.foreachRDD { rdd rdd.foreachPartition { partition val pool ConnectionPoolManager.getPool() val connection pool.getConnection() try { partition.foreach { record // 用connection写数据 } } finally { connection.close() // 归还连接到池不是真正关闭 } } }这里有几个关键点需要注意。transient注解让pool字段不被序列化因为ConnectionPoolManager这个对象本身会被序列化到Executor但连接池不应该被序列化。getPool()方法里的双重检查锁保证了线程安全因为一个Executor上可能有多个Task并发执行。connection.close()在HikariCP里是归还连接到池不是物理关闭所以可以放心调用。连接池的大小需要根据Executor的核数和并发Task数来调整。如果每个Executor有4个核最多同时跑4个Task那么maximumPoolSize设成4到8就够了。设太大反而会浪费资源还可能把数据库的连接数打满。3.3 三种方案的量化对比方案连接创建频率每批次连接数适用场景风险点每RDD建连接每批次一次1但不可用无序列化失败、连接泄漏每分区建连接每批次每分区一次等于分区数低频批次、小分区数连接创建开销大连接池Executor生命周期一次等于并发Task数高频批次、大分区数池大小配置不当从实际经验来看只要你的batch interval小于30秒或者分区数超过50就应该用连接池方案。每分区建连接的方式只适合batch interval较大比如1分钟以上、分区数较少比如10个以内的场景。4. 序列化问题为什么你的连接对象总是报错4.1 闭包序列化的底层机制Spark在分发Task到Executor时会把Task对应的闭包序列化成字节数组。闭包会捕获它引用的外部变量这些变量必须支持序列化。foreachRDD的方法体在Driver上执行但方法体内部的rdd.foreach或rdd.foreachPartition的闭包会被序列化发送到Executor。如果你在foreachRDD的方法体里定义了一个连接对象然后在rdd.foreach里引用它这个连接对象就会被闭包捕获进而被序列化。数据库连接、网络连接、文件句柄这些资源都不支持序列化所以会直接报java.io.NotSerializableException。更隐蔽的情况是你定义了一个包含连接字段的类这个类实现了Serializable接口但连接字段本身不可序列化。这种情况下序列化会在运行时失败而且报错信息可能不会直接指向连接字段排查起来比较费劲。4.2 transient注解的正确使用姿势transient是Scala和Java都支持的注解作用是标记某个字段不参与序列化。在Spark Streaming的场景里它通常用来标记那些不应该被序列化、需要在Executor端重新初始化的字段。class DataWriter extends Serializable { transient private var connection: Connection _ def write(records: Iterator[Record]): Unit { if (connection null) { connection DriverManager.getConnection(url, user, pass) } records.foreach { record // 用connection写 } } }这里connection被标记为transient序列化时会被跳过。到了Executor端反序列化后connection是null需要在第一次使用时重新创建。这种惰性初始化的模式在Spark里非常常见。但要注意transient只是让字段不被序列化它不会自动帮你重新初始化。你必须在代码里显式判断null并创建连接否则会报NullPointerException。而且如果多个Task共享同一个对象实例还需要考虑线程安全问题。4.3 一个真实的序列化报错排查过程有一次同事找我帮忙看一个任务报错信息是Task not serializable但代码里看起来没有任何明显的不可序列化对象。我让他把foreachRDD里的代码逐行注释最后定位到一行看起来人畜无害的代码val logger LoggerFactory.getLogger(this.getClass) dstream.foreachRDD { rdd rdd.foreach { record logger.info(sProcessing $record) // 这里引用了logger } }问题出在logger上。LoggerFactory.getLogger返回的Logger对象内部持有大量不可序列化的状态被闭包捕获后导致序列化失败。解决办法是在rdd.foreach内部重新获取Logger或者用transient标记Logger字段。这个案例说明序列化问题不一定来自你显式创建的连接对象任何被闭包捕获的、不可序列化的对象都可能成为罪魁祸首。排查的时候最有效的方法就是逐行注释二分定位。5. 幂等性与Exactly-OnceforeachRDD绕不开的难题5.1 At-Least-Once语义下重复写入的根源Spark Streaming默认提供的是At-Least-Once语义。这意味着在发生故障恢复时某些批次的数据可能会被重复处理。对于foreachRDD来说重复处理就意味着重复写入外部系统。重复写入的根源在于Spark Streaming的Checkpoint机制和WALWrite-Ahead Log。当Executor失败时Driver会根据Checkpoint信息重新调度失败的批次。如果失败发生在数据已经写入外部系统之后、但Checkpoint还没更新之前那么这个批次就会被重新执行导致重复写入。举个例子你的任务从Kafka读取数据用foreachRDD写入MySQL。某个批次的数据已经成功写入了MySQL但在更新Checkpoint时Driver崩溃了。恢复后Driver从上一个Checkpoint点重新开始这个批次会被重新处理同样的数据会被再次写入MySQL。如果你的写入操作是简单的INSERTMySQL里就会出现重复记录。5.2 用幂等写入对抗重复数据解决重复写入最直接的办法是让写入操作本身具备幂等性。幂等的意思是同样的操作执行一次和执行多次对外部系统的影响是一样的。对于MySQL常见的幂等方案有三种。第一种是用INSERT ... ON DUPLICATE KEY UPDATE前提是表上有唯一键。第二种是先用REPLACE INTO效果类似。第三种是先DELETE再INSERT但这种方式在并发场景下可能有问题。INSERT INTO user_events (event_id, user_id, event_type, event_time) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_id VALUES(user_id), event_type VALUES(event_type), event_time VALUES(event_time)对于HBase可以利用RowKey的唯一性用Put操作天然实现幂等——同样的RowKey重复写入后写的会覆盖先写的结果是一样的。对于Kafka可以启用Producer的幂等性enable.idempotencetrueKafka会为每条消息分配序列号Broker端会去重。但要注意Kafka的幂等性只在单个Producer会话内有效跨会话的重复还是需要业务层处理。5.3 事务性写入与手动提交Offset的配合如果你需要更强的Exactly-Once保证就需要把数据写入和Offset提交放在同一个事务里。基本思路是在处理每个批次时先开启事务写入数据同时把Offset信息也写入同一个事务最后提交事务。如果事务失败Offset不会提交批次会被重新处理。dstream.foreachRDD { rdd val offsetRanges rdd.asInstanceOf[HasOffsetRanges].offsetRanges rdd.foreachPartition { partition val connection getConnection() connection.setAutoCommit(false) try { partition.foreach { record // 写入业务数据 } // 在同一个事务里写入Offset offsetRanges.foreach { range writeOffset(connection, range) } connection.commit() } catch { case e: Exception connection.rollback() throw e } finally { connection.close() } } }这种方案的关键在于Offset的存储位置必须和业务数据在同一个数据库、同一个事务里。如果Offset存在ZooKeeper或Kafka里就无法和业务数据写入构成原子操作Exactly-Once就无从谈起。需要提醒的是这种方案会显著增加写入延迟因为每个批次都要等事务提交完成。而且如果事务回滚整个批次的数据都要重写对数据库的压力会成倍增加。在实际项目中我通常只在数据准确性要求极高的场景比如金融交易记录才用这种方案普通场景用幂等写入就够了。6. 性能调优让foreachRDD不再成为瓶颈6.1 批量写入代替逐条写入逐条写入是foreachRDD最常见的性能杀手。每写一条记录就发一次网络请求网络往返时间RTT累积起来非常可观。假设RTT是1毫秒写10000条记录就是10秒这还没算数据库端的处理时间。批量写入的思路是把多条记录攒成一个批次一次性发给外部系统。对于JDBC可以用addBatch()和executeBatch()rdd.foreachPartition { partition val connection getConnection() val statement connection.prepareStatement(INSERT INTO ...) var count 0 partition.foreach { record statement.setString(1, record.field1) statement.setInt(2, record.field2) statement.addBatch() count 1 if (count % 500 0) { statement.executeBatch() count 0 } } if (count 0) { statement.executeBatch() } statement.close() connection.close() }批量大小需要根据记录大小和数据库配置来调整。一般来说500到1000条一批比较合适。太小了效果不明显太大了可能超过数据库的max_allowed_packet限制。6.2 控制分区数避免连接爆炸RDD的分区数直接决定了foreachPartition的并发度也决定了同时创建的连接数。如果分区数是500每个分区创建一个连接那么瞬间就会有500个连接打到数据库上。大多数数据库的默认最大连接数是100到200直接就被打满了。控制分区数有几种方式。如果数据源是Kafka可以用directStream的repartition或者调整spark.streaming.kafka.maxRatePerPartition来控制。如果是receive方式可以调整spark.streaming.blockInterval来间接影响分区数。更直接的方式是在foreachRDD之前调用rdd.repartition(n)或rdd.coalesce(n)来显式控制分区数。但要注意repartition会触发Shuffle有额外的开销coalesce不触发Shuffle但只能减少分区数不能增加。我的经验是分区数控制在Executor总核数的2到3倍比较合适。比如你有10个Executor每个4核总共40核那么分区数设在80到120之间。这样既能保证并发度又不会创建过多连接。6.3 异步写入与背压机制的取舍如果外部系统的写入延迟比较高同步写入会拖慢整个批次的处理速度进而导致批次堆积。这时候可以考虑异步写入——把数据放到一个队列里由单独的线程池负责写入foreachRDD只负责入队不等待写入完成。object AsyncWriter { private val executor Executors.newFixedThreadPool(10) private val queue new LinkedBlockingQueue[Record](10000) def start(): Unit { (1 to 10).foreach { _ executor.submit(new Runnable { override def run(): Unit { while (true) { val record queue.take() // 写入外部系统 } } }) } } def write(record: Record): Unit { queue.put(record) // 队列满时会阻塞 } }异步写入能显著提升吞吐量但会带来两个问题。第一如果队列满了put操作会阻塞反而拖慢处理速度。第二异步写入无法保证数据在批次完成时已经落盘如果任务在这期间崩溃队列里的数据就丢了。Spark Streaming本身有背压机制spark.streaming.backpressure.enabledtrue可以根据处理速度动态调整数据摄入速率。但背压只控制摄入不控制写出。如果写出是瓶颈背压会导致摄入速率被压低整体吞吐量上不去。这种情况下要么优化写出逻辑要么增加写出并发度单纯靠背压解决不了问题。7. 那些文档里不会写的实战经验7.1 Checkpoint目录的清理与版本兼容用了foreachRDD的任务通常都需要开启Checkpoint因为要保存Offset和DStream Graph的元信息。但Checkpoint目录会随着时间不断增长如果不清理磁盘很快就会被占满。Spark Streaming本身没有提供自动清理Checkpoint的机制需要手动处理。常见的做法是写一个定时任务定期删除过期的Checkpoint文件。但要注意不能删除正在使用的Checkpoint否则任务恢复时会失败。另一个坑是Checkpoint的版本兼容性。如果你修改了DStream Graph的结构比如增加了一个转换操作从旧Checkpoint恢复时会报错因为新旧Graph不匹配。这种情况下只能删除Checkpoint重新开始或者用spark.streaming.stopGracefullyOnShutdown优雅停止后手动迁移。我的建议是如果业务逻辑可能频繁变更尽量不要依赖Checkpoint恢复而是用外部存储如ZooKeeper或数据库来保存Offset每次启动时从外部存储读取Offset重新构建DStream。这样DStream Graph的变更就不会受Checkpoint限制。7.2 foreachRDD内部的异常处理策略foreachRDD内部的异常如果没被捕获会导致整个批次失败进而触发重试。如果异常是持续性的比如数据库连不上重试会一直失败任务会陷入死循环。合理的做法是在foreachRDD内部做细粒度的异常处理。对于可恢复的异常如网络抖动可以重试几次对于不可恢复的异常如数据格式错误应该记录日志并跳过而不是让整个批次失败。rdd.foreachPartition { partition partition.foreach { record try { // 写入逻辑 } catch { case e: RetryableException // 重试逻辑 case e: Exception // 记录到死信队列或日志 logger.error(sFailed to process $record, e) } } }但要注意如果异常被吞掉了Spark Streaming会认为批次处理成功Offset会正常提交。那些被跳过的数据就永久丢失了。所以跳过数据的前提是你已经把失败的数据记录到了某个地方后续可以人工补偿。7.3 本地测试与生产环境的差异本地测试foreachRDD时通常用local[*]模式所有Task在同一个JVM里跑连接池、单例对象的行为和生产环境完全不同。本地测试通过不代表生产环境没问题。我踩过的一个坑是本地测试时连接池大小设成10跑得好好的到了生产环境每个Executor的JVM里都有一个连接池10个Executor就是100个连接直接把数据库打满了。本地测试时只有一个JVM连接池只有一份所以看不出问题。另一个差异是序列化。本地模式下闭包可能不需要真正序列化因为都在同一个JVM里所以一些序列化问题在本地不会暴露。到了集群模式闭包必须序列化问题才会显现。所以foreachRDD相关的代码一定要在真实集群环境上做集成测试至少要用yarn-client或standalone模式跑一遍才能发现连接池、序列化、并发这些方面的问题。7.4 监控与告警别等任务挂了才发现foreachRDD的写入失败往往不会立即导致任务崩溃而是表现为批次处理时间逐渐变长、数据延迟越来越大。如果没有监控可能等到数据延迟几小时才发现问题。需要监控的指标包括批次处理时间Batch Processing Time、调度延迟Scheduling Delay、每个批次的记录数、写入失败率。Spark Streaming的Web UI提供了前两个指标后两个需要自己在代码里埋点。dstream.foreachRDD { rdd val startTime System.currentTimeMillis() val count rdd.count() rdd.foreachPartition { partition // 写入逻辑 } val endTime System.currentTimeMillis() metricsReporter.report(write_latency, endTime - startTime) metricsReporter.report(record_count, count) }这些指标可以推到Prometheus或Graphite配上告警规则。比如批次处理时间连续5分钟超过batch interval的80%就触发告警。这样在问题恶化之前就能介入处理。8. 一个完整的生产级foreachRDD模板把前面讲的要点整合起来下面是一个可以直接参考的生产级模板。这个模板包含了连接池管理、批量写入、异常处理、幂等写入和监控埋点。object ProductionForeachRDDTemplate { def main(args: Array[String]): Unit { val conf new SparkConf() .setAppName(ProductionForeachRDD) .set(spark.streaming.backpressure.enabled, true) .set(spark.streaming.stopGracefullyOnShutdown, true) val ssc new StreamingContext(conf, Seconds(10)) ssc.checkpoint(hdfs://checkpoint/path) val kafkaParams Map( bootstrap.servers - kafka:9092, group.id - group1, auto.offset.reset - latest, enable.auto.commit - false ) val topics Set(topic1) val stream KafkaUtils.createDirectStream[String, String]( ssc, PreferConsistent, Subscribe[String, String](topics, kafkaParams) ) stream.foreachRDD { rdd val offsetRanges rdd.asInstanceOf[HasOffsetRanges].offsetRanges if (!rdd.isEmpty()) { val startTime System.currentTimeMillis() var totalCount 0L var failCount 0L rdd.foreachPartition { partition val pool ConnectionPoolManager.getPool() val connection pool.getConnection() connection.setAutoCommit(false) val statement connection.prepareStatement( INSERT INTO events (id, data, ts) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE data VALUES(data) ) var batchCount 0 try { partition.foreach { record try { val parsed parseRecord(record) statement.setString(1, parsed.id) statement.setString(2, parsed.data) statement.setLong(3, parsed.ts) statement.addBatch() batchCount 1 totalCount 1 if (batchCount % 500 0) { statement.executeBatch() connection.commit() batchCount 0 } } catch { case e: Exception failCount 1 logger.error(sFailed to process record: $record, e) } } if (batchCount 0) { statement.executeBatch() connection.commit() } } catch { case e: Exception connection.rollback() throw e } finally { statement.close() connection.close() } } // 提交Offset stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges) val endTime System.currentTimeMillis() metricsReporter.report(batch_latency, endTime - startTime) metricsReporter.report(total_count, totalCount) metricsReporter.report(fail_count, failCount) } } ssc.start() ssc.awaitTermination() } }这个模板里有几个设计决策值得说明。用createDirectStream而不是createStream是为了拿到OffsetRanges方便手动提交Offset。enable.auto.commit设为false避免Kafka自动提交Offset导致数据丢失。ON DUPLICATE KEY UPDATE保证了幂等性。批量大小设为500是经验值可以根据实际情况调整。异常处理分两层内层捕获单条记录的异常避免一条坏数据导致整个批次失败外层捕获批量提交的异常回滚事务并抛出让Spark Streaming重试整个批次。连接池的配置需要根据Executor数量和数据库承载能力来调整。如果每个Executor的maximumPoolSize是5有20个Executor那么总连接数是100。要确保数据库的max_connections大于这个值并留出余量给其他应用。监控埋点建议推到外部监控系统不要只打日志。日志在分布式环境下分散在各个Executor上排查问题很不方便。推到Prometheus或类似的系统可以统一查看和告警。这套模板我在多个生产任务里用过稳定性不错。但每个项目的数据库、数据量、延迟要求都不一样具体参数需要根据实际情况调整。比如批量大小数据记录大的时候要调小记录小的时候可以调大。连接池大小也是数据库性能好可以多开一些性能一般就要控制。最后说一个容易被忽略的点foreachRDD里的代码在Driver上执行的部分如果耗时太长比如做了复杂的计算会阻塞Driver的调度线程导致后续批次无法及时提交。所以foreachRDD方法体里尽量只做轻量的操作重活都放到rdd.foreachPartition里让Executor去干。这个边界意识是用好foreachRDD的关键。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表