业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计

· · C#, .NET, .NET Framework, Windows, 时区, 日期时间处理, 测试, 运维, 技术咨询

「服务器搬到云端的 VM 之后,报表上的时间全部差了 9 个小时」「只有配发到海外分支机构的设备,日报上的日期会变成前一天」「深夜的汇总批处理,好像有一天运行了两次」。跟日期时间与时区有关的咨询,往往都是以「环境一变化」这种形式冒出来的。代码明明一行都没改。

「我们是纯日本国内用的应用,跟时区没关系」——虽然常常这样想,但实际上大多数的咨询案例,恰恰就是发生在这种纯国内用的应用上。云端的 VM 或容器经常以 UTC 设置发放,海外的库或 SaaS 的 Web API 也会返回 UTC 或带偏移量的时间戳。就算应用自己以为只活在日本时间里,边界的另一端早就是 UTC 的世界了。如果在交换值的时候,没有明确标示「这个时间是以什么为基准」,就会在服务器迁移或上云的那一天,一次性爆发为 9 小时的落差。

本文以 .NET 的业务应用为前提,完整梳理 DateTime 的 Kind 与隐式转换陷阱、与 DateTimeOffset 的取舍、「存储与通信用 UTC 或带偏移量,只有显示才转本地时间」的原则、TimeZoneInfo 与夏令时、和数据库的边界,一直到用 TimeProvider 做测试设计。也会把设计评审时每次都会确认的项目,整理成清单的形式。

1. 先说结论

  • 日期时间事故的根本原因几乎都只有一个:「这个时间是以什么为基准」这项信息没有被保留下来的值,跨越了边界(数据库、API、文件)。发病的时间点不是改代码的那天,而是环境变化的那天。
  • DateTime 带有一个叫 Kind(Utc / Local / Unspecified) 的属性,默认值是 Unspecified。ToLocalTime 会把 Unspecified 当作 UTCToUniversalTime 则会当作本地时间,这种不对称的隐式解读,正是「差 9 小时」的典型来源。1
  • 新代码的默认选择是 DateTimeOffset。官方指南也明确写着「应考虑把它当作应用程序开发的默认日期时间类型」。2
  • 原则是「存储与通信用 UTC 或带偏移量,只有显示才转本地时间」。转成字符串时,用符合 ISO 8601 的往返格式 “o” 写出,再用 DateTimeStyles.RoundtripKind 读回。3
  • 时区转换用 TimeZoneInfo 进行。.NET 6 以后,IANA ID(Asia/Tokyo)和 Windows ID(Tokyo Standard Time)两种都能用,也有互相转换的 API4 不过 Windows 上解析 IANA ID 依赖 ICU,在较旧的 Windows Server 或不可变全球化配置下会失败(第 4 章)。5
  • 就算日本没有夏令时,只要经过海外分支机构的设备、与海外 SaaS 的联动、UTC 设置的服务器,就会踩到 DST 的「不存在的时间・模糊的时间」。6
  • 不要在代码里直接写 DateTime.Now,改成注入 TimeProvider(.NET 8 标准;旧环境用 Microsoft.Bcl.TimeProvider),让时间可以在测试中自由推进。7

2. DateTime 的 Kind ── 三个值的含义与隐式转换的事故

DateTime 除了日期时间的值(Ticks)之外,还带有一个叫 Kind 的属性。这个属性只有 3 个值,默认是 Unspecified。1

Kind 含义 主要来源
Utc 以 UTC 为基准的时间 DateTime.UtcNowToUniversalTime() 的结果
Local 以运行机器的本地时区为基准 DateTime.NowToLocalTime() 的结果
Unspecified 基准不明(默认) new DateTime(...)DateTime.Parse(多数情况)、从数据库读出的值

重点在于,普通写代码几乎都会满是 Unspecified。用构造函数做出来的值、从字符串解析出来的值、从数据库读出来的值,默认全部都是「基准不明」。这件事本身不是问题。问题在于,转换方法会对这个 Unspecified 的值悄悄假设一个基准1

调用 Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() 原样返回 转成 UTC 当作本地时间转成 UTC
ToLocalTime() 转成本地时间 原样返回 当作 UTC转成本地时间

重点是,同一个 Unspecified,会依调用的方法不同,被解读成「本地」或「UTC」,这种不对称才是关键。写成代码会是这样:

// 从数据库读出的值。很多路径下 Kind 会是 Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// 若运行机器是 JST(UTC+9):
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 ── 被当作 UTC,+9 小时
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 ── 被当作本地时间,-9 小时

事故就是这样发生的。数据库里以日本时间存储的 09:00(Unspecified),如果在显示前「为求保险」调用 ToLocalTime(),就会被当作 UTC 而变成 18:00。反过来,如果路径中某处重复插入了「存储前先转成 UTC」的转换,9 小时就会被扣掉两次。更麻烦的是,这个行为取决于运行机器的时区设置。开发机(JST)差 9 小时,UTC 设置的服务器则差 0 小时,于是就会出现「我这边重现不出来」的反馈。开头提到的「服务器迁移后差了 9 小时」,大多就是这种结构。

2.1 与 DateTimeOffset 的差异与取舍

DateTimeOffset 除了日期时间之外,还始终带着与 UTC 的偏移量(例如 +09:00),所以光看这个值本身,就能唯一确定它是世界上的哪一个瞬间。在记录日志、交易时间、系统事件记录这类「记录时间点」的用途中,官方指南明确建议把 DateTimeOffset 当作默认的日期时间类型来考虑2 因为根本没有依赖 Kind 隐式解读的空间,本文讨论的大部分事故在结构上就不会发生。

不过它并非万能。要注意 DateTimeOffset 拥有的是偏移量,并不是时区+09:00 究竟是日本还是韩国,光看这个值分不出来,也没有夏令时的调整规则。2 如果想重现「当地挂钟显示的时间」,就必须配合后面会提到的 TimeZoneInfo。整理成表格如下:

类型 拥有的信息 适合的用途 补充
DateTimeOffset 日期时间 + UTC 偏移量 记录发生时间、日志、API 边界 新代码的默认选择2
DateTime(采用 Kind=Utc) 仅日期时间 内部计算、与现有资产兼容 Kind 的管理全部要自己来
DateOnly / TimeOnly 仅日期/仅时间 业务日期、营业时间、截止时间 .NET Framework 无法使用2
TimeSpan 时间区间 经过时间、两个时间点的差  
TimeZoneInfo 时区的定义(含调整规则) 转换、判断夏令时 第 4 章

把现有的 DateTime 资产全部改写成 DateTimeOffset,往往不太现实,所以我们在改造项目中常采用的折中方案是「内部与存储统一用 Kind=Utc 的 DateTime,边界(API、序列化)才用 DateTimeOffset 或 “o” 格式字符串」。无论选哪一种,下一章的原则都是基础。

3. 原则 ── 存储与通信用 UTC 或带偏移量,只有显示才转本地时间

日期时间处理的设计原则,可以整理成 3 行:

  1. 发生时间用 DateTime.UtcNowDateTimeOffset.UtcNow 获取,并以 UTC(或带偏移量)的形式传递下去
  2. 跨越边界(数据库、API、文件、注册表)时,把格式与基准明确写进规格里
  3. 转成本地时间,只在要显示在界面或报表前的那一次

用本地时间存储的设计,等于让「值的含义」依赖服务器的操作系统设置这个外部状态。只要还在日本设置的本地服务器上运行,问题就不会显现,但迁移到云端 VM、在海外地区搭建 DR、开发环境与正式环境的设置差异——任何一项都会改变这个含义。用 UTC 存储的话,值的含义在任何环境下都相同。显示用的转换,则依用户端的设置(或用户主数据里的分支机构时区)进行,所以同一份数据在东京和柏林看起来,都会是各自正确的挂钟时间。

3.1 跨越边界的字符串表示用 ISO 8601/”o” 格式

用字符串跨越边界时(JSON、CSV、日志、配置文件),要用符合 ISO 8601 的往返格式 “o”。”o” 会把 DateTime 的 Kind、DateTimeOffset 的偏移量保留在字符串里,只要指定 DateTimeStyles.RoundtripKind 解析,就能还原成原本的值。3

using System.Globalization;

// 写出:2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// 读回:偏移量会被保留下来还原
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

请务必配合使用 CultureInfo.InvariantCulture。如果保持区域性默认值,在使用和历之类非公历区域性的设备上,年份的表示方式就会跟着改变。反过来绝对不能做的,是用像 "yyyy/MM/dd HH:mm" 这种不带基准信息的格式存储或通信。收到这个字符串的一方,只能猜测「这是以什么为基准」,而猜测在环境变化时就会猜错。顺带一提,System.Text.Json 默认的日期时间表示法也属于 ISO 8601 系统,所以在 JSON 边界上,直接沿用默认值是安全的做法。

「跨越边界的数据要在规格里明确标示格式」这个想法,不只限于日期时间。字符编码与换行符也有完全相同的讨论,写在「Windows 的字符编码与换行符」里。边界上的隐式假设,种类不同,出问题的方式却是一样的。

3.2 区分时间戳与「业务日期」

还有一个用来解开开头提到的「只有海外分支机构的日报日期会变成前一天」的区别。时间戳(世界上唯一确定的时间点)和业务日期(像「7 月 3 日的日报」这种标签)是不同的东西。如果把业务日期以「深夜 0 点的 DateTime」持有,再经过 UTC 转换,UTC+9 的 7 月 3 日 00:00,就会变成 UTC 的 7 月 2 日 15:00,一旦切出日期部分,就会偏移到前一天。这就是典型的发生机制。

业务日期应该用 DateOnly(在 .NET Framework 上则用 yyyy-MM-dd 格式的字符串或年月日的值)来持有,并且在规格里决定「用哪个时区来切日期」。只要规格上写清楚「日报的日期以分支机构的当地时间为基准」「结算以总公司的 JST 为基准」,实现只需要把 UtcNow 转换到对应的时区,再切出日期即可。如果规格上没写,那就等于实现者的机器设置变成了规格本身。

4. 时区转换 ── TimeZoneInfo 与 ID 体系

时区转换是用 TimeZoneInfoConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime 来进行的。要注意的是,这些 API 会检查 DateTime 的 Kind 与转换来源时区是否一致。举例来说,如果把 Kind=Utc 的值当作「转换来源是东京」传进去,就会得到 ArgumentException8 也就是说,Kind 管理不够严谨的代码,连时区转换 API 都无法正常调用。第 2 章的内容,直接连接到这里。

4.1 Windows 的时区 ID 与 IANA ID

指定时区用的 ID,有两套体系。

  Windows ID IANA ID
示例(日本) Tokyo Standard Time Asia/Tokyo
示例(德国) W. Europe Standard Time Europe/Berlin
管理方 Windows(注册表) IANA tz database
主要使用场景 Windows API、.NET(Framework) Linux、海外 SaaS、Web API、其他语言

.NET Framework 时代只能用 Windows ID,因此出现过「Web API 传来 Asia/Tokyo,却无法传给 FindSystemTimeZoneById」这种需要转换表的问题。.NET 6 以后,TimeZoneInfo.FindSystemTimeZoneById 两种 ID 都能接受,系统上没有的那一种 ID 会自动转换后解析。如果想明确转换,还新增了 TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId4

// .NET 6+:IANA ID 可以直接使用(用 Windows ID "Tokyo Standard Time" 结果相同)
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // 东京的挂钟时间
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // 柏林的挂钟时间

// ID 体系互相转换(.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

实务上的建议是,分支机构主数据或配置文件里保存的时区 ID,统一用 IANA ID。海外 SaaS、Linux 容器、其他语言之间都能通用的是 IANA ID,而 .NET 这一端只要是 6 以后,原则上可以直接接收。如果还残留运行在 .NET Framework 上的部分,就只在那个边界转成 Windows ID。另外,FindSystemTimeZoneById 找不到对应 ID 时会抛出 TimeZoneNotFoundException,请在录入 ID 的界面或启动时的验证中,尽早把这种情况挡下来。

还有一个重要的前提。Windows 上解析 IANA ID 依赖 ICU 库。在 NLS 模式或全球化不可变模式(InvariantGlobalization=true)下运行的应用,无法解析 IANA ID,TryConvertIanaIdToWindowsId 也会失败。5 另外,如果在操作系统没有内置 ICU 的较旧环境(例如 Windows Server 2019、Windows 10 1809 之前)上运行 .NET 6,除非额外附带应用本地 ICU,否则同样会踩到这个限制(.NET 7 以后,已改成在这些操作系统上也会使用 ICU9)。也就是说,「开发机(Windows 11)上 Asia/Tokyo 可以正常运行,但只有客户端的 Server 2019 会抛出 TimeZoneNotFoundException」这种情况确实会发生。若采用 IANA ID 作为主数据标准,请务必做到以下 3 点:(1) 确认实际运行环境的操作系统与 .NET 版本能正常解析 IANA ID;(2) 不要为了缩小容器体积之类的目的,轻易启用 InvariantGlobalization;(3) 在启动时的验证中加入兜底机制,用 TryConvertIanaIdToWindowsId 转成 Windows ID 后重试,作为保险。

5. 夏令时(DST)── 纯日本国内用也会踩到的场景

因为日本目前没有夏令时,常常会认为「DST 跟我们没关系」。但只要符合以下任何一项,就一定会踩到:

  • 海外分支机构或出差人员的设备上运行这个应用(设备的本地时区有夏令时)
  • 海外的 SaaS/Web API 联动,接收以当地时间为基准的时间戳或排程
  • 海外地区的服务器・VM 运行汇总或批处理作业
  • 有依海外分支机构当地时间结算的汇总(例如「各分支机构 0 点结算」)

在有夏令时的时区里,切换日会出现两种异常的时间。不存在的时间(春天,时钟往前跳的那段时间会被跳过;以德国来说是 3 月底的 02:00~03:00),以及模糊的时间(秋天,时钟往后调而出现两次的那段时间)。在 .NET 中,可以用 TimeZoneInfo.IsInvalidTime / IsAmbiguousTime 判断。6ConvertTimeToUtc 之类的转换 API,一旦传入不存在的时间就会抛出 ArgumentException,模糊的时间则会解读成标准时8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// 2026-03-29 是德国夏令时的开始日。当地时间 02:00~03:00 不存在
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) 会得到 ArgumentException

如果系统里有「接收当地时间字符串,转成 UTC 后存储」这种输入路径,这个异常就会潜伏成一年只会发生一天的 bug。实际可行的做法是,在输入验证阶段确认 IsInvalidTime,并在规格里明确写下「模糊的时间视为标准时处理」。

5.1 定期执行与 DST ── 运行两次・不运行的问题

定期执行是另一个经典的踩坑点。每天在当地时间 02:30 运行的任务,在夏令时开始日该时间点不存在,在结束日则存在两次。依调度器实现的不同,行为会分成「被跳过」「运行两次」「延后 1 小时运行」,因此以「每天只运行一次」为前提写成的汇总处理,就会发生重复汇总或漏算。对策是以下 3 项的组合:

  • 把排程基准改成 UTC(或没有夏令时的时区)。如果批处理不要求以当地时间启动,光是这样就能消除问题
  • 让处理具备幂等性。加入「该日的汇总若已存在则跳过」这种已执行标记,就算运行两次也不会出错
  • 汇总的键用业务日期持有(第 3.2 节)。不要从启动时间反推日期

任务计划程序上定期执行的设计(防止重复启动、失败时的排查方法),写在「任务计划程序的任务不执行、以 0x1 结束」;常驻服务端自行持有计时器的设计,则写在「Windows 服务的创建与运维」。不管采用哪种方式,DST 与排程之间的关系,都需要同样写进规格里。

6. 与数据库的边界 ── SQL Server/SQLite/ORM

在所有边界中,事故最多的是数据库。因为数据库的日期时间类型,大多不保留「基准是什么」这项信息,一旦存储,Kind 或偏移量的信息就会消失

6.1 SQL Server 的日期时间类型

类型 范围・精度 基准信息 新采用时的判断
datetime 1753 年起,约 1/300 秒精度 避免使用(官方明确标示新工作不建议使用)10
datetime2 0001 年起,最高 100 纳秒精度 ◎ 以 UTC 存储的字段首选10
datetimeoffset 相当于 datetime2 + 偏移量 保留偏移量 ○ 需要还原当地时间的字段10

datetime 是舍入粒度较粗、范围也较窄的旧类型,官方文档明确写着「新工作应避免使用,改用 datetime2 / datetimeoffset 等」。10 现有架构里的 datetime 不需要勉强迁移,但在新表里没有理由选它。

要用 UTC 存储到 datetime2,还是改用 datetimeoffset,取决于「之后是否需要还原输入当时的偏移量」。如果审计或法规要求保留「用户当地时间是几点」,就用 datetimeoffset;如果只需要确定时间点,UTC 的 datetime2 就足够。不过 datetimeoffset 拥有的也只是偏移量,并非时区(调整规则)本身,这点跟 DateTimeOffset 一样。如果连时区都需要,就另外用一个字段保存 IANA ID。

6.2 SQLite 没有日期时间类型

SQLite 本身没有日期时间的存储类型,Microsoft.Data.Sqlite 会把 DateTime / DateTimeOffsetTEXT 形式存储11 TEXT 的 ISO 8601 系格式,只要「格式与时区统一」,字符串排序就等于时间排序,但反过来说,一旦 UTC 和本地时间混进同一字段,排序与范围查询就会悄悄出错。使用 SQLite 时,只能靠应用端的约定,固定「这一列是 UTC,格式是这样」。连接与事务等 SQLite 的实务内容,整理在「C# 在业务应用中使用 SQLite」。

6.3 EF Core/Dapper 的注意事项 ── 读出时 Kind 会消失

从不保留基准信息的类型(datetime2、SQLite 的 TEXT 等)读出 DateTime,理所当然 Kind 会变成 Unspecified。「存储时明明统一用 UTC,但读出来的值某处又调用了 ToUniversalTime(),结果变成重复转换」这种事故,就是这样产生的。对策是在边界还原 Kind。用 EF Core 的话,可以通过值转换器一次性声明。

// EF Core:在模型端一次性声明「这一列是 UTC」
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // 写入:把非 UTC 的值(例如混入的 DateTime.Now)也在边界规范化为 UTC。
        // 要注意 Unspecified 会被当作本地时间转换
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // 读出:还原 Kind
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

写入端的规范化终究只是最后一道保险。因为 Unspecified 的转换取决于运行机器的时区设置,把 DateTime.Now 或 Unspecified 的值送进存储路径的代码本身,一旦发现就该直接修正。请理解成保险与约定的双重防护。如果是 Dapper 或原始的 ADO.NET,可以在映射完成后统一加一层转换,集中调用 DateTime.SpecifyKind。同样有效的是命名约定,在字段名、属性名里直接标明基准CreatedAtUtcupdated_at_utc),光是这样,评审时「对这个值调用 ToUniversalTime 很奇怪」就更容易被察觉,因为名字比文档更常被人看到。

7. 时间同步与测试 ── w32time 与 TimeProvider

7.1 怀疑「机器的时钟是准的」这个前提

到目前为止的讨论,都建立在「机器本身的时钟是准确的」这个前提上,而负责校准这个时钟的,是 Windows Time 服务(w32time)。w32time 会通过 NTP 与网络上的时间源同步,在 Active Directory 环境下,则会沿着域层级同步。这是 Kerberos 认证等对时间偏差敏感机制的基础。12

这对应用设计有两点含义。第一,不要把客户端 PC 的时钟当作业务逻辑的依据。同步中断的设备,时间轻易就会差个几分钟,所以事件顺序或结算时间的判断,应该用服务器端的时间进行,客户端时间只当参考信息。第二,遇到「时间好像不对」的问题反馈时,先用 w32tm /query /status 确认设备的同步状态,比开始排查应用的 bug 更快,1 分钟就能先做初步的排查。

7.2 停止在代码里直接写 DateTime.Now ── TimeProvider

阻碍日期时间处理测试的最大原因,是散落在代码各处、直接写死的 DateTime.Now。就算想测试「月底结算的判断」「跨年连号重置」「夏令时切换日的排程」,只要当前时间无法固定,就得等到那一天才能验证。

.NET 8 开始内置了标准的时间抽象概念 TimeProvider。从 GetUtcNow() / GetLocalNow() / LocalTimeZone 到计时器的生成,都能用单一抽象概念替换。就算是 .NET Framework 4.6.2 以后与 .NET Standard 2.0,也能通过 NuGet 的 Microsoft.Bcl.TimeProvider 使用同一个类型,所以就算是旧资产也能引入。测试用的实现 FakeTimeProvider,则由 Microsoft.Extensions.TimeProvider.Testing 包提供。7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // 业务日期是明确以「用分支机构的时区切日期」实现的(第 3.2 节)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

生产环境传入 TimeProvider.System,测试则用 FakeTimeProvider 自由固定或推进时间。

[Fact]
public void 即使跨年业务日期也能正确切换()
{
    // 固定在 JST 的除夕 23:30 开始
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // 瞬间重现跨年
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

FakeTimeProvider 除了能固定或手动推进时间,还能替换本地时区(SetLocalTimeZone),所以「德国设置的设备上日期变成前一天」这类 bug,也能在 CI 上的日本机器重现。如果是连 NuGet 都很难添加的 .NET Framework 旧项目,就算只用一个只有 DateTimeOffset UtcNow { get; } 的自定义 IClock 接口,效果也是一样的。重要的不是抽象概念有多华丽,而是把「当前时间」当成可以注入的依赖来处理

依经验,测试用例中至少该放进去的时间点有这 5 个:年末年初(跨年)、月底(31 日・30 日・2 月)、闰年的 2 月 29 日、目标时区的夏令时切换日(春・秋)、深夜 0 点前后(业务日期的边界)。这些都是「只在那一天」发作的 bug 经典藏身处,只要有 FakeTimeProvider,全部都能在几毫秒内测完。

8. 检查清单与判断表

日期时间处理,就跟 UUID 一样,是很容易因为「反正它一直都运行得好好的」而被放着不管的基础组件(跟「UUID 不会碰撞吗」里写的结构非常相似)。把新设计与评审时要看的项目固定下来,就能减少因人而异的差异。

新设计时的检查清单:

  • 发生时间的获取,是否统一用 DateTimeOffset.UtcNow / TimeProvider.GetUtcNow()
  • 存储・通信的基准(UTC 或带偏移量)与格式(”o” / ISO 8601),是否已明确写进规格书
  • 数据库的字段类型与基准是否已确定(SQL Server 的话是 datetime2(UTC) 或 datetimeoffset。字段名里直接标明 Utc
  • 是否区分时间戳与业务日期,并确定了切日期用的时区
  • 时区 ID 的管理体系(建议用 IANA ID)与非法 ID 时的错误处理是否已确定
  • 定期执行的 DST 方针(以 UTC 为基准排程 + 幂等化)是否已确定
  • TimeProvider / IClock 是否可注入,是否有覆盖时间边界的测试用例

评审时用 grep 找出来要怀疑的代码:

找到后要怀疑的代码 可能发生什么 修正方式
DateTime.Now 服务器迁移后会出现偏差。无法测试 UtcNow + 显示时才转换。注入 TimeProvider
ToLocalTime() / ToUniversalTime() Unspecified 的隐式解读(第 2 章) 在边界确定 Kind,只在显示前才转换
DateTime.Parse(s)(未指定 styles) 依运行环境的区域性・时区而不同 ParseExact + InvariantCulture + RoundtripKind
ToString("yyyy/MM/dd HH:mm") 存储・通信 基准信息消失 “o” 格式 + InvariantCulture
直接把 new DateTime(...) 用于比较・存储 混入 Unspecified SpecifyKind 或改用 DateTimeOffset
SQL Server 新增 datetime 字段 精度・范围・未来扩展性的问题 datetime2 / datetimeoffset10

这份检查清单里的大部分项目,如果是新开发项目,第一天就能确定好。反过来说,上线后才修正,就要变成「推算已存储数据的基准再进行迁移」这种考古工作(哪个时间段的数据是按哪种基准写进去的),成本会差一个量级。

9. 结语

日期时间与时区的事故,症状看起来五花八门——「差 9 小时」「日期变成前一天」「批处理运行了两次」——但原因始终一致:没有基准信息的值跨越了边界。对策也始终一致。获取用 UTC,存储与通信用 UTC 或带偏移量 + ISO 8601(”o”),只有显示才转本地时间。在边界上,把格式与基准明确写进规格,并在数据库的字段名里直接标明基准。时区用 TimeZoneInfo 与 IANA ID 处理,DST 的不存在时间・模糊时间,则靠输入验证与幂等的定期执行来接住。最后注入 TimeProvider,让跨年与夏令时切换日都能在测试中重现——做到这里,就能从「环境一变就被叫去救火的人」,变成「环境变化之前就先指出问题的人」。

本公司承接服务器迁移、上云所导致的时间偏差原因调查、日期时间处理相关的设计评审,以及海外分支机构对应(时区・DST 对应)的改造支持。就算是已经存在存储数据基准混杂的情况,也能协助盘点与规划迁移方案,如果不确定该怎么判断,欢迎与我们联系。

相关文章

相关咨询领域

合同会社小村软件承接业务应用的日期时间・时区处理设计评审、服务器迁移或上云所导致的时间偏差・日期偏差原因调查,以及海外分支机构对应与旧资产的日期时间处理改造。

参考链接

  1. Microsoft Learn, DateTime.Kind Property。说明 Kind 的默认值为 Unspecified,以及 Kind 的值对 ToLocalTime / ToUniversalTime 转换结果的影响(Unspecified 在 ToLocalTime 中被当作 UTC,在 ToUniversalTime 中被当作本地时间)。  2 3

  2. Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo。内容涵盖应考虑把 DateTimeOffset 当作应用程序开发的默认日期时间类型、DateTimeOffset 只拥有偏移量而不绑定时区、DateOnly / TimeOnly 在 .NET Framework 上无法使用等说明。  2 3 4 5

  3. Microsoft Learn, Standard date and time format strings。说明往返格式 “o” 符合 ISO 8601,会把 DateTime 的 Kind、DateTimeOffset 的偏移量保留在字符串里,并可用 DateTimeStyles.RoundtripKind 解析回原值。  2

  4. Microsoft Learn, What’s new in .NET 6。说明 .NET 6 中 TimeZoneInfo.FindSystemTimeZoneById 能接受 IANA/Windows 两种时区 ID 并自动转换,以及新增了 TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId。  2

  5. Microsoft Learn, .NET globalization and ICU。说明 Windows 上解析 IANA 时区 ID 与互相转换 API 依赖 ICU,NLS 模式・全球化不可变模式下无法使用,以及没有 ICU 的操作系统可通过应用本地 ICU 应对。  2

  6. Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method。说明夏令时切换时产生的「不存在的时间」的定义与判断方式,以及对应的 IsAmbiguousTime(模糊时间的判断)。  2

  7. Microsoft Learn, What is TimeProvider?。说明 TimeProvider 从 .NET 8 起标准内置,.NET Framework 4.6.2+/.NET Standard 2.0 可通过 Microsoft.Bcl.TimeProvider 包使用,涵盖 GetUtcNow/GetLocalNow/LocalTimeZone/计时器生成的抽象概念,以及测试用的 FakeTimeProvider 由 Microsoft.Extensions.TimeProvider.Testing 包提供。  2

  8. Microsoft Learn, TimeZoneInfo.ConvertTime Method。说明会要求 DateTime.Kind 与转换来源时区一致,不一致会得到 ArgumentException,模糊的时间会被解读为标准时,传入不存在的时间会得到 ArgumentException。  2

  9. Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019。说明 .NET 7 以后,在没有内置 ICU 的 Windows Server 2019 等环境上也会改用 ICU 库,在此之前则需要手动配置应用本地 ICU。 

  10. Microsoft Learn, datetime (Transact-SQL)。说明新工作应避免使用 datetime,改用 time/date/datetime2/datetimeoffset,以及 datetime2/datetimeoffset 精度较高、datetimeoffset 支持时区偏移量。  2 3 4 5

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite)。说明 SQLite 只有 4 种基本类型,Microsoft.Data.Sqlite 会把 DateTime/DateTimeOffset 以 TEXT 形式存储。 

  12. Microsoft Learn, Windows Time Service (W32Time)。说明 Windows Time 服务通过 NTP 同步网络上计算机的时间,Active Directory 域下的同步层级,以及 Kerberos 认证对时间同步的依赖。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

服务器迁移之后时间差了 9 个小时,原因是什么?
根本原因在于,「这个时间是以什么为基准」这项信息没有被保留下来的值,跨越了数据库、API、文件等边界。.NET 的 DateTime 默认 Kind 为 Unspecified,ToLocalTime() 会把它当作 UTC 处理并 +9 小时,ToUniversalTime() 则会把它当作本地时间并 -9 小时,而且是悄悄进行转换。这个行为又取决于运行机器的时区设置,所以在日本时间的开发机上看不出问题,一迁移到 UTC 设置的云端 VM 那天就会一次性爆发出来。
DateTime 和 DateTimeOffset 该用哪一个?
新代码的默认选择是 DateTimeOffset。因为它始终带着与 UTC 的偏移量,光看这个值本身就能唯一确定它是世界上的哪一个瞬间,结构上就不会因为 Kind 的隐式解读而出事。官方指南也明确建议把它当作应用程序开发的默认日期时间类型来考虑。不过偏移量本身并不是时区,如果需要夏令时的调整规则,还是要配合 TimeZoneInfo。如果现有的 DateTime 资产很多,一个实际可行的折中方案是:内部与存储统一用 Kind=Utc 的 DateTime,只在边界改用 DateTimeOffset。
只在日本国内使用的应用,也需要处理夏令时(DST)吗?
只要符合以下任何一项,就需要处理:程序会在海外分支机构或出差人员的设备上运行、会和海外的 SaaS 或 Web API 联动、批处理作业会在海外地区的服务器上运行。有夏令时的时区,在切换日会出现「不存在的时间」与「模糊的时间」,TimeZoneInfo 的转换 API 一旦传入不存在的时间就会抛出 ArgumentException。在当地时间 02:30 运行的定期作业,可能在夏令时开始日被跳过、在结束日运行两次,因此以 UTC 为基准排程并设计成幂等处理,才是对策。
日期时间处理的测试该怎么写?
不要在代码里直接写 DateTime.Now,改成注入 .NET 8 标准的 TimeProvider,让当前时间可以被替换。就算是 .NET Framework 4.6.2 以后的版本,也能通过 Microsoft.Bcl.TimeProvider 包使用同一个类型。测试中可以用 FakeTimeProvider 固定或推进时间,也能替换本地时区,因此跨年或夏令时切换日的 bug 都能在 CI 上重现。至少该测试的时间点有 5 个:年末年初、月底、闰年的 2 月 29 日、夏令时切换日、深夜 0 点前后。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表