連載Index(読む順・公開済リンクはここが最新): S00: 門前の誓い(総合Index)
障害調査で、ログの時刻とDBの時刻が噛み合わない。
「頭で補正できるし、いったん放置でいいか」と一瞬思った。
ログとクエリ結果を並べ、UTC表記とオフセットの有無を見比べた。
同じ「10:15」が2つある。けれど、片方はUTC、片方はローカルで、頭の補正が追いつかなくなる。
このページではズレの混入点と、最初から起きにくくする揃え方(保存/表示の境界)を整理する。
このページで手に入るもの
- 逆引き(症状→混入点→対策)が1枚で揃う
- タイムゾーンの直感を掴む図(グリニッジ→UTC→東西→日付の早い/遅い)
- 最短テンプレ(コピペ)2本:UTCで保存する/ローカル入力をUTCへ正規化する
- よくあるズレの判例(悪い例→直す→ポイント)が揃う
- レビューのチェックリスト(表)で確認観点が揃う
- セルフチェック(5問)で、ズレの説明と対応が自走できる
先に逆引き(症状→混入点→対策)
まず「どこでズレたか」を当てる。時間は混入点が定形化しやすい。
| 症状 | 混入点(どこでズレるか) | 事実を揃える観測 | まず当てる対策 |
|---|---|---|---|
| 予定が1日ズレる(0:00跨ぎ) | UI入力直後/API受信直後 | 入力値の文字列、オフセット有無、Kind/Offset | 入力はローカル+タイムゾーンで受け、UTCへ正規化 |
| ログの時系列が壊れる | ログ出力直前 | ログにUTC/オフセットが出ているか | ログはUTCを基準にし、表示で変換 |
| “毎日9:00”が季節でズレる | スケジュール確定直後 | 対象地域がDSTありか、タイムゾーン情報が保持されているか | “壁時計の9:00”+タイムゾーン情報で保持し、都度UTC化 |
| 外部APIの時刻が解釈できない | API受信直後 |
Z / +09:00 / 何も無し(曖昧) |
オフセット付きで受け渡しする規約へ寄せる |
| 環境(Windows/Linux)で落ちる | タイムゾーンID解決時 | Windows ID / IANA ID のどちらを使っているか | OSを跨ぐならID変換方針を決め、失敗時の扱いも決める |
タイムゾーンの直感(地球→グリニッジ→UTC→東西)
ここでは「壁時計の時刻」と「基準の時刻(UTC)」の関係を先に揃える。難しい話は後段へ送る。
この図で揃えるのは次の3点だけ。
- UTC:基準の時刻(世界で揃える“瞬間”の基準)
- オフセット(UTC+9 など):UTCとの差(その時点の差)
- タイムゾーン(Asia/Tokyo など):地域ルール(季節でオフセットが変わる地域がある)
最短テンプレ(コピペ)1:保存はUTC、表示で変換する
ここは最も効く規約。境界(どこで揃えるか)を先に置く。
- 貼る場所:DB保存直前/外部API送信直前/ログ出力直前
- 境界:この境界より内側はUTCで揃える(瞬間を揃える)
- 例外方針:入力不正は ArgumentException 系、状態不正は InvalidOperationException 系に寄せる
using System;
internal static class TimeNormalization
{
/// <summary>
/// DateTimeOffset を「UTCの瞬間」へ揃える(保存・送信・ログの基準)。
/// </summary>
/// <param name="value">変換対象の日時(オフセット付き)。</param>
/// <returns>UTC(+00:00)へ揃えた DateTimeOffset。</returns>
public static DateTimeOffset ToUtcInstant(DateTimeOffset value)
{
// DateTimeOffset は “瞬間” を持つので UTC へ揃えやすい
return value.ToUniversalTime();
}
/// <summary>
/// DateTime を「UTCの瞬間」へ揃える(保存・送信・ログの基準)。
/// </summary>
/// <remarks>
/// 境界:DB保存直前/外部API送信直前/ログ出力直前で当てる想定。
/// DateTime.Kind が Unspecified の場合は解釈が曖昧なため、ここで止める。
/// </remarks>
/// <param name="value">変換対象の日時(Kindが重要)。</param>
/// <returns>UTC(+00:00)へ揃えた DateTimeOffset。</returns>
/// <exception cref="ArgumentException">value.Kind が Unspecified の場合。</exception>
public static DateTimeOffset ToUtcInstant(DateTime value)
{
// DateTime は Kind によって解釈が変わるため、曖昧はここで止める
if (value.Kind == DateTimeKind.Unspecified)
throw new ArgumentException("DateTime.Kind=Unspecified は解釈が曖昧。入力側でUTC/ローカルを決めて渡す。", nameof(value));
// Local / Utc のどちらでも UTC の瞬間へ変換できる
var utc = value.ToUniversalTime();
return new DateTimeOffset(utc, TimeSpan.Zero);
}
}
最短テンプレ(コピペ)2:ローカルの日時+タイムゾーンからUTCへ正規化する
ここは「ログ/DBがズレて見える」の根を減らすテンプレ。壁時計の日時を、地域ルール付きで“瞬間”へ落とす。
- 貼る場所:UI入力直後/API受信直後(ローカル日時として入ってくる所)
- 境界:入力の直後にUTCの瞬間へ正規化し、以降はUTCで流す
- 例外方針:曖昧/無効の日時は ArgumentException(入力不正扱いに寄せる)
using System;
internal static class LocalTimeToUtc
{
/// <summary>
/// 「壁時計の日時」+「タイムゾーン情報」から、UTCの瞬間へ正規化する。
/// </summary>
/// <remarks>
/// 境界:UI入力直後/API受信直後で当て、以降はUTCで流す想定。
/// DSTがある地域では曖昧時刻/無効時刻が発生し得るため、ここで明示的に扱う。
/// </remarks>
/// <param name="localDateTime">壁時計の日時(Kind=Unspecified を前提)。</param>
/// <param name="timeZone">対象地域のタイムゾーン。</param>
/// <returns>UTC(+00:00)へ正規化した DateTimeOffset。</returns>
/// <exception cref="ArgumentNullException">timeZone が null の場合。</exception>
/// <exception cref="ArgumentException">無効時刻、または運用ルールが未整備の曖昧時刻を扱う方針の場合。</exception>
public static DateTimeOffset Convert(DateTime localDateTime, TimeZoneInfo timeZone)
{
if (timeZone is null) throw new ArgumentNullException(nameof(timeZone));
// Kind は Unspecified を前提に扱う(「壁時計の日時」だから)
if (localDateTime.Kind != DateTimeKind.Unspecified)
localDateTime = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified);
// 無効時刻:存在しない(DSTで飛ぶなど)
if (timeZone.IsInvalidTime(localDateTime))
throw new ArgumentException("指定タイムゾーンでは存在しない日時(無効時刻)。入力値の見直しが必要。", nameof(localDateTime));
// 曖昧時刻:同じ壁時計が2回現れる(DSTで戻るなど)
if (timeZone.IsAmbiguousTime(localDateTime))
{
// ここは運用ルールが必要。
// 例:早い方(標準時)を採用 / 遅い方(夏時間)を採用 / 追加UIで選ばせる など
// 最短では「どちらかを選ぶ」をコード化しておく。
var offsets = timeZone.GetAmbiguousTimeOffsets(localDateTime);
// 例:より小さいオフセット(標準時側)を採用する
// ※運用の要請で逆にすることもある
var chosenOffset = offsets[0] <= offsets[1] ? offsets[0] : offsets[1];
var ambiguousLocal = new DateTimeOffset(localDateTime, chosenOffset);
return ambiguousLocal.ToUniversalTime();
}
// 通常:オフセットは地域ルールから求める
var offset = timeZone.GetUtcOffset(localDateTime);
var local = new DateTimeOffset(localDateTime, offset);
return local.ToUniversalTime();
}
}
解説:定義→評価規則→型の変化→落とし穴
ここでは「どの情報が揃っていると安全か」を言語化して、混入点で止める判断を作る。
定義:壁時計/瞬間/地域ルール
- 壁時計の時刻:現地の「9:00」などの表示上の時刻。単体では“瞬間”が一意にならない
- 瞬間:UTC基準で一意な時刻。DB・API・ログの芯
- 地域ルール(タイムゾーン):オフセットが季節で変わる地域がある(DSTなど)
評価規則:どこまで情報が揃っているか
- オフセット付き(例:
2026-01-21T09:00+09:00)
→ 瞬間が一意になりやすい(UTCへ直行できる) - タイムゾーン付き(例:
2026-01-21 09:00+Asia/Tokyo)
→ 地域ルールでオフセットを求めて瞬間に落とす - どちらも無い(例:
2026-01-21 09:00だけ)
→ 受信側で解釈が割れ、ズレの最初の火種になりやすい
型の変化:C#で何を持つか
- DateTimeOffset:日時+オフセットを持つ。瞬間へ寄せやすい
- DateTime:Kindで解釈が変わる。Unspecifiedは曖昧になりやすい
落とし穴:二重変換・曖昧時刻・環境差
-
ToLocalTime()/ToUniversalTime()を境界で複数回当て、二重変換になる - DSTの曖昧時刻/無効時刻を「存在する前提」で扱い、予定がズレる
- タイムゾーンIDがOS依存で解決できず、環境差で落ちる
判例(混入経路が多い順に潰す)
ここは「頭で補正して放置」が起きやすい所を、混入点から潰す。
1) “9:00” をそのまま保存して、後でUTC扱いになる
壁時計の9:00を、瞬間として扱ってしまうパターン。
- 悪い例
UIのDateTimeをそのままDBへ保存し、どこかでUTC扱いになる - 直す
UI入力直後にTimeZoneInfoを添えてUTCへ正規化し、以降はUTCで流す - ポイント
混入点はUI入力直後。ここで揃えると、後段の変換が激減する
2) DateTime.Kind=Unspecified を勝手にUTC扱いしてズレる
曖昧な値を勝手に解釈し、後から誰も追えなくなるパターン。
- 悪い例
DateTime.Kind=UnspecifiedをToUniversalTime()へ通してしまう - 直す
Unspecifiedは境界で止め、入力側でUTC/ローカルを決めて渡す - ポイント
“曖昧を通す” のが一番長引く。境界で止める方が回復が早い
3) 文字列化でオフセットを落とし、再構築でズレる
時刻の見た目だけが残り、瞬間が落ちるパターン。
- 悪い例
yyyy/MM/dd HH:mm:ssへ整形して送る(オフセット無し) - 直す
ISO 8601でオフセット付きに寄せる(Zか+09:00を残す) - ポイント
送受信は “瞬間を落とさない” が最優先。表示用整形は最後に回す
4) “毎日9:00” をUTCで決め打ちし、地域ルールを落とす
定期実行を瞬間だけで持ち、壁時計の意味を落とすパターン。
- 悪い例
“毎日9:00” をUTCの時刻として保存する - 直す
“壁時計の9:00” と “タイムゾーン情報” を保持し、実行時にUTCへ落とす - ポイント
定期実行は「壁時計の約束」。瞬間は都度生成で揃える
チェックリスト:レビューで見る所(表)
レビューでは「境界で揃える」が守れているかを表で潰す。
| 観点 | 見るポイント | よくある落とし穴 |
|---|---|---|
| 保存 | DB/API/ログはUTCを基準にしているか | ローカル混入で時系列が壊れる |
| 入力 | UI入力直後にUTCへ正規化しているか | “9:00” が曖昧なまま流れる |
| 送受信 | オフセット付き(ISO 8601)で渡しているか | 文字列化でオフセットが落ちる |
| 変換回数 | 境界で一回に揃えているか | 二重変換でズレる |
| 定期実行 | 壁時計+タイムゾーン情報で保持しているか | UTC決め打ちで季節にズレる |
| 例外方針 | 無効/曖昧時刻の扱いが決まっているか | DSTで揺れる日時が残る |
セルフチェック(5問)
読後に「説明できる/直せる」へ寄せるための確認。
- 壁時計の時刻と、記録すべき瞬間(UTC)が別物だと説明できるか
- 混入点を「UI入力直後/API受信直後/DB保存直前/表示直前/スケジュール確定直後」に分解できるか
-
DateTime.Kind=Unspecifiedを境界で止める理由を説明できるか - DST(夏時間)の曖昧時刻/無効時刻を、例外または運用ルールで扱えるか
- 保存・送信・ログはUTC、表示で変換という規約を言語化できるか
回答の目安
- 1: 壁時計は地域の表示、瞬間は世界で一意な基準(UTC)
- 2: 混入点を特定すると、どこで揃えるかが決まり、二重変換が減る
- 3: Unspecifiedは解釈が割れ、勝手なUTC扱いがズレの火種になる
- 4: DST(夏時間)の曖昧/無効は “存在しない/2回ある” を明示し、例外か選択規則を決める
- 5: UTCで揃えるのは記録と比較、表示は利用者の地域へ合わせる
関連トピック
連載Index(読む順・公開済リンクはここが最新): S00: 門前の誓い(総合Index)