インフルエンザで休んだ影響で仕事が鬼のように溜まって消化不良のmikioです(こんな記事を書いている場合じゃない)。さて今回は、Tokyo Cabinetでリレーショナル風データベースを実現したテーブルデータベース(TCTDB)の実装について説明します。

SQLiteとの違いは?

SQLiteはアプリケーション組み込み型のSQL対応リレーショナルデータベースのライブラリです。TCのテーブルデータベースよりもはるかに高機能で、それでいて性能も大変優れています。いわゆるデスクトップアプリケーションに組み込むデータベースをお探しであれば、TCなんかではなく、断然SQLiteがおすすめです。

一方で、TCなどのDBMは、より単純なデータ操作をより高速に実行できるように設計および実装されています。典型的なユースケースとして、大規模Webサイトのアカウント管理や、データマイニングに伴う集計操作が挙げられます。ということで、そういった特定のユースケースではSQLiteよりも高い性能を示すことがTCTDBには期待されるわけですが、果たしてそうなのか、性能テストをしてみました。

a,b,c,d,eという5つのコラムを持つレコード群を100万件登録したデータベースを作ります。その構築にかかる時間と、それに対する検索にかかる時間を、SQLiteおよびTCTDBのそれぞれで測定してみます。各コラムは以下の条件を設定します。

  • a : 文字列型。主キー。”00000001″ 〜 “01000000″ のシーケンシャルな値。
  • b : 文字列型。インデックス有り。”00000001″ 〜 “01000000″ のシーケンシャルな値。
  • c : 数値型。インデックス有り。”1″ 〜 “1000000″ のランダムな値。
  • d : 文字列型。インデックスなし。”00000001″ 〜 “01000000″ のランダムな値。
  • e : 数値型。インデックスなし。”1″ 〜 “1000000″ のシーケンシャルな値。

コラムaで主キーのインデックスの性能を計り、コラムbでB+木インデックスのシーケンシャルな更新性能を計り、コラムcでB+木インデックスのランダムな更新性能を計ります。コラムdとコラムeはオマケです。検索はコラムbの完全一致(SQLiteでは「=」、TCTDBではSTREQ)で行います。実際のテストプログラムはこちらです。TCのバージョンは1.4.2で、SQLiteのバージョンは3.4.2です。CPUが4コアXeonでメモリ8GBのマシンで実行したところ、以下の結果になりました。値は10回の試行の平均値(秒)です。

write time read time file size
SQLite 45.562 (21948qps) 47.713 (20958qps) 92MB
TCTDB 9.561 (104591qps) 6.152 (162548qps) 105MB (70+20+15)

ということで、あくまでこのテストプログラムに限った話ではありますが、TCTDBはSQLiteの数倍の時間効率になることがわかりました。空間効率については、SQLiteの方が優れていることがわかりました。もちろん、スキーマやアクセスパターンやチューニングを変えればまた違う結果になるので、気になる方はテストプログラムをいじっていろいろ試してみてください。

余談ですが、SQLiteのamalgamationという最適化手法には驚きました。前処理で全部のソースコードを1ファイルに連結してからコンパイラにかけることで、通常のコンパイル単位をまたがった最適化を可能にするらしいのです。そしてそれだけ5〜10%も高速化するとのこと。そりゃいいと思ってTCでも試してみたのですが、B+木のwrite(tcbtest write casket 1000000)で1.3%、B+木のread(tcbtest read casket)で0.8%ほど高速化しました。5%は無理でしたが、それなりに効果はあるみたいですね。

データベースの構造

閑話休題。前回の記事でも述べましたが、TCTDBはTokyo Cabinetのハッシュデータベース(TCHDB)の上に実装され、主キーによってレコードを参照する操作が最も高速になるように仕上げられています。主キー以外のコラムによってレコードを参照する操作は補助的な位置づけで、コラムに対するインデックスはB+木データベース(TCBDB)の外部ファイルとして実装されています。

ハッシュデータベースでテーブルを表現するために、主キーをハッシュのキーとして、各キーに関連づけられた値としてコラムを直列化して記録しています。コラムはTCのユーティリティAPIにおけるマップ(TCMAP)として表現されます。マップの直列化にあたっては、キー(コラム名)と値の各々を直列化したバイト列 [ksiz:vsiz:kbuf:vbuf] をコラムの数だけ並べます。ksizはキーのサイズ、vsizは値のサイズ、kbufはキーの実体、vbufは値の実体です。ksizとvsizは固定長でなくBERエンコード風の可変長で表現することで領域を節約しています。

tdbhdbarc.png

SQLiteなどのほとんどのリレーショナルデータベースでは、コラム名はデータベースのメタデータとしてのみ保持しますが、TCTDBではコラム名も各レコードに入れています。この方式だと空間効率の点でかなり不利になり、上記のテストでSQLiteよりサイズが大きくなった一因ともなっているわけですが、その反面、予めスキーマを定義する必要がなくなるとともに、レコード毎に異なるスキーマを持たせることが可能となります。この点では、リレーショナルデータベースというよりも、オブジェクトデータベースとかドキュメントデータベースとかいったモデルに近いと言えるかもしれません。

コラムにインデックスを張ると、本体のデータベースの名前に「.idx.xxxx.lex」もしくは「.idx.xxxx.dec」といった接尾辞をつけた名前の外部ファイルが作成されます。「xxxx」の部分はコラム名で、「lex」または「dec」は型が文字列の辞書順(lexical)か10進数の数値順(decimal)かによって決まります。なお、主キーは空文字列のコラムとして扱うことでインデックスを張ることができ、その場合は主キーに対する完全一致以外の演算子を高速化する効果があります。

tdbbdbarc.png

レコード本体の管理とインデックスの管理はそれぞれTCHDBとTDBDBに任せるとして、レコード本体が更新された時にインデックスも更新するというのが、TCTDB層の仕事の半分と言っていいでしょう(残り半分は後述するクエリの処理です)。インデックスは、コラムの値をキーとして、その値のコラムを含むレコードの主キーを値としたデータベースです。ここでB+木データベースの重複キーを許す性質が生きてくるわけです。で、新規にレコードが追加された際には、そのレコードの各々のコラムに対して該当するインデックスを調べ、あればそれにtcbdbputdupでエントリの追記を行います。同じ理屈で、レコード本体が削除される際には、そのレコードの各々のコラムに対して該当するインデックスを調べ、コラムの値と主キーに対応するエントリを削除します。既存のレコードが更新される際には、先に削除処理を行ってから新規追加を行います。

TCHDBと同じくTCTDBにも圧縮オプションを指定することができます。TCTDBにはTCHDBよりも大きいレコードが格納される傾向にあるので圧縮し甲斐があるでしょうし、インデックスはB+木なのでページ毎の圧縮によって高い圧縮効率が期待できます。その他のチューニングオプションも全てTCHDBから引き継いでいます。また、キャッシュ設定はTCHDBとTCBDBの双方から引き継いでいます。

クエリオプティマイザ

TCTDBが検索クエリを処理する際には、クエリの内容に基づいてどの順番でインデックスを参照するかを判断するクエリオプティマイザが実行されます。オプティマイザといってもそんなに賢いものではなく、TCTDBではものすごく素朴な実装をしていて、以下のロジックになっています。

  1. クエリに含まれる条件指定の各々を巡回して、計算量が少なくなる条件指定を探す。すなわち、コラム名が一致し、型が一致し、B+木のカーソルジャンプができる条件を探す。
  2. 1の条件に当てはまれば、そのインデックスを使って、カーソルで探索範囲を限定しつつ、検索を行う。
    • カーソル上の各々のレコードにその他の条件指定との合致を選択し、全て真なら採用する。
    • 順序指定のコラム名と型がインデックスと同一ならば、採用順の昇順もしくは降順をそのまま結果集合の順序にする。
    • 結果集合の要素数が最大取得数指定に達して、順序指定がないか、上記の順序採用が成立していれば、そこで探索を終了する。
  3. 1の条件に当てはまらなければ、クエリに含まれる条件指定の各々を巡回して、係数が小さくなる条件指定を探す。すなわち、コラム名が一致し、型が一致する条件を探す。
  4. 3の条件に当てはまれば、そのインデックスを使って先頭から末尾まで訪問しつつ、検索を行う。
    • カーソル上の各々のレコードにその他の条件指定との合致を選択し、全て真なら採用する。
    • 順序指定のコラム名と型がインデックスと同一ならば、採用順の昇順もしくは降順をそのまま結果集合の順序にする。
    • 結果集合の要素数が最大取得数指定に達して、順序指定がないか、上記の順序採用が成立していれば、そこで探索を終了する。
  5. 3の条件に当てはまらなければ、クエリの順序指定に対してコラム名と型が一致するインデックスを探す。
  6. 5の条件に当てはまれば、そのインデックスを使って先頭から末尾まで訪問しつつ、検索を行う。
    • カーソル上の各々のレコードに全ての条件指定との合致を選択し、全て真なら採用する。
    • 結果集合の要素数が最大取得数指定に達すれば、そこで探索を終了する。
  7. 1,3,5のいずれの条件にもあてはまらなければ、本体のハッシュデータベースを使って先頭から末尾まで訪問しつつ、検索を行う。
    • イテレータ上の各々のレコードに全ての条件指定との合致を選択し、全て真なら採用する。
    • 結果集合の要素数が最大取得数指定に達して、順序指定がなければ、そこで探索を終了する。
  8. 順序指定があり、ここまでの手順で順序が決定していない場合、結果集合の並び替えを行う。
    • 結果集合の各々の要素に対して順序指定のコラムの値を取り出して、メモリ上でクイックソートする。
  9. 結果集合の上位から、最大取得数の分だけの主キーを呼び出し側に返す。

一般的には、手順1,3,5をいかに賢くするかがオプティマイザの勝負どころなようですが、TCTDBの実装では、コラム名や型の一致と演算子の種類のみを見てインデックスの採否を決定しています。本来であればインデックスのカーディナリティ度が高いものを優先して採用すべきであり、カーディナリティ度が低すぎるものは採用すべきではありません。例えば、「性別が男(sex STREQ male)の人を若い順(age NUMASC)に取り出す」ことを考えてみましょう。インデックスはsexにもageにも張ってあるとします。もしここでsexのインデックスを採用した場合、値が2種類しかないので効果的に探索範囲を狭めることができません。そしてその手順で全体の半数のレコードを採用してしまい、最後にageでソートする際には、ハッシュデータベースを参照するためのI/O負荷やソートのための使用メモリおよびCPU時間が大変なことになってしまいます。したがって、インデックスごとに統計的な手法でカーディナリティを算出しメタデータとして持っておき、それが高いものから採用する方が賢いのかもしれません。

しかし、なぜそうしていないのかと言うと、面倒だからですね。カーディナリティ度が予めわかったからと言って個々のクエリに対する効果は実際に実行してみなければわからないので、割に合いません(男女の2種類であっても、男が圧倒的に少なければ男という条件にはインデックスを使うべき)。どの条件がどのくらいの該当数になるのかはアプリケーション層(ユーザ層)の知識がないと予測できないので、むしろアプリケーション側から指定できた方がいいんじゃないかと思った次第です。

ということで、クエリオブジェクトに追加(tcqdbqryaddcond)した順番でループを回して各インデックスとの合致をコラム名と型と演算子の種類のみで判定し、最初に合致したものを採用することにしています。アプリケーションプログラマやエンドユーザは、カーディナリティが高そうな(つまり効果的に絞り込めそうな)条件指定から先にクエリオブジェクトに追加することで、だいたい望ましい性能を得ることができます。また、条件指定ではなく順序指定でインデックスを使いたい場合には、全ての条件指定でインデックスを使わないオプション(TDBQCNOIDX)を演算子に付与することで順序指定のインデックスを優先させることができます。先ほどの例がまさにそうで、年齢のインデックスを先に引いて若い順にレコードを参照しつつ、男が最大取得件数分だけ出てきた段階で探索を終了すれば、本体のハッシュデータベースを引く回数は極小で済むし、ソートも必要なくなります(sexとageの複合インデックスを使うのが時間効率としては最高なわけですが、それはまた別の問題)。

アトミックな更新

TCTDBは、主キーを使ったレコードの操作(取得、更新、削除)の他に、任意のコラムをつかった検索ができます。SQL的に考えると、主キーを使ったSELECTとINSERTとDELETEができるのに加えて、任意のコラムを使ったSELECTができるということです。あれ、でも待てよ、任意のコラムを使ったREPLACEとDELETEもできないとまずいんじゃないか。SELECTしてから結果集合の各要素にREPLACEやDELETEを発行すれば同じようなことはできそうだけど、極小のアトミック性を確保するためにわざわざトランザクションを使うのはだるいんじゃないかと。

ということで、クエリを使った検索結果の各々に対して更新または削除の操作をアトミックに行える仕組みが用意してあります。クエリオブジェクトに加えて、結果集合の各々に対するコールバック関数を与えてメソッドを呼び出すことで、任意の更新処理を行うことができます。コールバック関数の戻り値によって、レコードを削除する(TDBQPOUT)か、更新する(TDBQPPUT)か、何もしない(0)かの後処理が実行されます。例えば20歳から29歳までの女子の給料を10000円増やす処理は以下のように実装できます。

int baseup(const void *pkbuf, int pksiz, TCMAP *cols, void *op){
  const char *val = tcmapget2(cols, "sal");
  if(!val) return 0;
  char numbuf[32];
  sprintf(numbuf, "%d", atoi(val) + atoi(op));
  tcmapput2(cols, "sal", numbuf);
  return TDBQPPUT;
}

int foo(void){
  ...
  TDBQRY *qry = tctdbqrynew(tdb);
  tctdbqryaddcond(qry, "age", TDBQCNUMBT, "20,29");
  tctdbqryaddcond(qry, "sex", TDBQCSTREQ, "female");
  tctdbqryproc(qry, baseup, "10000");
  tctdbqrydel(qry);
  ...
}

これで、WHERE句に主キーの完全一致以外を指定したREPLACEやDELETEと同等の操作が行えるようになりました。MySQLのREPLACEとSQLiteのON CONFLICT句の両方のニーズが満たせるはずです。なお、単に該当のレコードを削除するのにいちいちコールバック関数を用意するのは面倒なので、tctdbqrysearchout(qry); だけで消せるようにもしています。

そういえば、参照と削除がアトミックにできるというという特徴は、キューを実現するのにぴったりですよね。キューの処理は以下のように実装するとよいでしょう。コールバック関数の中では、タスクの処理をするのではなく、タスクリストにコラムのデータを入れるだけにするのが重要です。なぜなら、コールバック関数の実行中は他のデータベース操作がブロックされるので、コールバック関数は一瞬で終わりたいからです。

int shifttasks(const void *pkbuf, int pksiz, TCMAP *cols, void *tasks){
  // コラムのマップオブジェクトを複製
  TCMAP *newcols = tcmapdup(cols);
  // プライマリキーをダミーのコラムとして入れる
  tcmapput(newcols, "", 0, pkbuf, pksiz);
  // タスクリストに入れる
  tclistpush(tasks, &newcols, sizeof(newcols));
  // 取得したレコードの削除を指示する
  return TDBQPOUT;
}

int bar(void){
  ...
  // タスクリストの初期化
  TCLIST *tasks = tclistnew();
  // クエリ組み立て
  TDBQRY *qry = tctdbqrynew(tdb);
  tctdbqrysetorder(qry, "seq", TDBQONUMASC);  // seqコラムの昇順に
  tctdbqrysetmax(qry, 10);                    // 最大10件を取得
  // クエリ実行
  tctdbqryproc(qry, shifttasks, tasks);
  // タスクの処理
  for(int i = 0; i < tclistnum(tasks); i++){
    TCMAP *cols = *(TCMAP **)tclistval2(tasks, i);
    // コラムを参照しつつ何かする
    const char *pkbuf = tcmapget2(cols, "");
    printf("the primary key: %s\\n", pkbuf);
    // コラムのリソースを破棄する
    tcmapdel(cols);
  }
  // 後始末
  tctdbqrydel(qry);
  tclistdel(tasks);
  ...
}

上記ではseqというコラムにタスクの優先順位(格納した時間でOK)を入れていることを前提としていますが、プライマリキーやその他のコラムを使ってもよいでしょう。また、昇順でなく降順で取り出せばスタックとして使えます。

コード内に「ポインタポインタ」が出てきてしまっているので念のため補足しておきます。shifttasks関数の中でtclistpushでリストオブジェクトに入れているのは(TCMAP *)型の変数の領域なので、つまりその領域を指すポインタ(TCMAP **)を引数にしています。同じ理屈で、bar関数の中のtcmapget2では、(TCMAP **)が返されるので、その参照先である(TCMAP *)を先ほど複製したマップのポインタとして利用することができるのです。えーと、複雑ですね。スクリプト言語バインディングを使うともう少し直感的になると思います。

まとめ

Tokyo Cabinetのテーブルデータベースに関して、SQLiteと比較しつつ性能を評価するとともに、内部の実装について説明しました。繰り返しになりますが、プロセス組み込みのリレーショナルデータベースが欲しい場合は相変わらずSQLiteを使うのが便利だし効率的です。一方で、TCTDBは主にクライアント/サーバモデルにおけるサーバ側で使いやすいように作っています。サーバ側だったらMySQLだろと言われそうですが、サーバ側でプロセス組み込み型であるというニッチなニーズもないことはないのと、あとは性能の高さが存在意義なのかなと思います。

TCTDBを作りながら感じたのは、Hyper Estraierのデータベースとものすごく似た構造になってきたということです。これで文字N-gramインデックスをサポートしたら完全に全文検索システムとか文書管理システムになってしまいますね。作者が同じなのだから仕方ないのですが、楽観的なメモリの使い方とか素朴すぎるクエリオプティマイザとか、2年以上経ってもあまり進歩していません。並列性と64ビット環境を意識している点が違うといえば違いますが…。

次回は、いよいよTokyo Tyrantにテーブルデータベースを組み込む話をします。

正月早々インフルエンザにかかって寝込んだmikioです。電車に乗る時や繁華街などに出る時はマスク着用が必須ですね。さて今回は、Tokyo Cabinetで実装したテーブル方式のデータベースについて紹介します。意外にどうして強力な機能なので、このネタは連載することを予告します。

テーブルデータベースとは

簡単に言えば、リレーショナルデータベースのテーブルのように、複数の列からなるレコードを格納できるデータベースです。SQLや表結合などの複雑な機能はサポートしませんが、そのぶん高速に動作します。つまり、DBMの速度で動くリレーショナル風データベースです(厳密にはリレーショナルデータベースではありません)。

tabledatabasecmp.png

TCの基本となるハッシュデータベースは、単純なkey/value型のデータベースであり、つまりキーにも値にもスカラ(数値や文字列などの特に構造を持たない単一の値)しか格納することはできません。キーや値にCSVやXMLなどで構造を持たせることはできますが、その構造を元にしてクエリを発行したりレコードを操作することはできません。一般的なアプリケーションで用いるほとんどのデータ構造はkey/value構造を組み合わせることで表現できるのですが、その組み合わせの分だけDBファイルを作ってそれを操作するコードを書くのはなかなか面倒なものです。

そこで、テーブルデータベースの登場です。ハッシュデータベースのように主キーでレコードを識別しながらも、リレーショナルデータベースのようにレコード内に名前をつけた複数のコラムを持たせることができます。ハッシュデータベースと違って、レコード内の個々のコラムの値を条件にしてレコードの集合を問い合わせられることが挙げられます。また、リレーショナルデータベースとは違って、あらかじめスキーマを定義する必要がなく、レコード毎に異なる種類のコラムを持たせることができます。

社員名簿を実装してみる

例として、社員名簿を考えてみましょう。社員毎にレコードを割り当てて、各レコードには、社員番号、名前、性別、入社日、所属部署名を持たせることにしましょう。以下のようなデータベースになります。

id name sex hdate div
1 空条承太郎 male 2005-03-21 brd,dev
81 東方仗助 male 2006-06-01 dev
92 汐華初流乃 male 2007-03-11 hr
127 空条徐倫 female 2007-05-23 brd,hr

ちなみに、MySQLでスキーマを表現するならば、以下のようになるでしょうか(divを正規化していないのはわざとで、”brd,hr” で役員会と人事部の兼務ということを示すことにします)。

create table staffs (
  id int primary key,
  name varchar(255),
  sex enum ( 'male', 'female' ),
  hdate date,
  div varchar(255)
);

上記をハッシュDBで実装するのは結構面倒ですね。名前DB、性別DB、入社日DBなどなどを別ファイルにして、それらを矛盾しないように更新するロジックを書くのは骨が折れます。一方で、テーブルデータベースを使えば簡単です。まずはTCに付属のコマンドラインツールで上記のデータを格納したDBを作ってみましょう。以下のコマンドを実行すればOKです(TSVインポートツールもあります)。

tctmgr create casket
tctmgr put casket 1 "name" "空条承太郎" "sex" "male" \
  "hdate" "20050321" "div" "brd,dev"
tctmgr put casket 81 "name" "東方仗助" "sex" "male" \
  "hdate" "20060601" "div" "dev"
tctmgr put casket 92 "name" "汐華初流乃" "sex" "male" \
  "hdate" "20070311" "div" "hr"
tctmgr put casket 127 "name" "空条徐倫" "sex" "female" \
  "hdate" "20070523" "div" "brd,hr"

予めスキーマを決めなくても、任意の名前をつけた属性をコラムとして格納できます。データベースの中身を見るには以下のコマンドを実行します。

tctmgr list -pv casket
--------
1    name  空条承太郎  sex  male   hdate  20050321  div  brd,dev
81   name  東方仗助    sex  male   hdate  20060601  div  dev
92   name  汐華初流乃  sex  male   hdate  20070311  div  hr
127  name  空条徐倫    sex  female hdate  20070523  div  brd,hr

テーブルデータベースでも、普通のハッシュデータベースと同じように、主キーを指定して値を取り出すことができます。

tctmgr get casket 81
--------
81   name  東方仗助    sex  male   hdate  20060601  div  dev

面白いのはここからです。主キー以外の任意のキーでも検索ができるのです。

# 名前が「空条」で始まる社員
tctmgr search -pv casket name STRBW "空条"

# 2006年以降に入社した男の社員
tctmgr search -pv casket hdate NUMGE 20060101 sex STREQ "male"

# 役員か人事部の社員で一番古株の人
tctmgr search -pv -ord hdate numasc -m 1 casket div STROR "brd,hr"

演算子いろいろ

ハッシュデータベースだと主キーの完全一致しかなかったのに、ずいぶんと便利になった感じがしますよね。そう思っていただくために、よく使いそうな演算子を結構頑張って用意したわけです。ポインタずらしつつ文字列操作をいちいち書かにゃならんし、テストケースもたんまり書かなきゃならんし、地味すぎて泣ける作業なのです。

合致条件の演算子には以下のものがあります。演算子の前に「~」をつけると否定の意味になります。

  • STREQ : 右辺の文字列が完全一致する
  • STRINC : 右辺の文字列を含む
  • STRBW : 右辺の文字列で始まる
  • STREW : 右辺の文字列で終わる
  • STRAND : 右辺の文字列内のカンマ区切りの文字列の全てを含む
  • STROR : 右辺の文字列内のカンマ区切りの文字列のいずれかを含む
  • STROREQ : 右辺の文字列内のカンマ区切りの文字列のいずれかと完全一致する
  • STRRX : 右辺の文字列の正規表現と一致する
  • NUMEQ : 右辺の数値と一致する
  • NUMGT : 右辺の数値より大きい
  • NUMGE : 右辺の数値と同じかより大きい
  • NUMLT : 右辺の数値より小さい
  • NUMLE : 右辺の数値と同じかより小さい
  • NUMBT : 右辺のカンマ区切りの2つの数値の間である
  • NUMOREQ : 右辺のカンマ区切りの文字列のいずれかと一致する

順序指定の演算子には以下のものがあります。デフォルトは順序不定です。

  • STRASC : 文字列の辞書順の昇順
  • STRDESC : 文字列の辞書順の降順
  • STRASC : 数値の昇順
  • STRDESC : 数値の降順

ということで、SQLを使わないデータベースライブラリでも結構実用的なアプリケーションが実装できそうな予感がしてきませんか? ブログだったら、各エントリをレコードにして、タイトルと著者名と本文と投稿日時をコラムにすればよいでしょう。メーラだったら、各メールをレコードにして、本文やSubjectやFromやToなどをコラムにすればよいでしょう。バイナリデータも入れられるので、簡易的なファイルシステムとして利用することもできます。

ただし、このデータベースがリレーショナルデータベースとは言えない決定的な要因として、表結合(複数のテーブルをまたがった演算)ができないことが挙げられます。表結合ができないということは、上記の所属部署のようにスカラ値で表現できないデータに第一正規化を施してテーブルを分割した場合、クエリを複数回に分けて発行しなければならないということを意味しています。

とはいえ、mixiなどの高負荷のシステムでは実際は表結合はあまり使われないんですね。なぜなら、テーブル毎に異なるマシン群に分散させたり、さらには論理的には同一のテーブルを主キーで区分して物理的に複数のマシンに分散させたりするからです。mixi社内用語だと前者はレベル1分散、後者はレベル2分散と呼ばれています。物理的に分散していても単一のデータベースとして扱えるソリューションもあるにはあるのですが、各種ベンダーソリューションの「癖」に振り回されるよりはおとなしくクエリを複数回発行する方を選ぶというのも現実的な技術選択のひとつです(まあ、TCの癖の方がもっとひどいという話もありますが)。

インデックスを張る

コラムに着目したクエリを発行できるといっても、所詮はハッシュデータベースを基礎としているので、主キーの完全一致以外の条件は全て全表スキャンを行うことになります。つまり全データをgrepでスキャンしているのと同じ計算量です。もちろんそれでは大規模なシステムでは使えないので、任意のコラムにインデックスを張る機能が提供されます。

コラムには型がありませんが、インデックスには型があります。文字列型の演算を高速化させたい場合は文字列型のインデックスを、数値型の演算子を高速化させたい場合は数値型のインデックスを張ることになります。ただし、型が異なる場合でもインデックスを張っておくと、メインのハッシュデータベースの全表スキャンの代わりに、それよりは小さいインデックスの全表スキャンを用いるので、計算量は同じですが処理時間は短くなります。なお、型が同じでも高速化の度合いは演算子によって異なります。

  • 文字列型インデックス:
    • 計算量が小さくなる演算子:STREQ、STRBW、STROREQ
    • 計算量は同じだが高速化する演算子:STRINC、STREW、STRAND、STROR、STRRX、NUMEQ、NUMGT、NUMGE、NUMLT、NUMLE、NUMBT、NUMOREQ
    • 計算量が小さくなる順序指定:STRASC、STRDESC
  • 数値型インデックス:
    • 計算量が小さくなる演算子:NUMEQ、NUMGT、NUMGE、NUMLT、NUMLE、NUMBT、NUMOREQ
    • 計算量は同じだが高速化する演算子:STREQ、STRBW、STROREQ、STRINC、STREW、STRAND、STROR、STRRX
    • 計算量が小さくなる順序指定:NUMASC、NUMDESC

インデックスの威力を見るために、また付録のテストコマンドを使ってみましょう。以下のコマンドを実行してください。テスト用のコラムを含んだ100万のレコードを格納したデータベースを作ります。

tcttest write casket 1000000

格納されたレコードの一部を見てみましょう。

tctmgr list -m 10 -pv casket
--------
1   str  1   num  1  type  22
2   str  2   num  1  type  28  flag  4,9,12
3   str  3   num  1  type  23  flag  4,7,10,15
4   str  4   num  1  type  27  flag  5,7,8
5   str  5   num  1  type  20  flag  4,7,8
6   str  6   num  5  type  20  flag  3
7   str  7   num  3  type  13  flag  1,2,5,6
8   str  8   num  6  type  20  flag  3,5,7,8
9   str  9   num  3  type  15  flag  1,3,5,7
10  str  10  num  6  type  29  flag  1,4,5,10

上記のデータベースに、”str” コラムが “98765″ で始まるレコードを問い合わせます。「-ph」オプションでヒントも出力します。

tctmgr search -m 10 -pv -ph casket str STRBW 98765
--------
98765   str  98765   num  35013   type  27  flag  4
987650  str  987650  num  210440  type  23
987651  str  987651  num  303719  type  10
987652  str  987652  num  531217  type  12
987653  str  987653  num  597234  type  16  flag  1,3,4
987654  str  987654  num  530354  type  15  flag  5,7
987655  str  987655  num  287157  type  31
987656  str  987656  num  918276  type  18  flag  5
987657  str  987657  num  617401  type  27  flag  4,5
987658  str  987658  num  243921  type  28  flag  3
  :::: scanning the whole table
  :::: limited matching: 10
  :::: result set size: 10
  :::: leaving the natural order
  :::: elapsed time: 1.00759

「scanning the whole table」と書いてあるので、全表スキャンが行われたことがわかります。そして検索に1秒ほどかかったこともわかります。これじゃあ遅すぎるということで、”str” コラムに文字列型のインデックスを張ってみましょう。

tctmgr setindex casket str

これで、もう “str” の検索は高速化されています。さきほどの検索をもういちどやってみます。

tctmgr search -m 10 -pv -ph casket str STRBW 98765
--------
98765   str  98765   num  35013   type  27  flag  4
987650  str  987650  num  210440  type  23
987651  str  987651  num  303719  type  10
987652  str  987652  num  531217  type  12
987653  str  987653  num  597234  type  16  flag  1,3,4
987654  str  987654  num  530354  type  15  flag  5,7
987655  str  987655  num  287157  type  31
987656  str  987656  num  918276  type  18  flag  5
987657  str  987657  num  617401  type  27  flag  4,5
987658  str  987658  num  243921  type  28  flag  3
  :::: using an index: "str" asc (STRBW)
  :::: limited matching: 10
  :::: result set size: 10
  :::: leaving the natural order
  :::: elapsed time: 0.00011

もちろん検索結果は全く同じなのですが、ヒントの部分に「using an index: “str” asc (STRBW)」と出ています。”str” に張ったインデックスが効いてSTRBWが高速化されたことを示しています。それによって、経過時間が0.0001秒ほどになったこともわかります。スゲー!

インデックスはデータベースを作った後で張ってもよいし、予めインデックスを張ってからレコードを追加してもかまいません。もちろん、インデックスとレコード本体は自動的に同期がとられます。

API

ここまでコマンドラインインターフェイスでデータベースを操作してきましたが、TCはライブラリなので、APIを用いてアプリケーションにデータベース操作機能を組み込むことができます。C言語のサンプルコードを見てみましょう。

#include <tcutil.h>
#include <tctdb.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdint.h>

int main(int argc, char **argv){
  TCTDB *tdb;
  int ecode, pksiz, i, rsiz;
  char pkbuf[256];
  const char *rbuf, *name;
  TCMAP *cols;
  TDBQRY *qry;
  TCLIST *res;

  /* データベースオブジェクトを作る */
  tdb = tctdbnew();

  /* データベースオブジェクトを開く */
  if(!tctdbopen(tdb, "casket.tdb", TDBOWRITER | TDBOCREAT)){
    ecode = tctdbecode(tdb);
    fprintf(stderr, "open error: %s\\n", tctdberrmsg(ecode));
  }

  /* レコードを格納する */
  pksiz = sprintf(pkbuf, "%ld", (long)tctdbgenuid(tdb));
  cols = tcmapnew3("name", "mikio", "age", "30", "lang", "ja,en,c", NULL);
  if(!tctdbput(tdb, pkbuf, pksiz, cols)){
    ecode = tctdbecode(tdb);
    fprintf(stderr, "put error: %s\\n", tctdberrmsg(ecode));
  }
  tcmapdel(cols);

  /* レコードを素朴な方法で格納する */
  pksiz = sprintf(pkbuf, "12345");
  cols = tcmapnew();
  tcmapput2(cols, "name", "falcon");
  tcmapput2(cols, "age", "31");
  tcmapput2(cols, "lang", "ja");
  if(!tctdbput(tdb, pkbuf, pksiz, cols)){
    ecode = tctdbecode(tdb);
    fprintf(stderr, "put error: %s\\n", tctdberrmsg(ecode));
  }
  tcmapdel(cols);

  /* TSVを使ってレコードを格納する */
  if(!tctdbput3(tdb, "abcde", "name\\tjoker\\tage\\t19\\tlang\\ten,es")){
    ecode = tctdbecode(tdb);
    fprintf(stderr, "put error: %s\\n", tctdberrmsg(ecode));
  }

  /* レコードを検索する */
  qry = tctdbqrynew(tdb);
  tctdbqryaddcond(qry, "age", TDBQCNUMGE, "20");
  tctdbqryaddcond(qry, "lang", TDBQCSTROR, "ja,en");
  tctdbqrysetorder(qry, "name", TDBQOSTRASC);
  tctdbqrysetmax(qry, 10);
  res = tctdbqrysearch(qry);
  for(i = 0; i < tclistnum(res); i++){
    rbuf = tclistval(res, i, &rsiz);
    cols = tctdbget(tdb, rbuf, rsiz);
    if(cols){
      printf("%s", rbuf);
      tcmapiterinit(cols);
      while((name = tcmapiternext2(cols)) != NULL){
        printf("\\t%s\\t%s", name, tcmapget2(cols, name));
      }
      printf("\\n");
      tcmapdel(cols);
    }
  }
  tclistdel(res);
  tctdbqrydel(qry);

  /* データベースを閉じる */
  if(!tctdbclose(tdb)){
    ecode = tctdbecode(tdb);
    fprintf(stderr, "close error: %s\\n", tctdberrmsg(ecode));
  }

  /* データベースオブジェクトを破棄する */
  tctdbdel(tdb);

  return 0;
}

TCのハッシュデータベースを使ったことがある人ならば、だいたい同じような使い方だと感じていただけると思います。違う点として、レコードを格納する際に値に文字列を指定する代わりに、コラムを含んだマップオブジェクトを指定することに注意してください。また、レコードを検索する部分はB+木のカーソルオブジェクトに近いインターフェイスになっています。データベースオブジェクトからクエリオブジェクトを作って、それに該当条件や順序指定などを施してから、検索結果を取り出します。検索結果は主キーのリストとして得られるので、それを用いてレコード本体を取り出してから表示しています。

え、なんか複雑で使いにくそうですか? まあ、CやC++だとこれが限界でしょう。近いうちにPerlとRubyとJavaのバインディングについては私自身で書きますが、そうなると、言語に備え付けの連想配列でレコードを表現できるので、典型的なイディオムを組み合わせて直感的にプログラミングできるようになるでしょう。クエリ言語をでっち上げてDBIなどの高水準なインターフェイスを作る猛者も現れるかもしれません。

まとめ

Tokyo Cabinetに追加されたテーブルデータベースについて説明しました。SQLは喋らないけどうまい具合にテーブルが扱える小粋なツールです。そもそもなんでこれを作ったかと言えば、個人ブログを再開すべくまたブログシステムを書き始めたからです。前回QDBMで作ったストレージレイヤに気にくわない点が多かったので、TCでもっとスマートに作れないかなと思い、せっかくだから汎用のライブラリに仕上げた次第です。TCをベースとしてO/Rマッパーや分散オブジェクトシステムを実装している諸兄にももしかしたら役立つかもしれないと期待しています。

TCのテーブルデータベースに近いユースケースをカバーするものとしてSQLiteが挙げられます。「SQLiteでいいじゃん。何で?」はFAQになりそうな予感がしますが、ぶっちゃけ、SQLiteに対して主張できる優位性は特にありません。まだ十分に性能テストができていませんが、できれば時間効率と空間効率で褒めてもらえるようにこれから頑張ります。今後、TCの抽象データベースでテーブルデータベースをサポートして、Tokyo Tyrantからもテーブルデータベースを扱えるようにする予定です。

次回はテーブルデータベースの実装について詳細に説明してみたいと思います。この次も、ワッフルワッフル!