leveldb-rubyでRubyからLevelDBを使う
タグ: ruby / 初版公開: 2020-03-07

概要

LevelDBはC++で書かれたKVSのライブラリです。イメージとしてはSQLite3のKVS版のようなものです。LevelDBに書き込んだデータはファイルに永続化されます。leveldb-rubyはLevelDBのRubyバインディングです。leveldb-rubyを使うとLevelDBをHash風のインタフェースで扱うことができます。

前提ソフトウェア

ソフトウェアバージョン備考
Ubuntu18.04-
Ruby2.5.1p57-
leveldb-ruby0.15-

インストール

前提としてLevelDBが必要です。Debian, Ubuntuの場合はlibleveldb-devをインストールして下さい。またネイティブライブラリをビルドできる必要がありますのでそこは別途構築して下さい。

apt-get install libleveldb-dev

インストールするGemはleveldb-rubyです。

gem install leveldb-ruby

もしくはGemfileに以下を記述してbundle installして下さい。

gem "leveldb-ruby"

主な使い方と特徴

LevelDB::DB.newでデータを永続化するディレクトリを指定してLevelDBを開きオブジェクトを作ります。このオブジェクトはHash風のインタフェースで使うことができます。ただしキーと値は文字列(String)である必要があります。LevelDBはバイナリセーフであるためバイナリの文字列でもキーや値として使用できます。

require 'leveldb'

db = LevelDB::DB.new('leveldb_data')

# Hash風に読み書き
p db['foo'] = 'bar' #=> 'bar'
p db['foo'] #=> 'bar'

# deleteで削除
p db.delete('foo') #=> 'bar'
p db.delete('foo') #=> nil

# put, getを使った読み書き
p db.put('foo', 'bar') #=> 'bar'
p db.get('foo') #=> 'bar'

# バイナリのキーや値を扱える
require 'securerandom'

p bin_key = SecureRandom.random_bytes #=> "O\xF3\xA6\xD9\x966F|\xC0\xFC2\tFe}\x9E"
p bin_val = SecureRandom.random_bytes #=> "\x19\xE0\x92\x8Bs\xAE\xC1\xF2d\xCE\xBE\xE1\xD8i0\xBC"

p db[bin_key] = bin_val #=> "\x19\xE0\x92\x8Bs\xAE\xC1\xF2d\xCE\xBE\xE1\xD8i0\xBC"
p db[bin_key] #=> "\x19\xE0\x92\x8Bs\xAE\xC1\xF2d\xCE\xBE\xE1\xD8i0\xBC"

シェルからファイルを見てみればわかりますが、LevelDBのデータは指定したディレクトリ以下に永続化されます。ディレクトリやファイルは勝手に作られデータ量に応じて増えていきます。

$ find leveldb_data
leveldb_data
leveldb_data/LOCK
leveldb_data/MANIFEST-000002
leveldb_data/LOG
leveldb_data/000003.log
leveldb_data/CURRENT

LevelDB::DBはおおよそHashのように使用できますが、fetchなど一部のメソッドは利用できません。メソッドの差分は以下の通りでした。またeachmapできる順序がキーの昇順であるなど動作には細かい違いがあります。

require 'leveldb'

db = LevelDB::DB.new('leveldb_data')

# LevelDB::DBで利用できるメソッド
p db.methods #=> [:get, :close!, :iterator, :close, :keys, :delete, :member?, :inspect, :exists?, :[], :[]=, :contains?, :includes?, :put, :size, :each, :batch, :pathname, :options, :values, :chain, :to_set, :lazy, :to_h, :include?, :max, :min, :find, :to_a, :entries, :sort, :sort_by, :grep, :grep_v, :count, :detect, :find_index, :find_all, :select, :filter, :reject, :collect, :map, :flat_map, :collect_concat, :inject, :reduce, :partition, :group_by, :first, :all?, :any?, :one?, :none?, :minmax, :min_by, :max_by, :minmax_by, :each_with_index, :reverse_each, :each_entry, :each_slice, :each_cons, :each_with_object, :zip, :take, :take_while, :drop, :drop_while, :cycle, :chunk, :slice_before, :slice_after, :slice_when, :chunk_while, :sum, :uniq, :instance_variable_defined?, :remove_instance_variable, :instance_of?, :kind_of?, :is_a?, :tap, :instance_variable_set, :protected_methods, :instance_variables, :instance_variable_get, :private_methods, :public_methods, :public_send, :method, :public_method, :singleton_method, :define_singleton_method, :extend, :to_enum, :enum_for, :<=>, :===, :=~, :!~, :eql?, :respond_to?, :gem, :freeze, :object_id, :send, :to_s, :display, :nil?, :hash, :class, :singleton_class, :clone, :dup, :itself, :yield_self, :then, :taint, :tainted?, :untaint, :untrust, :untrusted?, :trust, :frozen?, :methods, :singleton_methods, :equal?, :!, :==, :instance_exec, :!=, :instance_eval, :__id__, :__send__]

# HashにあってLevelDB::DBにないメソッド
p ({}.methods - db.methods) #=> [:index, :<=, :replace, :clear, :>=, :empty?, :fetch, :shift, :select!, :length, :values_at, :filter!, :delete_if, :reject!, :keep_if, :assoc, :rassoc, :compact, :flatten, :>, :compact!, :to_hash, :to_proc, :<, :default, :rehash, :store, :default=, :default_proc, :default_proc=, :each_value, :each_key, :each_pair, :transform_keys, :transform_keys!, :transform_values, :transform_values!, :fetch_values, :update, :slice, :merge!, :invert, :has_key?, :has_value?, :merge, :key?, :value?, :compare_by_identity, :compare_by_identity?, :dig, :key]

ハッシュにない機能として範囲指定によるアクセスが可能です。LevelDBの内部はLSTが用いられており、データはキーでソートされた状態で保存されます。このためLevelDBは範囲指定でキーと値を取得することができます。以下は範囲指定でeachする例です。

require 'leveldb'

db = LevelDB::DB.new('leveldb_data')

db['001'] = 'a'
db['002'] = 'b'
db['003'] = 'c'
db['004'] = 'd'
db['005'] = 'e'

db.each(from: '002', to: '004') do |k, v|
  p k, v #=> "002" "b" "003" "c" "004" "d"
end

LevelDBのデータはデフォルトでSnappyで透過的に圧縮されます。Snappyによる圧縮がどの程度うまくいくかによりますが、永続化に要する容量はプログラムで扱う際の容量よりも小さくなると期待できます。

LevelDBにはいくつかオプションがあり LevelDB::DB.newの第二引数で指定することができます。Snappyによる圧縮有無の他にブロックサイズやバッファサイズ等を調節することができます。leveldb-rubyで指定できるオプションは Class: LevelDB::Options にまとまっています。以下は圧縮無しに指定する例です。既に作成済みのDBを別のオプションで開くとエラーとなることがあります。

db = LevelDB::DB.new 'leveldb_data', compression: LevelDB::CompressionType::NoCompression
p db.options #=> #<LevelDB::Options:0x0000556b63952c60 @create_if_missing=true, @error_if_exists=false, @paranoid_checks=false, @write_buffer_size=4194304, @max_open_files=1000, @block_size=4096, @block_restart_interval=16, @compression=LevelDB::CompressionType::NoCompression>

性能

LevelDB事態の性能については少々古いですがGoogleによるベンチマークがありますので以下を参照して下さい。SQLite3, Kyoto Cabinet’sと性能を比較しLevelDBが高速だとしています。

雑ですがleveldb-ruby経由で性能を確認してみます。検証環境はAWSのt3.nano(2CPU, 512MB RAM)に30GBのEBSボリュームを利用しています。T2/T3 Unlimitedは有効で、EBSのバーストクレジットは十分な状態です。実行するプログラムは以下のとおりです。念のためページキャッシュをクリアしています。値を作る処理もありますので純粋なleveldb-rubyの性能ではなく参考程度となります。

#!/bin/bash
rm -r leveldb_data

sudo sh -c 'echo 1 > /proc/sys/vm/drop_caches'
bundle exec ruby write.rb

sudo sh -c 'echo 1 > /proc/sys/vm/drop_caches'
bundle exec ruby read.rb
# read.rb
require 'leveldb'
require 'benchmark'

db = LevelDB::DB.new('leveldb_data')

Benchmark.bm do |x|
  x.report "Read" do
    10_000_000.times do |i|
      k = "%016d" % i

      db[k]
    end
  end
end
# write.rb
require 'leveldb'
require 'benchmark'

db = LevelDB::DB.new('leveldb_data')

Benchmark.bm do |x|
  x.report "Write" do
    10_000_000.times do |i|
      k = v = "%016d" % i

      db[k] = v
    end
  end
end

実行結果は以下のとおりです。1,000万回の書き込みに30秒、1,000万回の読み込みに16秒でした。

       user     system      total        real
Write 19.884539  12.670534  32.555073 ( 30.172090)
       user     system      total        real
Read 15.850135   0.020184  15.870319 ( 16.018496)

データをファイルに永続化するためLevelDBの読み書きではIOが発生します。書き込みはデフォルトでバッファリングされます。実際の性能はLevelDBに指定するオプション、OSのページキャッシュ、IO性能に依存します。用途にあわせて計測されることをおすすめします。

想像される用途

ぱっと想像されるのはRubyでハッシュのデータを永続化したいとき、もしくは永続化したデータを再利用したい場合でしょうか。例えばプログラムに対する設定値や辞書のようなデータをLevelDBに保存しておくような使い方が考えられます。

範囲でデータを取得できることに注目した用途もありそうです。例えばキーにタイムスタンプを含めて時系列データを格納し範囲指定で参照するような使い方も考えられます。

実メモリに収まらない大きなデータをハッシュのように扱いたい場合も利用できます。ただしメモリが足りなくなると頻繁にIOが発生して遅くなるため、とりあえず動くようにはできますが、実用的な速度で使えるかは用途次第かと思います。

注意点

並列アクセス

LevelDBの制限で、同じLevelDBを開けるプロセスは1つです。マルチスレッドで読み書きすることはできますが、マルチプロセスで読み書きすることはできません。複数のプロセスで同時に同じLevelDBを開くとIO error: lock leveldb_data/LOCK: Resource temporarily unavailable (LevelDB::Error)が発生します。

leveldb-rubyのメンテナンス

これまでleveldb-rubyを紹介してきましたが、このライブラリは残念ながらあまりメンテされていません。Gemに同梱されているLevelDBのバージョンは8年前のものです。LevelDB 1.2に対応するPRも出ていますが放置されています。

LevelDBのRubyバインディングはleveldb-rubyはダウンロード数から最もメジャーだと考えています。他にLevelDBをRubyから扱えるようにするライブラリにはFFI経由でLevelDBを使うleveldbやLevelDBを同梱しないネイティブなバインディングのleveldb-nativeがあります。

アプリケーションでの利用

ファイルにデータを保存するKVSのため複数サーバ、複数プロセスで動作させることが前提になっているWebアプリケーションやワーカーにはあまり適さないかも知れません。

そもそも主キーによる参照やカバリングインデックスによる範囲アクセスであればRDBMSでも十分高速であることが多いためKVSが必要かも一考しましょう。本当にKVSが必要ということであればまずはRedisやAWSのDynamoDBなど一般的なデータストアの使用を検討しましょう。

特にLevelDBによる永続化を目的とする場合はデータの可搬性やバックアップについて考慮が必要です。カジュアルに使い始めることはできますが、実際にLevelDBをどのように活用するかはいろいろ考える必要があります。

利用例

拙作ですが具体的な利用例を紹介します。BestGems.orgではGemのダウンロード数やランキングの推移のデータを保存するのにleveldb-rubyを使っています。他のデータ保持方法も検討しましたが、t3.micro(2CPU, 1GB RAM)にアプリケーション本体とPostgreSQLが同居するタイトな動作環境で、100tps程度のAPI呼び出しに対して十分な処理性能を得られたのはLevelDBだけでした。

データの量は現時点でおよそ1,000万キー、圧縮後の容量にして4GBほどです。LevelDBにアクセスする専用のプロセスを用意しアプリケーションサーバからdRuby経由でRPCでアクセスするようにしています。これまで2年以上これといって問題にならず利用ができています。