データ検索システムをTokyoCabinetだけで作ってみた
今仕事で開発しているシステムに、KVSを使えないかと、
以前からいろいろ模索していました
devsumi2010の前後で検証していたときは、
「分散KVSをクラスタのキャッシュにする」という目的で、
どちらかというと書き込みの速度をチェックしていました
しかし、プロジェクトの予算とか概要が見えてくる中で、
「クラスタどころか用意できてもサーバ1〜2台」という話になり、
そのくせ「複雑で柔軟な検索」がWebからできなければならない・・・と
これをMySQLでやるのはパフォーマンスに難があったため、
じゃあTokyoCabinetならどうなのか・・・と、検証を進め、
検索コアをTokyoCabinetだけで構築した、というのが今回のお話
要件
- 100万件を超えるデータの検索
- 一度に検索できる範囲が100万というだけで、全データはその数十倍ある
- しかも複数のユーザが並列に
- 当然高速に
- しかもサーバ1台で
- 検索結果の項目は動的
- 条件はユーザの任意なので、いくらでも複雑化
- 検索結果全件をページ送りできなければならない
- 100万/20 = 5万ページだとしても
- マスタDBはWebシステムの外にある
- ここから必要な部分だけWebシステムに切り出して、検索用データにconvert
- この連携は週一のみ
- 連携時、まとまったデータ単位で追加/削除
- 更新はない
試行錯誤
MySQL only (Index + Raw Data)
- Index的テーブル(検索条件のみコード化して格納、Indexは全カラム)
- Raw Dataテーブル(データをそのまま格納、Indexなし)
- 検索条件から動的にSQLを生成し、Index的テーブル群からIDリストを取得
- IDリストから指定項目を動的に取得
=> 検索はともかく、データ構築部分が重たすぎる
MySQL + TokyoCabinet
- データ構築部分をTokyoCabinetに任せる
- DBのテーブルごとにテーブルファイルを生成
=> わりと高速化された
この時点では「一定件数を超えそうなら一部だけ」
(全体はバックエンドサーバでファイル出力)という仕様だったが、
「全件検索結果表示&ページ送り」という仕様が出てきた
TokyoCabinet only
- TCのテーブルのみに絞る
- 検索/出力と分けず、全データを格納
- 頻繁に検索されるカラムにだけindex
- 全体で数M程度のテーブルはindexなし
試しにTDB::Query経由で適当な検索を行ったところ、
DBと同等か状況によっては速くなると判明
SQLは動的生成だったので、Queryを動的に構築するのも手間は変わらない
逆に、リソースをTokyoCabinetのみに集約できるため、
MySQLに似たデータを保持するよりも、当然HDDを節約できる
(データがでかいのであまりHDDを浪費したくない)
TokyoCabinetは「ファイル」なので、取り回しが楽
(生成したデータを他のクラスタサーバにそのままコピーするとか)
大雑把な検索ロジック
- リクエストからQueryを構築
- TDBから検索してIDリストを取得
付随するサブテーブルの結果IDリストも抜き出す(別途使用するため) - IDリストや検索情報をいったんキャッシュ
普通のHashTableに格納(not Table)
リソース名+連番をkey
検索パラメータ等はYAMLで文字列化して格納 - キャッシュデータからページに該当する部分のIDを抜き出す
keyに連番が含まれるので、ページ送りで表示するリソース名とIDの範囲がわかればOK - データ構築
パフォーマンス
- テスト機スペック
- Pentium DualCore E5400 @ 2.70GHz
- Mem 4GB
- CentOS5.4
- Ruby1.9.1-p378
- TokyoCabinet+Rubyバインディング(最新版)
- 検索は最大の100万件で40〜60秒
(まだロジック的なチューニングの余地あり) - 一般的な条件なら0.1〜数秒くらい
- ページ送りは20件で0.01秒前後
- 10000件/ページで3秒くらい
- そもそも、サーバが3秒で処理しても、ブラウザの描画が追いつかない
- Chromeだとしても(´・ω・`)
全件の場合が遅いので、先に結果をキャッシュしておき、
全件検索と判断されたらそっちにバイパスする仕組みに
=> 全件検索が0.003/ページ送りが0.01秒に短縮
とはいえ、「ほぼ全件」に対応できない
=> 全体の70%程度を引っ張る条件だと30〜40秒かかる
これらはローカルの環境なので、ユーザは一人だし、ネットワークの負荷も低い
一方、テスト機は4万くらいの安物PCなので、
本番機相当のスペックだともっと速いかも?
なぜTokyoTyrantではダメなのか?
- 「テーブル」で管理したいため
テーブルごとにファイルを生成するため、
データファイル一つで管理するTokyoTyrantでは対応できない
(まさかテーブルごとにTokyoTyrantを起動するわけにも・・・)
- ほぼreadonlyであるため
一度構築したデータが部分的に更新されることはなく、
ただひたすら読まれるだけであり、Writeの排他制御が不要
- ネットワークを経由したくない
readonlyのキャッシュであれば、生成時に整合性が担保されている限り、
各自のローカルから読み込んだ方が圧倒的に速い
TokyoCabinetの面倒な点
インターフェースを共通化するため、
Rubyっぽくないインターフェースになっているため、
これをwrapしてRubyらしいコーディングを可能にした
これは作者のポリシーによるものなので、
使う側でそれに従ったコードを書くか、
今回のようにwrapperを書くかは検討が必要
- 自力でフレームワーク的なものを構築する手間
普段DBが暗黙的にやっている最適化を、
ある程度自前で実装していかないといけない
今回は扱うデータに特化した形でフレームワークを組んだので、
細かい部品はともかく、ほとんど汎用性がない
(汎用的に作れればgem化も考えたけども・・・)
setは問題ないが、getのときASCII-8BITとして取得するため、
force_encoding("UTF-8")が必須
これをwrapper内で処理するようにした
- 開くファイルが多くなる
検索結果はあくまでIDの羅列なので、
各IDから指示された項目でデータレコードの構築が必要になる
今回の場合、複数のテーブルからデータを構築しなければならないため、
同時にいくつものテーブルファイルを開くことになる
この時、テーブルごとにコネクタがBlockで処理にすると、
Blockが多重化して気持ち悪い
Resource1.open do |res1| Resource2.open do |res2| Resource3.open do |res3| data1 = res1["hoge"] data2 = res2["piyo"] .... end end end # 実際はもっと多くなる
そもそも、指定項目によって開くテーブルが変わり、
毎回全部開く必要がないと考えると、
この書き方は非常に冗長だし、サーバリソースを無駄にしている
そこで、一つのオブジェクトでまとめて(open/closeの)面倒を見る
ResourceManager.open do |res| res.get(:hoge, 1) # hogeテーブルの id:1 を get res.get(:piyo, "TC") # piyo テーブルの id:TC を get # block内で初めてそのリソースが開かれるときにopenされる # 結果、不要なテーブルは開かない end # まとめてclose