過去をふり返るためのしくみ (主に MySQL)

どうも、いちい (@ichii386) です。 GREE Advent Calendar 2013 の 24 日目です。

もうクリスマスですね。もしかしたらプレゼントを買わなきゃいけないのに何も準備してなくて、今日は仕事終わったら急いで買いに行かなきゃ、なんて焦っている人もいるかもしれません。

去年のクリスマスは?

去年は何買ったんだったかな、と思い返し、

  • 20日に給料とボーナスが出たので、予算はいくらくらいだった
  • 相手はちょうど引っ越したので、部屋にモノが少なかった

なんてとこから「あー、ペンダントライトをプレゼントしたんだったな」とか思い出します。しかし去年はちょっと高かった割にあまり喜んでもらえなかったし、今年はボーナスが減ったのでできたら予算は減らしたいです。(※完全に架空の話なんでご注意ください)

今年を改善するためにも、まずは去年をふり返ることが重要です。プレゼントを決めるには予算という指標があり、それを計算するプログラムがあります。これを再実行するわけですね。

過去をふり返るための要件

この手の集計プログラム (もしくはバッチ処理) はいつ再実行しても同じ結果になることが期待されます。でないと去年のプレゼントを決めた経緯がわかりません。

プログラム自体はコードレベルで同じならいいのですが、サービスの仕様変更とともに計算に利用していたライブラリが変わってしまうことがあります。そのため仕様変更がありそうなものは、コードに任せずデータとして保存しておく必要があります。あまりいい例ではありませんが、アクセス元 IP アドレスからアクセス元地域を決めるような場合がそうです。

一方、プログラムが直接利用するデータについて、ここでは以下の 2 種類に分類することにします。
- トランザクションログやアクセスログのような、なんらかの action を表すデータ
- ユーザ属性のような、なんらかの property を表すデータ

前者についてはよくある話しで、適当なファイルにでも書いて置いておけば良いです。去年のバッチは去年のファイルを読む、と。

後者がクセモノで、多くの場合 property は MySQL のようなデータベースに保存されます。そのままでは1年前のバッチの再実行を現在のデータで計算してしまいます。必要なのは、あるデータベースのあるテーブルのあるカラムのその当時での値なのです。

ということで、どうやって過去の property なデータを残すかを考えましょう。

前提として

本質的には、すべてのデータベースのすべての時点での値をどこかに保存しておけば済む話です。しかしナイーブには空間計算量 (一般的にはディスク容量) の意味で効率が悪く、現実的ではありません。

a. データの符号化を変える/圧縮する

一般論ですが、以下の方法でディスク容量を削減できます。

  • テキストデータをバイナリに変換する
  • 短いコンテキストでの圧縮
    -- 単純に gzip のような汎用的な圧縮をする。ただし空間計算量を時間計算量に移転することになるのでバランスは必要
  • 長いコンテキストでの圧縮
    -- ある property の取りうる値を網羅して enum 型に変換するなど

b. 粒度を1日単位にする

すべての時点ではなく、1日単位で保存できれば良いと決めてしまえばだいぶ量が減ります。

注意点としては、1日の間に何度も変わる property に対して「この日のこの値はこれだ!」と決めなくてはなりません。例えば去年のクリスマスは雨が夜更け過ぎに雪に変わったとして、この日の天気を雪と決めるようなロジックが必要になります。さもないと、クリスマスを雨の日と雪の日にダブルカウントして12月の合計日数が31を超えてしまう問題が起きたりします。

MySQL の中で過去をふり返る

a. 毎日データをコピーしておく

粒度を1日に設定した上で、あくまでナイーブにやるならば以下があります。おそらく多くの方がバックアップを目的に同様のことをしているかと思います。

  • mysqldump してどこかに保存しておく
  • MySQL のデータが入ったディレクトリをそのままコピーする

しかし、これはあくまでバックアップにしかなりません。当然日数分のディスク容量が必要ですし、その日のデータを読むためには MySQL を起動しないといけません。可用性を求めるならば日数分だけ TCP/IP の意味で資源 (アドレスもしくはポート番号) を予約することになります。

b. binlog, relaylog を見る

MySQL の場合 replication の仕組みの一環として、 binlog もしくは slave での relaylog にすべてのクエリーを保存することができます。

あくまで MySQL が内部で使うためのものなので、これをバッチ処理から直接参照するのは現実的ではないように思います。また「値を1だけ増やしてね」と書いてあるだけで、結局いくつになるのかはクエリーからは分からない場合もあります。

c. trigger で保存する

クエリーによるデータ更新の差分だけを保存するように trigger を書き、適当な slave に仕込む方法があります。これはまさにデータベースの仕組みを使っており、良さそうです。

ただ、最も細かい粒度でのデータを保存することになり、粒度1日を実現するのは一般には困難です。

MySQL の外で過去をふり返る

trigger みたく差分だけを残したいが、粒度は1日にしたい。これを MySQL の外から実現することを考えます。

a. git で保存する (ネタ)

MySQL で engine=CSV として git add し、 transaction のたびに git ci してしまう、というのはどうでしょう? 原理的には更新 hunk ぶんだけの容量で済みますし、1日分の diff をまとめることもできます。

…が、 git はそういうためのモノじゃないんで、実用的ではないでしょう。(誰かやってみてくださいw)

b. filesystem で snapshot を取る

更新前と更新後のデータが共存すると言えば Copy-On-Write (COW) ですよね。ということで、 COW なファイルシステムでスナップショットを取る、というのが最後に挙げる方針です。

当然ながらこの方針にもメリット・デメリットがあります
- メリット
-- 任意の (時系列の) 粒度で snapshot を作成できる
-- 差分のみの容量で snapshot を維持できる
-- snapshot を直接読むことができる
- デメリット
-- filesystem レイヤでの snapshot を MySQL が理解できなければならない

運の良いことに MySQL には MyISAM というシンプルな engine があり、テーブルをオープンするときに単にファイルが存在すれば読み込むことができます。それゆえ MyISAM を使える状況であればデメリットが回避でき、さらに可用性についても同じ MySQL インスタンス上から参照するように仕込むこともできます (詳細はここでは割愛します)。

COW なファイルシステムとしては ZFS, Brtfs のほか、 ext3cow, NetApp 社の WAFL (Write Anywhere File Layout), Microsoft 社が Windows Server 向けに開発している ReFS などがあるようです。

そのファイルシステム上で直接 MySQL が動くことが条件になるので制約はありますが、実際に弊社内では ZFS を使ってこの仕組みが動いています。スナップショット作成は一瞬、ディスク容量にして 100 倍以上の削減が可能になっています。

まとめ

以上ダラダラ書きましたが、まとめ。

  • 集計バッチ処理はいつ再実行しても動くようにすべし
    -- もっと言うなら、(最近ちょっと流行ってるぽい言葉を借りて) 冪等であるべし
  • データはアクセスログだけじゃない、日々更新されるデータもふり返る必要がある
    -- 実は変化しうるデータがアルゴリズムに混じってないかをよく考える
  • 差分を残すなら COW
    -- いわゆる圧縮よりもよっぽど容量削減になる
  • MySQL (MyISAM), ZFS の組み合わせで日々のスナップショットはかんたん
    -- MyISAM がシンプルだからこそ可能

1年前でも何も考えずに日付だけ指定して実行すればちゃんと動く、それが集計バッチの理想ですよね。

明日は最終日、@masaki_fujimoto の記事です。よろしく!