こんにちは。開発部最後の良心、mikioです。今回はLua処理系の並列化とそこでのKyoto Cabinetの利用法についてご紹介します。

サーバサイドスクリプティングといえばLua

Kyoto CabinetのLuaバインディングは後回しにしてKyoto Tyrant的なサーバの設計を進めていたのですが、やはりそのサーバにもスクリプティング機能を持たせたくなりました。つまり、サーバがデフォルトで提供する機能群だけでなく、ユーザがスクリプト言語で記述した任意の機能を追加して利用できるようにするということです。

Tokyo TyrantではLua拡張と呼ばれる機能を用いてそれを実現しています。サーバの起動時にLuaのスクリプトを記述したファイルを読み込ませて、そこで定義した関数をリモートから呼び出せるようにしています。そこで実行されるLuaの処理系にはTTが管理するデータベースを操作するためのオブジェクトが予め定義されているので、それを介して任意のデータ処理を行うことができます。

Kyoto Tyrant(仮称)の設計はまだまだ固まっていませんが、おそらくLuaを組み込むことになるでしょう。TTでは独自のDB接続用モジュールを書いていましたが、そのモジュールの構造はTCのLuaバインディングと酷似するものでした(というかほとんどコピペで作りました)。二重管理が嫌いな私としては、KTではKCのLuaバインディングそのものを呼び出す形でスクリプティング機能を実現したいと思います。

なぜLuaなのかという点については以前の記事でも述べましたが、言語仕様が小さくて覚えやすく、処理系も高速で軽量でリエントラントだからです。

Luaバインディング

先日、KCのLuaバインディングをリリースしました。TCのそれよりもさらに使いやすくなっています。KCのJava、Python、Ruby、Perlの各バインディングと同様に、言語共通のIDLに準拠するインターフェイスを備えるとともに、Lua言語に合わせたスタイルで操作できるようにもしています。具体的なAPIやサンプルについてはAPI文書をご覧ください。

Luaならではのインターフェイスと言えばクロージャとメタテーブルと汎用for文でしょうか。

require("kyotocabinet") -- KCのモジュールをロードする
kyotocabinet.import()   -- kyotocabinet.*をグローバル名前空間に取り込む

-- データベースを操作するためのコールバック関数
function dbproc(db)

   -- メタテーブルによってテーブルインターフェイスで書き込み
   db["foo"] = "hop"
   db["bar"] = "step"
   db[3] = "jump"

   -- クロージャによるトランザクションでレコードを更新
   function tranproc()
      db["foo"] = 2.71828
      return true
   end
   db:transaction(tranproc)

   -- Visitorパターン風にレコードを更新
   function mulproc(key, value)
      return tonumber(value) * 2
   end
   db:accept("foo", mulproc)

   -- 汎用for文でレコードを横断取得
   for key, value in db:pairs() do
      print(key .. ":" .. value)
   end

   -- Visitorパターン風に全レコードを更新
   function upproc(key, value)
      return string.upper(value)
   end
   db:iterate(upproc)

   -- 外部カーソルでレコードを横断取得
   function curproc(cur)
      cur:jump()
      function printproc(key, value)
         print(key .. ":" .. value)
         return Visitor.NOP
      end
      while cur:accept(printproc) do
         cur:step()
      end
   end
   db:cursor_process(curproc)

end

-- 上記関数をデータベースに適用
-- (openやcloseが不要なところがナイス)
DB:process(dbproc, "casket.kch")

Luaの実力

裏の仕組みは違えども、上記と同じような使い方はPythonやRubyやPerlでもできるので、Luaの利点として挙げるようなものではありません。というかミニマリズムに基づくLua言語の記述力は他の言語に比べると貧弱なのは否めず、言語としては個人的にはあんまり好きではありません。特に配列の添(ry

言語の機能やライブラリの拡充度からいくと他者に大きく水をあけられてはいますが、処理系の効率の良さがその欠点を補って余りあるというのがLuaの存在意義でしょう。特筆すべきはネイティブスレッドとの親和性が高いことです。

Luaはコルーチン(協調スレッド)と呼ばれる並行プログラミングのための機構を備えていますが、これは各スレッドが自分で実行権を手放すことによって「並行」処理を実現するための機構であり、「並列」処理つまり複数のCPUコアを同時に使って演算をする機構ではないので、ここでは言及しません。並列処理を行ってサービスのスループットを向上させるためにはあくまでネイティブスレッドを使う必要があります。

Lua処理系のAPIはいかなるグローバル変数も静的変数も使っておらず、完全にリエントラントです。その点はJavaも同じですが、Lua処理系のインスタンスはメモリ使用量も少ないし起動にかかるオーバーヘッドも小さいので、スレッド毎にLua処理系のインスタンスを割り当てるのにうってつけなのです。

サーバサイドスクリプティングでLuaを用いる場合、サーバのスレッドプールに存在するスレッドの上でLua処理系のインスタンスが動くことになります。その際に各スレッドに別個のLua処理系のインスタンスを割り当てると、Lua処理系に関してスレッド間のレースコンディションが起こり得ないので、排他制御に伴うオーバーヘッドを一切被ることなく、目的の処理を実行することができます。というより、Lua処理系はネイティブスレッドに対する排他制御機能を自身では全く持たないので、この方法がLuaによる唯一の並列化手法であると言えます。

しかし、Lua処理系のインスタンスをスレッド毎に割り当てると、スレッド間でデータを共有することができなくなるという欠点が生じます。ひとつのデータベースを複数のスレッドで同時に扱えないと意味がないので、それだと困ってしまいます。

そこで、KCのLuaバインディングでは、複数のLua処理系で同一のデータベースオブジェクトを共有する仕組みを提供することにしました。スレッド間で共有したいデータはすべてKCのデータベースに入れれば目的を達成できます。KC自体も並列性が高いのでそのようなデータ操作も並列に行うことができます。オンメモリDBを使えばファイルIOのオーバーヘッドを気にする必要はありませんし、逆にメモリに収まらないような規模のデータもファイルDBに収めることで共有と永続化ができます。また、オンメモリツリーDB(赤黒木)やファイルツリーDB(B+木)を使えば順序を管理できるので、リストやプライオリティキューとみなしてタスク管理に使うこともできます。

DBオブジェクトの共有

比較のために、まずは単一のLuaインスタンスのみでDBを作る方法について見てみます。

db = DB:new()
db:open("casket.kch", DB.OWRITER + DB.OCREATE)
db:set("japan", "tokyo")
db:set("england", "london")
db:set("germany", "berlin")
db:close()

DB:newメソッドを呼び出すと、その内部でC++のDBポインタが作られ、それをLuaから操作するためにLuaオブジェクト(Luaテーブル)としてラップしたものを返します。ここではそれをdbという変数で受けて、以降の操作ではそれを介してデータベースファイルを開いたりレコードを書き込んだりデータベースを閉じたりしています。

次に、複数のLuaインスタンスで単一のDBを共有する方法について見てみましょう。C++でネイティブスレッドを立てて、その各スレッドでLuaインスタンスを作って使うわけです。Lua層でなくC++層でDBポインタを作り、それを使いまわすのです。

// DBを作る
kyotocabinet::DB db;
db.open("casket.kch", DB.OWRITER | DB.OCREATE)

// スレッドクラス
class ThreadImpl : pubic kyotocabinet::Thread {
pubic:
  // コンストラクタでDBポインタを受け取る
  ThreadImpl(DB* db) : db_(db) {}
  // スレッドの処理内容
  void run() {
    // Luaインスタンスを作る
    lua_State* lua = luaL_newstate();
    // Luaの標準ライブラリを開く
    luaL_openlibs(lua);
    // DBポインタをLuaの軽量ユーザデータとしてラップ
    lua_pushlightuserdata(lua, db_);
    // 上記をグローバル変数としてエクスポート
    lua_setglobal(lua, "_db");
    // Luaスクリプトをロードする
    luaL_loadfile(lua, "myscript.lua");
    // 上記スクリプトを実行する
    lua_call(lua, 0, 0);
    // スレッドプールのワーカーとして働き、ジョブがあれば処理する
    while (alive) {
      Job job = get_job();                 // ジョブを取り出す
      lua_getglobal(lua, "do_job");        // Lua関数を取得
      lua_pushstring(lua, job.message());  // ジョブの内容を引数に指定
      lua_call(lua, 1, 0);                 // 上記関数を呼び出す
    }
    // Luaインスタンスを破棄する
    lua_close(lua);
  }
private:
  DB* db_;
};

// スレッドを作る
ThreadImpl t1(&db);
ThreadImpl t2(&db);

// スレッドを開始する
t1.start();
t2.start();

// イベントループ
while (alive) {
  Event ev = get_event();   // イベントを取り出す
  set_job(ev.job());        // ジョブを登録する
}

// スレッドの停止を待って回収
t1.join();
t2.join();

// DBを閉じる
db.close();

上記ではKCのスレッドライブラリを使っているおかげで、Java風にスレッド処理を定義することができています。Threadクラスを継承してrunメソッドをオーバーライドして、そうしてできたオブジェクトのstartメソッドを呼ぶというやつです。もちろん、別のスレッドライブラリを使ってもいいですし、POSIXスレッドやWin32スレッドのAPIを直接叩いてもいいです。

要点は、メインスレッドでDBを作って、そのポインタを個々のワーカスレッドに与えるということです。上記ではLuaの「_db」というグローバル変数としてそのポインタをLua側に渡しています。Lua側ではDBポインタをDBオブジェクトにラップして使います。また、上記ではワーカスレッドがジョブキューのコンシューマとして働くことを想定しており、各ジョブに対応してLuaの「do_job」という関数を呼ぶようにしています。となると、Lua側のスクリプトは以下のような内容になるでしょう。

-- 起動処理
db = DB:new(_db)

-- タスクを処理するために呼ばれる
function do_job(message)
  -- DBを使ってIDを採番して表示してみる
  local jobid = db:increment("jobid", 1)
  print(jobid, message)
end

Luaスクリプトはスレッドの起動時に読み込まれて実行されます。先頭で「_db」というグローバル変数からDBポインタを取り出して、DBクラスのコンストラクタに渡してDBオブジェクトを生成しています。そして、do_job関数を定義しておいて、その中でDBオブジェクトを操作しています。

実際の性能

Javaバインディング(Javaスレッド)とLuaバインディング(ネイティブスレッド)の性能比較をしてみました。KCのファイルハッシュデータベースに対して合計100万レコードの書き込みと読み込みをスレッド数を変えて行った場合にかかる時間を測定したものです。

Java Lua
1スレッド書き 6.903秒 2.073秒
1スレッド読み 6.649秒 2.026秒
2スレッド書き 4.372秒 1.455秒
2スレッド読み 3.711秒 1.105秒
4スレッド書き 2.880秒 1.154秒
4スレッド読み 2.287秒 0.818秒

JavaもLuaもスレッド数の増加に応じて所用時間が短くなって高速化していることがわかります。Luaは1スレッドのみでも4スレッドのJavaを凌駕する性能を持ち、そしてスレッド数を増やすとさらに高速化するということです。

Lua+ネイティブスレッドの構成だと少なくともユーザランドでは一切の排他制御をしないので、理論的にはCPUコア数を上限とするスレッド数に対して線形にスケールするはずです。実際にはDB層やOS層で多少のブレーキがかかるので線形とまではいかないわけですが、スレッドを増やすことで並列化の恩恵が体感レベルで受けられるということが今回の実験で確かめられたと思います。

まとめ

Web業界で生活しているとLua使いに出会うことは非常に稀なのですが、サーバサイドでもLuaは実用になる技術なのです。かく言う私もマイナー言語と思って優先度を下げていましたが、マルチコア・メニーコア時代にはLuaのような軽量な処理系をスレッド毎に割り当てるという手法も面白いんじゃないかなと思う次第です。C/C++で書いた高効率なサーバ実装にアドオンでき、Javaより高性能なスクリプト言語として機能するLua。最適化された世界に少しの柔軟性を持たせるLua。Lua最強説。

そして、複数のLuaインスタンスを並列させることによってインスタンス間でデータが共有できなくなる問題をKCは見事に解決してくれます。夢が広がりんぐですね。クラウドのトレンドには全く乗ってないプリミティブなアプローチではありますが、コア数の増加に対してスケールするようにすれば単一ノードの性能もまだまだ上げられます。KCはKVSのバックエンドとしてのみでなく、いわばDomain Specific Databaseのような実装を支援するツールとして育てていきたいと考えています。

夏が近づくとウキウキしてくるmikioです。昨日ついにリリースされたKyoto Cabinet 1.0について今回は報告します。

1.0の位置づけ

コミュニティ毎や製品毎にバージョン番号割り当ての方針は異なるわけですが、私の個人的なポリシーでは、1.0には特別な意味があります。すなわち、0.xのバージョンはbeta版的な位置づけで、「実サービスに使うのはちょっと待った方がいいですよ」ということを意味します。一方で、1.xはstable版的な位置づけで、「よろしければ実サービスでも使ってみてください」ということを意味します。私がstable版に設定する原則を以下に列挙します。

  • 安定稼働を至上命題とする(バグがあればその修正を最優先する)
  • APIを変更しない(変更するとしても後方互換性を維持する)
  • DBファイルのフォーマットを変更しない(変更するとしても後方互換性を維持する)
  • なるべく機能追加しない(追加するとしてもモジュールを分割して行う)
  • テスト追加とリファクタリングと文書執筆は継続的に行う。
  • 余剰工数でなるべくユーザサポートを行う。

安定性というのは永遠の課題です。1.0になったからってバグがないと言いきれるわけでもない(見つかったバグはもちろんすべて潰しているが、見つかっていないバグがもう無いということは証明できない)ので、あくまで気持ちの問題にすぎません。しかし、結構テストを書いたのでポピュラーな環境とユースケースでは普通に動くとは思います。

特徴のおさらい

1.0になった今や、Kyoto Cabinetは以下の特徴を備えます。いつの間にかWindowsがサポートされているあたりが激アツですね。

  • 時間効率:ハッシュDBはO(1)、ツリーDBはO(logN)の時間計算量。更新能力は100万qps以上。
  • 空間効率:レコードあたりのフットプリントが、ハッシュDBだと8~16バイト、ツリーDBだと2~4バイト。
  • 並列性:ハッシュDBはレコード単位のread-write lockのみ、ツリーDBはツリーDBはページ単位のread-write lockのみ。
  • 利便性:Visitorパターン風インターフェイスによるレコード操作の一般化。多相DBによるアルゴリズムの動的変更。
  • 堅牢性:手動トランザクション、自動トランザクション、自動リカバリ等の実装。
  • 移植性:UNIX系(Linux、FreeBSD、Solaris、Mac OS X)だけでなくWindows(VC++)もサポート。
  • 他言語バインディング:C++がコアだが、C, Java、Python、Ruby、Perlのバインディングも提供。

Tokyo Cabinetに比べると、KCは特に並列性と利便性と移植性に優れています。ただしシングルスレッドでの時間効率はTCの方が2倍くらいよいです。とはいえ、マルチコア・メニーコアが主流となってくる2010年代ではTCよりもKCの方が活用できると思いますので、ぜひKCの採用を検討していただきたいと思います。なお、TCの保守も継続してバグ修正等の対応はします。

他(多)言語バインディングのススメ

バージョン1.0を出すための条件として、ポピュラーなプログラミング言語の中で私がサポートできるもののバインディングは揃えてしまおうと考えていました。その理由は主に2つあります。ひとつは、バインディングを書く過程でコア側のAPIをいくつか変更したくなることは容易に予想できたからです。もうひとつは、各種の言語のインターフェイスを設計しながら個々の言語の視点でコア側を眺めると、C/C++視点では見つけられなかった設計上の問題がいろいろ検出できるので、それに事前に対応しておきたかったからです。

上記はTCとそのバインディングを書いた時の経験則ですが、おそらくDBM以外のC/C++ライブラリに関しても同じことが言えると思います。他言語バインディングを書く時には特にスレッド対応の点で苦労します。JavaやRubyなどのガベージコレクションを前提とする言語では、言語処理系側に所有権を預けたオブジェクトのデストラクタが制御できないタイミングで呼ばれるので、デストラクタの呼び出しタイミングについて何らかの前提条件を設定していた場合に困ったことになります。今回の例で言うと、あるDBオブジェクトに対して作ったカーソルオブジェクトの生存期間よりもDBオブジェクトの生存期間は長くなければいけないという前提があったのですが、GCでオブジェクトを破棄する場合にはその順序は制御できないのです。そういう場合には、カーソルクラスをラップした「カーソルデポジット」クラスを容易して、DBの生存期間に関連づけてカーソルを破棄するようにしなければなりません。

他にも、Java1.1やRuby1.8やPerl5などにおけるスレッド実装のように、グリーンスレッド(ネイティブスレッドを使わずにユーザ空間でスレッドを実装するモデル)の場合には、C/C++側で使っているネイティブスレッドのロック機構やTLS(スレッドローカルストレージ)が全く機能しないので、個々の処理系のロック機構やTLSを使って排他制御を実装しなおさなければならないという問題があります。この問題に関してはKCでは対応できていません。今後すげー暇になったら対応するかもしれませんが、利得が低いので多分やらないと思います。

他言語バインディングを書く上でスレッドの次にハマるのが、文字列の扱いについてです。JavaやPython3のように文字列クラス(JavaのString、Python3のUnicode)とバイト配列クラス(Javaのbyte[]、Pytohn3のbytes)が分離されている処理系もあれば、PerlやRubyのように双方を同じ型として統合して扱う処理系もあります。分離脳で考えている時に統合スタイルを見ると、別概念を無理やり一緒にしちゃっている感じがして気持ち悪いですが、一方で統合脳で考えている時に分離スタイルを見ると、似たような概念をわざわざ別クラスに分けたおかげで手間が2倍になる気がして気持ち悪いです。個人的には分離でも統合でもなくバイト列だけあればいい派で文字列なんてものがなければいいと思っているのですが、まあいずれも好み(および主想定ユースケース)の問題なんで、郷に入れば郷に従えというのが結論です。

KCとしては、分離スタイルの言語では、ライブラリへの入力(set等のメソッドの引数)としては文字列もバイト列もポリモーフィックに両方受け付けて、内部的にバイト列に変換した上でコアに渡します。ライブラリから出力(get等のメソッドの戻り値)としては、基本的にバイト列を返し、メソッド名に「_str」という接尾辞をつけたメソッドを呼び出した場合には文字列を返すようにしています。一方で統合スタイルの言語では、文字列はUTF-8前提で解釈して暗黙的にバイト列と相互変換して扱うようにしています。

といった感じで、ある言語の世界にどっぷり浸かりながら別の言語に触れると、奇妙に見える点を見つけてはdisってしまうのが人情というものですが、いくつかの言語のバインディングを通して書いてみると見方が変わってきます。こと歴戦の猛者達が議論の限りを尽くして設計している言語処理系に限っては、それぞれにちゃんとした理由があってやっていることがほとんどなのだなぁと実感できるのです。また、自分の実装が特定の言語でしか通用しない前提条件を設けてしまっていることに気づいて修正する機会を得ることができます。TCもKCも他言語バインディングの実装とそれに伴うコアの修正によって製品の品質(と自分の偏狭さ)がかなり改善したので、機会があればぜひ皆さんもC/C++実装+他言語バインディングという開発スタイルに挑戦してみてください。

Perlバインディング

Perlの会社に勤めているので、Perlバインディングについて宣伝しておかないと恰好がつきませんね。どの言語でもできることは基本的に変わらないのでRubyバインディングの記事を見ていただければ機能性については理解したいただけるとは思いますが、いちおうPerlの文脈で書き直してみます。

まずは基本的なユースケースですね。ライブラリを取り込んで、データベースオブジェクトを作成して、データベースを開いて、いくつかレコードを格納して、検索してみて、さらに全レコードの一覧を取得してみて、最後にデータベースを閉じます。

use KyotoCabinet;

# データベースオブジェクトを作る
my $db = new KyotoCabinet::DB;

# データベースを開く
if (!$db->open('casket.kch', $db->OWRITER | $db->OCREATE)) {
    printf STDERR ("open error: %s\n", $db->error);
}

# レコードを格納する
if (!$db->set('foo', 'hop') ||
    !$db->set('bar', 'step') ||
    !$db->set('baz', 'jump')) {
    printf STDERR ("set error: %s\n", $db->error);
}

# レコードを検索する
my $value = $db->get('foo');
if (defined($value)) {
    printf("%s\n", $value);
} else {
    printf STDERR ("get error: %s\n", $db->error);
}

# レコードの一覧を取得する
my $cur = $db->cursor;
$cur->jump;
while (my ($key, $value) = $cur->get(1)) {
    printf("%s:%s\n", $key, $value);
}
$cur->disable;

# データベースを閉じる
if (!$db->close) {
    printf STDERR ("close error: %s\n", $db->error);
}

TIEHASH

KCでは、言語共通の機能の他に、各言語独自のスタイルでもごにょごにょできるようにしています。Perl独自といえばやはりtieですよね。こんな風に使います。

use KyotoCabinet;

# ハッシュ変数をデータベースに結びつける
my $db = tie(my %db, 'KyotoCabinet::DB', 'casket.kch');

# レコードを格納する
$db{'foo'} = 'hop';   # 文字列が基本
$db{bar} = 'step';    # キーの引用符は省略できるよ
$db{3} = 'jump';      # 数字だって使えちゃう

# レコードを検索する
printf("%s\n", $db{'foo'});

# トランザクション内で更新する
$db->transaction(sub {
    $db{'foo'} = 2.71828;
    1;  # 真を返せばcommit、偽を返せばabort
});

# Visitorパターンでレコードの値を2倍にする
$db->accept('foo', sub {
    my ($key, $value) = @_;
    $value * 2;  # 戻り値が新しい値になる
});

# 標準イテレータでレコードの一覧を得る
while (my ($key, $value) = each(%db)) {
    printf("%s:%s\n", $key, $value);
}

# KC独自イテレータでキーの値を大文字にする
$db->iterate(sub {
    my ($key, $value) = @_;
    uc($value);
});

# カーソルでレコードの一覧を得る
$db->cursor_process(sub {
    my ($cur) = @_;
    $cur->jump;
    while ($cur->accept(sub {
            my ($key, $value) = @_;
            printf("%s:%s\n", $key, $value);
            KyotoCabinet::Visitor::NOP;
        })) {
        $cur->step;
    }
});

# 関連付けを破棄してデータベースを閉じる
undef($db);
untie(%db);

tieしたハッシュ変数を介してはハッシュインターフェイスのメソッドしか呼び出せません。そこで、ここではトランザクション等のKC独自インターフェイスも同時に使うために、tieの戻り値である元来のオブジェクトも利用しています。

my $db = tie(my %db, 'KyotoCabinet::DB', 'casket.kch');

上記で、$dbがKC元来のDBオブジェクトの参照を保持する変数で、%dbはハッシュ変数です。$db{”hoge”} とした時にはハッシュ変数の方が評価され、$db->hogeとした時には元来のオブジェクトの参照変数の方が評価されますが、両者はあくまで別個の変数です(紛らわしいようならば別の名前にするのもよいでしょう)。なので、スコープ内で破棄する時には別個に破棄する必要があります。

undef($db);
untie(%db);

もちろん、ハッシュ互換インターフェイスしか使わないのであれば、tieの戻り値を何にも代入しないでOKです(その場合は当然最後のundefも必要ありません)。

メモリ使用量

tieがあることで組み込みハッシュの省メモリな代替としてKCを使えるわけですが、実際どのくらいメモリ使用量が少なくなるかを見てみましょう。100万レコードを格納した際のメモリ使用量と時間を計ってみました。

メモリ使用量 経過時間
標準ハッシュ 99.332 MB 1.683 秒
KC(オンメモリDB) 53.262 MB 5.431 秒
KC(ファイルハッシュDB) 40.031 MB 7.315 秒
KC(ファイルツリーDB) 60.258 MB 6.486 秒

ということで、KCのオンメモリDBを使えば、時間効率が多少落ちる代わりに、メモリ使用量を半減させられます。ファイルハッシュDBやファイルツリーは上記のテストではデフォルトの設定ですが、チューニング次第でメモリ使用量をさらに減らすこともできます。例えばファイルハッシュDBで ‘casket.kch#msiz=0′ とすればメモリ使用量は3.5MBまで減らせますし、ファイルツリーDBで ‘casket.kct#msiz=0#pccap=0′ としても同じく3.5MBまで減らせます。

経過時間が、KCだといずれも標準ハッシュより大きく劣っていますね。KCのコア自体は100万レコード格納を1秒未満で終わらせる能力があるので、多くの時間をPerl層で食ってしまっているようです。その要因としてはバインディングのXSUB関数を直接呼ぶのでなくPerlコードを一回噛ませてしまっていることと、さらに関数呼び出しの際にスカラのコピーが行われていることが考えられます。おそらく、pm層を省けばあと1秒くらい縮められ、インターフェイスをリファレンス渡しに変更すればさらに1秒以上は縮められると思います。でもそれらのメリットよりはデメリットの方が大きいのでおそらくやらないでしょう。

JavaとPython

KCはどちらかと言えばサーバサイドのさらにバックエンド寄りのユースケースを第一目的として作っているので、その層でC++と並んでよく使われるJavaのバインディングもサポートしました。PythonやRubyやPerlは処理系自体の並列性がないのでKCの並列性能を生かしにくいのですが、JavaVMの並列性はかなり良好なので、C++以外でKCを使うとしたらまずJavaをオススメします。

Google App Engineに採用されるなどで日本でも人気の出てきたPythonのバインディングもサポートしました。個人的にはPythonのプログラミング経験がほぼゼロだったのですが、とりあえずC用のAPI文書だけを読んでPythonバインディングを実装することができました。対象の言語を書いたことがなくてもバインディングは書けるというのは本当です。PythonバインディングではRubyバインディングと同様に「並列モード」を用意してあるので、KCコードの実行時に処理系のロックを外して並列処理を行うことができます(ただしPython処理系に比べてKCが高速すぎるので現状では意味はないです)。なお、現状ではPython3のみのサポートとなっていますが、Python2用のバインディングもいずれは書く予定です。

まとめ

Kyoto Cabinetの正式バージョンたる1.0がリリースされました。計画されていた全機能を実装し、コマンドラインユーティリティやテストツールも付属しています。各種言語バインディングも提供されているので、ぜひ試してみてください。

当初の計画ではLuaバインディングも作る予定だったのですが、ちょっと延期します。テーブルDBやKyoto Tyrantの方が需要がありそうなのでそちらの検討をする所存です。

静かに暮らしたいmikioです。今回は、新進気鋭のDBMであるKyoto CabinetのRubyバインディングを駆使してお手軽にデータベースプログラミングを行う方法について述べます。

Kyoto Cabinetのおさらい

Kyoto Cabinet(KC)は、Tokyo Cabinet(TC)に比べて、最適化された性能よりも保守性を重視したDBMの実装です。オブジェクト指向プログラミングの技法を用いて、少ないコード記述量で容易に機能追加できるように設計しています。また、実装としては、空間効率の向上と並列処理性能の向上を重視しています。以下のプレゼン資料も参考になると思います。

TCでもハッシュ表やB+木などのデータ構造を動的に切り替えて同じインターフェイスで操作するための「抽象データベース」という機構がありましたが、KCでは同じことを「多相データベース(polymorphic database)」という名前の機構として提供しています。なぜ「抽象データベース」という名前を止めたかと言うと、KCでは各種のデータ構造のDBクラスの基底クラスとして純粋仮想関数のみを持つ抽象クラスを導出していて、そちらを抽象データベースと呼びたいからです。多相データベースは、敢えてデザインパターンの用語で言えば、抽象データベースの実装クラスを実行時にデータベース名によって切り替えてインスタンス化するファクトリメソッドを内包したストラテジ的な具象クラスです。

使い方はTCの抽象データベースAPIとほぼ同じで、DBオブジェクトをインスタンス化しておいてから、openメソッドでデータベースを開いた時の名前で実際に開かれるデータベースの構造が決定されます。C++では以下のようなコードになります。

PolyDB db;
db.open("casket.kch", PolyDB::OWRITER | PolyDB::OCREATE);
...
db.close();

上記では、データベース名が「.kch」という拡張子を持つので、ファイルハッシュデータベースが開かれます。もし拡張子が「.kct」なら、ファイルツリーデータベースが開かれます。open、close、set、getなどなどの全てのメソッドはどのデータ構造でも同じインターフェイスで操作できるので、いちいち全てのAPI文書に目を通す必要はありません。抽象データベース(FileDB)のAPIだけ知っていればよいのです。なお、拡張子をつけたくないという人は、「casket#type=kch」などとすれば、「casket」というファイル名のままでハッシュDBを作ることができます。

多相データベースで利用できる具象データベースの一覧を以下に示します。

プロトタイプハッシュDB
ファイル名:「-」そのもの
機能:STLのunordered_map(ハッシュ表)を使ったオンメモリのDB
レコード参照の時間計算量:O(1)
ロックモデル:DB全体のリードライトロック
プロトタイプツリーDB
ファイル名:「+」そのもの
機能:STLのmap(赤黒木)を使ったオンメモリのDB
レコード参照の時間計算量:O(log N)
ロックモデル:DB全体のリードライトロック
キャッシュDB
ファイル名:「*」そのもの
機能:二重リンクリストでLRU消去機能を実装したオンメモリのDB
レコード参照の時間計算量:O(1)
ロックモデル:レコード単位の通常ロック
ファイルハッシュDB
ファイル名:拡張子が「.kch」
機能:ハッシュ表と二分探索木を使ったファイル上のDB
レコード参照の時間計算量:O(1)
ロックモデル:レコード単位のリードライトロック
ファイルツリーDB
ファイル名:拡張子が「.kct」
機能:B+木とLRUページキャッシュを使ったファイル上のDB
レコード参照の時間計算量:O(log N)
ロックモデル:ページ単位のリードライトロック

伝統的なDBMに相当する機能を持つのはファイルハッシュDBであり、これがKCにおいて最も重要な実装になります。TCのファイルハッシュDBでもレコード単位のロックを実現していましたが、新規レコードを挿入する際にDB全体をロックする区間がありました。KCではそれも無くしてほぼ完全なレコード単位ロックになっています。また、PthreadのロックでなくCPUのcas命令を使った自作のロックを使うことでロック自体の性能も向上させています。

次に重要なのがファイルツリーDBで、速度はファイルハッシュDBに劣りますがキーの順序が保証される実装です。TCにおけるファイルツリーDBの実装ではロックモデルがDB全体のリードライトロックでしたが、KCではページ単位になっているところが飛躍的な進化といえるでしょう。また、ページキャッシュはTCでは単一のLRUリストでしたが、KCではInnoDBなどを真似して2段式(中間挿入&格上げ方式)のLRUリストを採用することにより、実際のユースケースでのヒット率を向上させています。

キャッシュDBは文字通りキャッシュとして利用するための実装で、並列性と省メモリ性能に特長があります。プロトタイプハッシュDBとプロトタイプツリーDBは主に比較テストのためにのみ存在する機能ですが、ツリーの方は順序の保持が重要なオンメモリDBとして利用する価値があるかもしれません。プロトタイプDBは純粋なテンプレートとして実装されているので、std::mapに互換する実装であれば何でもスレッドセーフなDBオブジェクトとしてラッピングできるという利点もあります。

Rubyバインディング

KCのRubyバインディングは、上述の多相データベースAPIをRubyから操作できるようにしたものです。つまりKCの全ての機能をRubyから利用することができるということです。C++もRubyもクラスベースのオブジェクト指向言語として共通した機能を持っている言語ですので、KCのAPIも両者でだいたい同じ感じになっています。各種言語においけるAPIの指針はIDL(kyotocabinet.idl)で定義しているので、もし別の言語でのバインディングを書いてくれる方がいらっしゃれば参考にしていただきたいところです。ちなみに、C言語のプログラムからKCを使いたい人のために、多相データベースを extern “C” な関数でラップしたCバインディングもコアパッケージに同梱されています。

本題のRubyバインディングに話を戻します。自分で言うのもなんですが、すごく…使いやすいです。最も基本的なサンプルプログラムは以下のようになります。

require 'kyotocabinet'
include KyotoCabinet

# データベースオブジェクトを作成する
db = DB::new

# データベースを開く
unless db.open('casket.kch', DB::OWRITER | DB::OCREATE)
  printf("open error: %s\n", db.error)
end

# レコードを格納する
unless db.set('foo', 'hop') and
    db.set('bar', 'step') and
    db.set('baz', 'jump')
  printf("set error: %s\n", db.error)
end

# レコードを参照する
value = db.get('foo')
if value
  printf("%s\n", value)
else
  printf("get error: %s\n", db.error)
end

# 全てのレコードを横断的に参照する
class Traverser < Visitor
  def visit_full(key, value)
    printf("%s:%s\n", key, value)
    return NOP
  end
end
db.iterate(Traverser::new, false)

# データベースを閉じる
unless db.close
  printf("close error: %s\n", db.error)
end

上記ではIDLに合わせたインターフェイスしか使っていないので、C++やCや今後現れるだろうその他の言語と同じノリで記述することができます。統一されてわかりやすい反面、Rubyの良さが生かしきれていないとも言えるでしょう。ご安心ください。RubyっぽいAPIも用意してあります。

基本操作の簡略化

データベースを開いたら閉じるというのは極めて重要なことですが、閉じ忘れるというバグは実にありがちなものです。C++ではDBオブジェクトをスタックに貼り付けることでRAIIによる保証を得ることができます。closeしていないオブジェクトが破棄されると暗黙的にcloseされるようになっており、スコープから抜けた時点でオブジェクト破棄されることは言語が保証してくれるということです。Rubyにおいては、DBオブジェクトを作ってファイルを開いてから、最後に閉じるという操作の間に任意のユーザ定義操作を挟むという構造をイテレータで表現することでRAIIっぽくしてみます。

# DBを開いて、何かして、閉じる
err = DB::process('casket.kch') { |db|
  db['nagisa'] = 'kagikakko'
  p db['nagisa']
}
# エラーがあれば表示する
if err
  p err
end

newやopenやcloseがコードから消失しています。DB::processメソッドを呼び出すと、DBオブジェクトを準備した上で、引数のブロックのパラメータに与えて実行します。ブロックが終了したら、正常終了でも例外などの大域脱出でも、必ずDBは閉じられます。ついでに、組み込みのHashクラスと同じように “[]=” メソッドでレコードを格納して “[]” メソッドでレコードを参照していることにもお気づきいただいたと思います。その他にもHashクラスと互換のメソッドを実装していますので、それを使っている既存のコードにKCを適用するのはとても簡単です。

横断操作の簡略化

集合(=データベース)に格納されている要素(=レコード)をひとつひとつ参照すると言えば、DB#eachメソッドです。KCでももちろん対応しています。全レコードの内容を表示するには以下のようにします。

db.each { |key, value|
  printf("%s:%s\n", key, value)
}

さらに便利なことに、KCでは、イテレータでレコードの値を更新することもできてしまいます。DB#iterateメソッドを用います。全レコードの値の小文字を大文字に置き換えるには以下のようにします。

db.iterate { |key, value|
  value.upcase
}

たったこれだけです。DB#iterateはブロックの戻り値を新しい値とみなしてデータベースに更新をかけてくれます。よって、値に対する任意の加工をブロック内で定義することができます。なお、更新したくない場合にはVisitor::NOPという値を返し、そのレコードを消したい場合にはVisitor::REMOVEを返します。

ちょっと複雑になりますが、カーソル(外部イテレータ)も使えます。全レコードの内容を表示するには以下のようにします。

cur = db.cursor
cur.jump
while cur.accept { |key, value|
    printf("%s:%s\n", key, value)
    Visitor::NOP
  }
  cur.step
end

カーソルでもブロックの戻り値を新しい値とみなして更新をかけることになっているので、レコードの値に対する任意の加工を行うことができます。NOPとREMOVEについても同じです。なお、内部イテレータとカーソルの使い分けですが、内部イテレータは一連のイテレーション操作をアトミックに行いたい時に使い、カーソルはアトミックでなくてもいいから複数のイテレーション操作を並行して走らせたい時に使うということになります。

Visitor風インターフェイスの詳細

既に横断検索のところで話題に出てしまっていますが、ユーザが定義したブロックを呼び出してレコードの値の参照や更新を任意に行わせるという操作ができるというところがKCのセールスポイントです。元来はVisiotorというインターフェイスを実装したオブジェクトをDB#acceptメソッドに渡すという以下のようなAPIになっています。DB#iterateやCursor#acceptも同様です。

class Visitor
  NOP = 0
  REMOVE = 1
  def visit_full(key, value) end
  def visit_empty(key) end
end

class Cursor
  def accept(visitor = nil, writable = true, step = false) end
end

class DB
  def accept(key, visitor = nil, writable = true) end
  def iterate(visitor = nil, writable = true) end
end

各メソッドで、visitorがnilの場合にはブロックパラメータが呼び出されます。ブロックにはレコードのキーと値が渡されます(該当のレコードが無い場合には値はnilになる)。writableはDBの内側のコードでリードライトロックの判断に用いられるもので、コールバックによって更新が発生しうる場合には真、更新が絶対に発生しない場合には偽にします。stepはカーソルを自動的に進めたい場合に真にします。なお、C++ではvisitorとして渡すオブジェクトはVisitorクラスを継承している必要がありますが、Rubyではduck typingに基づくので継承関係は必要ありません。通常パラメータとして与える場合にはvisit_fullやvisit_emptyを備えるオブジェクトを、ブロックパラメータとして与える場合にはkeyとvalueを受け取るブロックを指定すればOKです。

暗黙の型変換

KCではレコードのキーと値は単なるバイナリ(C/C++ではcharの配列)であり、RubyではそれをStringクラスとして表現しています。ただ、キーや値に指定するデータを全て明示的にStringクラスのオブジェクトに変換しているとかなり面倒なので、多くのクラスを暗黙的に変換してくれます。以下のコードはいずれもそれっぽく動作します。trueは “true”、falseは “false”、nilは空文字列に変換されます、

db["tako"] = "ika"                   # String
db[:kyoto] = :cabinet                # Symbol
db[12] = 34                          # Fixnum
db[12345678901234] = 98765432109876  # Bignum
db[12.34] = 56.78                    # Float
db[true] = false                     # TrueClass/FalseClass
db[nil] = nil                        # NilClass

障害対応とトランザクション

開いたデータベースを閉じないでプロセスが終了すると、データベースが壊れたり書いたはずのレコードが消失したりするリスクがあります。RAII的な手法を使えばcloseを呼び忘れるというバグは無くすことができますが、シグナルでプロセスが殺されたり電源断でマシンごと落ちたりすればやはり閉じ損ねが生じることになります。TCではそのような事態においては「データベースが壊れた」というエラーを提示してユーザに明示的な修復を求めることにしていました。たとえ全てのレコードが生存していたとしても、正常に終了しなかった場合は破壊とみなして明示的対応を求めるということです。一貫性が失われているかもしれない状態で通常運用を続けるのはよろしくないという判断で敢えてそうしています。

しかし、自分でマシンを落としておきながらヒステリックな反応をする人が結構多いようなので、KCのデフォルトではデータベースが壊れた場合にも勝手にベストエフォートで修復して通常運用を続けるようにポリシーを変更しました。open/closeの呼び出しで上げ下げされるフラグをDB内で管理し、起動時に前回のcloseが検出されない場合には、fsckのようにDB全体をスキャンして整合性を回復させます。このような動作が望ましくない場合には、openメソッドのmodeパラメータに DB::ONOREPAIR を加えてください。

というか、「そもそも壊れるなよ」と言いたい人もいるかと思います。closeで更新操作のdurabilityを確定させるのではなく、setやremoveなどの呼び出し毎にdurabilityを確定させたい場合には、自動トランザクション機能を使ってください。これは、各々の更新操作の前に更新内容のログをとるWAL(write ahead logging)という手法でdurabilityを保証するものです。ただし、WALを書くためのオーバーヘッドによって性能が半減することは避けられません。自動トランザクションを有効にするにはopenメソッドのmodeパラメータに DB::OAUTOTRAN を加えてください。さらに、ファイルシステムが不整合を起こした場合に備えて DB::OAUTOSYNC を加えることもできます。これはDBファイルやWALファイルの更新の度にfsyncシステムコールでディスクとの同期をとるものです。むちゃくちゃ遅くなりますが、単一マシンでのdurabilityを最大化させたければこの機能を利用してください(単一マシンでのdurabilityにそこまで重きを置くかはよく考えてほしいところですが)。

自動トランザクションは毎回の更新操作で暗黙的にコミットされるトランザクション機能ですが、任意の操作群をまとめてコミットしたり、ロールバックさせたりしたいこともあるかと思います。その場合は明示的にトランザクションを実行することもできます。begin_transactionでトランザクションを開始して、end_transactionで終了します。end_transactionの引数が真の場合、トランザクションの更新内容がコミットされて確定します。偽の場合、トランザクションの更新内容は破棄されて、トランザクション開始前の状態にロールバックされます。KCではトランザクションを実行できるのは同時に1スレッドだけですので、isolationレベルはSerializable相当ということになります。

db.begin_transaction
db["tako"] = "ika"
db["uni"] = "ikura"
db.end_transaction(true)

開始したトランザクションは必ず終了させたいわけですから、例によってイテレータでそれを保証した方がいいですよね。もちろんサポートしてますよ。transactionメソッドに与えたブロックの実行前にトランザクションを開始し、ブロックの戻り値が真の場合はトランザクションはコミットされ、偽の場合は破棄されます。

db.transaction {
  db["tako"] = "ika"
  db["uni"] = "ikura"
  true
}

例外は不採用

Rubyにおいては、モジュール内で起きたエラーを例外として報告してそれを呼び出し側で補足してエラー処理を行うというスタイルが一般的ですが、KCではエラーは例外ではなく戻り値で報告するようにしています。TCと同じく、IDLに基づいて言語間で統一的なインターフェイスを提供したいというのがその理由の第一です。CやLuaやPerlなど例外機構のない言語でも同じように利用できるようにしたいし、C++で例外安全でないライブラリとも共存させたいということです。

さらに、例外になるエラーとそうでないエラーの区別を仕様書にイチイチ書かなきゃならないの煩雑で嫌だというのが第二の理由です。DB#getが返すrecord not foundエラーを例外にすべきか、であればDB#openが返すfile not foundエラーやpermission deniedエラーはどうなのか、DB#casやDB#incrementのconflictエラーはどうなのか、といったあたりの議論とそれに基づく仕様変更が面倒なので、一貫して全て例外でないということにしました。

メモリ使用量対決

TCの抽象データベースでも好評でしたが、言語組み込みのHashクラスの代替として省メモリな連想配列としてDBMを使うというのも一興です。そこで、言語組み込みのHashクラスとKCの各種データベースとで時間性能とメモリ使用量を測ってみました。「00000000」から「00999999」までの100万レコードの格納にかかった時間とメモリ使用量を比べてみます。

経過時間 メモリ使用量 ファイルサイズ
組み込みHash 7.018 秒 177.41 MB -
プロトタイプハッシュDB 3.442 秒 135.12 MB -
プロトタイプツリーDB 6.802 秒 157.55 MB -
キャッシュDB 2.648 秒 74.05 MB -
ファイルハッシュDB 3.418 秒 41.77 MB 36.52 MB
ファイルツリーDB 3.250 秒 61.06 MB 19.00 MB

ということで、省メモリな連想配列が欲しければ、まずKCのキャッシュDBを使ってみてください。速度もメモリ使用量もお得です。それより少し遅くてもいいからもっとメモリ使用量を減らしたい場合には、データの特性に応じてファイルハッシュDBやファイルツリーDBを使ってみてもよいでしょう。

チューニングパラメータ

openメソッドに渡す名前でデータ構造を選択できることは既に述べましたが、名前の後に「#」で区切ってチューニングパラメータを指定することもできます。各データ構造でサポートされるパラメータを以下に示します。

プロトタイプハッシュDB、プロトタイプツリーDB
なし
キャッシュDB
bnum, capcount, capsize
ファイルハッシュDB
apow, fpow, opts, bnum, msiz, dfunit, erstrm, ervbs
ファイルツリーDB
apow, fpow, opts, bnum, msiz, dfunit, erstrm, ervbs, psiz, rcomp, pccap

それぞれのパラメータの意味を以下に示します。サイズを指定する際には「10m」のように接尾辞で単位を指定することもできます。

  • bnum : ハッシュ表のバケット数。デフォルトは100万くらい。格納するレコードの総数の2倍くらいがオススメ。
  • capcount : 格納するレコード数の上限。溢れたはLRUなものから削除される。デフォルトは制限なし。
  • capsize : 格納するレコードサイズの合計の上限。溢れた分はLRUなものから削除される。デフォルトは制限なし。
  • apow : レコードのアラインメント。2の冪で指定。デフォルトは3(ハッシュ)か8(ツリー)。
  • fpow : フリーブロックプールのサイズ。2の冪で指定。デフォルトは10。
  • opts : “s”(small)、”l”(linear)、”c”(compress)を含めた文字列。デフォルトはなし。
  • msiz : mmapする領域のサイズ。デフォルトは64MB。
  • dfunit : 動的デフラグの単位。デフォルトはなし。動的デフラグをしたい場合は8くらいがオススメ。
  • erstrm : エラーレポートの出力先。”stdout” か “stderr”。デフォルトはなし。
  • ervbs : エラーレポートの冗長フラグ。”true” か “false”。デフォルトは偽。
  • psiz : B+木のページサイズ。デフォルトは8192バイト。4096でもOK。
  • rcomp : B+木の比較関数。”lex”(lexical)か “dec”(decimal)。デフォルトはlexical。
  • pccap : ページキャッシュのサイズの合計の上限。デフォルトは64MB。

大抵の値はデフォルトのままでOKですが、bnumだけは設定した方がよいでしょう。また、巨大なデータベースを扱う場合、メモリに余裕があるならmsizやpccapを増やすと性能が劇的に改善します。1億レコードを格納する10GBのファイルハッシュデータベースを作るなら、”casket.kch#bnum=200000000#msiz=16g” などとすればよいでしょう。

Rubyスレッド対応

まず現状を説明すると、KCのRubyバインディングは、Ruby 1.8系ではスレッドセーフではありませんが、Ruby 1.9系ではスレッドセーフです。ここで言うスレッドセーフとは、Rubyレベルのスレッドに対してのことです。KCはネイティブスレッド(POSIXスレッド)に対してはネイティブのロック機構やTLS(スレッド局所記憶)を用いてDBオブジェクトの排他制御を行うことによってスレッドセーフを達成しています。しかし、1.8系におけるRubyスレッドは単一のネイティブスレッド上でユーザ層のコンテキストを切り替えて動作する仕組みであり、ネイティブのスレッド関連機能が働かないために、必ずしもRubyスレッドセーフにはならないのです。このあたりの問題は最近発覚して調査やテストをしているところで、おそらく私の理解が甘いところが多いので、識者のご意見を賜りたく存じます。

1.9系はRubyスレッドとネイティブスレッドが1対1で対応するっぽいので、ネイティブのロックやTLSが期待通りに動作すると思われます。しかし、KCではDB#acceptメソッドなどでRubyコードをコールバックする関係で、KCのクリティカルセクションの中でRubyのGVL(Global Vm Lock。一般にはGIL)が開放されて別スレッドが同じクリティカルセクションに突入しようとしてデッドロックする問題があります。よって、現状では全てのAPIをRubyのMutexクラスによるクリティカルセクションで保護してデッドロックを防いでいます。この問題に関しては、Thread#criticalに相当する機能でDB#acceptなどを保護することで対応できると思っていますが、1.9ではThread#criticalが廃止されているのでそれに相当する機能が使えるか否かを調査しているところです。

上記が解決するかDB#accept等を廃止するかした場合、1.9系では、ネイティブのスレッド関連機能に任せてスレッドセーフを達成できるため、ネイティブコードを実行している最中にはGVLを外すことができると思われます。1.9系においてもGVLによってRubyコードの並列性はないわけですが、ボトルネックになることが多いIO関連機能の処理をGVL外で実行することによってプログラム全体のスループットを上げることが期待できます。DBMはIO関連機能の典型なのでこの手法がまさにうってつけだと思っています。thread.cのrb_thread_blocking_regionが使えるかどうか検討しています。

で、とりあえずRubyのMutexでKCのDBオブジェクトを保護している現状では1.8系でもスレッドセーフだと言えるんじゃないかと思ったのですが、それは早計でした。KCのエラーコードはネイティブのTLSで管理しているのですが、1.8系ではそれをRubyのTLSにコピーする必要があるようです。1.8系と1.9系で実装をかき分けたり、効率が悪い方に合わせて効率が良い方にオーバーヘッドを強いるのはちょっと心苦しいこともあり、現状では1.8系のスレッドセーフ性は放置ということになっています。現状でTLSのコピーをして1.8対応したとしても上述の1.9向けの最適化をする足かせになってしまうので、1.8系スレッド対応の優先度は低いというのが正直な気持ちです。

まとめ

Kyoto CabinetのRubyバインディングの使い方と内部事情について簡単に説明しました。まだまだインターフェイスや実装に改良の余地があるとは思っていますが、実用できるレベルにはなってきたと思っています。実際に自分で使ってみた限りではかなり快適で性能も悪くないので、皆様にもぜひお試しいただければ幸いです。今後は1.9向けの最適化を手探りでも進めていこうと思っています。

蛇足ですが、「あんたのところPerlの会社じゃないの?」というツッコミに対するレスを予め示しておきます。もちろんPerlバインディングも作る予定なのですが、バインディングシリーズの最初に取り組むのは回避したということです。PerlのXSを書くのは興味深い仕事ではあるのですが、ルールが多くて神経をすり減らす面もあり、設計と実装を同時に進めると両方が疎かになってしまうと判断しました。そして、私のスキルセットにあるスクリプト言語(Perl、Java、Ruby、Lua)の中で最もバインディングを作りやすいRubyにまずは取り組んだというわけです。先にRubyでインターフェイスの設計とC++側の実装を整えておけば、その他の言語でも比較的楽に作業を進めることができるでしょう。急がば回れ。急いては事を仕損ずる。

飲み屋に行くとかなりの確率で荷物を忘れて帰るmikioです。さて、今回はここ2ヶ月ほどで急ピッチで開発した軽量データベースライブラリ「Kyoto Cabinet」について紹介します。

開発の動機

以前から軽量データベースライブラリとしてご好評いただいているTokyo Cabinetですが、DBMとして必要十分な機能と性能を備えていてなかなか良いものだと自負しております。ただ、開発を進める中でいくつか不満な点があったのも事実です。端的に言えば、全てC言語で記述して、標準ライブラリ(とzlib/bzip2)以外の機能は全て自作しているので、最適化がしやすい反面、メンテナンスの難易度が高くなってしまっているというのが不満です。

そこで、多少性能が悪くなってもいいから、私自身としてお気楽に開発およびメンテナンスができて、移植性も高いような実装を作ってみようと思い立ったのが昨年10月頃。様々な検討を経てついにC++を採用し、STLを使って楽々にプログラミングしてみようということになりました。

特徴

KCの基本設計はTCのものを踏襲しているので、TCと同じく以下の点が優れています。

  • プロセス組み込みなので、ネットワーク通信等のオーバーヘッドがない。
  • 静的ハッシュの単純な構造なので高速で並列性も高い。
  • データベースファイルが小さく空間効率が高い。
  • オンメモリデータベースとファイルデータベースを使い分けられる。
  • トランザクションによってACID属性を確保。
  • 動的デフラグでデータベースの肥大化を抑止。
  • APIが単純で使いやすい。
  • 名前がかっこいい。

一方で、KCは実装上の工夫をさらに重ねて、TCに比べて以下の利点を獲得しています。

  • さらに空間効率が高い。
  • さらに並列性が高い。
  • 下層の抽象化による非POSIX環境への対応。
  • レコード操作の究極的な抽象化。
  • 実装上の細かい改善。

KCの性能はTCに比べてかなり低く、現状では半分以下です。しかし、ユーザランドでの並列性は高いはずなので、OSやハードウェアの並列性が上がれば、並列処理で使った場合の全体のスループットがTCに勝つ日も来るかもしれません。

空間効率が高い

いかなるデータベースにおいても、レコードを管理するためのデータを記憶しておく必要があり、TCやKCではレコードに前置するヘッダとしてそれを表現しています。データベースの空間効率を高めるにはこのヘッダをできるだけ小さくすることが重要です。

TCでは通常モードで14バイト、ラージモードで22バイトのヘッダ領域をレコード毎に必要としていました。通常モードとラージモードの違いはレコードのアドレッシングのために4バイトの領域を使うか8バイトの領域を使うかの違いです。4バイトだと2^32=4GBまでの値を表せ、それにデフォルトアラインメントの16バイトを掛けた64GBまでのデータベースを扱えることになります。8バイトだと2^63=8EBまでのデータベースを扱えます。

今日の一般的な計算機環境を考えると、4バイトアドレッシングは非力すぎる一方、8EBのストレージなんてものも存在していません。そこで、KCではモードを統一して、6バイトアドレッシングを採用しています。6バイトだと2^48=256TBまでの値が扱え、デフォルトアラインメントは8なので、2PBまでのデータベースを扱えることになります。1台のデータベースで扱えるサイズとしては当面はこれで十分でしょう。なお、アラインメントは32768まで上げることができるので、ライブラリとしての最大データベースサイズはTCと同じく8EBまでとなります。

アドレッシングの幅が4バイトから6バイトに上がった分だけ空間効率が悪化するので、それが最小限になるように努力もしています。レコードを識別するためのマジックナンバをサイズ管理用の領域に押し込んだり、後述の二分探索木をバランスさせるためのキーを動的に再計算することで記憶領域をとらないようにしたりして、16バイトまで抑えました。TCの通常モード14バイトに比べると多少増えていますが、ラージモードの22バイトに比べるとかなり減っています。

TCやKCでは静的ハッシュ法によるインデックス(バケット配列)を管理することでレコードの探索を高速化していますが、バケット配列の要素が足りない場合でも著しく遅くならないようにするために、ハッシュ値の衝突管理を二分探索木で行って計算量を抑制しています。とはいえ、単一のマシンでどれだけのレコードを管理できるかはあらかじめ予測できるはずなので、きちんとチューニングすれば二分探索木の機構は不要とも言えます。そこで、きちんとチューニングできる人達のために、「線形モード」も用意しました。二分探索でなく線形リストとして衝突管理を行うことで子供のアドレスの6バイトを節約するのです。つまり、線形モードを使えば、バケット配列のサイズに対してセンシティブな性能特性にはなるけれど、ヘッダのサイズを10バイトにまで減らせるのです。

さらに、KCは使うけどデータベースサイズが32GB以下だと確実に分かっている場合には4バイトのアドレッシングで十分なので、「スモールモード」も用意しました。線形モードとスモールモードを併用するとレコード毎のヘッダは8バイトで済みます。ここまでやればTCに対して優位性を示せるでしょう。

並列性が高い

TCやKCではPthread(POSIXスレッド)パッケージでマルチスレッドの排他制御を行っています。例えばデータベースファイルのサイズというメタデータを更新するためには、mutexでロックをかけて、更新を行って、ロックを解除するという手順を踏んでいます。この構造自体は仕方ないことですが、変数一つを更新するためにmutexのロック/アンロックのオーバーヘッドがかかるのは非効率なので、できればもっと軽量な方法を使いたいところです。

うまいことに、GCCのバージョン4からはi386のアトミック演算機能を呼べる拡張機能がサポートされているので、それを使うことにしました。もちろんIntel系以外のCPUを使った環境やGCC以外のコンパイラでビルドする場合にはそれだとうまくいかないので、その場合はspinlockで該当の変数を保護する代替実装を適用するようにしています。

それ以外にも、並列性に着目してコード全体を見直して、ロックフリーとはいかないまでも、クリティカルセクション内でブロックし得るシステムコールを一切呼ばないというところまでは来ています。

TCと同じく、「pread/pwriteというシステムコールは明示的な内部状態(ファイル位置)を持たないので並列性が高いと言われつつも実際には期待外れな問題」に対処するためにmmapを積極活用しています。KCでは全てのファイルI/Oをmmap経由でやろうとも思ったのですが、諸事情で断念して、結局mmapとpread/pwriteを併用する実装になっています。

非POSIX環境への対応

TCでは最適化のためにモジュール化の原則を崩しまくっていわばモノリシックな構造になっているので、POSIX依存のコードをそうでないOS用に書き換えるのが絶望的に難くなっています。とはいえ、TCは主にWebサービスのバックエンドとして利用することを想定しているので、LinuxやFreeBSDやSolarisで動けば十分だという割り切りの上で設計をしていたので、これは問題ではありませんでした。いわゆる組み込み系やWindows等のデスクトップ環境で動く必要はないということです。

一方、KCは組み込み系やWindowsでも動くようにする予定です。よって、多少オーバーヘッドがあろうとも、処理系依存部分は徹底的にモジュール化して、C++03標準で定義されていないシンボルはハッシュDB本体の実装には一切現れないようにしました。主にファイルI/Oを抽象化するFileクラスとスレッド管理を抽象化Threadクラスがそれを担っています。

Windows版はまだ開発に着手できていませんが、数ヶ月以内にはリリースしたいと思っています。構造を単純化したのでPure Java版も作れるだろうとかDBフォーマットをRFCにできたら格好いいとか妄想は膨らみますが、多分口だけで終わると思います。

レコード操作の抽象化

KCを設計する際に、「KVSとは何だろう」「DBMとは何だろう」とかいう哲学的な部分を考察しました。その結果、KVSとは以下のメソッドを宣言したインターフェイスであるという仮説が導き出されました。

  • set : キーと値の組を記憶し、後でgetできるようにする。
  • get : キーを指定して、そのキーを伴って直前に記憶された組の値を取得する。
  • remove : キーを指定して、そのキーを伴って直前に記憶された組の記憶から消す。
  • open : 直前にcloseした際の記憶を復元する。
  • close : setやremoveによって更新された論理構造を記憶装置に保存する。

KVSの「Key-Value」はset/get/removeを示し、「Store/Storage」はopen/closeを示す感じでしょうか。そして、DBMとは、上記KVSの実装形態の一つであり、実装として以下の条件を満たすものです。

  • ファイル記憶 : 記憶状態をファイルシステム上のファイルに保存する。
  • プロセス組み込み : ネットワーク通信を伴わず、関数呼び出しだけの低いオーバーヘッドでメソッドを呼び出せる。

これらはあくまでオレオレ定義にすぎませんが、KCはDBMであり、DBMはKVSなので、KCはKVSだと考えています。まあ、「分散しないものはKVSとは言えない」とか、「ACIDじゃないものはそもそもDBとは言えない」とかいうオレオレ定義をするのも自由なので、TCやKCを実際のところ何と呼ぶのかはユーザの皆様にお任せすることにします。

話を戻して、KVSインターフェイスの一実装であると認識されるKCですが、並列処理を想定した場合、キーと値の組に対する操作はset/get/removeだけでは済まないというのが課題になります。単一スレッドの直列処理の場合、レコードの状態をgetしてから、その内容やそれ以外の状況に応じてそのレコードをsetしたりremoveしたりすれば、キーと値の組に対するいかなる操作も実現することができます。一方で、並列処理の場合、getしてからset/removeするまでの間に別のスレッドがレコードの状態を変更してしまう可能性があるため、何らかの排他制御機能が必要となります。そのためには主に以下の二つの戦略があります。

  • lock/unlockメソッドの定義 : 該当のレコードをlockしてから、任意の操作を行って、最後にunlockするのをアプリケーションの責任で行う。
  • 各種の操作を全てライブラリ側で定義 : 上記の操作のひとつひとつをライブラリ側で実装する。

前者はライブラリ側の実装が簡素になるとともに、アプリ側で排他制御の有無や粒度を自由に設定できるという利点がある反面、実装が複雑になり、デッドロック等の酷いバグを生み出す原因になります。後者逆の特性で、アプリ側は不自由ながらも簡潔になる反面、ライブラリ側のメンテナンスは非常に大変になります。TCでは後者を採用して、putcatとかaddintとかaddbouleとか多数のメソッドを実装しまくるという苦労を味わっていますが、KCではその苦労を少しでも軽減したいものです。

ここでKVSの定義を再検討してみましょう。レコードに対するいかなる操作も、「特定のレコードの状態を調べて、それに応じて更新操作をしたりしなかったりする、という挙動をアトミックに行う」という風に抽象化できます。例えば、各種操作は以下のような言い換えが可能です。

  • set : レコードの状態を調べるが、その状態の如何に関わらず新しい値に書き換えて更新する。
  • get : レコードの状態を調べて、レコードがあれば値を返し、元の状態をそのまま残す。
  • remove : レコードの状態を調べて、レコードがあれば削除して、なければ何もしない。
  • increment : レコードの状態を調べて、レコードがあればそれを数値とみなして、なければ0とみなして、付与の値を足した値をもって、レコードの値を書き換える。
  • append : レコードの状態を調べて、レコードがあればその値に、なければ空文字列に、付与の値を後置した値をもって、レコードの値を書き換える。

例えて言うなら、キーに対応するレコードの空間にアプリケーションの実行者を案内して、その値の読み書きを自由にさせるという操作を、その空間に限定してアトミックに行わせるということです。すなわち、KVSの仕様としては、操作の具体的な内容ではなく、アトミック性を保証する範囲が重要であるということになります。その考えに基づいてKVSを定義すると、以下のようになります。

  • キーに対応する値の更新履歴を1世代以上記憶することができる。
    • openメソッドは記憶のある時点のスナップショットを復元する
    • closeメソッドは記憶のその時点のスナップショットを保存する
  • キーに対応するレコードの状態の取得と更新をアトミックに行える
    • 付与のキーに基づくレコードがある場合、そのレコードの値を渡してコールバック関数を呼ぶ
    • 付与のキーに基づくレコードがない場合、何も渡さずコールバック関数を呼ぶ
    • 上記のコールバック関数が値を返したなら、その値を元のキーに対応する値として記憶する

具体的なAPI

ここまでの議論で新たに認識したKVSインターフェイスをC++のAPIに落とし込んでみましょう。

class DB {
  // レコードの空間を訪問する操作主体のクラス
  class Visitor {
  public:
    // 訪問時、更新が必要ない(no operation)の場合に返す値
    static const char* const NOP;
    // 訪問時、そのレコードを削除したい場合に返す値
    static const char* const REMOVE;
    // 仮想デストラクタ
    virtual ~Visitor() {}
    // 既存のレコードを訪問した場合に呼ばれるコールバック関数
    virtual const char* visit_full(const char* kbuf, size_t ksiz,
                                   const char* vbuf, size_t vsiz, size_t* sp) = 0;
    // 存在しないレコードを訪問した場合に呼ばれるコールバック関数
    virtual const char* visit_empty(const char* kbuf, size_t ksiz, size_t* sp) = 0;
  };
  // 訪問者を受け入れる
  bool accept(const char* kbuf, size_t ksiz, Visitor* visitor, bool writable);
  // データベースを開く
  bool open(std::string& path, int mode);
  // データベースを閉じる
  bool close();
};

上記はKyoto Cabinetの実際のインターフェイスです。アプリケーションは、openでデータベースオブジェクトを利用可能にした後、DB::Visitorを実装した任意のクラスを定義して、それをデータベースオブジェクトのacceptメソッドに渡して操作を行い、最後にデータベースオブジェクトを閉じます。データベースから「hoge」というキーに対応する値を検索して表示するアプリケーションは以下のようになります。

DB db;
db.open("casket", DB::OREADER);
class : public DB::Visitor {
  virtual const char* visit_full(const char* kbuf, size_t ksiz,
                                 const char* vbuf, size_t vsiz, size_t* sp) {
    cout << string(kbuf, ksiz) << " has " string(vbuf, vsiz) << endl;
    return NOP;
  }
  virtual const char* visit_empty(const char* kbuf, size_t ksiz) {
    cout << string(kbuf, ksiz) << " is empty" << endl;
    return NOP;
  }
} visitor;
db.accept("hoge", 4, &visitor, false);
db.close();

毎回Visitorを実装するのは面倒なので、KCの実際のDBクラスには典型的な操作であるset/get/remove/append/incrementをacceptを使って実装したものがビルトインとして組み込まれています。ただ、レコードの操作は必ずacceptを経由するようにしているので、ライブラリ内の実装はとても簡潔で保守しやすくなっています。

詳細についてはKCのチュートリアルAPI文書をご覧ください。

実装上の細かい改善

KCではハッシュ関数としてMurMur hashを採用しています。TCで採用していた各バイトを37倍して足すハッシュ関数だと、自然言語の単語のようなものをキーにする際にはとても効率がよいのですが、intなどのバイナリ表現をそのままキーにすると衝突率が高まるという弱点があります。TCでは主なユースケースのひとつとしては全文検索の転置インデックスを想定していたのでそれでもよかったのですが、KCではより汎用性を重視して、数値のバイナリでも偏りが出にくいという評判のあるMurMurを採用した次第です。MurMur hashと同じような特性を示すFNV hashも捨てがたいところで、自然言語の単語ではFNVの方がよさげでしたが、やはり数値バイナリでの安定性が上のような気がするMurMurを採用しました。とはいえ、ハッシュ関数はプラグインで入れ替えられるようにする予定です。

TCでは値の圧縮にgzipとbzip2を切り替えて使えるようにしていましたが、bzip2モードを使う人があまり多くない割に、libbz2-devパッケージをインストールしていなくてはまる人が多いので、bzip2は廃止しました。ただし、TCと同じく任意の圧縮関数をプラグインで入れ替えられるようにしているので、bzip2はもちろん、LZOやLZSSやLZMAを使うことも可能です。

特定のハッシュ関数や圧縮関数で作ったデータベースファイルは同じ関数のセットを組み込んだDBオブジェクトで開かないと不整合が起きるわけですが、チェックサム機構を備えることでそういった不整合を事前に検出できるようにもしています。

TCでは全レコードを横断的に参照するために内部イテレータ方式を採用していました。内部イテレータ方式は、データベースの内部にどこまで読んだかという情報を保持するので、アトミック性の確保が容易になり、実装も単純になる反面、同時に一つのイテレータしか使えないという弱点があります。KCでは内部イテレータと外部イテレータの両方をサポートしています。外部イテレータ方式は、どこまで読んだかという情報を保持するカーソルオブジェクトを生成してアプリケーション側で管理するものです。アトミックに全レコードを操作したい場合には内部イテレータを用いて、アトミック性はいらないから複数スレッドで並列にスキャンを行いたい場合には外部イテレータを使うとよいでしょう。

さらに、KCではイテレータによるレコードアクセスもacceptメソッドを経由して行うので、Visitorによる任意のアトミック処理ができ、そしてイテレーション中のレコードの書き換えも簡単に行うことができます。どうせならSTLのstd::map::iteretorと互換にしようともちらっと思いましたが、あまりに面倒なので挫折しました。

まとめ

Kyoto CabinetはTokyo Cabinetの後継の製品で、性能は悪化していますが、機能性と並列性と移植性と保守性は向上しています。まだまだアルファバージョンで、実用に耐える品質とは言い難い状態ですが、そこそこ使える状態まではもっていく所存です。

TCは不要なのかというのがFAQになりそうなのでここで答えておくと、そんなことはありません。おそらく、TCが使える環境ではTCを使い続けるのがよいでしょう。POSIX準拠でない環境(主にWindows)でDBMが欲しい場合にはKCが役立つと思います。そういう意味では、KCはTCの後継というより、QDBMを共通の親とするTCの兄弟と言った方が適切かもしれません。

ドラクエは卒業して、もっと英語漬けをやっているmikioです。さて今回は、データベースサーバTokyo Tyrantとテーブルデータベースを使ってリアルタイム検索システムを構築する方法について語ります。

テーブルDBを分散させたい

Tokyo TyrantでもテーブルDBがサポートされているわけですが、これはリアルタイム検索システムへの布石です。テーブルDBは任意のコラムにインデックスを張ることができ、時系列のコラムにインデックスを張ればその値によって古いコラムを効率的に消すことができます。チュートリアルの「Persistent but Expirable Cache」でもその方法を示しています。また、任意のコラムに分かち書きトークン方式もしくは文字N-gram方式で転置インデックスを張ることができます。これらを総合すると、最新のデータのみを保持してサイズと性能を一定に保ったインデックスを維持できることになります。

上記で最新のデータを検索できるわけですが、次にそれをスケールアウトできるようにしたくなります。「リアルタイム検索システム」として運用しようとするならば、更新の負荷をできるだけ小さくするために、インデックスを小さな規模で分割しておくことが望ましいでしょう。更新の負荷、とりわけ登録文書を削除する処理の負荷が重いのです。Posting Listから該当の文書IDの要素を消すために全体をスキャンしなきゃなりません。

ところで、TCやTDでの転置インデックスは必ずPosting Listを同一のページに入れる構造になっていて、でかいPosting Listを格納しようとした時に効率が悪いというご意見をいただくことがよくあります。たしかに、でかいPosting Listはチェーンでつないで別ページで管理する方が文書の登録は速くて済むでしょう。しかし、それだと検索や削除は必ずしも速くならないのです。私の見解としては、Posting Listが長くてやってられなくなった時点でインデックスの分割を上位レイヤで検討すべきということです。遥か昔にSnatcherという全文検索システムを作っていた時はチェーン方式を採用していたのですが、その時に懲りました。

並列メタ検索メソッド

分割されたデータベースへの文書の登録は、IDのハッシュ値でいわゆるアルゴリズム方式の水平分散で行えばよいでしょう。削除も同様です。問題は検索操作です。全てのデータベースに対して個別に検索を行って、その結果をマージする必要があります。ちと大げさですが、これを「メタ検索」と呼びます。ですが、メタ検索はソート条件などを維持する必要があって実装が面倒なので、アプリ側の工夫だけで頑張れというのは酷ですよね。なので、メタ検索用のユーティリティを用意しました。以下のシグネチャです。

/* Search records for multiple servers in parallel.
   `qrys' specifies an array of the query objects.
   `num' specifies the number of elements of the array.
   The return value is a list object of zero separated columns
   of the corresponding records. */

TCLIST *tcrdbparasearch(RDBQRY **qrys, int num);

それぞれ別のRDBオブジェクトから作ったクエリオブジェクトを格納した配列とその要素数を与えると、並列してクエリを投げて、その該当のレコードを取得して順序を整えてから、結果セットをリストとして返します。結果セットはキーだけでなくレコード本体も取得してきているので、1回のネットワーク通信で検索結果を提示できます。処理は内部でスレッドを立てて勝手に並列化してくれます。

利用例を以下に示します。この関数は、データベースオブジェクトの配列rdbsとその要素数divnumと検索式exprを渡すとメタ検索を行ってくれます。

static void searchprint(TCRDB **rdbs, int divnum, const char *expr){

  // クエリオブジェクトの準備
  RDBQRY *qrys[divnum];
  for(int i = 0; i < divnum; i++){
    qrys[i] = tcrdbqrynew(rdbs[i]);
    // textコラムの全文検索
    tcrdbqryaddcond(qrys[i], "text", RDBQCFTSEX, expr);
    // numコラムの降順に整列
    tcrdbqrysetorder(qrys[i], "num", RDBQONUMDESC);
  }

  // 一撃で並列検索
  TCLIST *res = tcrdbparasearch(qrys, divnum);
  int rnum = tclistnum(res);

  // 結果セットの個々の要素をループ
  for(int i = 0; i < rnum; i++){
    // コラムオブジェクトを結果セットから抜き出す
    TCMAP *cols = tcrdbqryrescols(res, i);
    // 各コラムを印字する
    tcmapiterinit(cols);
    const char *name, *value;
    while((name = tcmapiternext2(cols)) != NULL){
      value = tcmapget2(cols, name);
      printf(" [%s=%s]", name, value);
    }
    printf("\n");
    tcmapdel(cols);
  }

  // 片付け
  tclistdel(res);
  for(int i = 0; i < divnum; i++){
    tcrdbqrydel(qrys[i]);
  }
}

皮算用

最近のPCはコアが多いから、各マシンに4つくらいTTを立ててもいいでしょう。仮にそのマシンを4台セットで利用するなら、16個のTTサーバが動くことになります。1サーバに10万件のデータを入れるならば、総計160万件のデータを検索対象にすることができます。これはmixiの1日の日記を全部格納しても余裕の数です。耐障害性を考えて二重化しても8台で済みます。もちろん、実際のインデックス分割数とマシン台数は検索と更新の負荷に応じて決定すべきです。

1日分しか検索対象にできないなんて意味がないと言われるかもしれません。でも、リアルタイム検索はそれでいいのです。24時間も稼げれば、その間に別の大規模なインデックスを構築できます。古いインデックスとリアルタイムインデックスのメタ検索を行えば全コンテンツをリアルタイム検索していることになるというわけです。

まとめ

Tokyo Tyrant上でテーブルDBを動かせばリアルタイム検索ができます。そのためにはインデックスを小さく分割して分散させます。分散したサーバを対象とした検索操作はメタ検索メソッドを用いれば簡単です。リアルタイム検索は最新のものだけを扱い、全体のインデックスと相互補完する目的で用います。

このアーキテクチャでどこまでスケールするのか、あるいはどこまで性能が出るのかは、まだ実データで実験していないので確たることは言えません。いずれちゃんとした性能測定をしてまた報告したいと存じます。TT+テーブルDBを再利用するのではなくリアルタイム検索専用のインデックスを書き起こした方がもちろん高効率が実現できるのでしょうし、あるいはインデックスを作らないでオンメモリのキャッシュを逐次探索するモデルの方が適する場合もあるでしょう。とはいえ、テーブルDBを並べるだけという手軽さの点では今回の手法も利点があるかなと思っています。