ドラクエは卒業して、もっと英語漬けをやっているmikioです。さて今回は、データベースサーバTokyo Tyrantとテーブルデータベースを使ってリアルタイム検索システムを構築する方法について語ります。

テーブルDBを分散させたい

Tokyo TyrantでもテーブルDBがサポートされているわけですが、これはリアルタイム検索システムへの布石です。テーブルDBは任意のコラムにインデックスを張ることができ、時系列のコラムにインデックスを張ればその値によって古いコラムを効率的に消すことができます。チュートリアルの「Persistent but Expirable Cache」でもその方法を示しています。また、任意のコラムに分かち書きトークン方式もしくは文字N-gram方式で転置インデックスを張ることができます。これらを総合すると、最新のデータのみを保持してサイズと性能を一定に保ったインデックスを維持できることになります。

上記で最新のデータを検索できるわけですが、次にそれをスケールアウトできるようにしたくなります。「リアルタイム検索システム」として運用しようとするならば、更新の負荷をできるだけ小さくするために、インデックスを小さな規模で分割しておくことが望ましいでしょう。更新の負荷、とりわけ登録文書を削除する処理の負荷が重いのです。Posting Listから該当の文書IDの要素を消すために全体をスキャンしなきゃなりません。

ところで、TCやTDでの転置インデックスは必ずPosting Listを同一のページに入れる構造になっていて、でかいPosting Listを格納しようとした時に効率が悪いというご意見をいただくことがよくあります。たしかに、でかいPosting Listはチェーンでつないで別ページで管理する方が文書の登録は速くて済むでしょう。しかし、それだと検索や削除は必ずしも速くならないのです。私の見解としては、Posting Listが長くてやってられなくなった時点でインデックスの分割を上位レイヤで検討すべきということです。遥か昔にSnatcherという全文検索システムを作っていた時はチェーン方式を採用していたのですが、その時に懲りました。

並列メタ検索メソッド

分割されたデータベースへの文書の登録は、IDのハッシュ値でいわゆるアルゴリズム方式の水平分散で行えばよいでしょう。削除も同様です。問題は検索操作です。全てのデータベースに対して個別に検索を行って、その結果をマージする必要があります。ちと大げさですが、これを「メタ検索」と呼びます。ですが、メタ検索はソート条件などを維持する必要があって実装が面倒なので、アプリ側の工夫だけで頑張れというのは酷ですよね。なので、メタ検索用のユーティリティを用意しました。以下のシグネチャです。

/* Search records for multiple servers in parallel.
   `qrys' specifies an array of the query objects.
   `num' specifies the number of elements of the array.
   The return value is a list object of zero separated columns
   of the corresponding records. */

TCLIST *tcrdbparasearch(RDBQRY **qrys, int num);

それぞれ別のRDBオブジェクトから作ったクエリオブジェクトを格納した配列とその要素数を与えると、並列してクエリを投げて、その該当のレコードを取得して順序を整えてから、結果セットをリストとして返します。結果セットはキーだけでなくレコード本体も取得してきているので、1回のネットワーク通信で検索結果を提示できます。処理は内部でスレッドを立てて勝手に並列化してくれます。

利用例を以下に示します。この関数は、データベースオブジェクトの配列rdbsとその要素数divnumと検索式exprを渡すとメタ検索を行ってくれます。

static void searchprint(TCRDB **rdbs, int divnum, const char *expr){

  // クエリオブジェクトの準備
  RDBQRY *qrys[divnum];
  for(int i = 0; i < divnum; i++){
    qrys[i] = tcrdbqrynew(rdbs[i]);
    // textコラムの全文検索
    tcrdbqryaddcond(qrys[i], "text", RDBQCFTSEX, expr);
    // numコラムの降順に整列
    tcrdbqrysetorder(qrys[i], "num", RDBQONUMDESC);
  }

  // 一撃で並列検索
  TCLIST *res = tcrdbparasearch(qrys, divnum);
  int rnum = tclistnum(res);

  // 結果セットの個々の要素をループ
  for(int i = 0; i < rnum; i++){
    // コラムオブジェクトを結果セットから抜き出す
    TCMAP *cols = tcrdbqryrescols(res, i);
    // 各コラムを印字する
    tcmapiterinit(cols);
    const char *name, *value;
    while((name = tcmapiternext2(cols)) != NULL){
      value = tcmapget2(cols, name);
      printf(" [%s=%s]", name, value);
    }
    printf("\n");
    tcmapdel(cols);
  }

  // 片付け
  tclistdel(res);
  for(int i = 0; i < divnum; i++){
    tcrdbqrydel(qrys[i]);
  }
}

皮算用

最近のPCはコアが多いから、各マシンに4つくらいTTを立ててもいいでしょう。仮にそのマシンを4台セットで利用するなら、16個のTTサーバが動くことになります。1サーバに10万件のデータを入れるならば、総計160万件のデータを検索対象にすることができます。これはmixiの1日の日記を全部格納しても余裕の数です。耐障害性を考えて二重化しても8台で済みます。もちろん、実際のインデックス分割数とマシン台数は検索と更新の負荷に応じて決定すべきです。

1日分しか検索対象にできないなんて意味がないと言われるかもしれません。でも、リアルタイム検索はそれでいいのです。24時間も稼げれば、その間に別の大規模なインデックスを構築できます。古いインデックスとリアルタイムインデックスのメタ検索を行えば全コンテンツをリアルタイム検索していることになるというわけです。

まとめ

Tokyo Tyrant上でテーブルDBを動かせばリアルタイム検索ができます。そのためにはインデックスを小さく分割して分散させます。分散したサーバを対象とした検索操作はメタ検索メソッドを用いれば簡単です。リアルタイム検索は最新のものだけを扱い、全体のインデックスと相互補完する目的で用います。

このアーキテクチャでどこまでスケールするのか、あるいはどこまで性能が出るのかは、まだ実データで実験していないので確たることは言えません。いずれちゃんとした性能測定をしてまた報告したいと存じます。TT+テーブルDBを再利用するのではなくリアルタイム検索専用のインデックスを書き起こした方がもちろん高効率が実現できるのでしょうし、あるいはインデックスを作らないでオンメモリのキャッシュを逐次探索するモデルの方が適する場合もあるでしょう。とはいえ、テーブルDBを並べるだけという手軽さの点では今回の手法も利点があるかなと思っています。

ドラクエのプレー時間がついに150時間を突破して妻の視線が痛いmikioです。今回は、かんたんCMS「Tokyo Promenade」にスクリプト言語Luaを組み込んでカスタマイズする方法について述べます。

なぜスクリプト言語処理系を組み込むのか

Tokyo Promenade(TP)はCで書かれていて軽量で高速に実行できるCMSです。PerlやRubyなどのスクリプト言語で書かれたCMSはそのソース自体を編集して改良するのが容易ですが、Cの場合は再コンパイル作業が必要だし下手に手を出すとメモリ破壊などの致命的なバグを入れてしまう可能性が比較的高いので、ソース自体を編集してカスタマイズを行うのは現実的ではありません。

そこで、TPではプレゼンテーション層の機能をできるだけテンプレート側に委譲させるとともに、さらに装飾の多くはCSSを編集するだけで変更できるように配慮しています。テンプレートにはJavaScriptを埋め込むこともできますから、入力パラメータに対して決定的な(つまり入力パラメータとTPのデータベースだけに依存してそれ以外の外部要因に依存しない)情報を表現するのであれば、そのカスタマイズ性は現状で十分だと言えます。

HTMLとCSSとJavaScriptをいじればほとんどのカスタマイズはできるのに、なぜサーバ側でのスクリプティングが必要なのでしょうか。主にJavaScriptの制限に起因する2つの理由があります。ひとつは、クライアント側のJavaScriptだけでは計算結果の保存をクッキーに頼ることになるので、サイズの大きいデータを保存したり、ユーザ間で共有したりするのが難しいということです。もう一つは、クライアント側のJavaScriptだけでは外部のサイト(クロスドメイン)のデータを通信で引っ張ってくることができないということです。

最も単純な例としてアクセスカウンタが挙げられます。これはページが閲覧される度に数値をカウントアップするだけの機能ですが、その値を全ユーザで共有する必要があるので、クライアント側の努力だけで実装するのは困難です。ページが閲覧される度に起動されるプロシージャをサーバ側に仕掛けることができればこの問題は簡単に解決します。

実用的な例だと、記事内にあるアンカーテキストのリンク先で書籍のISBNが書かれている場合に、その書籍のタイトルや表紙画像をAmazonから取得して表示することが考えられます。このように、リソース識別子に関連付けられたメタデータを外部サイトから取得して記事に埋め込んで表示したいというニーズは各種考えられますが、セキュリティ上の理由でクロスドメインの通信を禁じられているクライアント側の努力だけではそれに答えることはできません。

アクセシビリティ重要

Amazonの書籍情報を表示したいだけならば、Amazonが提供する埋め込み用のHTMLスニペットを記事に張り付ければいいと人は言うかもしれません。しかし、それはTPの思想からは外れます。TPの記事の素データはWiki記法で文書の論理構造を記述したものです。デフォルトでたまたまHTMLに変換して表示することができるようになっているだけで、HTMLは表現方法の一形態にすぎません。LaTeXやDocBookやroffを介してプレゼンテーションを行うUAへの対応を今後追加するかもしれませんし、そのような対応を容易にしておくことがTPのアクセシビリティのポリシーなのです。その観点に立つと、Wiki記法にHTMLを混ぜてしまったらHTMLとしてしか表現できないようになってしまうので、許容できません。

URIの普遍性という観点も忘れてはなりません。書籍紹介をする際にAmazonのURLを記事に埋め込むことがよくありますが、Wiki記法のレベルでそれをやるのは望ましくありません。その代わりに、ISBNなどの、より普遍性の高いものをリンク先のURIとして記述して、表示時にAmazonのURLに変換する方がアクセシビリティ(アベイラビリティ)が高いと言えます。仮にAmazonが潰れたり買収されたとしてもISBNは不変なので、表示スクリプトを改修してbk1や紀伊國屋にある同等の書籍紹介ページへ誘導することができるからです。

TPは記事の作者の思考を直列化するための機構です。したがって、言及の意図が「Amazonの書籍紹介ページ」に向けられているのであればAmazonのURLを使うべきですが、そうでなく「書籍の内容」に向けられているのであれば、ISBNなどを使うべきです。アクセシビリティを担保するためにまず第一に重視すべきはURIであり、alt属性やその他のメタデータはその補助にすぎないというのが私の考えです(もちろん、そのURIを解釈できない処理系のためにalt属性などを付随させるのは有用であり、その努力は尊敬されるべきものです)。

実際に組み込んでみる

さて、前置きはほどほどにして、インストール手順に進みましょう。TPにLua拡張を組み込むには、ビルド時に以下のような指定を行います。もちろん、その前提としてLuaをインストールしてあることが必要です。

$ ./configure --enable-lua
$ make
$ sudo make install

そして、テンプレートファイル promenade.tmpl の冒頭にある設定に以下の行を加えます(最新バージョンのデフォルトテンプレートではその行が最初からあります)。

[% CONF scrext "hogehoge.lua" \%]

こうすると、TPの起動時に hogehoge.lua というファイルが読み込まれて、それに記述された関数が各種のトリガに応じて呼び出されるようになります。トリガと関数の仕様は以下のようになっています。該当の関数が実装されていない場合は単に無視されます。

  • _begin() : DBを読み込む前に呼ばれます。引数なし。戻り値はテンプレートのbeginmsgという変数に格納されます。
  • _end() : DBを閉じた後に呼ばれます。引数なし。戻り値はテンプレートのendmsgという変数に格納されます。
  • _procart(wiki) : 各記事の表示データを作る直前に、そのWiki記法データを表示用に加工するために用います。引数は元のwiki記法文字列で、戻り値は加工後のwiki記法文字列です。
  • _procpage(html) : ページ全体のHTMLを表示する直前に、HTMLを表示用に加工するために用います。引数は元のHTML文字列で、戻り値は加工後のHTML文字列です。

また、以下の機能が組み込まれています。

  • _params : CGIスクリプトの呼び出しパラメータがテーブルとして保存されているグローバル変数。
  • _user : ユーザの認証情報(nameとpassとinfo)がテーブルとして保存されているグローバル変数。
  • _strstr(src, pat, repl) : 文字列の単純な検索と置換を行う組み込み関数。srcは対象文字列で、patはマッチングパターンで、replは置換文字列。replが省略された場合は一致の検査のみを行う。
  • _regex(src, pat, repl) : 正規表現による検索と置換を行う組み込み関数。srcは対象文字列で、patはマッチングパターンで、replは置換文字列。replが省略された場合は一致の検査のみを行う。

Luaの標準ライブラリにも文字列検索や文字列置換の機能はあるので_strstrと_regexは不要とも言えますが、標準のものはびっくりするほど使いにくいので、_strstrと_regexは重宝することになると思います。ちなみにそれらはTCのLuaバインディングにもおまけで組み込まれています。

サンプル

ネタでいくつか実装してみましょう。ログインユーザに挨拶文を出すためのスクリプトは以下のようになります。

function _begin()
   local name = "World!!"
   if _user then
      name = _user.name
   end
   return "Hello, " .. name .. "!!"
end

各記事をラムちゃん風にするためのスクリプトは以下のようになります。

function _procart(wiki)
   wiki = _strstr(wiki, "ります。", "るっちゃ。")
   wiki = _strstr(wiki, "です。", "っちゃ。")
   wiki = _strstr(wiki, "だ。", "だっちゃ。")
   return wiki
end

不適切な語を含む記事のタイトルにラベルをつけるスクリプトは以下のようになります。

function _procart(wiki)
   wiki = _strstr(wiki, "Tokyo", "Osaka")
   if _regex(wiki, "*sex") or _regex(wiki, "*fuck") then
      wiki = _regex(wiki, "^#!", "#! [sexual] ")
   end
   return wiki
end

簡易的なアクセスカウンタは以下のようになります(排他制御をしていないので本気利用はできませんが…)。上記3例はJavaScriptでもできますが、この例はLuaならではと言えるでしょう。

tmpfile = "/tmp/promscrcount.txt"

function _begin()
   local file = io.open(tmpfile, "r+")
   local count = 0
   if file then
      count = file:read()
      file:seek("set", 0)
   else
      file = io.open(tmpfile, "w")
   end
   if not count then
      count = 0
   end
   count = count + 1
   file:write(string.format("%d\n\n", count))
   file:close()
   return "The access count is " .. count .. "."
end

まとめ

Tokyo PromenadeにLuaを組み込む方法とその思想的背景について説明しました。クライアント側で完結するカスタマイズはJavaScriptでやった方が簡単だしサーバの負荷も軽くなるのでよいのですが、それがサーバ側の能力が必要な場合に組み込みのLuaで補うというのは合理的な選択だと思います。そして、言及するリソースの種類に応じたプレゼンテーションを人間が書き分けるのではなく、記事にはURIのみを記述して表示時にスクリプトが適切なプレゼンテーションを生成するということの意義についても伝われば幸いです。

TPそれ自体をスクリプト言語で書けばいいじゃんとか、サーバ側のスクリプト言語もJavaScriptでいいじゃんとか、いろいろご意見があると思います。CとLuaの組み合わせが絶対最強だと主張するつもりはありません。でも、自分で使ってみた限りでは、そこそこイケてるんじゃないかと思っています。