🕘

日時を正しく扱おう

2024/11/15に公開

WEDの武者(@knu)です。ふだんよくお茶を嗜むのですが、長いことタイマーに頼りすぎたせいか「1秒」の絶対感覚が鈍ったのを実感していました。しかし、懐かしの「24」を3シーズンくらい見たことで、あの秒カウントのジングルが脳内にインストールされ、だいぶ直った気がします。ポッ、ピッ、ポッ、ピッ……。

今回は、日時をデータとして正しく扱うためのヒントをいくつか紹介します。WEDのレシート買取アプリONEのバックエンドの大きな部分はRuby on Railsで作られていますので、そこから少しだけ例に引くこともありますが、内容としては一般に通じる話をしたいと思います。

「現在時刻」を気軽に考えない

プログラムが処理を進める間にも、時間は経過します。関数の入り口で時刻を取得した際には 2024年12月31日 23:59:59 だったけど、処理を進める中で 2025年1月1日 00:00:00 を迎える、ということがあるかもしれません、というかあります。

たとえば、開始時に12月のデータを集計しはじめて、処理中に日をまたいでしまうなんてこともあり得るでしょう。そのとき、集計結果の保存先を指定する際に現在時刻を再取得して、翌年1月のサマリーとして記録してしまったらひどいことになります。

また、相対性理論と関係なく、コンピューター・システムにおける時の歩みは一様ではありませんし、連続的とも限りません。NTPによる自動補正や閏秒の溶かし込み、操作者による時刻調整、機器のスリープ・復帰、DSTのあるローカル時間など、時刻がジャンプするケースが多々あります。つまり、「1分待機する」という処理と「現在時刻の1分後の時刻になるまで待つ」は同じではありません。

ミクロ的に見ると、時刻の取得はCPUの計算やメモリアクセスに比べて相対的に高価な処理(システムコールやハードウェアI/Oが必要)です。ゲームエンジンなどでは、ハードウェアアクセス不要で時刻代わりに使えるイベントループや画面更新回数のカウンタを、tickなどの独自単位で提供したりする例があります。

このように、システムが提供する「時計」はいわゆる「現在時刻」を示すものだけに限りません。単調増加(monotonic)な起動時からのシステム経過時間(時刻の補正・変更の影響を受けない)、サスペンド期間も含む経過時間、低精度ながら高速に取得できるバリエーションなどさまざまに用意されており、目的に応じた使い分けが可能です。

「現在時刻」と思われているものが、実はそうでないこともあります。SQLには CURRENT_TIMESTAMP() / NOW() といった関数がありますが、同一ステートメント内ではもちろんのこと、同一トランザクション中は何度呼んでも同じ日時を返します。複数のカラムに現在時刻を記録するときに CURRENT_TIMESTAMP() をいくつも書いたらぜんぶ少しずつずれるとなったら不便なので、「機能」としてそうなっているわけです。

汎用言語にそうした機構はないためうっかりしやすいですが、たとえば条件分岐の条件式と本体でそれぞれ現在時刻を取得して使っていたら要注意です。性能の問題だけでなく不整合の元なので、一度取得したものを使い回すようにしましょう。

現在時刻の取得しすぎ問題への処方としては、各関数内で現在時刻を取得するのでなく、現在時刻を引数で取る、変数で持ち回るなどが対策になります。引数で「現在時刻」を値として受け取るようにすると、テストを書きやすいというメリットもあります。Rubyの世界では時刻もモックし放題なので意識から抜けやすいですが、Goなどでは時刻を注入できる作りにしておかないとテストで困りがちです。

なお、逆にRailsでは 1.week.agodatetime.past? のように現在時刻の取得が暗黙裏に行われるメソッドがあるので、気づかずにレースコンディションを作り込んでしまわないよう注意が必要です。

タイムゾーンに留意しよう

ある期間内の集計や、ログインボーナスなどの仕組みにおいては日付の区切りが重要です。また、日時をリテラルで記述する際も、それが実際の稼働環境でどこのローカルタイムとして解釈されるかを意識しなければなりません。いつも明示的にタイムゾーンを指定しましょう。

ONEでは、日ごとのレシート買取枚数や合計金額などを日本時間をベースに集計していますが、人気の「なんでもレシート」ミッションを朝昼夜の三部制にリニューアルした際、「夜のなんでもレシート」の開催時間帯が日本時間の午後6時から午前5時と日付をまたぐことになりました。このミッションも一日あたりの買取上限枚数などが設定されているのですが、従来の日本時間0時からの24時間ごとに集計するのでは正しく処理できません。そこで、ミッションごとに異なる集計期間、日付区切りを持つ機構を導入することになりました。言わば独自のタイムゾーンと言えます。

なんでもレシート

Railsでは、日時データをDBに保存する際にはUTCに変換します。アプリケーションタイムゾーンは現場次第ですが、グローバルな事業展開を見据え、ONEでは強くUTCを採用しました。しかし、多くの処理では日本時間の日付を念頭に切り替えや集計などを行うため、都度日本時間に変換する手間を掛けています。

なお、日本の標準時は常時UTCと9時間ずれているだけですが、多くの国ではDST(夏時間)があったりするのでそこまで単純ではありません。春の切り替え日には時刻が1時間進み、秋の切り替え日には1時間巻き戻ったりします。なので文字列比較で時刻の大小を比較したりはできないし、単純な時差の足し引きでタイムゾーン変換することもできません。日付部分を1日進めたものと、86400秒を足した日時は必ずしも一致しませんし、閏秒を考慮に入れた場合はさらに複雑です。そして厄介なことに、そうした処理に起因する不具合が顕在化するのは特定の日時近傍に限られるのです。

さらに、実行環境のローカルタイムゾーンの違いという問題もあります。日本に住むほとんどの人はPCを日本時間に設定しているので、処理系が提供する現在時刻を取得する関数はだいたい日本時間で値を返します。これを前提に開発してしまうと、実際のクラウドの稼働環境ではシステムタイムゾーンがUTCだったりして、意図せぬ結果になることがあります。

エンジニアのみなさんの多くは、おそらく始業は9時より遅く、終業は0時よりだいぶ前だと思いますが、たまに0時から9時の間にCIを走らせるとタイムゾーンの問題でこけるテストが見つかったりします。気づかずにリリースしてしまうことを未然に防ぐため、深夜早朝にCIを自動実行する現場もあります。DependabotやRenovateの実行時刻をそこに設定するのもありですね。

日時の複雑さと精度の問題

日付は歴史経緯の塊です。サービスやシステムで「正しく」扱うのはものすごく大変です。

  • 10進、60進、24進混在システム
  • 言語や地域、個人によって好まれる表記が違う
    • 海外サービスは自然言語による「相対表示」が大好きだよね…
  • 「3/31の一ヶ月後」っていつ?
  • 「2/29の一年後」っていつ?
  • 一日の区切りは何時?一週間の始まりは月曜日、それとも日曜日?
    • これらは機能や場面によって異なります。
  • 「時間の長さ」を何でも秒数に換算して表現できるわけではない
    • 月、年はもちろん日でさえも
  • DSTの切り替わりや閏秒があるので、86400秒足せば「1日後」になるとは限らない
  • 時刻をUTC以外で保存・出力してしまうと後で変換が大変

また、時刻の精度をどの程度扱えるかはDBやOSによって異なります。

  • なし(整数秒精度)

  • ミリ(10^{-3})秒

  • マイクロ(10^{-6})秒

  • ナノ(10^{-9})秒

  • それ以外

    古の「FAT」というファイルシステムでは、ファイルのタイムスタンプの記録に用いるビット数を極限までケチった結果、なんと偶数秒の単位でしか保存できません。

よく、「このバナーは日曜日の23:59まで表示してください」のような依頼があったりしますが、実際に「一日の終わり」を表現するには 23:59:59.999999999 と限界であるナノ秒まで指定するしかないし、閏秒が挿入される時間には XX:59:60.999999999 という可能性すらあります。(これは扱うのがつらすぎるので、閏秒については国際的に廃止の方向で進んでいます)

よって「終わり」を表現する際は、「最後の瞬間」ではなく「終わった直後の時刻」を用い、 now < expired_at のように等号なしの大小比較で判定するようにするとすっきりします。Railsでは end_of_day のようなメソッドが提供されているので気軽に使ってしまいがちですが、精度の問題で支障を起こしやすいと言えます。

日時型の存在しないJSONで時刻を運ぶ際などに使われるepoch秒数も要注意です。倍精度浮動小数点型(double)の有効桁数は十数桁しかありませんから、せいぜいミリ秒程度の精度でしか正確に時刻を表現できません。Railsだと、ActiveJobなら日時データをそのまま運べますが、Sidekiqを生で使うときなどはJSONで表現できるepoch秒数を使いがちです。意図せず精度が失われていないか確認しましょう。

時刻の精度の問題は、パジネーション(pagination)や更新検知の際にも顕在化します。前回取得した最後のレコードのタイムスタンプを次回のリクエストの since パラメータに指定して、その後の更新を差分取得するような方式がよくありますが、ここでもepoch秒数の整数値を使う例が多く見られます。しかし、タイムスタンプが指定の整数値を超えるものを返す、とすると、データが秒未満の精度で時刻を持っている場合、秒より下にぴったり0が並ぶということはめったにありませんから、前回取得したレコードも含まれてしまいます。ならばと秒単位で切り上げて比較条件を「それ以上」と改めたとしても、同じ秒の中でさらに更新があった場合には取りこぼしが生じてしまうことになります。

HTTPで If-Modified-Since ヘッダを使う場合も同様です。HTTPにおける時刻表現には秒の精度しかないため、秒未満の間隔で複数回の更新が発生するようなリソースに対してはうまく機能しません。

従って、高頻度の更新が予測できるデータを提供する場面においては、パジネーションや更新検知を時刻ベースで行うのではなく、一意なIDやハッシュ値、シーケンスなどを使って行うように設計することが推奨されます。HTTPでは「ETag」が使えます。

国際化へのハードル

日本ローカルのサービスを国際化させようと思ったら、言語の翻訳だけでなく、日時データについても考えることが一気に増えます。

ユーザごとに住んでいる地域が違うのはもちろんですが、ユーザ自身もタイムゾーンを越えて移動するので、都度クライアント側のタイムゾーンを取得し、それを意識した処理が必要になります。

ONEは2024年現在は日本国内限定サービスとなっていますが、もし国際展開するとなった場合、レシートごとにどの国・地域のものかが重要になりますし、それによって印字されている日時を正しく解釈しないといけません。位置情報まで取るのはプライバシーの問題もありますので、レシートにお店の住所があればそれで位置を特定し、そのタイムゾーンを取得し…とやるべきことは多いですね。

そこにはアナログデータ固有の問題もありますが、それに限らず、データの種類によりレコードごとにタイムゾーン情報も保持するようなことは十分に考えられます。

まとめ

日付や時刻を取り扱うには偏執的なこだわりと悲観的な思考が必要です。そして、それでもなお間違うのが人類が生み出したモンスター、日時です。

「日時を正しく扱うのは簡単じゃない」という認識を常に持ち、強く生きていきましょう。

WED Engineering Blog

Discussion