2026-08-13
この記事をシェア
「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 / MariaDB | 2/28 | 丸め |
| PostgreSQL | 2/28 | 丸め |
| Oracle | 2/28(※月末保存の副作用あり) | 丸め+α |
Java java.time | 2/28 | 丸め |
Python relativedelta | 2/28 | 丸め |
| PHP | 3/3 | オーバーフロー |
| JavaScript | 3/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つある」
- 完全に一致する — PHPもMySQLもJSも、日数の加減算に流派の違いはない。第2章の不整合が消える
- 1対1対応になる — 開放日と対象日が必ず1対1。集中も空白も原理的に発生しない
- 不利益変更にならない — 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
- 同一システム内で月演算の経路を混ぜない — 混ぜるなら必ず片方に寄せる
- 予約リードタイムは「◯日前」で定義する — 実装差も負荷集中も同時に消える
日付ライブラリを信用するな、という話ではありません。どの流派の実装なのかを知った上で使え、という話です。
参考資料
- 民法(e-Gov法令検索)第143条 暦による期間の計算
- MySQL 8.4 Reference Manual: Date and Time Functions
- PHP Manual: 相対的な書式
- Oracle Database SQL Language Reference: ADD_MONTHS
- RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar)
- Java Platform SE: java.time.LocalDate
本記事は2026年8月15日時点の各処理系の仕様に基づきます。バージョンや設定によって挙動が変わる可能性があるため、採用前にご自身の環境で必ず実測してください。
