RubyとRailsとMySQLの時刻について

こんにちは! VOYAGE MARKETINGの @sayadroid です。

最近は、自社の長寿メディアを丸っとリニューアルするプロジェクトに携わっています。

元来PHP, symfony(1.x(小声))で書かれているそのメディアが、 Ruby on Railsで生まれ変わる予定です。

弊社では様々なメディアが、様々な言語で動いているため、 言語の組み込みクラスの仕様をうろ覚えで書くと、 細かな挙動を勘違いしてしまうことなどもあります。

そんな中で、最近ハマったRuby関連の、罠小ネタを一つ紹介します。


前提
Ruby on Rails 4.2.0
server timezone = JST

MySQL5.6 timezone = UTC
 「質問」テーブル
* body: 質問文
* opened_at: 掲載開始日時
* closed_at: 掲載終了日時
要件
opened_at「今日の00:00:00」
closed_at「今日の23:59:59」

としてレコードをINSERTしたい。

「今日」を取得することはDateTime由来でもTime由来でも可能だが、 タイムゾーンを明示的に利用したいので、Time.zoneを使うことが望ましい。

比較として、DateTimeを利用した時の挙動も確認しておきたい。

挙動確認
[1] pry(main)> b = Time.zone.now.beginning_of_day
=> Wed, 07 Apr 2015 00:00:00 JST +09:00
[2] pry(main)> e = Time.zone.now.end_of_day
=> Wed, 07 Apr 2015 23:59:59 JST +09:00

[3] pry(main)> b2 = DateTime.now.beginning_of_day
=> Wed, 07 Apr 2015 00:00:00 +0900
[4] pry(main)> e2 = DateTime.now.end_of_day
=> Wed, 07 Apr 2015 23:59:59 +0900

[5] pry(main)> require 'factory_girl_rails'
=> true

[6] pry(main)> FactoryGirl.create(:question, body: '現在の天気は?', opened_at: b, closed_at: e)
(0.1ms) BEGIN
SQL (0.4ms) INSERT INTO `questions` (`body`, `opened_at`, `closed_at`, `created_at`, `updated_at`) VALUES ('現在の天気は?', '2015-04-01 15:00:00.000000', '2015-04-02 14:59:59.999999', '2015-04-02 08:55:05.394558', '2015-04-02 08:55:05.394558')

[7] pry(main)> FactoryGirl.create(:question, body: '現在の天気は?', opened_at: b2, closed_at: e2)
(0.1ms) BEGIN
SQL (0.2ms) INSERT INTO `questions` (`body`, `opened_at`, `closed_at`, `created_at`, `updated_at`) VALUES ('現在の天気は?', '2015-04-01 15:00:00.000000', '2015-04-02 14:59:59.000000’, '2015-04-02 08:55:24.628060', '2015-04-02 08:55:24.628060')

結果

mysql> SELECT * FROM questions;
+----+--------------------------------+---------------------+---------------------+-------------+---------------------+
| id | body | opened_at | closed_at | | created_at | updated_at |
+----+--------------------------------+---------------------+---------------------+-------------+---------------------+
| 1 | 現在の天気は? | 2015-04-07 15:00:00 | 2015-04-07 15:00:00 | 2015-04-07 09:31:54 | 2015-04-07 09:31:54 |
| 2 | 現在の天気は? | 2015-04-07 15:00:00 | 2015-04-07 14:59:59 | 2015-04-07 09:32:44 | 2015-04-07 09:32:44 |
+----+--------------------------------+---------------------+---------------------+-------------+---------------------+
Time.zone.now.end_of_day で発行された変数がSQLに流れた時

closed_at: 2015-04-07 14:59:59.999999

→ DB(MySQL)に入った時には 2015-04-09 15:00:00 になってる(罠)

DateTime.now.end_of_day で発行された変数がSQLに流れた時

closed_at: 2015-04-07 14:59:59.000000

→ DB(MySQL)に入った時には 2015-04-07 14:59:59

さて、どうしてこのようなことが起きたのでしょうか。 問題は2点。

1点目(Ruby側)

Time classとDateTime classは、必ずしも挙動が同じとは限りません。 ざっくり言うならば、Time classの方が比較的精度が高いです。

そのため、「end_of_day」の23:59:59より細かいマイクロ秒の世界で Time classを利用すれば.99999....となり、 DateTime classを利用すれば .0000…となります。

2点目(MySQL側)

MySQL5.5まで切り捨てだったマイクロ秒の扱いですが、 MySQL5.6以上ではマイクロ秒は丸め込まれます。

MySQL :: MySQL 5.6 Reference Manual :: 11.3.6 Fractional Seconds in Time Values

なので.9999…は翌00分とみなされてしまったということです。


対処
  • 根本的対処

→ 「今日の23:59:59まで」の扱い方を「翌日00:00:00 未満」とする。

マイクロ秒を気にしたくない場合は、アプリケーション側で制御するといいですね。

  • 要件は見直さずに対処

Rails経由でActiveRecordでINSERTしようとするとき、

この問題(丸め込みが問題になる場合)を 解決するべく開発されているgemがあります。

kamipo/activerecord-mysql-awesome · GitHub

なお、Ruby on Rails4.2.1以上からはこの問題は気にしなくて良くなります。

上記gemの作者さんが、RailsにPull Requestを出してこの問題に対応してくださいました。

https://github.com/rails/rails/compare/v4.2.0...v4.2.1

フレームワークも、日々ミドルウェアや言語自体のアップデートに伴い 更新されていくというのがリアルタイムで見れた良い例でした。

時間や日付の扱いについては、

どういった用途で、どちらのクラスを使うのが望ましい、 といった基準をチームで認識合わせしておけると安心ですね。

おまけ:timecop

ちなみに、こういった日付関係のアプリケーションを実装するにあたっては、 timecopというgemを利用してテストを書いています。

github.com

timecop.freeze時を止めたり

timecop.travel過去や未来を行き来できる

ちょっぴり心躍るgemです。