mixi engineer blog

*** 引っ越しました。最新の情報はこちら → https://medium.com/mixi-developers *** ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

DBMによるテーブルデータベース その五

ついに発売されたスト4のコンシューマ機版をやりたくてしょうがないけど筐体を買ってもらえないので、駅前のゲーム屋のディスプレー前で垂涎するばかりのmikioです。今回は連載の最終回で、各種スクリプト言語を使ってお手軽にテーブルデータベースを操作する方法について説明します。

TokyoCabinet::TDB

まずは、TCのPerlバインディングRubyバインディングの最新版を入手してください。それぞれテーブルデータベースを扱うための TokyoCabinet::TDB というクラスが加わっています。以下のようなIDLによるガイドラインに準拠したインターフェイスが提供されますので、使い方は言語にかかわらず同じようになるはずです。

module TokyoCabinet {
  interface TDB {
    boolean open(in string path, in long omode);
    boolean close();
    boolean put(in string pkey, in Map cols);
    boolean out(in string pkey);
    Map get(in string pkey);
    boolean setindex(in string name, in long type);
    long long genuid();
  };
  interface TDBQRY {
    void addcond(in string name, in long op, in string expr);
    void setorder(in string name, in long type);
    void setmax(in long max);
    List search();
    boolean searchout();
    string hint();
  };
};

例によって、社員名簿を作ったり検索したりしてみます。Perlバインディングはこんな風になります。

use TokyoCabinet;

my $tdb = TokyoCabinet::TDB->new();
$tdb->open("casket.tct", $tdb->OWRITER | $tdb->OCREAT);
$tdb->setindex("name", $tdb->ITLEXICAL);

$tdb->put("1",   { "name" => "空条承太郎", "sex" => "male",
                   "hdate" => "20050321", "div" => "brd,dev" });
$tdb->put("81",  { "name" => "東方仗助", "sex" => "male",
                   "hdate" => "20060601", "div" => "dev" });
$tdb->put("92",  { "name" => "汐華初流乃", "sex" => "male",
                   "hdate" => "20070311", "div" => "hr" });
$tdb->put("127", { "name" => "空条徐倫", "sex" => "female",
                   "hdate" => "20070523", "div" => "brd,hr" });

my $qry = TokyoCabinet::TDBQRY->new($tdb);
$qry->addcond("name", $qry->QCSTRBW, "空条");
$qry->setorder("hdate", $qry->QONUMDESC);
my $res = $qry->search();
foreach my $pkey (@$res){
    my $cols = $tdb->get($pkey);
    printf("%s: %s\n", $pkey, $cols->{"name"});
}

$tdb->close();

一方、Rubyバインディングだとこんな風になります。Perlとほとんど変わらないですよね。

require 'tokyocabinet'
include TokyoCabinet

tdb = TDB::new()
tdb.open("casket.tct", TDB::OWRITER | TDB::OCREAT)
tdb.setindex("name", TDB::ITLEXICAL)

tdb.put("1",   { "name" => "空条承太郎", "sex" => "male",
                 "hdate" => "20050321", "div" => "brd,dev" })
tdb.put("81",  { "name" => "東方仗助", "sex" => "male",
                 "hdate" => "20060601", "div" => "dev" })
tdb.put("92",  { "name" => "汐華初流乃", "sex" => "male",
                 "hdate" => "20070311", "div" => "hr" })
tdb.put("127", { "name" => "空条徐倫", "sex" => "female",
                 "hdate" => "20070523", "div" => "brd,hr" })

qry = TDBQRY::new(tdb)
qry.addcond("name", TDBQRY::QCSTRBW, "空条")
qry.setorder("hdate", TDBQRY::QONUMDESC)
res = qry.search()
res.each() do |pkey|
    cols = tdb.get(pkey)
    printf("%s: %s\n", pkey, cols["name"])
end

tdb.close()

検索結果に対するアトミックな更新も、たった1ステップで書けちゃうんです。例えば、発現と同時に射程距離内の全ての社員の性転換をするには以下のようにします。まずはPerl版。procメソッドの引数に無名関数のリファレンスを渡すだけです。

$qry->proc(sub {
    my $pkey = shift;
    my $cols = shift;
    $cols->{"sex"} = $cols->{"sex"} eq "male" ? "female" : "male";
    $qry->QPPUT;
});

そしてRuby版。procメソッドのブロックに処理を記述します。もはや4行。チャリオッツレクイエムも驚く能力ですね。PerlでもRubyでも、ブロックの最後に必ずQPPUT(更新)、QPOUT(削除)、0(何もしない)のいずれかを評価することが重要です。

qry.proc() do |pkey, cols|
    cols["sex"] = cols["sex"] == "male" ? "female" : "male"
    TDBQRY::QPPUT
end

今までTCのテーブルDBは表結合ができないと説明してきましたが、実はこのアトミックな更新機能(というか該当レコードの更新機能付きイテレータ)の中で別のテーブルの値を引っ張ってくることで、内部結合も外部結合も自由に表現することができます。まあ、任意のコードが呼び出せるから何でもできると言っているだけなのですが...。

ところで、「エラーを例外として投げるようにAPIを改良してみました」というパッチをいただくことがよくあります。しかし、お気持ちは大変ありがたいのですが、本家のパッケージとしてはそれは採用できません。(最大公約数的な)IDLに準拠して、(私にとっての)保守性を維持し、(他の言語を使っている人にとっての)学習コストを下げることを重視したいからです。例外を使ったエラー処理を好むプログラマも多いことは知っていますが、本家としてはインターフェイスの細かい論争に時間を使いたくないので、「ラッパーを書いて適当に調整してくれ」というスタンスを貫きたいと思います。

TokyoTyrant::RDBTBL

TT経由でもTCを直接いじった場合と同じことがほとんど同じインターフェイスでできます。TokyoTyrant::RDB はTCの抽象データベースAPIに相当する機能(つまりハッシュDBとB+木DBの共通機能)を提供するものでしたが、そのサブクラスとしてテーブルDB独自の機能を持たせたものが TokyoTyrant::RDBTBL です。まずは社員名簿ピュアPerl版インターフェイスの例を見てください。

use TokyoTyrant;

my $rdb = TokyoTyrant::RDBTBL->new();
$rdb->open("localhost", 1978);
$rdb->setindex("name", $rdb->ITLEXICAL);

$rdb->put("1",   { "name" => "空条承太郎", "sex" => "male",
                   "hdate" => "20050321", "div" => "brd,dev" });
$rdb->put("81",  { "name" => "東方仗助", "sex" => "male",
                   "hdate" => "20060601", "div" => "dev" });
$rdb->put("92",  { "name" => "汐華初流乃", "sex" => "male",
                   "hdate" => "20070311", "div" => "hr" });
$rdb->put("127", { "name" => "空条徐倫", "sex" => "female",
                   "hdate" => "20070523", "div" => "brd,hr" });

my $qry = TokyoTyrant::RDBQRY->new($rdb);
$qry->addcond("name", $qry->QCSTRBW, "空条");
$qry->setorder("hdate", $qry->QONUMDESC);
my $res = $qry->search();
foreach my $pkey (@$res){
    my $cols = $rdb->get($pkey);
    printf("%s: %s\n", $pkey, $cols->{"name"});
}

$rdb->close();

openメソッドの引数以外は、TCとTTで全く同じ構造になっていることがお分かりいただけるかと思います。現に私もコピペと検索置換で上記のソースを書きました。もちろんピュアRuby版インターフェイスでも同様です。

require 'tokyotyrant'
include TokyoTyrant

rdb = RDBTBL::new()
rdb.open("localhost", 1978)
rdb.setindex("name", RDBTBL::ITLEXICAL)

rdb.put("1",   { "name" => "空条承太郎", "sex" => "male",
                 "hdate" => "20050321", "div" => "brd,dev" })
rdb.put("81",  { "name" => "東方仗助", "sex" => "male",
                 "hdate" => "20060601", "div" => "dev" })
rdb.put("92",  { "name" => "汐華初流乃", "sex" => "male",
                 "hdate" => "20070311", "div" => "hr" })
rdb.put("127", { "name" => "空条徐倫", "sex" => "female",
                 "hdate" => "20070523", "div" => "brd,hr" })

qry = RDBQRY::new(rdb)
qry.addcond("name", RDBQRY::QCSTRBW, "空条")
qry.setorder("hdate", RDBQRY::QONUMDESC)
res = qry.search()
res.each() do |pkey|
    cols = rdb.get(pkey)
    printf("%s: %s\n", pkey, cols["name"])
end

rdb.close()

ダラダラとソースを載せたのは、データベースを使ったプログラムはそんなに難しいものじゃないということを主張したいからです。おそらく、単純なファイルを読み書きするよりも簡単だと思っていただけたと思います。SQLなんてない方が簡潔だし直感的ですよね。そして、データベースがローカルにあろうがリモートにあろうがほとんど同じやり方でプログラミングできることもおわかりいただけたと思います。これで、私が本来目標としていた、「個人ブログを簡単に作るためのストレージ」が実現できました。とりあえずはこれで満足です。

永続的だが時限的なキャッシュ

マーケティング戦略上は、TTはmemcachedとMySQLの隙間を埋める技術として位置付けるのが妥当でしょう。リレーショナルデータベースとして必要十分な機能と性能を備えるMySQLと、大規模Webサイトで求められるスループットを実現するための単純かつ高速なキャッシュサーバであるmemcachedの間のギャップはものすごく大きいわけですが、とりあえずはmemcached寄りの位置づけで考えてみます。すなわち、以下のような存在です。

  1. memcachedのように高速かつ並列に操作できる。
  2. メモリでなくファイルにデータを格納することでデータを永続化でき、実メモリ容量以上のデータも扱える。
  3. 単純なkey/valueでなく、各レコードに複数のプロパティを持たせられ、それを対象として検索できる。
  4. 不要な(古い)データは勝手に消え、データベースサイズを一定に保つことができる。

私は別にTCやTTで食っているわけじゃないのですが、世のニーズに応えてみるとなにげにいいことがあるかもしれません。で、現状のTTを考えるに、1と2は既に実現できています。最近はSSDが一般化してきたので、データベースファイルをSSD上に置けば、スループットをmemcached並に保ったままで、1台あたり数10〜数100ギガバイトのキャッシュを安価に提供することができるようになりました。3に関しては、今回のテーブルデータベースがサポートされたことで実現できました。一般的なWebサービスのデータ管理に必要な機能はほとんど提供できていると思います。そして最後に残ったのは、4の、古いデータを自動的に消す機能です。これはmemcachedの最大の特徴のひとつですが、キャッシュサーバではないTTにとっては結構厄介な要求になります。しかし、今回はちょっとしたトリックを使ってその要求を充足してみます。

memcachedでは各レコードに "exptime" という属性値を付与することで、そこで指定された時間が過ぎたらそのレコードが暗黙的に消されるようにできます。TTでもテーブルデータベースを使えば、レコードに消去されるべき日時の属性値を付与することができそうです。例えば、各レコードに "x" というコラムとして消去予定日時を持たせて登録する処理と、不要なデータを消す処理は、以下のRubyコードをクライアント側から実行することで実現できます。

def putx(rdb, pkey, cols, lifetime)
  cols["x"] = Time.now.to_i + lifetime
  rdb.put(pkey, cols)
end

def expire(rdb)
  qry = RDBQRY::new(rdb)
  qry.addcond("x", RDBQRY::QCNUMLT, Time.now.to_i.to_s)
  qry.searchout
end

コラム "x" に数値型のインデックスを張っておけば、QCNUMLT演算子の処理が高速化されるので、レコードの削除は一瞬で済むようになります。性能的には問題ないし、レコードを格納する際に上記のputxメソッドを使うようにするのも直感的でしょう。ダサいのは、定期的にexpireメソッドをクライアントが発行しなければならない点ですね。CGIやmod_xxxなどのWebサーバ上で実行されるアプリケーションがクライアントである場合には、それらに定期的な実行を任せるのは無理があります。TTと同じマシンにcronスクリプトを仕掛けてexpireを定期実行させるのも手ですが、それくらいの機能はTT自体に持たせてしまってもよいでしょう。ということで、TTのLua拡張を使って、任意の周期で任意の処理を呼び出せる仕組みを容易しました。例えば上記のexpireはLuaだと以下のように記述できます。

function expire()
   local args = {}
   local cdate = string.format("%d", _time())
   table.insert(args, "addcond\0x\0NUMLE\0" .. cdate)
   table.insert(args, "out")
   local res = _misc("search", args)
   if not res then
      _log("expiration was failed")
   end
end

このファイルをttexpire.luaという名前で保存したとしたら、以下のようにTTのサーバを起動させることで、expire関数を1秒毎に定期実行させることができます。-extpcオプションは複数回指定することができ、呼び出した関数ではレコードの削除だけでなく任意の処理を行えるので、バックアップやハートビートなど、様々な用途に応用することができます。

ttserver -ext ttexpire.lua -extpc expire 1.0 "casket.tct"

memcachedでは "flags" という属性をレコードに付与して、クライアント側で任意の意味を持たせて運用することができます。TTでもテーブルのコラムとしてそれを持たせれば同様のことができます。多くの言語のmemcachedクライアントではリストやハッシュなどの複合データ構造を直列化する方法の種類を持たせるためにそれを利用しているようです。ただ、その程度の利用法ならば直列化したデータの先頭1バイトにそのフラグを接頭させればよいので、テーブルデータベースを使うまでもないと個人的には思っています。サーバ側の処理に活用しないデータ片をわざわざ独立させて空間効率を悪化させるのは嫌なので、この件には深入りしないでクライアントライブラリに任せることにします。

超まとめ

単純なハッシュデータベースの値に構造を持たせることから始まり、それにインデックスとクエリオプティマイザをつけてRDBMSのテーブルっぽく利用できるようにしたのがTCのデーブルデータベースです。それを抽象データベースAPIにマッピングした上でネットワーク対応を施し、さらに言語バインディングを実装しまくることで、今こうして、そこそこ使いやすいデータベース機能を提供できているのではないかと思っています。特に「永続的だが時限的なキャッシュ」は、一見矛盾した概念でありながらも、Web業界でのニーズをがっちり捉えているのではないかと勝手に予感してワクワクしています。テーブル関連の機能は自分でもまだそれほど使い込んでいないので、これからアプリケーションを作り込んだりその実運用をする活動を経て、さらに機能と性能の改善を図っていきたいと思っています。

どんなに優秀で経験を積んだ技術者であろうと、未来(その製品がどんな風に使われて、どんな不具合が出て、どんなクレームがつけられるか)を予見する能力には限界があります。したがって、TCやTTのような実用を目的とする製品は、実際のサービスで運用していただいて、発生したクレームをフィードバックする過程を経ねば成立しません(それでも全てのユースケースを網羅することは不可能なので「完成」とは言えない)。そういう意味では、私のような未熟者でも、実サービスに触れて開発者や運用者と密に意思疎通をとりながら仕事が進められるおかげで、そこそこ使える製品を世に出せているような気がします。

つまり何が言いたいのかというと、実用的なものを作りたいのであれば、実用してくれる人々の近くにいると有利だということです。例えば分散ストレージを作りたいのであれば(TC/TTは分散ストレージではありませんが)、弊社やその他のWebサービス企業に籍を置くのも一興でしょう。あるいは界隈で開かれる勉強会に参加して人づてにニーズを聞き出して製品に反映させるのもよいでしょう。作った製品をオープンソースで公開してフィードバックを集めるのもよいでしょう。私も現状に甘んじず、様々な取り組みをしていこうと思います。ということで、世の皆様(そして弊社の人々)、TCやTTの新機能を実際に使ってみてご意見いただけると幸いです。また、各種イベントにて喋る機会があれば参上する所存なので、お声がけくださいませ。