尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RocksDB Java API 性能优化实战:JNI 数据搬运基准测试与 PinnableSlice 改造

RocksDB Java API 性能优化实战:JNI 数据搬运基准测试与 PinnableSlice 改造 RocksDB Java API 性能优化实战JNI 数据搬运基准测试与 PinnableSlice 改造【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb导读本文围绕 RocksDB 的 JavaJNIAPI 性能优化展开完整还原了官方在 2023 年 11 月发布的一篇深度技术分析通过合成基准测试基于 JMH系统量化byte[]、ByteBuffer、Unsafe内存等数据载体在 Java/C 之间传输的真实开销并据此指导 API 接口设计与get()实现改造。读者读完后将掌握 JNI 数据搬运各机制的性能差异、RocksJava 中PinnableSlice减少拷贝的底层原理以及一套可直接落地的客户端自供缓冲 批量传输 避免多余拷贝高性能编程准则。背景RocksDB Java API 的两个核心关切RocksDB 是一个可嵌入的、持久化的高性能 key-value 存储库仓库根目录见 README.md。它的 Java 绑定RocksJava通过 JNI 与 C 核心交互而这一层交互的性能与开发体验直接决定了上层 Java 应用的吞吐与易用性。官方博客即本文所依据的 docs/_posts/2023-11-06-java-jni-benchmarks.markdown总结了 Evolved Binary 团队在此方向上的三项工作构建合成基准代码找出 Java 与 C 之间数据传输最高效的方式用合成基准的结论指导 API 接口的合理化设计在 Java API 内做机会性性能优化与修复已经带来了可观的改进。这三条主线构成了本文的全部内容先讲怎么测再讲测出了什么最后讲据此改了什么、怎么改。合成 JNI API 性能基准隔离出数据搬运本身的成本JNI 提供了多种机制在 Java 缓冲区与 C 缓冲区之间搬运数据但这些机制并不平凡JNI 系统必须确保在 JVM 直接控制之外的代码访问期间Java 内存不会被移动、不会被垃圾回收。因此不同的取址/拷贝策略性能差异很大。合成基准仓库中的测试专门用于隔离一个用 C 实现、以 JavaJNIAPI 为上层、数据密集型的典型 Key/Value 存储中 Java 与 C 双向交互的成本。基准使用 Java Microbenchmark Harness (JMH) 搭建可重复的测量环境逐个对比所有候选方案更详细的测量结果维护在 Synthetic JNI performance 仓库的 DataBenchmarks 文档中本文引用其结论。基准模型基准建立在一个简洁的模型之上在 C 侧磁盘数据被表示为内存中的(key, value)映射对一次 fetch 查询期望结果是持有value内容的 Java 对象——可以是标准的byte[]或ByteBuffer也可以自定义一个持有值引用的对象例如指向com.sun.unsafe.Unsafe内存的FastBuffer。候选数据类型所有候选类型在底层其实相互关联主要分为三类1.byte[]字节数组最简单的数据容器。byte[]与 C 之间的传输有三种 JNI 机制GetArrayCritical()在 C 侧直接取得底层数组的 C 指针进入临界区访问GetByteArrayElements()/ReleaseByteArrayElements()获取/释放字节数组内容的引用或拷贝对临界区的要求更宽松但因此更可能或必然产生额外拷贝GetByteArrayRegion()/SetByteArrayRegion()在裸 C 缓冲区与字节数组内容之间传输数据。这类操作必须在拷贝期间钉住数据其底层机制与 critical 操作可能相似也可能不同性能因此可能不同。2.ByteBuffer字节缓冲ByteBuffer抽象了一组字节的集合最初就是为了在某些场景下支持更高效的 I/O 操作而引入的。它分两种间接缓冲indirect标准类型和普通 Java 对象一样使用堆上内存直接缓冲direct包装堆外off-heap内存可直接参与网络 I/O通过ByteBuffer.allocateDirect()分配。在 C 侧可以用JNIEnv.NewDirectByteBuffer()将本地C内存包装成直接字节缓冲用JNIEnv.GetDirectBufferAddress()取得其地址、JNIEnv.GetDirectBufferCapacity()取得容量。3.Unsafe内存com.sun.unsafe.Unsafe.allocateMemory()返回的句柄本质上就是一个指向裸内存的指针C 侧可直接把它当作原生缓冲区使用前提是记录/记住分配的字节数也可以在 C 侧通过NewDirectByteBuffer()把它包装成字节缓冲。Java 侧则由自定义的FastBuffer类提供对该 unsafe 内存的访问。分配成本的排除策略为了单独测出传输机制本身的性能基准把分配排除在测量成本之外测试初始化阶段预先分配一批各类型的缓冲区每次基准运行从预分配的 FIFO 列表取一个现有缓冲区、用完归还。一个小测试已确认这种请求—归还周期的开销与基准 API 调用本身相比可以忽略不计。这一设计值得注意它把分配成本与传输成本彻底解耦从而能清晰地回答哪种传输机制最快而把如何分配作为另一条独立结论见后文经验教训单独讨论。GetJNIBenchmark 测量结果基准在一台无其他负载的虚拟机上运行了约 6 小时误差条很小结果可信度高。大数据规模下的结论间接ByteBuffer带来额外成本它本质上是普通byte[]之上的一层开销JNI 侧只能通过其封装的byte[]访问SetRegion与GetCritical性能相当推测SetRegion的幕后行为与进入临界区 →memcpy()→ 退出临界区非常相似GetElements系列C 传 Java效率一致地更低落后于SetRegion和GetCritical把数据读入裸内存缓冲Unsafe句柄或 nettyByteBuf的地址与更高效的byte[]操作成本相当读入直接nio.ByteBuffer成本同样相当ByteBuffer作为普通 Java 对象传过 JNI但 JNI 有专门方法取得直接缓冲的地址因此用ByteBuffer的get()成本就是底层 Cmemcpy()的成本。小数据规模下的结论间接ByteBuffer仍是最大的开销来源同样可以归结为相对byte[]的纯额外开销在最小数据规模下nettyByteBuf与 unsafe 内存比byte[]略高效比直接nio.ByteBuffer略低或相当。这可以解释为即便只是为了取得直接缓冲地址C 侧也需要调用一次 JNI 模型存在微小成本。这些差距以纳秒计非常小。结果后处理拷贝出缓冲的成本基准的后处理模型是把结果转移到byte[]中——即使结果本身已是byte[]这一额外成本看似不公平但其目的是模拟任何类型结果的最少处理步骤。结论如下用byte[]的批量方法把结果拷入byte[]与nio.ByteBuffer性能相当用 unsafe 方法逐字word by word访问Unsafe缓冲区内容是低效的因为访问是在 Java 侧逐字进行的nettyByteBuf的内容访问同样低效推测同样是用普通 Java 机制逐字访问。PutJNIBenchmark 测量结果对Put方法做了类似但深度较浅的合成基准足以确认其性能画像与get()相似/对称与get()一样用GetElements实现 C/JNI 与 Java 对象之间的传输是性能最差的方案而其他 JNI 机制彼此差异不大。合成 API 的经验教训性能分析表明对于get()只要内部数据传输使用 JNI region 方法取数据到预分配的byte[]与其他任何机制一样高效在 Java 侧把结果拷出或使用直接而高效使用byte[]可以避免直接nio.ByteBuffer所需的手动内存管理而后者多出的工作量并没有带来任何收益C 实现中GetRegionJNI 方法比GetCritical更值得优先采用二者性能持平但GetRegion是更高级、更简单的抽象。最关键的一条教训无论选择哪种 JNI 传输机制缓冲区的分配机制与分配模式对最终性能至关重要。基准中引入了 netty 的池化分配器pooled allocator参与测试getIntoPooledNettyByteBuf使用池化分配器与getIntoNettyByteBuf同其他基准一样在 setup 阶段预分配之间的性能差异非常显著。另一条同等重要的教训只要条件允许数据应批量传输使用数组拷贝或缓冲拷贝机制并且值得考虑在底层 C 层支持常见的变换操作。API 设计建议测量结果中存在一定噪声但核心共识可以概括为两条铁律不需要的拷贝不要做Dont make copies you dont need to make能避免的分配/释放尽量避免Dont allocate/deallocate when you can avoid it将其翻译成高效 API 的设计准则RocksJava 需要做到支持由客户端提供结果缓冲的 API 方法支持byte[]型 API作为把数据送入广泛 Java 用途可直接使用形态的最简途径支持直接ByteBuffer当ByteBuffer参与一连串基于ByteBuffer的操作时可以减少拷贝。这种流式模型最可能被性能敏感型客户端采用因此决定支持它支持间接ByteBuffer理由有二其一直接/间接缓冲 API 保持一致其二实现简单——可以直接包装byte[]导向的方法继续保留每次调用自行分配返回缓冲的方法因为初次接触 RocksDB API 的用户用起来最顺手。高性能的 Java 与 RocksDB 交互最终需要客户端做出架构层面的决策在性能敏感处使用更复杂的客户端自供缓冲API 方法不要做不必要的分配/释放在合理之处回收复用自有缓冲或者确保你传入 RocksDBget()/put()的正是最终目的地缓冲你的缓存、或目标网络缓冲区。依照这些原则官方当时正通过 Java API consistency between RocksDB.put(), .merge() and Transaction.put(), .merge() 这一 PR 在 Java 的 fetch 与 store 两类 API 上统一新增一批方法。优化减少 API 实现内部的拷贝分析完 JNI 性能后团队重新审查了 RocksJNI 核心代码寻找优化机会并注意到一个突出问题Java API 的部分get()方法尚未利用PinnableSlice新方法。修复它是一个直接了当的改动并已合入代码库对应 PRImprove Java API get() performance by reducing copies。性能结果用该 PR 中同步更新的 JMH 性能测试本文仓库中对应实现见 GetBenchmarks.java可以对各个get()方法变体运行基准java -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar -p keyCount1000,50000 -p keySize128 -p valueSize1024,16384 -p columnFamilyTestType1_column_family GetBenchmarks.get GetBenchmarks.preallocatedByteBufferGet GetBenchmarks.preallocatedGet纵轴为吞吐量ops/sec越高越好。可以看到经过 PR 增强的各个get()方法变体性能都出现了小而一致的提升。说明当前仓库中该 JMH 基准的可调参数见 GetBenchmarks.java包括列族测试类型no_column_family/1_column_family/20_column_families/100_column_families、keyCount、keySize与valueSize命令行示例中的-p参数即用于覆盖其中的若干项benchmark 会先向各列族写入keyCount条数据并flush再测量读取吞吐。原理分析从std::string到PinnableSlice在PinnableSlice出现之前RocksDB 最基础的原生Get()API 是这样的Status Get(const ReadOptions options, ColumnFamilyHandle* column_family, const Slice key, std::string* value)引入PinnableSlice之后新代码的正确写法是Status Get(const ReadOptions options, ColumnFamilyHandle* column_family, const Slice key, PinnableSlice* value)但 RocksDB 必须兼容旧代码因此在db.h中有一个inline方法用后者重新实现前者。而 RocksJava API 的实现此前一直无缝沿用基于std::string的get()——这正是问题所在。考察 Java 调用get()时发生了什么。以 JNI 入口Java_org_rocksdb_RocksDB_get__JJ_3BII_3BIIJ为例对应源码见 rocksjni.cc旧实现路径是创建一个空的std::string value调用std::string变体的DB::Get()用 JNI 的SetByteArrayRegion()把结果std::string拷入 Java。第 3 步必然产生一次从 C 缓冲区到 Java 缓冲区的拷贝——至少这一次拷贝几乎无法避免。但第 2 步内部做了什么它实际是创建一个PinnableSlice(std::string)把value作为该 slice 的后备缓冲调用PinnableSlice变体的DB::Get()判断 slice 是否钉住了pin数据若是把钉住的数据拷入value并释放若没有钉住数据数据本来就在value中因为尝试钉住但没成功。所以第 2 步也花了一次拷入std::string的拷贝。而 RocksDB 中的缓冲区可能很大多一次大缓冲拷贝是必须重视的成本。修复方法很简单——在 Java APIJNI实现中改为直接使用PinnableSlice当前仓库的实现可见 kv_helper.h 中的JByteArrayPinnableSlice与 kv_helper.h 中的JDirectBufferPinnableSlice创建一个使用自有默认后备缓冲的PinnableSlice()调用PinnableSlice变体的 RocksDBDB::Get()用SetByteArrayRegion()把PinnableSlice指示的数据直接拷入 Java 输出缓冲然后释放 slice判断 slice 是否成功钉住数据若是直接用SetByteArrayRegion()把钉住的数据拷入 Java 输出缓冲然后释放 pin若没有钉住数据数据就在PinnableSlice的默认后备缓冲里同样只需用SetByteArrayRegion()直接拷入 Java 输出缓冲。在PinnableSlice成功钉住数据的情况下这一改造省掉了中间那次到std::string的拷贝在未能钉住的情况下仍存在那次额外拷贝因此观测到的性能提升幅度取决于数据何时能被钉住。好消息是基准结果表明钉住在相当多的场景下都会发生。PinnableSlice 钉住何时生效与 RocksDB 核心团队讨论后确认核心的PinnableSlice优化最可能命中从 block cache 加载的页而不是 memtable 中的页若付出额外编码努力memtable 场景也可能成功钉住那将进一步改善这些基准的结果。这也解释了为什么本仓库当前实现中get()的 JNI 入口已普遍采用JByteArrayPinnableSlice/JDirectBufferPinnableSlice例如 rocksjni.cc、rocksjni.cc 等处的DB::Get()调用而MultiGet则直接使用std::vectorPinnableSlice承载多键结果见 rocksjni.cc——这正是减少拷贝思路在源码中的落地证据。总结可复用的性能准则传输机制选型Get/SetByteArrayRegion与GetCritical性能相当均优于GetElementsbyte[]与直接ByteBuffer都是高效载体间接ByteBuffer和逐字访问的Unsafe/netty 访问应避免分配策略优先缓冲区分配模式比传输机制更影响最终性能尽量预分配、复用、池化批量传输数据搬运应尽量用数组/缓冲的批量拷贝完成API 设计同时提供客户端自供缓冲与调用内分配两套get()/put()接口前者服务性能敏感场景后者保证易用性实现层面在 JNI 侧直接使用PinnableSlice变体的DB::Get()让 block cache 命中的大值读取省掉一次到std::string的中间拷贝。这些准则不仅适用于 RocksDB也适用于任何Java (JNI) C 数据密集架构的系统设计。【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表