2013年7月5日金曜日

trigger ベースの履歴

railsを使って色々なアプリを作っているが、どのアプリでもmysqlで全てのテーブルの変更履歴を取るようにしている。なにかの不具合でデータがおかしくなった場合などに調査、復元がしやすいためだ。業務アプリで履歴データが無い、というのは今となっては考えられない。

履歴を実現するために、rails には vestal_versions や paper_trails など変更履歴を自動で記録してくれる便利な gem がある。これらを使うことで save のタイミングで逐一履歴を保存してくれるようになる。便利。しかし、これらのgemには一つ問題がある。これらは通常 before_save や after_save で実装されているため、save メソッドを使わずに sql を直接実行による更新をしてしまうと,
その変更の履歴が抜け落ちてしまう。これでは  mysql console からの操作が非常にやりにくい。

理想としては、言葉通り「全て」の変更履歴を残せる仕組みが良い。rails から保存しようが、mysql から直接 update かけようが、必ず履歴が残るようにしたい。そのような仕組みを実現するには、履歴を作成する機能がデータベースレベルで処理されることが必要となる。となるとデータベースの標準機能として存在しているのが最も好ましいのだが、残念ながらそのような機能を持つデータベースは見たことが無い。少なくとも mysql にはない。が、mysql の trigger を使って履歴を作成する機能を実装できる。それをざっくり説明する。

履歴のデータ構造としては acts_as_versioned などと同じ形式で、元テーブルと同じ構造+history_id(primary key) を持つテーブルを履歴対象テーブルごとに用意して、元テーブルに追加・変更・削除があるたびに履歴テーブルにデータを追記されていくようにする。

ではその履歴の追記をどうするかだが、mysql ではテーブルに対して insert, update, delete が行われたときに trigger を実行することができる。これを使って、insert, update, delete それぞれの処理時に履歴テーブルへ最新のデータがコピーされるような trigger を作成する。こうすることでDBレベルで履歴の記録が可能となる。(drop table, truncate table では履歴は作られない)

後は migration 時にこの trigger が自動で作成されるようにすればよい。create_table, change_table の実行時に trigger を作成するようにパッチを当てるだけでよい。

だた、この方法にも問題点はいくつかある。まず mysql の制限で、ひとつのテーブルに、ひとつの種別(insert, update, delete)に対して一つしか trigger を作ることができない。したがってこの履歴の仕組みを使った時点でtriggerはほかの用途には実質使えなくなる。

次に、常に履歴作成が走ってしまうというのも問題ではある。履歴が走る時と走らない時が全くコントロールできなくなる。1万行の update を実行すると同時に 1万行の履歴 insert が走る。ただこれについては全て mysql の中で自動で実行され、ruby のコードを介さないため、それほど大きな問題になっているという認識ではない。

また、これはなぜなのかよくわからないが、mysql の create trigger は異常に時間がかかる。数秒単位でかかるため、rake db:migrate でテーブルを最初から作るというときには数分単位で待たされてしまう。

最後に、これはもっとも深刻だが、当然ながらすべての履歴を保持するということはデータ量が莫大になるということになる。データ量x平均更新回数が履歴のサイズになるため、更新の頻繁なテーブルは履歴が爆発的に膨らんでしまう。そのようなものについては定期的にアーカイブが必要になり、そうした場合アプリの中で履歴を参照してロジックを実装してしまうとアーカイブもできず身動きが取れなくなる。

そういうデメリットはあるものの、履歴があるのと無いのとではトラブル時の安心感がまったく違う。間違ってデータを変更削除してしまった場合でも簡単に復旧できるので重宝する。もはや履歴が無いというのは考えられない。