ITADN

Parquet vectored I/O hardcoded ON + on-heap allocator causes executor OOM on S3FileIO reads of high-compression tables (regression in 1.11.0)

#16600Openandyguwc 创建于 2026-05-29
A
andyguwccommented
### Summary Iceberg 1.11.0 made two changes in `iceberg-parquet` that, in combination, force every `S3FileIO`-backed Parquet read through parquet-hadoop's vectored I/O code path using a hardcoded on-heap `HeapByteBufferAllocator`. For tables with high compression ratios (common for JSON / wide-text columns under zstd), this leads to executor `java.lang.OutOfMemoryError: Java heap space` during `BatchScan` reads — especially during MERGE on the target side — with no user-facing configuration to disable the behavior from outside Iceberg source. This regression did not exist in 1.10.x: the prerequisite adapter class was not present, and the read-options builder did not hardcode vectored I/O on. ### Environment - Apache Iceberg: **1.11.0** (`iceberg-spark-runtime-3.5_2.12-1.11.0.jar`) - Spark 3.5 (AWS Glue 5.1 runtime), Java 17 (Corretto 17.0.19) - FileIO: `S3FileIO`, Glue Data Catalog - Affected workers: G.1X (4 vCPU / 16 GB), R.2X (8 vCPU / 64 GB), various counts - Affected table characteristics (observed across ≥5 production CDC tables): - `write.parquet.compression-codec=zstd` - `write.target-file-size-bytes=536870912` (512 MB) - default `write.parquet.row-group-size-bytes` (128 MB) - observed zstd compression ratios: **7.4×, 7.6×, 13.2×, 14.3×** ### Symptom `java.lang.OutOfMemoryError: Java heap space` during target-side `BatchScan` of a `MERGE INTO`, cascading executor losses (`Remote RPC client disassociated`), job aborts after 4 task retries on the same stage. Representative stack trace: ``` java.lang.OutOfMemoryError: Java heap space at java.base/java.nio.HeapByteBuffer.<init>(HeapByteBuffer.java:64) at java.base/java.nio.ByteBuffer.allocate(ByteBuffer.java:363) at org.apache.iceberg.shaded.org.apache.parquet.bytes.HeapByteBufferAllocator.allocate(HeapByteBufferAllocator.java:34) at org.apache.iceberg.parquet.ParquetIO$ParquetRangeReadableInputStreamAdapter$$Lambda$.../apply(Unknown Source) at org.apache.iceberg.io.RangeReadable.readVectored(RangeReadable.java:108) at org.apache.iceberg.parquet.ParquetIO$ParquetRangeReadableInputStreamAdapter.readVectored(ParquetIO.java:180) at org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader.readVectored(ParquetFileReader.java:1357) at org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader.readAllPartsVectoredOrNormal(ParquetFileReader.java:1274) at org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader.internalReadRowGroup(ParquetFileReader.java:1185) at org.apache.iceberg.shaded.org.apache.parquet.hadoop.ParquetFileReader.readNextRowGroup(ParquetFileReader.java:1135) at org.apache.iceberg.parquet.ParquetReader$FileIterator.advance(ParquetReader.java:161) ... at org.apache.iceberg.spark.source.BaseReader.next(BaseReader.java:139) ``` ### Root cause (line-cited against `apache-iceberg-1.11.0`) Two changes landed together in 1.11.0: **1.** `ParquetIO` introduced a new adapter that exposes parquet-hadoop's multi-range `readVectored` to any `SeekableInputStream` that also implements `RangeReadable` (which includes `S3InputStream`): [`parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java#L141-L185`](https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java#L141-L185) ```java static class ParquetRangeReadableInputStreamAdapter< T extends org.apache.iceberg.io.SeekableInputStream & RangeReadable> extends DelegatingSeekableInputStream implements RangeReadable { ... @Override public void readVectored(List<ParquetFileRange> ranges, ByteBufferAllocator allocate) throws IOException { List<FileRange> delegateRange = convertRanges(ranges); delegate.readVectored(delegateRange, allocate::allocate); } } ``` **2.** `Parquet.ReadBuilder` unconditionally enables vectored I/O when building `ParquetReadOptions` for the reader path: [`parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java#L1486`](https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java#L1486) ```java optionsBuilder.withUseHadoopVectoredIo(true); ParquetReadOptions options = optionsBuilder.build(); ``` There is no config lookup, no `if` branch, no honoring of `parquet.hadoop.vectored.io.enabled`. Even when a user explicitly sets `spark.hadoop.parquet.hadoop.vectored.io.enabled=false` (which flows into the Hadoop Configuration), line 1486 overrides it back to `true`. Empirically confirmed by identical post-override stack traces. In combination, every `S3FileIO`-backed read going through Iceberg's batch/row reader path now uses parquet-hadoop's vectored read codepath. The Parquet vectored read allocates one `ByteBuffer` per column chunk via the allocator returned by `ParquetReadOptions.getAllocator()`. That allocator defaults to `HeapByteBufferAllocator.getInstance()` because Iceberg never calls `.withAllocator(...)` on the builder. For a 128 MB compressed row group on a table with 14× compression ratio, this single allocation pre-stages ~128 MB on-heap to be decompressed to ~1.5 GB working set per row group. With 4 concurrent tasks per executor on a G.1X-class worker (~10 GB heap), cumulative on-heap pressure overruns the JVM. ### Why this is a regression `apache-iceberg-1.10.2` for comparison: - [`ParquetIO.java`](https://github.com/apache/iceberg/blob/apache-iceberg-1.10.2/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java) does NOT contain `ParquetRangeReadableInputStreamAdapter`. Only `ParquetInputStreamAdapter` exists, which does not implement `RangeReadable`. parquet-hadoop's `f instanceof RangeReadable` check fails and `readAllPartsVectoredOrNormal` takes the "Normal" branch. - [`Parquet.java`](https://github.com/apache/iceberg/blob/apache-iceberg-1.10.2/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java) does NOT call `optionsBuilder.withUseHadoopVectoredIo(true)`. Net effect on 1.10.2 and earlier: vectored I/O was effectively inert for `S3FileIO`-backed reads, the on-heap allocator was never invoked for bulk column-chunk reads, and the OOM described above does not occur. ### Why this is impactful - **No outside-source mitigation exists.** Setting `spark.hadoop.parquet.hadoop.vectored.io.enabled=false` is silently overridden by line 1486. There is no Spark / Iceberg configuration that disables this code path. - **No allocator configuration is exposed.** `ParquetReadOptions.Builder` supports `.withAllocator(...)`, but Iceberg does not call it, so users cannot supply `DirectByteBufferAllocator` to move these buffers off-heap. - **The user-visible message is misleading.** `Java heap space` + executor losses suggests "grow your workers," but doing so just works around a misallocation rather than fixing it. The buffers in question are bulk I/O staging — they belong off-heap (alongside Spark shuffle / unified memory), not on-heap. - Production workarounds we've had to apply per affected table: - `spark.executor.cores=2` (reduce concurrent on-heap allocations) - Worker class bump (G.1X → R.1X / R.2X) - Reduce `write.parquet.row-group-size-bytes` from 128 MB to 64 MB or 32 MB, then `rewrite_data_files` with `rewrite-all: true` to migrate existing files Each carries cost (compute), runtime tax (cores=2 halves slot count), or a one-time data-rewrite cost (row-group reduction). ### Proposed fixes Two small, complementary changes resolve the regression: **Fix A — honor the existing Parquet/Hadoop conf for vectored I/O.** Change [`Parquet.java#L1486`](https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java#L1486) from: ```java optionsBuilder.withUseHadoopVectoredIo(true); ``` to either honor the standard Parquet property explicitly (default still `true`): ```java optionsBuilder.withUseHadoopVectoredIo( PropertyUtil.propertyAsBoolean(properties, ParquetReader.HADOOP_VECTORED_IO_ENABLED, // or a new constant true)); ``` …or simply drop the line and let `ParquetReadOptions` resolve from the underlying `ParquetConfiguration` it was built with (the `HadoopReadOptions.builder(conf)` branch at line 1469 already passes the Hadoop conf through). This gives operators an escape hatch matching standard Parquet semantics. **Fix B — make the allocator configurable, default to off-heap for the vectored read path.** Expose a Parquet-allocator selection via an Iceberg read property (e.g. `read.parquet.bytes-allocator=heap|direct`) and route it into the builder: ```java ByteBufferAllocator allocator = selectAllocator(properties); optionsBuilder.withAllocator(allocator); ``` Fix A alone is sufficient to unblock production users immediately. Fix B is the correct architectural answer — bulk Parquet column-chunk staging buffers belong off-heap, not on the JVM execution heap, regardless of whether vectored I/O is opted into. ### Reproduction Any Iceberg table satisfying: - Backed by `S3FileIO` - `write.parquet.compression-codec=zstd` (or another high-ratio codec) - Wide rows / JSON-ish columns (so the uncompressed working set is large) - ≥128 MB row groups (the default) Run a `MERGE INTO` or `BatchScan` workload on Iceberg 1.11.0 with default executor configuration on a worker with ≤16 GB heap. Heap OOM appears in `HeapByteBufferAllocator.allocate` inside `ParquetRangeReadableInputStreamAdapter.readVectored`. Setting `spark.hadoop.parquet.hadoop.vectored.io.enabled=false` does not prevent the OOM — confirmed by identical post-override stack traces. ### Versions tested | Version | Vectored I/O code path | OOM observed | |---|---|---| | 1.7.2 | inert (no `RangeReadable` adapter) | no | | 1.10.0 | inert | no (separate `Connection pool shut down` issue) | | 1.10.2 | inert | no | | **1.11.0** | **active + hardcoded on-heap** | **yes, persistent** | Happy to provide additional event-log metrics or a minimal reproducible Spark job if useful.
0 条评论