Railsと周辺のTimeZone設定を整理する (active_record.default_timezoneの罠)

今更感があるというか、自分も分かってるつもりでRails以外でも触るMySQL DBの扱いで少し困ったので再整理しておく。
ちなみにバージョンは以下の場合である。

- Rails 4以降
- MySQL 5.1以降

RailsにおけるTimeZone関連の設定は以下のとおり。

- config.time_zone (各地の都市名等を指定する)
- config.active_record.default_timezone (:utc or :local)

考えなければいけない時間は概ね以下のとおり。

- システムのタイムゾーンと、システム時間
- Railsのタイムゾーンと、Time.nowとTime.zone.now(Time.current)
- データベースのタイムゾーンとデータベースに記録されている時間。

おさらい、Time.zone.nowはActiveSupport::TimeWithZoneクラスのインスタンス。ActiveRecordでdatetime型のカラムにアクセスした場合もこれになる。

ActiveRecordのdefault_timezoneのデフォルト値は:utc。

既に良く分からなくなってきた……。

ややこしいので、とりあえずデータベースは自分が良く使っているMySQLのみを考える。基本的にはMySQLに関する話だと思って欲しい。

## 時間の対応関係
JSTは`+9:00`、CSTは`-6:00`

| 概要                                                          | System TZ | System Time | Rails TZ   | ActiveRecord TZ | Time.now | Time.current    | DB TZ       | DB Time | AR Instance Time |
| -----                                                         | -----     | -----       | -----      | -----           | -----    | -----           | -----       | -----   | -----            |
| 何も考えずにRailsを使う場合のデフォルト                       | JST       | 2:00        | UTC        | :utc            | 2:00 JST | 17:00 UTC (TWZ) | SYSTEM(JST) | 17:00   | 17:00 UTC (TWZ)  |
| time_zone Tokyo                                               | JST       | 2:00        | Tokyo(JST) | :utc            | 2:00 JST | 2:00 JST (TWZ)  | SYSTEM(JST) | 17:00   | 2:00 JST (TWZ)   |
| active_record.default_timezone :local                         | JST       | 2:00        | Tokyo(JST) | :local          | 2:00 JST | 2:00 JST (TWZ)  | SYSTEM(JST) | 2:00    | 2:00 JST (TWZ)   |
| time_zone なし, active_record.default_timezone :local         | JST       | 2:00        | UTC        | :local          | 2:00 JST | 17:00 UTC (TWZ) | SYSTEM(JST) | 2:00    | 17:00 UTC (TWZ)  |
| time_zone Central Time                                        | JST       | 2:00        | CST        | :utc            | 2:00 JST | 11:00 CST (TWZ) | SYSTEM(JST) | 17:00   | 11:00 CST (TWZ)  |
| time_zone Central Time, active_record.default_timezone :local | JST       | 2:00        | CST        | :local          | 2:00 JST | 11:00 CST (TWZ) | SYSTEM(JST) | 2:00    | 11:00 CST (TWZ)  |

## 整理
まず、ここまでで一旦整理する。

Time.nowはRubyの組み込みなのでシステムのタイムゾーンしか見ない。OSの時間と常に一致する。Time.localの出力結果もOSのタイムゾーンと一致する。

TimeWithZoneクラスは`config.time_zone`に左右される。
Ruby組み込みのメソッドで取得したUTCの時間を基準に、設定されているタイムゾーンの時間に変換する。
ActiveRecordのインスタンスに対してアクセサを利用して時間をやり取りする場合はTimeWithZoneで行われる。
仮にTimeクラスを渡しても代入時にTimeWithZoneに変換される。

`config.active_record.default_timezone`の設定はDBを読み書きする際に、DBに記録されている時間を`Time.utc`で読むか`Time.local`で読むかを設定する。
`:utc`の場合DBに記録されている時間はUTC扱いで、この時DBサーバのタイムゾーン設定は考慮しない。
ActiveRecordのインスタンスが持っているTimeWithZoneの値をUTCに変換し、その時刻をDBに書き込む。
`:local`の場合は、DBに記録されている時間はシステムのタイムゾーンとして扱う。
ActiveRecordのインスタンスが持っているTimeWithZoneの値をシステムのタイムゾーンに変換し、その時刻をDBに書き込む。

## まとめ
`config.time_zone`は、Railsのアプリケーション上で表示したい時間の基準となるタイムゾーンでOSとは独立している。しかし基本的にはOSのタイムゾーンと合わせておくのが安全だと思う。
もし、マシンが海外にあってOSの時計はUTCだが、メインターゲットが日本だったりする場合は、日本のタイムゾーンにしたくなるかもしれない。
そうすると、ActiveRecordやActiveSupportに関する時間は、デフォルトで日本時間で表示される。
しかし、Time.now等を混在させると`strftime`等で整形した場合に時間がズレてしまう。
徹底してTimeWithZoneを使うなら大丈夫かもしれないが、OSのタイムゾーンに合わせておいて、適宜タイムゾーンを変換する方が分かりやすいように思う。
まあ、シリコンバレーに行って、特定の国向けのサービスを作るとかでない限りは、あんまり考えることは無いかもしれない。


`config.active_record.default_timezone`は、DBのタイムゾーンと一致させておくべきだ。
DBのタイムゾーンがUTCなら`:utc`にDBのタイムゾーンがSYSTEMなら`:local`にしておく。

## 問題点
表を見れば分かるのだが、初っ端のデフォルトからDBに記録される値がズレている。
これは、普通日本で使うマシンの時計はJSTで、MySQLのタイムゾーンのデフォルトがSYSTEMでJSTになるからだ。
しかも、MySQLのDATETIME型はタイムゾーン情報を持てないし、ActiveRecordはMySQLのタイムゾーン設定を考慮しない。
そのため、UTCじゃないマシンで何も考えずにデフォルトの設定でRailsを利用するとDBに記録される時間がズレることになる。
JSTになっているDBのタイムゾーンに合わせて正しい時間で書き込むためには、`config.active_record.default_timezone`を`:local`にしておく必要がある。

しかし、残念なことに`config.active_record.default_timezone`の設定はかなり地味だったりする。
デフォルトで生成されるコンフィグファイルの雛形には影も形も出てこない。
Rails Guidesにはちゃんと説明があるのだが、ちゃんと読んでないと知らないままだったりする。
自分も知ってはいたものの、設定するの忘れてたり、どっちが正しかったんだっけ？となることがしばしばある。
これは、Railsから出ない限りは余り問題にならないから、なおのこと気付きにくい。

なので、これが`:utc`でMySQLがSYSTEMになったまま運用されているRailsアプリが一杯あると思う。
Railsから出ないでDBに触っていればOKなのだが、Rails外で時間を触るとこれはバグの温床になる。

まず、MySQLの内部で時間を取得する場合。例えばMySQLの`NOW()`関数を使うとOSの時間と一緒の時間が取れるのだが、カラムに記録されている時間は9時間前の時間になっている。もしこの値を使おうとすると時間が噛み合わなくなる。しかも、MySQLが取得する時間そのものにはタイムゾーン情報は無い。なので、これを使って比較すると日付の境目当たりでバグって死ぬことが良くある。
RailsでTime.currentを取得して、ActiveRecordのクエリ経由で時間を渡せばちゃんと変換されてクエリに渡るので、正しく比較できる。

更に、MySQLのタイムゾーンを考慮する別システムからDBにアクセスする場合。特にJDBCからMySQLに接続する場合等、オプションがカオスで訳が分からないことになっていて、デフォルトでどういう挙動をするか良く分からない。もしMySQLサーバのタイムゾーンを考慮して時間を変換してクエリを実行する動作をする場合、やはり記録されている時間から9時間ズレてしまう。

というわけで、新しくRailsのシステムを開発する場合は、DBのタイムゾーン設定と`config.active_record.default_timezone`を合わせるように注意した方が良い。
もし、その設定に気付かず(忘れて)運用を開始してしまった場合は、MySQLのタイムゾーンの設定をUTCにした方が良いかもしれない。
他のシステムで触っていなければ、これでカラムの値とタイムゾーンが一致する。

しかし、ここにもまだ問題があって、もしTIMESTAMP型が混在してたらタイムゾーンを変えると困ったことになる。
TIMESTAMP型は書き込む時にサーバのタイムゾーン設定を確認してUTCに変換してからデータを保存する。取得する時はUTCからサーバのタイムゾーンに変換してから表示する。
なので、記録時と異なるタイムゾーンに変更すると取得した時の時間がズレてしまう。
(ALTER TABLEで型を変えたらどうなるかまでは調べてません……。)

既に時間がごっちゃに記録されていたら、まあ諦めてどっちかに合わせるしか無いかな……。


そもそも、こんなことで色々悩むのも、Railsのデフォルト設定値とMySQLのデフォルト設定値が噛み合ってない上にMySQLの時間カラムはタイムゾーン情報持ってないし、RailsはMySQLのタイムゾーンを無視するし、JDBCはタイムゾーン考慮して勝手に時間変換するしで、何が何の設定で何から何に時間を変換してるのかがさっぱり分からんからだ。
もう時差とかタイムゾーンの無い世界に行きたい……。

Postgresとかはカラム自体にタイムゾーン込みでデータ保持できるのかな。とすると、MySQLが無駄にややこしいだけ、という話にもなるが……。orz