时间处理是后端里最容易“本地没问题,线上出事故”的部分。尤其是预约、提醒、账单、报表这类功能,用户看到的是本地时间,系统存储和计算却最好使用统一时间。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/Shanghai、America/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 格式
数据库字段通常建议存储 timestamp 或 datetime,但要明确驱动如何解释时区。很多线上问题不是 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 里没有隐含的本地时区。以后要接入移动端、开放平台或异地团队时,大家拿到的都是同一个时刻。
测试多个时区
时间逻辑最好写单元测试,而且不要只测北京时区。可以选几个有代表性的地点:UTC、Asia/Shanghai、America/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 包就很好用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。