Skip to content

SidePlugin Java Binding

rockeet edited this page Sep 6, 2025 · 59 revisions

(一)Java 程序迁移到 ToplingDB 只需要极小的工作量

ToplingDB 在 Java Binding 中只增加了必要的 class SidePluginRepo,这是一个非常简单的 class(此处仅列出声明):

class SidePluginRepo extends RocksObject {
    public SidePluginRepo(); // constructor
    public void importAutoFile(String fname) throws RocksDBException;
    public RocksDB openDB(String js) throws RocksDBException;
    public RocksDB openDB(String js, List<ColumnFamilyHandle> out_cfhs) throws RocksDBException;
    public RocksDB openDB() throws RocksDBException;
    public RocksDB openDB(List<ColumnFamilyHandle> out_cfhs) throws RocksDBException;
    public void startHttpServer() throws RocksDBException;
    public void closeHttpServer();
    public void closeAllDB(); // conform to C++ native name CloseAllDB
    public void close(); // synonym to closeAllDB
    public void put(String name, String spec, Options opt); // rarely used
    public void put(String name, String spec, DBOptions dbo);  // rarely used
    public void put(String name, String spec, ColumnFamilyOptions cfo); // rarely used
}
两句题外话

这么小的工作量,我已经多年没碰过 java,jni 更是从来没用过,实现这个 Binding,现学现卖,从头至尾,也只花了不到一天的时间。所以,良好的架构设计,可以极大地减小工作量,如果按照 SidePlugin 的架构,rocksdbjava 中的代码(java 代码与 c++ 各约 4 万行)至少可以缩减 90%。

参考 C++ 版的 101,Java 版的写法没啥大的不同;再参考实际的例子 SideGetBenchmarks,相信大家很容易使用 java 调用 ToplingDB。

其中的 put 方法是为了兼容已有代码:如果现存代码对各种 Option 进行了一些设置,并且这些设置无法通过配置文件进行配置(例如自定义了 java 版的 CompactionFilter),就先把 option put 到 SidePluginRepo,然后再调用 importAutoFile —— importAutoFile 会补充 put option 中的缺失选项,并覆盖同名选项!

将这个 Binding 再精简归纳总结:

    void importAutoFile(String fname) throws RocksDBException;
    RocksDB openDB(4 个重载) throws RocksDBException;
    void startHttpServer() throws RocksDBException;
    void close();
    void put(String name, String spec, 三种 option); // 很少使用

openDB 的四个重载中:

  1. js 参数一般是 json/yaml 中定义的 dbname,极少数情况下可以是 db 的 json 定义
    • 没有该参数的 openDB 指的是打开 json 中 open 对象指定的 db, 这是多数情况
  2. out_cfhs 指的是打开包含多个 ColumnFamily 的 db
    • 没有该参数的 openDB 指的是打开仅使用 default cf 的 db

一般情况下,在 openDB ... startHttpServer ... closeHttpServer ... closeAllDB 中,closeHttpServer 可以省略,因为 http 会在 closeAllDB 中自动关闭,同时,close 是 closeAllDB 的同义词,从而,可以简化为 openDB ... startHttpServer ... close,在使用了 try (...) {} 的情况下,close 也可以省略。

仍然提供 closeHttpServer 的原因在于,用户可能并不想在 db 的整个生存期内一直开启 http

(二)RocksIterator Zero Copy

ToplingDB jni 为 RocksIterator 增加了 Zero Copy 的能力。

ToplingDB 的 native iter 迭代每个 kv 不到 50 纳秒,现有的接口扫描每条 kv 要 4 次 jni 调用并拷贝 kv,zero copy 在无需拷贝的前提下扫描每条 kv 只要 1 个 jni 调用,大幅提升了扫描性能。

2.1 RocksIterator 新增的方法

(点击展开) RocksIterator 新增的方法是 LowLevel 方法,允许用户程序可以通过 Unsafe 接口来使用 ZeroCopy,性能最高,但使用麻烦。
  // 新增的方法,用于支持 Zero Copy
  public Unsafe getUnsafe(); // 方便用户直接获取 Unsafe 实例
  public long getZeroCopyKeyPtr();
  public long getZeroCopyKeyLen();
  public long getZeroCopyValuePtr();
  public long getZeroCopyValueLen();
  public boolean isValueFetched();

  // 如果未使用下述 xxxWithValue 方法而是使用 xxx 方法,需要显式调用
  // fetchValue 通过 jni 获取 value,效率低于 xxxWithValue。
  // 典型场景是 value 很大,同时可通过 key 判断是否需要 fetchValue
  public void fetchValue();

  public void nextWithValue();
  public void prevWithValue();
  public void seekToFirstWithValue();
  public void seekToLastWithValue();
  public void seekWithValue(final byte[] target);
  public void seekForPrevWithValue(final byte[] target);
  public void seekWithValue(final ByteBuffer target);
  public void seekForPrevWithValue(final ByteBuffer target);

2.2 RocksIterator 旧有方法的改进

2.2.1. 旧有方法也支持 zero copy

推荐方案:如果传递给 key()value() 的 ByteBuffer 是由 DirectSlice.newZeroCopyDirectBuffer() 创建的,就会用上 zero copy。

  • 这个方法只是创建一个 DirectBuffer 的“壳”,在 zero copy 中通过 Hack 手段修改其指向的内存;
  • zero copy 通过 Unsafe.putLong/putInt 直接修改用户传入的 DirectBuffer 对象的 (address,capacity) 字段,至少比 DirectSlice.newZeroCopyDirectBuffer() 快 100 倍。所以 DirectBuffer 要重复使用!

最小化迁移:如果现有代码中 DirectBuffer 是由 ByteBuffer.allocateDirect(capacity) 创建的, 要使用 zero copy,需要定义环境变量,并添加 java 的启动参数:

export ROCKSDB_FORCE_DIRECT_BUFFER_ZERO_COPY=true
java --add-opens java.base/jdk.internal.ref=ALL-UNNAMED ....
最小化迁移的细节,太长不看

这就需要在 zero copy 之前,先将原本在 ByteBuffer.allocateDirect(capacity) 中 分配的内存释放掉,这是通过使用反射来调用 DirectByteBuffer.cleaner.clean() 来实现的, 释放之后设置 DirectByteBuffer.cleaner = null。这又是件脏活,因为 jdk.internal.ref.Cleaner 是无法通过常规方式访问的,要使用反射访问,运行时还要额外的选项:

java --add-opens java.base/jdk.internal.ref=ALL-UNNAMED ...

总之,这种方式不是推荐的方案,不仅是因为使用了非标准的技术,还因为这会导致用户申请的内存被 zero copy “借来的内存”代替,有可能破坏现有逻辑:当用户设置 DirectBuffer.position 非零 或 limit 需要的尺寸时。

——因为 zero copy 总是将 DirectBuffer 进行重置:position=0, limit 和 capacity 为其长度(remaining 也等于其长度)。

2.2.2. 不使用 zero copy 时

旧有方法的改进:isValid() 和 byte[] 版的 key()/value() 不再需要单独的 jni 调用。

2.2.3. 旧有方法对 value 也支持 Eager/Lazy Fetch

  • 增加了环境变量 TOPLINGDB_EAGER_FETCH_VALUE,默认为 false;
  • RocksIterator 支持实例级 enableEagerFetchValue(bool),优先级高于环境变量;
  • 这个改进是独立的,无论是否使用 zero copy,这个改进都是生效的。
优势 劣势
Eager Fetch 需要 value 时,减少了 jni 调用 如果不需要访问 value 则造成浪费
Lazy Fetch 不需要访问 value 时,
避免了获取 value 的开销
需要 value 时增加了 jni 调用

2.3. 极速 count

RocksIterator 新增了 countKeysInRange(begKey, endKey[, fixedKeyLen]) 方法,比写 java 代码自己实现该功能快3倍以上。

非 0 的 fixedKeyLen 是个深度优化,需要满足条件:

  • 范围内的 key 全部是定长 -- 该条件必须满足,否则是未定义行为
  • begKey, endKey 长度等于 fixedKeyLen
  • fixedKeyLen 仅对 {8,12,16,20,24,28,32} 有优化
  • 在 MyTopling 中该优化可达每秒 4000 万条 KV

(三)点查的 Zero Copy

要在点查中使用 Zero Copy,必须修改现有代码:
  使用 DirectSlice.newZeroCopyDirectBuffer() 创建 DirectBufer, 并在点查前后使用 ReadOptions.startZeroCopy() / finishZeroCopy(), 为了避免粗心导致错误,推荐使用 ReadOptions.autoZeroCopy().

只有在 startZeroCopy() / finishZeroCopy() 之间,zero copy 才是安全的, 超出这个范围,zero copy 就有可能出错——这个出错的可能性非常低,但必须排除这个可能。

  • Iterator 使用 zero copy 不需要 startZeroCopy() / finishZeroCopy(),因为 Iterator 本身就是一个隐式的 Context 对象,可以保证它引用的内存合法有效,不需要 startZeroCopy() / finishZeroCopy() 来保活。

可以在同一对 startZeroCopy() / finishZeroCopy() 之间执行多个 get 操作,这样做 效率更高,如果可能的话,应该尽量这样做,但是也不应太多,比如多到几千几万个,因为每个 zero copy 的结果在 Native 侧都可能会 Pin 住一些资源,直到 finishZeroCopy 的时候这些资源才会被释放。

旧有的使用 DirectBuffer 的 db.get 支持 zero copy,key, value 参数必须同时都是 DirectBuffer,这是上游 rocksdb 旧有的限制。

get 方法新增重载:key 是 byte[],可选参数 offset 和 len;value 是 ByteBuffer。

multiGetByteBuffers 也支持 zero copy;推荐新增方法 multiGetZeroCopy,更快更易用。

环境变量 ROCKSDB_FORCE_DIRECT_BUFFER_ZERO_COPY 在这里也有效力。

注意事项1:zero copy 总是将 value 的 DirectBuffer 进行重置: 设置 position=0, limit 和 capacity 为 value 长度(remaining 也等于长度)。 当现有代码依赖非0 position 或 limit 非全部 value 长度时, 环境变量 ROCKSDB_FORCE_DIRECT_BUFFER_ZERO_COPY=true 会将它们覆盖,导致 严重错误,甚至出现 segfault。虽然这不是典型用法,但是必须注意这一点。

注意事项2:在 zero copy 作用范围内,不能使用不带 ReadOption 参数的 get/multiGet/keyExists 等方法,因为这些方法在 JNI 端会使用一个临时 ReadOptions 对象,进而导致出现问题。

注意事项3:创建 DirectBuffer 很慢,所以创建出来的 DirectBuffer 必须重复使用才有价值,否则是负优化,参考以下测试(CPU 是 老古董 E5-2682v4):

JIT 预热后 测试 典型耗时
ToplingDB C++ Iter::Next 50 纳秒
ToplingDB C++ DB::Get 200 纳秒
JNI 调用(什么都不干的空 JNI 函数) 40 纳秒
JNI GetByteArrayRegion 4 字节 22 纳秒
DirectSlice.newZeroCopyDirectBuffer() 280 纳秒
60 纳秒
ByteBuffer.allocateDirect(capacity=99) 550 纳秒
Unsafe.allocateMemory/freeMemory
分配尺寸 len 1k ~ 10K 指数分布
190 纳秒
160 纳秒

要想 DirectSlice.newZeroCopyDirectBuffer() 从280纳秒减小到60纳秒,需要添加 --add-opens java.base/java.nio=ALL-UNNAMED 命令行选项。

(四)环境变量

4.1 环境变量 USE_INTERNAL_UNSAFE

主要目的是提高 byte[] 创建即拷贝的性能:

对于返回 byte[] 的现存方法,ToplingDB 尽可能把 Native Slice 返回给 Java 并在 Java 侧创建 byte[] 并进行拷贝,这比在 JNI 侧创建 byte[] 并进行拷贝更快。通常情况下 Java 无法跳过创建 byte[] 时全部填0的初始化,使用 Java9 以上的 jdk.internal.misc.Unsafe.allocateUninitializedArray 可以实现这个优化,因为新创建出来的 byte[] 我们要马上把 Native 传来的 Slice 拷贝到里面, 此时全部填0的初始化纯属浪费。

优化方案:当环境变量 USE_INTERNAL_UNSAFE=true 时,跳过 byte[] 创建时全部填0的初始化,此时必须添加 java 启动参数: --add-exports java.base/jdk.internal.misc=ALL-UNNAMED

4.2 环境变量 ROCKSDB_FORCE_DIRECT_BUFFER_ZERO_COPY

如前所述。

4.3 环境变量 TOPLINGDB_EAGER_FETCH_VALUE

如前所述。

4.4 环境变量 ROCKSDB_GET_PREFER_ZERO_COPY

默认值为 true, 表示 RocksDB.get 优先使用 zero copy, 不过这里的 zero copy 指的是尽量在 java 端执行 copy, 因为可使用 allocateUninitializedArray 来 创建 byte[] 并在 java 端使用 Unsafe 执行 copy, 减少了 JNI 交互, 充分利用 了 JVM 的优化能力。

完全的 zero copy 需要使用带 ByteBuffer value 参数的 get 。

(五)运行 SideGetBenchmarks

步骤 0:安装必备工具和前置依赖

sudo yum -y install git gcc-c++ jemalloc-devel
sudo yum -y install java-latest-openjdk java-latest-openjdk-devel maven
sudo yum -y install libaio-devel gflags-devel zlib-devel bzip2-devel libcurl-devel liburing-devel

步骤 1:编译并安装 toplingdb

git clone https://github.com/topling/toplingdb.git --depth 1
cd toplingdb
git submodule update --init --recursive
ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts # silent git
make -j`nproc` DEBUG_LEVEL=0 shared_lib
sudo make install-shared PREFIX=/opt DEBUG_LEVEL=0

步骤 2: 编译 rocksdbjava

# 请设置正确的环境变量 JAVA_HOME, 这里设置的是 open jdk 的默认路径
# Makefile 会尝试检测 JAVA_HOME, 但最好还是自己指定
export JAVA_HOME=/usr/lib/jvm/jre-openjdk
make rocksdbjava -j`nproc` DEBUG_LEVEL=0

# 安装 librocksdbjni 动态库(可选)
sudo make install-jni PREFIX=/opt DEBUG_LEVEL=0

注意:因为动态库尺寸太大,ToplingDB 编译出来的 jar 包中不包含动态库。

步骤 3: 将 rocksdbjava 加入 maven 仓库

你现在应该位于 toplingdb 仓库的根目录

提示:步骤 3+4 已包装在 toplingdb/java/jmh/build.sh 中。

cd java/target
cp rocksdbjni-8.10.2-linux64.jar rocksdbjni-8.10.2-SNAPSHOT-linux64.jar
mvn install:install-file -Dfile=rocksdbjni-8.10.2-SNAPSHOT-linux64.jar \
    -DgroupId=org.rocksdb -DartifactId=rocksdbjni \
    -Dversion=8.10.2-SNAPSHOT -Dpackaging=jar

步骤 4: 编译 SideGetBenchmarks

假定你现在位于 toplingdb 仓库的根目录

提示:步骤 3+4 已包装在 toplingdb/java/jmh/build.sh 中。

cd java/jmh
mvn clean package

步骤 5: 运行 SideGetBenchmarks

假定你现在已经位于 toplingdb/java/jmh

提示:本步骤已包装在 toplingdb/java/jmh/run.sh 中。

mkdir -p /dev/shm/db_bench_enterprise
cp ../../sideplugin/rockside/src/topling/web/{style.css,index.html} /dev/shm/db_bench_enterprise

# 将 toplingdb 和 librocksdbjni 的目录加入 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/opt/lib:$LD_LIBRARY_PATH

# LD_PRELOAD 至少包含以下两项,libjemalloc 必须是第一个
export LD_PRELOAD=libjemalloc.so:librocksdbjni-linux64.so

java -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar \
     -p keyCount=1000 -p keySize=128 -p valueSize=32768 \
     -p sideConf=../../sideplugin/rockside/sample-conf/db_bench_enterprise.yaml \
     SideGetBenchmarks

运行起来之后,可以访问 http://127.0.0.1:2011 观测 db 状态。

说明 1:社区版用户请将 db_bench_enterprise.yaml 替换为 db_bench_community.yaml,这两个文件都在 toplingdb/sideplugin/rockside/sample-conf 中, sideplugin/rockside 是 ToplingDB 的 submodule。

社区版强行使用 db_bench_enterprise.yaml 也可以正常工作,只是 compact 中不会创建 ToplingZipTable 的 SST,与 yaml 中 level_writers 定义的不一致。

说明 2:设置 LD_PRELOAD 是因为 toplingdb 和 jemalloc 动态库使用了 init exec 类型的 TLS(线程局部存储),这种 TLS 访问速度最快,但使用了这种 TLS 的动态库必须预先加载,或者由可执行文件显式链接。而 java 加载 jni 是由 jvm 动态加载的,如果不用 LD_PRELOAD,会导致动态库中的这种 TLS 初始化失败。

(六)限制

目前使用 Java 实现的组件无法用 Java 代码注册到 SidePlugin 的插件库中,一般情况下这并不是什么严重问题,唯一的问题是使用分布式 Compact 会有些麻烦。

例如在 Java 中实现了自定义的 CompactionFilter/MergeOperator ,因为无法注册到 SidePlugin 的插件库中,所以无法为 dcompact_worker 预加载相应的动态库以运行分布式 Compact。除非使用以下方式:
  (1)实现专门的动态库,在其中用 jni 创建 JVM 并调用 Java 代码,并创建 C++ 包装类,把相应的组件注册到 SidePlugin 中。
  (2)使用 C++ 实现相同功能的插件并注册到 SidePlugin 中,例如数据都是 protobuf 格式,用 C++ 实现相应插件并不困难,而且性能还更高。实际项目中应首选该方案。

例如 flink rocksdb state backend 就是用 C++ 实现的 CompactionFilter,用 jni 包装后再通过 java 来管理

Clone this wiki locally