跳至内容
时间处理不只是格式化:时区、单调时间、精度与业务日期

时间处理不只是格式化:时区、单调时间、精度与业务日期

2026年6月15日·
yanlong

“2026-11-01 01:30”究竟是哪一刻?在实行夏令时的地区,它可能出现两次。时间问题的根源通常不是格式化,而是把时间点、当地时间、时间段和业务日期混成了同一种值。

先分清四种概念

    flowchart TD
    T["业务中的时间"] --> I["Instant:时间线上的唯一时刻"]
    T --> L["Local DateTime:某地墙上显示的日期时间"]
    T --> D["Duration:两个时刻之间的经过时间"]
    T --> B["Business Date:账期 / 营业日"]
    L --> Z["必须结合 IANA 时区才能映射到 Instant"]
  
  • 事件发生时间、创建时间:存 UTC 时间点,同时保留必要的来源时区信息。
  • “上海每天 09:00 执行”:这是当地时间规则,不能先固定成 UTC 小时。
  • “30 分钟后超时”:使用 time.Duration 和截止时间。
  • “2026-07-05 这个账期”:本质是业务日期,不应随意塞进午夜 time.Time

解析布局不是格式占位符

Go 用参考时间 01/02 03:04:05PM '06 -0700 描述布局:

t, err := time.Parse(time.RFC3339Nano, "2026-07-05T09:30:00.123456+08:00")

loc, err := time.LoadLocation("Asia/Shanghai")
local, err := time.ParseInLocation("2006-01-02 15:04:05", input, loc)

不要用 time.Local 代表用户时区:它取决于进程环境。使用 IANA 名称(如 Asia/Shanghai)并确保部署镜像包含时区数据库;需要自包含时可导入 time/tzdata,但二进制会增大。

时区不仅是固定偏移。America/New_York 的规则随历史和夏令时变化,-05:00 无法表达未来的切换。对预约类业务,应保存用户输入的当地时间、IANA 时区和生成规则,以便解释和重算。

time.Now 可能携带单调时钟

墙上时间会因 NTP、人工调整而跳变。Go 返回的 time.Time 可以同时携带单调时钟读数,SubBefore 等在双方都含该读数时会用它计算经过时间。

    flowchart LR
    N["time.Now()"] --> W["墙上时间:可序列化"]
    N --> M["单调读数:只在进程内"]
    M --> S["Sub / Before:测量耗时更稳"]
    W --> J["JSON / 数据库"]
    J --> X["单调读数丢失"]
  
start := time.Now()
doWork()
elapsed := time.Since(start) // 适合测量进程内耗时

序列化、ParseUnix 等构造出的值不携带单调读数,这是正确行为:单调时钟只在当前进程有意义。不要把 time.Now().UnixNano() 当耗时计时器。

比较、精度与数据库

优先用 t1.Equal(t2) 判断是否代表同一时刻。结构体 == 还会比较 Location 指针和单调读数,因此两个“同一时刻”的值可能不相等。

数据库列、驱动和外部 API 的精度可能只有微秒或毫秒。写入后再读出,不一定与原始纳秒值 ==。在边界主动统一精度:

stored := t.UTC().Truncate(time.Microsecond)

这里的单位必须匹配真实存储层。测试中也不要用毫无依据的 WithinDuration(time.Second) 掩盖错误。

time.Duration 是纳秒计数的 int64,上限约 290 年。把不可信数字直接乘 time.Second 可能溢出;先检查范围,再转换。

“一天”不是永远 24 小时

tomorrowSameLocalTime := t.AddDate(0, 0, 1)
after24Hours := t.Add(24 * time.Hour)

在夏令时切换日,这两者可能不同。营业日、按月续费等日历运算用 AddDate 并定义月底规则;缓存 TTL、超时和锁租约使用 Duration。

业务日期可以单独建模:

type Date struct { Year int; Month time.Month; Day int }

func (d Date) In(loc *time.Location) time.Time {
	return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc)
}

午夜只是把日期映射为时间点的一种选择,不应反过来成为日期的身份。跨时区报表尤其要先定义“按哪个业务时区切日”。

工程检查

  • API 时间点使用带偏移的 RFC 3339;数据库统一存 UTC。
  • 调度规则和用户预约保留 IANA 时区,不只存偏移。
  • 进程内耗时用 time.Since;跨进程时间差接受时钟偏差现实。
  • 比较时间点用 Equal,持久化前明确精度。
  • 测试覆盖夏令时跳过/重复、本月月底、闰年和时区数据库变化。

进一步阅读:time 包文档