StarRocks 使用 JNI 读取数据湖表引发的堆内存溢出分析

使用StarRocks用于数据湖,实时或离线数仓表查询是一个常见的需求。而大部分湖仓(如Paimon、Iceberg、Hive 等)是Java生态,StarRocks 通过 JNI(Java Native Interface)Connector 支持与这些组件的交互。本文从一条简单的sql查询导致的 OutOfMemoryError,剖析 StarRocks 是在什么情况下会使用 JNI 读取器,分析为啥 BE 也会有堆内存溢出的问题。


问题背景

执行一条查询paimon表的sql

csharp 复制代码
select * from xxx where dt='xxx' limit 10;

执行过程中 StarRocks 的 BE 进程抛出 java.lang.OutOfMemoryError: Java heap space 异常,导致查询失败。

kotlin 复制代码
Exception in thread "Thread-523" java.io.IOException: Failed to open the paimon reader.        at com.starrocks.paimon.reader.PaimonSplitScanner.open(PaimonSplitScanner.java:121)Caused by: java.lang.UnsupportedOperationException: 14600369 cannot be satisfied.        at org.apache.paimon.data.columnar.heap.HeapBytesVector.reserve(HeapBytesVector.java:105)        at org.apache.paimon.data.columnar.heap.HeapBytesVector.appendBytes(HeapBytesVector.java:77)        at org.apache.paimon.format.parquet.reader.BytesColumnReader.readBinary(BytesColumnReader.java:89)        ...        at org.apache.paimon.reader.RecordReaderIterator.<init>(RecordReaderIterator.java:36)        at com.starrocks.paimon.reader.PaimonSplitScanner.initReader(PaimonSplitScanner.java:107)        at com.starrocks.paimon.reader.PaimonSplitScanner.open(PaimonSplitScanner.java:116)Caused by: java.lang.OutOfMemoryError: Java heap space        at org.apache.paimon.data.columnar.heap.HeapBytesVector.reserve(HeapBytesVector.java:100)        ... 20 more

初步怀疑是表数据过大导致,但查询更大的 Paimon 或 Hive 表时问题并未复现。因此,转向分析是否为 paimon reader 模块的 bug,或读取主键表时堆内存未释放导致溢出。


问题分析

于是把 BE 进程的堆内存 dump 下来,通过MAT分析堆内存里的对象,如下

堆内存里的byte[]数组是 Paimon Scanner 在读取数据时生成的大量中间对象,如果人为触发GC,大部分也能被GC回收。于是梳理了下代码分析 BE 是怎么通过 JNI 创建相关的读取器。 通过 GDB 调试 BE 进程,查看初始化 JNI Scanner 的堆栈,确认了堆栈

less 复制代码
#0  starrocks::JniScanner::_init_jni_table_scanner (this=this@entry=0x7f2335864600, env=env@entry=0x7f2348db6340, runtime_state=runtime_state@entry=0x7f2300ebd000)    at be/src/exec/jni_scanner.cpp:101#1  0x0000000007b25fcd in starrocks::JniScanner::do_open (this=0x7f2335864600, state=0x7f2300ebd000) at be/src/exec/jni_scanner.cpp:52#2  0x0000000007b36ce7 in starrocks::HdfsScanner::open (this=this@entry=0x7f2335864600, runtime_state=runtime_state@entry=0x7f2300ebd000) at be/src/exec/hdfs_scanner.cpp:219#3  0x0000000007aaa202 in starrocks::connector::HiveDataSource::_init_scanner (this=this@entry=0x7f233593a800, state=state@entry=0x7f2300ebd000)    at be/src/connector/hive_connector.cpp:736#4  0x0000000007aab431 in starrocks::connector::HiveDataSource::open (this=0x7f233593a800, state=0x7f2300ebd000) at be/src/connector/hive_connector.cpp:185#5  0x00000000048459ec in starrocks::pipeline::ConnectorChunkSource::_open_data_source (this=this@entry=0x7f24e2576150, state=<optimized out>, state@entry=0x7f2300ebd000,     mem_alloc_failed=mem_alloc_failed@entry=0x7f247dc5ec4f) at be/src/exec/pipeline/scan/connector_scan_operator.cpp:757#6  0x0000000004845c2d in starrocks::pipeline::ConnectorChunkSource::_read_chunk (this=0x7f24e2576150, state=0x7f2300ebd000, chunk=0x7f247dc5eda0)    at be/src/exec/pipeline/scan/connector_scan_operator.cpp:784    ...

在 hive_connector.cpp 代码里判断是创建对应的 JNI_Scanner 还是使用StarRocks原生的 Scanner。

其中,Avro、RCFile 和 SequenceFile 等文件格式是通过 Java Native Interface(JNI)读取的,而非 StarRocks 原生 Reader。因此,对比 Parquet 和 ORC 等格式,这些文件格式的读取性能可能较低,因为多了一层 c++ 调用 java的开销。

scss 复制代码
    if (_datacache_options.enable_cache_select) {        scanner = new CacheSelectScanner();    } else if (_use_partition_column_value_only) {        DCHECK(_can_use_any_column);        scanner = new HdfsPartitionScanner();    } else if (use_paimon_jni_reader) {        scanner = create_paimon_jni_scanner(jni_scanner_create_options).release();    } else if (use_hudi_jni_reader) {        scanner = create_hudi_jni_scanner(jni_scanner_create_options).release();    } else if (use_odps_jni_reader) {        scanner = create_odps_jni_scanner(jni_scanner_create_options).release();    } else if (use_iceberg_jni_metadata_reader) {        scanner = create_iceberg_metadata_jni_scanner(jni_scanner_create_options).release();    } else if (use_kudu_jni_reader) {        scanner = create_kudu_jni_scanner(jni_scanner_create_options).release();    } else if (format == THdfsFileFormat::PARQUET) {        scanner = new HdfsParquetScanner();    } else if (format == THdfsFileFormat::ORC) {        scanner_params.orc_use_column_names = state->query_options().orc_use_column_names;        scanner = new HdfsOrcScanner();    } else if (format == THdfsFileFormat::TEXT) {        scanner = new HdfsTextScanner();    } else if ((format == THdfsFileFormat::AVRO || format == THdfsFileFormat::RC_BINARY ||                format == THdfsFileFormat::RC_TEXT || format == THdfsFileFormat::SEQUENCE_FILE) &&               (dynamic_cast<const HdfsTableDescriptor*>(_hive_table) != nullptr ||                dynamic_cast<const FileTableDescriptor*>(_hive_table) != nullptr)) {        scanner = create_hive_jni_scanner(jni_scanner_create_options).release();    }

Hive表的是否走JNI读取器是在be侧,根据文件的格式来决定。AVRO, RC_BINARY, RC_TEXT, SEQUENCE_FILE 文件格式是通过 JNI 读取的,而PARQUET, ORC, TEXT这些使用StarRocks原生Reader。

而对于paimon表,是在FE里根据逻辑决定是否使用JNI读取器,具体见 PaimonScanNode.java

  • 当会话变量 forceJNIReader 为 true, 无论文件格式如何,都会使用 JNI 读取器
  • 如果 DataSplit 无法转换为 RawFile 则使用 JNI 读取器。或者,即使转为RawFile,但文件格式未知(fromType(p.format()) == THdfsFileFormat.UNKNOWN),也会使用 JNI 读取器
  • 对于非 DataSplit 类型,如Paimon 系统表,直接使用 JNI 读取器处理
scss 复制代码
        boolean forceJNIReader = ConnectContext.get().getSessionVariable().getPaimonForceJNIReader();        Map<BinaryRow, Long> selectedPartitions = Maps.newHashMap();        for (Split split : splits) {            if (split instanceof DataSplit) {                DataSplit dataSplit = (DataSplit) split;                Optional<List<RawFile>> optionalRawFiles = dataSplit.convertToRawFiles();                if (!forceJNIReader && optionalRawFiles.isPresent()) {                    List<RawFile> rawFiles = optionalRawFiles.get();                    boolean validFormat = rawFiles.stream().allMatch(p -> fromType(p.format()) != THdfsFileFormat.UNKNOWN);                    if (validFormat) {                        Optional<List<DeletionFile>> deletionFiles = dataSplit.deletionFiles();                        for (int i = 0; i < rawFiles.size(); i++) {                            if (deletionFiles.isPresent()) {                                splitRawFileScanRangeLocations(rawFiles.get(i), deletionFiles.get().get(i));                            } else {                                splitRawFileScanRangeLocations(rawFiles.get(i), null);                            }                        }                    } else {                        long totalFileLength = getTotalFileLength(dataSplit);                        addSplitScanRangeLocations(dataSplit, predicateInfo, totalFileLength);                    }                } else {                    long totalFileLength = getTotalFileLength(dataSplit);                    addSplitScanRangeLocations(dataSplit, predicateInfo, totalFileLength);                }                BinaryRow partitionValue = dataSplit.partition();                if (!selectedPartitions.containsKey(partitionValue)) {                    selectedPartitions.put(partitionValue, nextPartitionId());                }            } else {                // paimon system table                long length = getEstimatedLength(split.rowCount(), tupleDescriptor);                addSplitScanRangeLocations(split, predicateInfo, length);            }        }

以 Paimon Scanner 为例,jni_scanner.cpp 中定义了 JNI 调用流程:

  • 获取 Java 类和方法:通过 JNI 接口找到目标 Java 类及其方法。
  • 调用 Java 方法:使用 JNI 函数调用 Java 的静态或实例方法。
  • 处理返回值:从 Java 方法获取返回值并在 C++ 中处理。
ini 复制代码
Status JniScanner::_init_jni_method(JNIEnv* env) {    // init jmethod    _jni_scanner_open = env->GetMethodID(_jni_scanner_cls, "open", "()V");    RETURN_IF_ERROR(_check_jni_exception(env, "Failed to get `open` jni method"));
    _jni_scanner_get_next_chunk = env->GetMethodID(_jni_scanner_cls, "getNextOffHeapChunk", "()J");    RETURN_IF_ERROR(_check_jni_exception(env, "Failed to get `getNextOffHeapChunk` jni method"));
    _jni_scanner_close = env->GetMethodID(_jni_scanner_cls, "close", "()V");    RETURN_IF_ERROR(_check_jni_exception(env, "Failed to get `close` jni method"));
    _jni_scanner_release_column = env->GetMethodID(_jni_scanner_cls, "releaseOffHeapColumnVector", "(I)V");    RETURN_IF_ERROR(_check_jni_exception(env, "Failed to get `releaseOffHeapColumnVector` jni method"));
    _jni_scanner_release_table = env->GetMethodID(_jni_scanner_cls, "releaseOffHeapTable", "()V");    RETURN_IF_ERROR(_check_jni_exception(env, "Failed to get `releaseOffHeapTable` jni method"));    return Status::OK();}

paimon scanner 就是通过 PaimonSplitScannerFactory 这个工厂类创建对应的 PaimonSplitScanner

c 复制代码
// ---------------paimon jni scanner------------------
std::unique_ptr<JniScanner> create_paimon_jni_scanner(const JniScanner::CreateOptions& options) {
    const auto& scan_range = *(options.scan_range);
    const HiveTableDescriptor* hive_table = options.hive_table;
    const auto* paimon_table = dynamic_cast<const PaimonTableDescriptor*>(hive_table);

    std::map<std::string, std::string> jni_scanner_params;
    jni_scanner_params["split_info"] = scan_range.paimon_split_info;
    jni_scanner_params["predicate_info"] = scan_range.paimon_predicate_info;
    jni_scanner_params["native_table"] = paimon_table->get_paimon_native_table();
    jni_scanner_params["time_zone"] = paimon_table->get_time_zone();

    std::string scanner_factory_class = "com/starrocks/paimon/reader/PaimonSplitScannerFactory";
    return std::make_unique<JniScanner>(scanner_factory_class, jni_scanner_params);
}

那么,JVM的堆内存大小又是在哪里设置的呢?分析下start_backend.sh脚本,脚本里设置了JAVA_HOME,并根据其值配置 LD_LIBRARY_PATH 环境变量,以确保 Java 相关的本地库(JNI)能够被正确加载使用。

bash 复制代码
if [ "$JAVA_HOME" = "" ]; then
    echo "[WARNING] JAVA_HOME env not set. Functions or features that requires jni will not work at all."
    export LD_LIBRARY_PATH=$STARROCKS_HOME/lib:$LD_LIBRARY_PATH
else
    java_version=$(jdk_version)
    if [[ $java_version -gt 8 ]]; then
        export LD_LIBRARY_PATH=$JAVA_HOME/lib/server:$JAVA_HOME/lib:$LD_LIBRARY_PATH
        # JAVA_HOME is jdk
    elif [[ -d "$JAVA_HOME/jre"  ]]; then
        export LD_LIBRARY_PATH=$JAVA_HOME/jre/lib/$jvm_arch/server:$JAVA_HOME/jre/lib/$jvm_arch:$LD_LIBRARY_PATH
        # JAVA_HOME is jre
    else
        export LD_LIBRARY_PATH=$JAVA_HOME/lib/$jvm_arch/server:$JAVA_HOME/lib/$jvm_arch:$LD_LIBRARY_PATH
    fi
fi

启动脚本里并没有对堆内存设置大小,JVM这块堆内存是jdk初始化设置的最大堆内存:32178700288(大概30G)

而堆内存的大小是不被算在 BE 内存里,通过配置 mem_limit 指定 BE 内存,如果使用 JNI,top查看实际内存的使用基本上都会超过这个配置的内存大小。

问题解决

StarRocks 通过jni创建PaimonSplitScanner实例,每个scanner实例需要一定的堆内存做merge处理(具体见读主键表堆栈),假设每个实例对堆内存的使用是定量,那么对堆内存的需求跟实例数量成比例。也就是创建太多的scanner实例,而实例数是跟并发有关,因此可以通过减小并发(如果服务是混部可以通过num_cores, mem_limit限制 be 的资源使用也能减少并发)或者在start_backend.sh启动脚本里增加最大堆内存大小(-Xmx)参数,通过提高堆内存大小来解决堆内存溢出问题。 更多大数据干货,欢迎关注我的微信公众号---BigData共享

相关推荐
zxsz_com_cn1 小时前
智能化设备健康管理:中讯烛龙预测性维护系统引领行业变革
大数据·架构
Pigwantofly1 小时前
SpringAI入门及浅实践,实战 Spring‎ AI 调用大模型、提示词工程、对话记忆、Adv‎isor 的使用
java·大数据·人工智能·spring
拓端研究室2 小时前
专题:2025电商增长新势力洞察报告:区域裂变、平台垄断与银发平权|附260+报告PDF、原数据表汇总下载
大数据·人工智能
阿里云大数据AI技术3 小时前
[VLDB 2025]面向Flink集群巡检的交叉对比学习异常检测
大数据·人工智能·flink
青云交3 小时前
电科金仓 KingbaseES 深度解码:技术突破・行业实践・沙龙邀约 -- 融合数据库的变革之力
大数据·数据安全·数字化转型·kingbasees·企业级应用·融合数据库·多模存储
shinelord明3 小时前
【计算机网络架构】网状型架构简介
大数据·分布式·计算机网络·架构·计算机科学与技术
lucky_syq4 小时前
Flink窗口:解锁流计算的秘密武器
大数据·flink
gorgor在码农6 小时前
Elasticsearch 的聚合(Aggregations)操作详解
大数据·elasticsearch·搜索引擎
Aurora_NeAr8 小时前
大数据之路:阿里巴巴大数据实践——大数据领域建模综述
大数据·后端