ぱろっと・すたじお

技術メモなどをまったりと / my site : http://parrot-studio.com/

データ検索システムを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なし)
  1. 検索条件から動的にSQLを生成し、Index的テーブル群からIDリストを取得
  2. IDリストから指定項目を動的に取得

=> 検索はともかく、データ構築部分が重たすぎる

MySQL + TokyoCabinet
  • データ構築部分をTokyoCabinetに任せる
  • DBのテーブルごとにテーブルファイルを生成

=> わりと高速化された


この時点では「一定件数を超えそうなら一部だけ」
(全体はバックエンドサーバでファイル出力)という仕様だったが、
「全件検索結果表示&ページ送り」という仕様が出てきた

TokyoCabinet only
  • TCのテーブルのみに絞る
  • 検索/出力と分けず、全データを格納
  • 頻繁に検索されるカラムにだけindex
    • 全体で数M程度のテーブルはindexなし

試しにTDB::Query経由で適当な検索を行ったところ、
DBと同等か状況によっては速くなると判明
SQLは動的生成だったので、Queryを動的に構築するのも手間は変わらない


逆に、リソースをTokyoCabinetのみに集約できるため、
MySQLに似たデータを保持するよりも、当然HDDを節約できる
(データがでかいのであまりHDDを浪費したくない)


TokyoCabinetは「ファイル」なので、取り回しが楽
(生成したデータを他のクラスタサーバにそのままコピーするとか)

大雑把な検索ロジック

  1. リクエストからQueryを構築
  2. TDBから検索してIDリストを取得
    付随するサブテーブルの結果IDリストも抜き出す(別途使用するため)
  3. IDリストや検索情報をいったんキャッシュ
    普通のHashTableに格納(not Table)
    リソース名+連番をkey
    検索パラメータ等はYAMLで文字列化して格納
  4. キャッシュデータからページに該当する部分のIDを抜き出す
    keyに連番が含まれるので、ページ送りで表示するリソース名とIDの範囲がわかればOK
  5. データ構築

パフォーマンス

  • 検索は最大の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

現システムの問題点


ページ送りのためにローカルの結果をキャッシュするため、
クラスタ化するとまた最初の問題に立ち戻ってしまう


ただし、最初の検証時に比べ、
「KVSとのうまい付き合い方」がわかっているため、
仕組みを高速化できる可能性は高い


ポイントは「keyに対応するvalueをどこまで軽量化できるか」
これでパフォーマンスが全然違う
YAML経由だと相当重たい)