减少98%的S3 API调用 | 探索OpenDAL的RangeReader奥秘

减少98%的S3 API调用 | 探索OpenDAL的RangeReader奥秘

💡 原文英文,约2400词,阅读约需9分钟。
📝

内容提要

在GreptimeDB中,使用OpenDAL作为统一的数据访问层。最近,一位同事告诉我,执行Copy From语句从S3导入一个800 KiB的Parquet文件需要10秒。经过一些调查和对OpenDAL文档和实现的审查,我在这里记录并简要总结了我们的发现。OpenDAL的IO操作都围绕着Operator展开,Operator是在main.rs中构建的。OperatorBuilder::new函数中添加了ErrorContextLayer和CompleteLayer两个层,最后调用finish函数。在Copy From场景中,没有添加LruCacheLayer层。在ParquetRecordBatchStream的构造中,使用了BufReader,但实际上是多余的。在RangeReader中,每次调用seek都会重置内部状态,并在下一次read调用时发起新的远程请求。这导致了性能问题。

🔎

延伸解读

未启用缓存层是性能瓶颈的关键

在Copy From场景中,构建Operator时没有添加LruCacheLayer,导致每次读取都直接访问S3。相比之下,查询场景通过LruCacheLayer将S3文件缓存到本地文件系统,减少远程调用。缺少这一层使得小文件导入也产生大量S3请求,显著增加延迟。

RangeReader的seek与read交互导致请求放大

ParquetRecordBatchStream读取每列数据时,会先调用RangeReader的seek再read。每次seek都会重置RangeReader内部状态,丢弃之前的字节流,使得后续read必须发起新的远程请求。对于包含50个RowGroups和12列的800KiB文件,最终产生了600次S3 GET请求,这是10秒延迟的直接原因。

BufReader在seek场景下反而多余

代码中使用BufReader包装reader,但BufReader在调用seek后会清空内部缓冲区,无法为后续读取提供缓存效益。因此,在频繁seek的Parquet读取模式下,BufReader不仅没有优化性能,还增加了不必要的内存拷贝和状态管理开销。

❓

Q&A

为什么从S3导入一个800 KiB的Parquet文件到GreptimeDB需要10秒?

主要原因是OpenDAL的RangeReader在每次seek后都会重置内部状态,导致后续read发起新的远程请求。Parquet读取时,每个RowGroup的每一列都会触发一次seek和read,因此800 KiB文件(50个RowGroup,12列)产生了600次S3 GET请求,每次请求约数百毫秒,累计约10秒。

OpenDAL的Operator是如何构建的?添加了哪些层?

Operator通过OperatorBuilder::new(accessor)构建,默认添加ErrorContextLayer和CompleteLayer;调用finish时再添加TypeEraseLayer。在查询场景中还会额外添加LruCacheLayer,但Copy From场景没有添加。

Copy From场景中为什么没有使用LruCacheLayer会导致性能下降?

LruCacheLayer会将S3文件缓存到本地文件系统,读取时直接返回本地文件流,避免重复远程请求。Copy From场景未添加该层,每次读取都直接访问S3,加上RangeReader的seek重置问题,导致大量远程调用,性能下降。

ParquetRecordBatchStream读取元数据时进行了哪些操作?

首先调用seek(SeekFrom::End(-FOOTER_SIZE_I64))读取文件末尾的FOOTER_SIZE字节,解析出metadata_len;然后再次seek到元数据起始位置,读取metadata_len字节并解析元数据。

RangeReader的poll_read方法中状态机是如何工作的?

初始状态为Idle,根据offset是否已知决定进入SendStat或SendRead状态;SendRead状态会发起远程read请求,完成后转为Read状态;Read状态从字节流读取数据,读完后重置为Idle。每次seek都会将状态重置为Idle,导致下一次read重新发起远程请求。

在ParquetRecordBatchStream中使用BufReader有什么问题?

BufReader在调用seek后会清空内部缓冲区,因此无法利用缓冲带来的性能优势,实际上是多余的。

🏷️

标签

➡️

继续阅读