3月31日の1ヶ月前の1ヶ月前は? 日付演算に潜む非結合性

2026-08-15 | システム設計・実装

【技術相談】本件の内容に関して30分間の無料相談承ります →

3月31日の一か月前って何月何日? 「1ヶ月前を2回」≠「2ヶ月前」

この記事をシェア

「1ヶ月前を2回」と「2ヶ月前」は、同じじゃない。

整数の世界なら、-1を2回引くのと-2を1回引くのは必ず同じ結果になります。ところが日付の世界では、それが成り立ちません。予約システム、サブスクリプションの請求日、契約の更新日——月単位の日付計算が絡むところには、たいてい同じ地雷が埋まっています。

この記事では、新人の佐藤さんが持ち込んだ1枚のバグ票を起点に、月演算がなぜ壊れるのか、処理系によってなぜ答えが違うのか、そしてどう設計し直せばいいのかを対話形式で追っていきます。

プロローグ:新人が持ってきたバグ票

新人・佐藤「先輩、予約システムでよく分からない不具合が出てて……。仕様は『希望日の1ヶ月前から予約受付開始』なんですけど、3月31日の枠の受付開始日が、画面によって違う日付になるんです」

先輩・田中「ほう。じゃあ、まずウォーミングアップから。3月31日の1ヶ月前って何月何日だ?」

佐藤「2月31日……はないので、2月28日ですよね。うるう年なら29日」

田中「正解。日本の民法143条2項ただし書きに、応当日がなければその月の末日、と書いてある。ドイツ民法(BGB §188)もフランス民事訴訟法641条も同じ。イギリスは判例法だが Dodds v Walker (1981) で同じ結論だ。世界共通と思っていい」

佐藤「じゃあ余裕ですね。日付計算なんて」

田中「そうか。じゃあ本題だ。3月31日の1ヶ月前の、さらに1ヶ月前は?

佐藤「えっと、2月28日の1ヶ月前だから……1月28日?」

田中「そう。1月28日だ。じゃあ、3月31日の2ヶ月前は?」

佐藤「1月31日……」

田中「……」

佐藤「あれ」

田中「気づいたか」

佐藤1ヶ月前を2回やったら1月28日、2ヶ月前を1回やったら1月31日。3日ズレてる……?」

第1章:日付演算は「足し算」ではない

田中「そう。ここが今日の核心だ。我々は無意識に、日付の加減算を整数の足し算みたいに扱ってる。でも実際は違う」

佐藤「整数なら、-1を2回と-2は絶対に同じですよね」

田中「数学の言葉で言えば結合法則が成り立たない(d - 1M) - 1M ≠ d - 2M だ。理由は単純で、末日への丸め処理が情報を捨てているから

佐藤「情報を、捨てる」

田中「3月31日の『31日』という情報が、2月28日に丸められた瞬間に消える。次の演算はもう『28日』としてしか扱えない。不可逆な圧縮だよ、これは」

佐藤「じゃあ、逆演算も成り立たないってことですか」

田中「試してみろ。3月31日の1ヶ月前は2月28日。その1ヶ月後は?」

佐藤「3月28日……。戻ってこない!」

田中(d - 1M) + 1M ≠ d。往復すると別の場所に着く。日付計算にバグが多いのは、こういう性質を持った演算を、みんな整数の足し算だと思って書いてるからだ」

📌 押さえておきたい2つの性質

  • 非結合性: (d - 1M) - 1M ≠ d - 2M(3/31 なら 1/28 と 1/31 で3日差)
  • 非可逆性: (d - 1M) + 1M ≠ d(3/31 → 2/28 → 3/28 で元に戻らない)

第2章:言語とDBで答えが違う

佐藤「でも先輩、うちのシステムはPHPとMySQLですけど、両方とも同じ答えになりますよね? 民法準拠なら」

田中「……なると思うか?」

佐藤「え」

田中「やってみろ」

-- MySQL
SELECT DATE_SUB('2026-03-31', INTERVAL 1 MONTH);
-- → 2026-02-28
// PHP
echo (new DateTimeImmutable('2026-03-31'))
       ->modify('-1 month')->format('Y-m-d');
// → 2026-03-03

佐藤3月3日!? なんで3月から出ないんですか!」

田中「PHPは丸めない。オーバーフローさせる。内部で一度『2026年2月31日』という存在しない日付を作って、それを正規化する。2月は28日までだから、3日はみ出して3月3日になる」

佐藤「28 + 3 = 31 ……だから3月3日。合ってるといえば合ってるけど……」

田中「JavaScriptの Date.setMonth() も同じ挙動だ。C言語の mktime() の伝統だな。法律は丸め、C系の実装ははみ出す。この二大流派の衝突が、君のバグ票の正体だ」

佐藤「画面によって受付開始日が違ったのは、PHPで計算してる画面とSQLで計算してる画面が混在してたから……」

田中「3月31日の枠が、PHP経路なら3月3日、MySQL経路なら2月28日に開く。25日もズレる。しかも1〜3月以外はズレないから、テストをすり抜けやすい」

第3章:もっと怖い話をしよう

田中「Oracleを使ってる現場だと、さらに一段ヤバい」

-- Oracle
SELECT ADD_MONTHS(DATE '2026-02-28', 1) FROM dual;
-- → 2026-03-31

佐藤「……31日? 28日じゃなくて?」

田中「Oracleの ADD_MONTHS は『入力が月末なら出力も月末にする』という独自仕様なんだ。2月28日は2月の末日だから、1ヶ月後は3月の末日、つまり3月31日」

佐藤「じゃあ Oracle だと、3月31日 → 2月28日 → 3月31日 で往復できる?」

田中「できてしまう。丸めた情報が復活するんだ。一貫性があるように見えて、実は別の罠だ。2月28日は『2月28日』なのか『2月の末日』なのか、区別がつかなくなる。平年の2月28日にサブスク契約すると、翌月の請求日が31日になる」

佐藤「意図してないですよね、それ絶対」

田中「ちなみに主要処理系の派閥はこうだ」

処理系3/31 の1ヶ月前流派
MySQL / MariaDB2/28丸め
PostgreSQL2/28丸め
Oracle2/28(※月末保存の副作用あり)丸め+α
Java java.time2/28丸め
Python relativedelta2/28丸め
PHP3/3オーバーフロー
JavaScript3/3オーバーフロー
iCalendar (RFC 5545)存在しない扱いスキップ

佐藤「RFC 5545だけ third party すぎませんか」

田中「カレンダー規格は『毎月31日に繰り返す』なら31日がない月は発生させない。法律は丸め、C系ははみ出し、カレンダーは飛ばす。三者三様に筋は通ってる。困るのは、それを1つのシステムに混ぜたときだけだ」

第4章:予約が2月28日に集中する理由

佐藤「もうひとつ相談があって。『1ヶ月前の7:00〜17:00に受付』という仕様なんですけど、2月28日にアクセスが集中するんです」

田中「それは丸めの必然だ。逆写像を取ってみろ。2月28日に開く枠は、対象日でいうと何日分だ?」

佐藤「3月28日、29日、30日、31日……4日分!」

田中「そして1月29日・30日・31日に開く枠は?」

佐藤「2月29日、30日、31日……全部存在しない。ゼロだ」

田中3日間の空白のあとに4倍のバースト。これがセットで来る。同じことが4/30、6/30、9/30、11/30でも小規模に起きてる。サーバー負荷の問題であると同時に、公平性の問題でもある」

佐藤「3月31日を取りたい人だけ、4倍の競争率で戦わされてる」

田中「そういうことだ。仕様が生んだ不公平であって、バグですらない。だから誰も直さないまま何年も残る」

第5章:処方箋

佐藤「どう直せばいいですか」

田中月演算をやめて、日数演算にする。仕様を『31日前』に変えるんだ」

DATE_SUB(reserve_date, INTERVAL 31 DAY)
$d->modify('-31 days')

田中「メリットは3つある」

  1. 完全に一致する — PHPもMySQLもJSも、日数の加減算に流派の違いはない。第2章の不整合が消える
  2. 1対1対応になる — 開放日と対象日が必ず1対1。集中も空白も原理的に発生しない
  3. 不利益変更にならない — 31日 ≧ どの月の日数。リードタイムが「1ヶ月」を下回らないので、規約変更の説明がしやすい

佐藤「3月31日の受付開始は結局2月28日ですよね」

田中「そう。でも3月30日は2月27日、3月29日は2月26日、と自然にバラける。ピークが均される」

佐藤「他の選択肢もありますか」

田中「応当日がない場合を翌月1日に繰り下げる手もある。3/29〜3/31を3/1に開放する。RFC 5545寄りの発想だが、リードタイムが1ヶ月を切るので規約上は説明が要る。あとは公共施設方式で、前月1日一斉開放+抽選。7:00〜17:00の10時間しか受付枠がない設計なら、先着順である限り毎朝7:00のサンダリングハード問題は残るから、抽選の併用は真剣に検討する価値がある」

エピローグ

佐藤「日付って、こんなに難しかったんですね」

田中「日付とタイムゾーンと文字コード。この3つは『簡単そうに見えて実は難しい』の御三家だ。共通してるのは、人間の都合で作られた体系を、機械が扱おうとしているという点だな。うるう年も、月の長さがバラバラなのも、技術的な理由なんかない。ローマ皇帝の都合だ」

佐藤「アウグストゥスが8月を31日にしたせいで、僕が今日残業してるわけですか」

田中「そういうことだ。……で、最後にもう一問いいか」

佐藤「まだあるんですか」

田中2028年3月31日の1ヶ月前の1ヶ月前は?」

佐藤「2028年はうるう年だから、3/31 → 2/29 → 1/29 ……あ、平年だと1/28で、うるう年だと1/29。4年に1回だけ答えが変わる

田中「よくできました。じゃあそのテストケース、書いといてくれ」

まとめ

  • 「1ヶ月前を2回」≠「2ヶ月前」 — 丸めが情報を捨てるため、結合法則が成立しない
  • 往復しても元に戻らない(d - 1M) + 1M ≠ d
  • PHP / JavaScript は丸めずオーバーフローする — 3/31 の1ヶ月前は 3/3
  • 同一システム内で月演算の経路を混ぜない — 混ぜるなら必ず片方に寄せる
  • 予約リードタイムは「◯日前」で定義する — 実装差も負荷集中も同時に消える

日付ライブラリを信用するな、という話ではありません。どの流派の実装なのかを知った上で使え、という話です。

参考資料

本記事は2026年8月15日時点の各処理系の仕様に基づきます。バージョンや設定によって挙動が変わる可能性があるため、採用前にご自身の環境で必ず実測してください。

この記事が役に立ったらシェアしてください

この記事が参考になったら、ぜひシェアしてください。

カテゴリ

システム設計・実装

公開日

2026-08-15

💬 無料技術相談のご案内

この記事でご紹介した技術について、導入や活用のご相談を30分間無料承っております。

  • 「自社でも導入できる?」といった技術的な疑問
  • 既存システムとの連携・移行に関するご相談
  • コスト感や導入スケジュールの目安

30年以上のIT経験をもとに、率直にお答えします。強引なセールスや勧誘は一切ありません。

野口真一 野口真一

お気軽にご相談ください

記事に関するご質問や、AI・IT技術導入のご相談など、お気軽にお問い合わせください。