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

高速ログフォーマットの提案

バイナリ形式のログフォーマットが必要になったので(テキストだと生バイト列を出力できなくて困る。Base64はイヤ)、どうせならオレオレフォーマットではなく一般的に使えるフォーマットだといいなーと思いメモ。

  • 最初の発想
  • 改良1:定義ファイル(静的型付け)
  • 改良2:自己記述性(動的型付け)
  • 改良3:enum


最初の発想は、ログに出力したい内容を MessagePackシリアライズして出力するだけ。↓こんな感じ。(表記はJSON)

[アクセスログ, {"時刻": 1230415655, "URL": ["index.html"], "UA": "Mozilla/5.0 ... Firefox 3.0.5"}]
[アクセスログ, {"時刻": 1230415656, "URL": ["index.html"], "UA": "Mozilla/5.0 ... Safari/525.13"}]
[アクセスログ, {"時刻": 1230415656, "URL": ["top", "login.png"], "UA": "Mozilla/5.0 ... Safari/525.13"}]

MessagePackでシリアライズすることで、配列や連想配列を格納したり、数値を文字列化せずにそのまま格納したりできる。


このログのフォーマットは↓このような型になっている。

[ログ種別:String, {フィールド名:String: フィールド値:*, ...}:Map]

人間が読みたいときは簡単なツールを使って整形して出力する。↓こんなイメージ。

アクセスログ: 時刻=Sun Dec 28 07:07:35 0900 2008, URL=/index.html, UA=Mozilla/5.0 ... Firefox 3.0.5
アクセスログ: 時刻=Sun Dec 28 07:07:36 0900 2008, URL=/index.html, UA=Mozilla/5.0 ... Safari/525.13
アクセスログ: 時刻=Sun Dec 28 07:07:36 0900 2008, URL=/top/login.png, UA=Mozilla/5.0 ... Safari/525.13


ログを フィールド名=>フィールド値連想配列で格納するポイントはSQLっぽく検索できることで、たとえば↓このような検索式で「2008年1月1日までの/index.htmlへのアクセス数」を表現できる。*1

select count(*) from アクセスログ where URL = ["index.html"] and 時刻 < 1199113200


このフォーマットでは「時刻」や「URL」などのフィールド名が何度もログに書き込まれるので冗長だし、それにフィールド名を毎回文字列比較していたら遅い。
そこであらかじめ定義ファイルを作っておくことが考えられる。

改良1:定義ファイル(静的型付け)

あらかじめ↓このような定義ファイルを作っておけば、ログにフィールド名を書き込まずに済む。

{アクセスログ: [時刻, URL, UA]}

ログファイルは↓このようになる。

[アクセスログ, 1230415655, ["index.html"], "Mozilla/5.0 ... Firefox 3.0.5"]
[アクセスログ, 1230415656, ["index.html"], "Mozilla/5.0 ... Safari/525.13"]
[アクセスログ, 1230415656, ["top", "login.png"], "Mozilla/5.0 ... Safari/525.13"]


このログを検索するときは先ほどと同じように

select count(*) from アクセスログ where URL = ["index.html"] and 時刻 < 1199113200

という表現で検索できるが、定義ファイルがあれば

select count(*) from アクセスログ where FIELD[2] = ["index.html"] and FIELD[1] < 1199113200

というようにフィールド名を配列のインデックスに変換できるので、毎回文字列比較をせずに済む。


ただ定義ファイルを作るのは厳格な検証ができる反面、定義ファイルを作るのが面倒、後からフォーマットを変えるのが大変という問題がある(後からフォーマットを変えるなという話もあるが…。廃止したフィールドはnilにでもしておけばいいし。ただログは記録として重要なので、昔のフォーマットとは必ず互換性を持たせたいし、定義ファイルをログファイルとは別のファイルで取っておくのはちょっと大変、かもしれない)。
そこでログファイル自体にインタフェース定義を書き込んでしまうのはどうだろう。

改良2:自己記述性(動的型付け)

ログファイル自体にインタフェース定義を書き込むことで、定義ファイルを別に用意せずに済むようにしてみる。
本当のログとインタフェース定義を区別するために、インタフェース定義には先頭に「true」を付けてログファイルに書き込むことにする。

[true, アクセスログ, "時刻", "URL", "UA"]
[アクセスログ, 1230415655, ["index.html"], "Mozilla/5.0 ... Firefox 3.0.5"]
[アクセスログ, 1230415656, ["index.html"], "Mozilla/5.0 ... Safari/525.13"]
[アクセスログ, 1230415656, ["top", "login.png"], "Mozilla/5.0 ... Safari/525.13"]

ログを検索するときは、ログファイルを読んでいく過程でインタフェース定義を見つけた段階でフィールド名を整数に配列のインデックスに変換する。
要素数やフィールド名を変えたくなったら、単にログファイルに新しいインタフェース定義を追記してやればいい。


ところで、このログの中には "index.html" や "Mozilla/5.0 ... Safari/525.13" といった文字列がたくさん出てくるが、文字列はサイズが大きいからログファイルが肥大化するし、それに文字列の比較は遅い。
そこでこの文字列をenumにしてしまうのはどうか。enum名=>enum値の対応付けは、インタフェース定義と同様にログファイルに書き込んでしまえばいい。

改良3:enum

ログとenum定義を区別するために、enum定義には先頭に「false」を付けてログファイルに書き込むことにする。

[true, アクセスログ, "時刻", "URL", "UA"]
[false, "index.html", 1]
[false, "Mozilla/5.0 ... Firefox 3.0.5", 2]
[false, "Mozilla/5.0 ... Safari/525.13", 3]
[アクセスログ, 1230415655, [1], 2]
[アクセスログ, 1230415656, [1], 3]
[false, "top", 4]
[false, "login.png", 5]
[アクセスログ, 1230415656, [4, 5], 3]

これでログファイルのサイズを大幅に削減できる*2
また特に「XXXがYYYという文字列であるログの数を数える」というような検索をするときは、本来なら文字列の比較をしなければならないところを整数の比較でできるので、速い。


ただこれには欠点があって、書き込まれている整数がenum値なのか、本当に整数なのかが区別できない。
「MessagePackを改造して2種類の整数型を表現できるようにする」という強引な方法なら解決できるが、もうちょっとスマートな方法で解決したいところ。
インタフェース定義の部分にフィールド名だけではなく、enumが入るか否かを記述しておけばいいかもしれない。

*1:この検索式はSQL的な式よりもMapとReduceのペアで記述すると分散処理ができて良いのではないか。記述言語はLuaなどで

*2:MessagePackでは128より小さい整数は1バイト、要素数が16個より少ないArrayは1バイトのオーバーヘッドで格納できる。これを考えると先ほどの例では1つのログを約10バイトで格納できる。