読者です 読者をやめる 読者になる 読者になる

mixi engineer blog

ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

Inside Tokyo Cabinet その壱

algorithm

約半年間の沈黙を破ってOSSの世界に戻ってきつつあるmikioです。先日、Tokyo Cabinet(以下「TC」と呼びます)というデータベースライブラリをリリースしました。今回から数回に分けて、TCの設計と苦労話について連載してみます。

DBMとは

TCは、いわゆるDBMの系譜のデータベースライブラリで、単純なハッシュテーブルをファイル上で永続化するだけの機能を提供します。DBMはAT&Tの古代UNIXの時代から受け継がれる伝統芸能なのですが、私はそういう枯れた技術が大好きなのです。 プログラマの皆さんは、PerlやRubyではハッシュ(連想配列)と呼ばれ、JavaやC++ではmapと呼ばれるような、何らかのキーに関連づけてなんらかの値を記録するデータ構造って実によく使いますよね。例えばmixiでは、ユーザアカウントに関連する情報(名前とかニックネームとか)は、ユーザIDをキーにしたハッシュに格納されています。ものすごく単純化した例を示すとこんな感じですね。

my %nicknames; 
$nicknames{"123456001"} = "batara"; 
$nicknames{"123456002"} = "mikio"; 
$nicknames{"123456003"} = "penguin"; 
... 
printf("nickname of 123456001: %s\\n", $nicknames{"123456001"}); 
printf("nickname of 123456002: %s\\n", $nicknames{"123456002"}); 
printf("nickname of 123456003: %s\\n", $nicknames{"123456003"});

文字列だろうが数字だろうが、スカラとして表せるものは何でもハッシュに入れることができる(より複雑なデータ構造の場合にはスカラに直列化してから入れればOK)だけでなく、ハッシュ内のレコード数がどんなに多くなっても一瞬でレコードを格納したり取り出したりできるので、とにかくめっちゃ便利です。これに慣れると、ハッシュがない言語なんて全く使う気にならないくらいです。

しかしながら、普通のハッシュはメモリ上にデータを保持することを前提として実装されているので、プログラムが終了するとデータは消えてしまいますし、メモリ容量以上のデータをうまく扱うことができません。キーと値の対応関係を別のファイルに書き出しておいてから、毎回プログラムが起動する度にハッシュを再構築するという手法も考えられなくはないですが、取り扱うレコード数が数千を超えたくらいからそれでは遅くてやっていられなくなります。ましてやmixiのユーザアカウントのように1000万を越える場合はプログラムを起動するだけで何時間かかるかわかりませんし、1レコードのサイズが100バイトとしたら100*10000000で最低1GB(実際には数10GB)ものメモリを食うので泣きが入ってしまいます。

そこで、DBM(DataBase Manager)の登場です。メモリ上のハッシュの様な使い勝手と性能を持ちながら、データをファイルに書き込むことで、レコードを永続化し、実メモリを越える容量のデータを取り扱うことができます。

DBM自体は「key/value」ペアの格納と削除と探索を行う機能を提供するだけですが、より複雑なロジックを司るハーネスをかぶせることで様々なシステムに進化します。リレーショナルデータベースのストレージエンジンとしても、プライマリキーをキーにしてレコードの実データを値にしたDBM風の機能が使われています。あるいは、ファイル名(実際にはinode)をキーにしてメタデータや実データを値にすることで、ファイルシステムを実現することができます(DBM自体がファイルシステムに依存しているという矛盾はさておき)。さらに、検索語をキーにしてそれを含む文書のIDを値にすることで、全文検索エンジンのインデックスを実現することもできます。ソフトウェアの花形といわれるシステムの裏には常にDBM風の機能が隠れているといっても過言ではありません。

ハッシュテーブルの高速性

必ずしもDBMを使わなくっても、例えばCSVでもデータベースは実現できます。上記のサンプルコードのデータベースは以下のようなCSVファイルとして記録してもよいのです。

1234560001,batara 
1234560002,mikio 
1234560003,penguin

CSVファイルから特定のレコードを捜し出す際には、逐次探索を行います。すなわち、ファイルの先頭から末尾まで読み進めていって、キーとなるフィールドが完全一致するものを見つけた時点で対応する行の値を返すという処理を行います。したがって、該当のレコードがある場合は全レコードの平均半分を調べることになり、該当のレコードがない場合は全て調べることになります。つまり、探索にかかる計算量はレコードの数やデータベースのサイズに比例します。この状況を、レコード数をnとして「O(n)」と表します。「O」は「Order」で、「n」は「レコード数nに依拠する」という意味です。レコードを追加する場合や削除する場合の計算量も同じです。これでは遅すぎて、残念ながら1000万件規模では使いものになりません。

それに対して、DBMはハッシュテーブルを使って、データの探索や追加や削除を高速に行います。ハッシュテーブルの探索にかかる時間計算量は「O(1)」と表されます。「O」は「Order」で、「(1)」は「1に依拠する」、つまり「変化しない」あるいは「レコード数nとは関係ない」という意味です。これは凄いことですよね。レコード数が1件でも1000万件でも(1件あたりは)ほぼ同じ時間で処理ができるのですから、たとえmixiでも安心して使えるわけです。

ここまでは前置きで、ここからが本題です。では、なぜハッシュテーブルは高速なのでしょう。それは一言でいえば、「探索領域を限定するから」です。ここで、ハッシュテーブルの説明に至る前に、CSVの探索を高速化するワークアラウンドについて考えてみましょう。単一のCSVファイルに全てのレコードを保存するのではなく、「0.csv」「1.csv」「2.csv」...「9.csv」の10個に分けることにします。そして、キーの数値を10で割った余りでファイル名を特定して、そのファイルに格納します。

  • 「1234560001,batara」 → 1234560001 % 10 == 1 → 「1.csv」
  • 「1234560002,mikio」 → 1234560002 % 10 == 2 → 「2.csv」
  • 「1234560003,penguin」 → 1234560003 % 10 == 3 → 「3.csv」

こうしてCSVファイルを作っておいた上で、「1234560002」に該当する値を探す場合は、格納する時と同じ計算を行って、「2.csv」を特定して、その中のレコードを探せばよいことになります。探索範囲を10分割しているのだから、探索する量も10分の1になり、すなわち探索が10倍高速化されることになります。ここですぐ気づくように、1000万件のレコードを格納しても、1000万個のファイルに分割して格納するようにすれば、実際に逐次探索する回数はほぼ1回になります。ハッシュテーブルとは、上記の手法を一般化したものです。格納するキーの数に匹敵するくらいの数に探索範囲を分割することで、逐次探索する回数を実質1回にするのです。DBMはハッシュテーブルを単一(もしくは少数)のファイルで実現する機能です。

ハッシュ関数

キーが数値でなくて英字などの文字列であったり、さらには任意のバイナリコードであっても大丈夫なように、それらに一定の計算をして、特定の範囲に収まる数値を生成する必要があります。このような数値を「ハッシュ値」と呼び、キーからハッシュ値を生成する関数を「ハッシュ関数」と呼びます。ハッシュ値が同一であるキーが複数ある状態を「衝突」と言いますが、この衝突ができるだけ少ないものがいいハッシュ関数だと言われています。 ハッシュ関数のソースコードをTCから抜き出してみます。kbufはキーの領域のポインタで、ksizはそのサイズで、hdb->bnumには通称「バケット数」=「分割する数」が入っています。つまり、キーの各バイトを足していく過程で既存の値に37を掛けておくということです。

static uint64_t tchdbbidx(TCHDB *hdb, const char *kbuf, int ksiz){ 
  uint64_t hash = 751; 
  while(ksiz--){ 
    hash = hash * 37 + *(uint8_t *)kbuf++; 
  } 
  return hash % hdb->bnum; 
}

付与の変域の中で値をできるだけ均一にバラけさせるために、ものすごくたくさんの手法が提案されています。しかし、私がいろいろと試した限りでは、あんまり凝ったものはだいたいダメな感じで、上記くらいで十分です。37でなく31を掛けても結構いいですが、シーケンシャルな数値や英単語を扱う限りにおいては37の方が安定しているような気がしなくもないです。最後に余り算で使うことになるバケット数は素数がいいらしいとknuth先生は言っているそうで、TCでも素数を割り当てていますが、実際のところはどうでもいいみたいです(2の冪にしてビットマスクした方が高速でよいという噂もあります)。なお、751は私の高校受験の際の受験番号です(つまりこの値は本当にどうでもいいです)。 各バイトで掛ける数について、ちょっと参考になるかもしれない実験結果を紹介しましょう。「00000001」「00000002」「00000003」...「01000000」というような、シーケンシャルな数値を8桁で表したキー100万個の集合を対象に、上記のハッシュ関数の衝突数を調べてみました。

バケット数 29の衝突数 31の衝突数 37の衝突数 41の衝突数
524287 747269 687903 684950 828933
1048573 324231 584752 326856 316006
2097143 101076 149083 186656 296237
3145721 208943 196271 149690 57268
4194301 154133 82535 89437 38347
5242877 24000 13518 50740 399460
6291449 1800 276680 26870 68320
7340009 31428 16440 18785 30816
8388593 24992 4480 48000 3920

うーむ。決定的によいものはなさそうですね。結局のところ、入力パターンを規定した場合にさえ、どのようなハッシュ関数が最良かは言いきれないので、「えいや」で決めるしかなさそうです。空前の素数ブームなのでとりあえず素数。んでもって3と7は縁起がよさそうなので37。そして751(ry

単純なファイルフォーマット

ハッシュ値ごとにファイルを分けて保存するというのも悪くないアイデアですが、100万個とかのファイルを作るとなるとファイルシステムが大変なことになってしまいます。そこで、全レコードを単一のファイルに保存することにして、ハッシュ値に該当するレコードのファイル内の位置を記録した配列を管理することにします。これを「バケット配列」と呼ぶことにします。また、バケット数はバケット配列の要素数という意味になります。

さて、今から最も単純なDBMのフォーマットを設計していくことにします。まず、バケット配列をファイルの先頭に配置して、実際のレコードはその後ろに置くことにしましょう。例えばバケット数が1000個で、その各要素をint型(4バイト)の数値で表現するならば、ファイルの先頭4000バイトまではバケット配列を記録するのに使い、4001バイト目(オフセットで言えば4000バイト目)以降はレコードの本体を記録します。

次に各レコードのフォーマットを考えます。CSVファイルの場合はレコードの区切りを改行で表していましたが、DBMではキーや値に改行を含められることも考えて、区切り文字でなく、サイズを別に記録することで区切りを判定することにします。また、CSVでは同じハッシュ値の次のレコードは自明(同一ファイル内の次の行)でしたが、DBMでは全てのレコードが同一ファイルにあるので、「同一のハッシュ値を持つ次のレコードの位置」も記録することにします。以上をまとめると、各レコードには以下の領域が必要となるわけです。

  • キーのサイズ:int型(4バイト)
  • 値のサイズ:int型(4バイト)
  • 次のレコードの位置:int型(4バイト)
  • キーの実データ:任意のバイト列
  • キーの値の実データ:任意のバイト列

ここで、いくつかレコードを格納した場合の模式図を見てみましょう。「1234560003,penguin」と「1234570003,devil」はハッシュ値がともに3で衝突しているので、後から入れた「1234570003,devil」が「1234560003,penguin」の後ろにぶら下がっていることに注意してください。なお、このような衝突の管理方法を「セパレートチェーン」などと呼ぶこともあります。 dbmsimple.png

まとめ

上記でDBMの基本的な構造はおわかりいただけたと思います。ハッシュテーブルを使えば高速にデータを探索できますし、それをファイル上で扱うことで大規模かつ永続的な用途にも使えるようになります。 しかし、問題はここからなのです。以下のような問題を解決すべく、ここから改善改善改善の旅が始まります。

  • バケット数が十分に確保できない場合にどうするか
  • レコードを消すときにどうするか
  • 既存のレコードの値を変更するにはどうするか
  • 合計サイズがint型で表現できないくらい大きい場合にどうするか
  • 各レコードのヘッダ部分をもっと圧縮できないか
  • 探索や更新を並列で行うにはどうするか

DBMのような基本的な機能は、世界一できる奴が世界一効率の良い実装を公開してくれればそれが決定版になりそうなものですが、探してもなかなかないんですよね(ハッシュ関数と同じでそもそも最良なんてないというのがその理由なわけですけど)。そして、GDBMやBerkeley DBなどの優れた実装が既にあるなかで、それらのよいとこ取りをしてQDBMというデータベースマネージャを作りました。それでもまだ改良の余地があると感じたので、このたびTCを作った次第です。馬鹿の一つ覚えのようにDBMを作ってどうするのかと思われるかもしれませんが、根幹の部分で頑張っておけばそれより上の層で幸せになれることを、ここまで読んでくれた皆さんにはおわかりいただけていると思います。

この連載ではTCの仕様決定や技術選択の過程を晒していく予定なのですが、「そこもっとこうした方がいくね?」という点があれば私にこっそりご連絡いただけると大変嬉しい夏の日です。