Go 时区处理入门:time.Location、UTC 和用户本地时间

用预约提醒场景讲 Go 中 UTC 存储、本地时区展示、time.LoadLocation、ParseInLocation 和跨时区测试。

时间处理是后端里最容易“本地没问题,线上出事故”的部分。尤其是预约、提醒、账单、报表这类功能,用户看到的是本地时间,系统存储和计算却最好使用统一时间。Go 的 time 包很强,但初学者如果只会 time.Now()Format,很容易在时区上踩坑。

本文用“用户预约提醒”做例子,讲几个基本原则:数据库里存 UTC,展示时转用户时区,解析用户输入时使用明确的 time.Location,测试时不要依赖机器默认时区。

存储用 UTC

服务端内部最稳的做法是统一用 UTC:

type Reminder struct {
	ID        int64
	UserID    int64
	FireAtUTC time.Time
}

func NewReminder(userID int64, fireAt time.Time) Reminder {
	return Reminder{
		UserID:    userID,
		FireAtUTC: fireAt.UTC(),
	}
}

数据库字段可以命名为 fire_at,但团队里要明确它存的是 UTC。很多问题来自“字段里到底是什么时区”没人知道。代码里把变量命名为 FireAtUTC 虽然啰嗦,但对入门项目很有帮助。

展示时转用户时区

用户在上海,就希望看到北京时间;用户在纽约,就希望看到纽约时间:

func FormatForUser(t time.Time, tz string) (string, error) {
	loc, err := time.LoadLocation(tz)
	if err != nil {
		return "", err
	}
	return t.In(loc).Format("2006-01-02 15:04"), nil
}

调用:

text, err := FormatForUser(reminder.FireAtUTC, "Asia/Shanghai")

time.LoadLocation 使用 IANA 时区名,如 Asia/ShanghaiAmerica/New_York。不要只存 +08:00 这种偏移。偏移不能表达夏令时规则,也不能表达某个地区未来规则变化。

解析用户输入

用户提交:

{"fire_at":"2025-03-10 09:30","timezone":"Asia/Shanghai"}

解析:

func ParseUserTime(value string, tz string) (time.Time, error) {
	loc, err := time.LoadLocation(tz)
	if err != nil {
		return time.Time{}, fmt.Errorf("load timezone: %w", err)
	}
	t, err := time.ParseInLocation("2006-01-02 15:04", value, loc)
	if err != nil {
		return time.Time{}, fmt.Errorf("parse time: %w", err)
	}
	return t.UTC(), nil
}

time.Parse 默认按 UTC 解析没有时区的字符串;ParseInLocation 才会把这个本地时间放到指定时区里解释。预约功能通常需要后者。

不要依赖服务器本地时区

time.Now() 返回带本地时区信息的时间,但线上服务器可能是 UTC,本地开发机可能是 Asia/Shanghai。不要写依赖机器默认时区的业务逻辑:

today := time.Now().Format("2006-01-02")

如果“今天”是用户所在时区的今天,应该:

func TodayIn(t time.Time, tz string) (string, error) {
	loc, err := time.LoadLocation(tz)
	if err != nil {
		return "", err
	}
	return t.In(loc).Format("2006-01-02"), nil
}

这样语义清楚:今天不是服务器的今天,而是用户时区的今天。

测试时固定时间

不要在测试里直接用当前时间判断复杂逻辑。可以传入 now:

func ShouldFire(now time.Time, reminder Reminder) bool {
	return !now.Before(reminder.FireAtUTC)
}

测试:

func TestShouldFire(t *testing.T) {
	fireAt := time.Date(2025, 3, 10, 1, 30, 0, 0, time.UTC)
	reminder := Reminder{FireAtUTC: fireAt}

	now := time.Date(2025, 3, 10, 1, 31, 0, 0, time.UTC)
	if !ShouldFire(now, reminder) {
		t.Fatal("expected reminder to fire")
	}
}

把时间作为参数传入,测试会稳定很多。业务代码里可以由调用方传 time.Now().UTC()

夏令时边界

有些地区有夏令时,某些本地时间可能不存在,某些时间可能出现两次。比如凌晨跳时。入门阶段不必记住每个规则,但要知道“时区不是固定偏移”。这也是为什么建议存 IANA 时区名。

对于非常敏感的预约系统,可以在用户选择时间后,把最终 UTC 时间回显给用户确认,或者在 UI 上显示时区。比如“2025-03-10 09:30 Asia/Shanghai”。产品文案清楚,技术风险会小很多。

API 字段怎么设计

接口字段最好直接表达语义。比如订单接口里常见两类时间:

{
  "created_at": "2025-01-09T03:08:00Z",
  "display_time": "2025-01-09 11:08",
  "timezone": "Asia/Shanghai"
}

created_at 给程序使用,保留精确时刻;display_time 给页面快速展示;timezone 说明展示依据。不要只返回 "2025-01-09 11:08",调用方不知道它是上海时间、东京时间还是服务器本地时间。

如果团队内部接口只给前端使用,也可以只返回 RFC3339 字符串,让前端根据用户设置格式化。但后端仍然要在文档里写清楚:字段是 UTC 时刻,不是业务日期。这个约定能减少很多跨端争论。

数据库和 JSON 格式

数据库字段通常建议存储 timestampdatetime,但要明确驱动如何解释时区。很多线上问题不是 Go 写错,而是数据库连接串、本地时区和字段类型混在一起。一个稳妥做法是:程序写入前统一转 UTC,读取后也按 UTC 处理,只有展示时才加载用户时区。

type EventDTO struct {
	ID        int64  `json:"id"`
	StartsAt string `json:"starts_at"`
}

func NewEventDTO(id int64, startsAt time.Time) EventDTO {
	return EventDTO{
		ID:        id,
		StartsAt: startsAt.UTC().Format(time.RFC3339),
	}
}

这样 DTO 里没有隐含的本地时区。以后要接入移动端、开放平台或异地团队时,大家拿到的都是同一个时刻。

测试多个时区

时间逻辑最好写单元测试,而且不要只测北京时区。可以选几个有代表性的地点:UTCAsia/ShanghaiAmerica/New_York。纽约会遇到夏令时,虽然国内业务很少主动处理夏令时,但跨境产品、日志平台和第三方日历经常会碰到。

func TestFormatForLocations(t *testing.T) {
	base := time.Date(2025, 1, 9, 3, 8, 0, 0, time.UTC)
	cases := []string{"UTC", "Asia/Shanghai", "America/New_York"}

	for _, name := range cases {
		t.Run(name, func(t *testing.T) {
			loc, err := time.LoadLocation(name)
			if err != nil {
				t.Fatal(err)
			}
			got := base.In(loc).Format("2006-01-02 15:04")
			if got == "" {
				t.Fatal("empty formatted time")
			}
		})
	}
}

这种测试看起来简单,却能逼着你把“存储时刻”和“展示格式”分开。只要函数签名里需要传 *time.Location,代码就不容易偷偷依赖服务器本地环境。

小结

Go 处理时区的核心原则是:内部和数据库尽量使用 UTC,用户输入解析时使用 time.ParseInLocation 和明确的 time.Location,展示时用 t.In(loc) 转到用户时区。不要依赖服务器默认时区,也不要只存偏移量代替地区时区。

时间问题最怕含糊。变量名、数据库约定、API 字段和测试都要说清楚“这是 UTC 还是用户本地时间”。一旦边界明确,Go 的 time 包就很好用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页