Go 的 time 包 API 不多,但几乎每个函数都有一个「看起来对、跑起来错」的用法。这篇把九个坑放在一起,每个都附了能直接 go run 的代码和我本机的输出。
环境:Go 1.25.0,darwin/arm64,本机时区 Asia/Shanghai。
坑一:用 == 比较时间
go
t1 := time.Now()
t2 := t1.Round(0) // 去掉单调时钟读数,墙上时间不变
fmt.Println(t1 == t2, t1.Equal(t2))
sh, _ := time.LoadLocation("Asia/Shanghai")
t3 := t2.In(sh)
fmt.Println(t2 == t3, t2.Equal(t3))
输出:
text
false true
false true
time.Time 是个结构体,里面除了墙上时间,还有单调时钟读数和一个 *Location 指针。== 比较的是整个结构体,所以同一个时刻只要单调时钟读数不同、或者时区指针不同,结果就是 false。
更隐蔽的是拿它当 map 的 key:
go
m := map[time.Time]int{t1: 1}
_, ok := m[t2]
fmt.Println(ok) // false
同一个时刻,查不到。要当 key,先统一成 t.UTC().Round(0) 或者干脆用 t.UnixNano()。比较一律用 Equal、Before、After。
坑二:time.Parse 默认是 UTC,差了 8 小时
go
p1, _ := time.Parse("2006-01-02 15:04:05", "2026-10-05 10:00:00")
p2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2026-10-05 10:00:00", sh)
fmt.Println(p1, "|", p2, "|", p1.Sub(p2))
text
2026-10-05 10:00:00 +0000 UTC | 2026-10-05 10:00:00 +0800 CST | 8h0m0s
字符串里没有时区信息时,Parse 按 UTC 解释。前端传上来的「2026-10-05 10:00:00」通常是北京时间,用 Parse 解析完存库,再按本地时间显示,就凭空多了 8 小时。
没有时区的字符串,一律用 ParseInLocation 并明确传时区。
坑三:AddDate 加一个月,1 月 31 日变成 3 月 3 日
go
jan31 := time.Date(2026, 1, 31, 0, 0, 0, 0, sh)
fmt.Println(jan31.AddDate(0, 1, 0).Format("2006-01-02"))
aug31 := time.Date(2026, 8, 31, 0, 0, 0, 0, sh)
fmt.Println(aug31.AddDate(0, 1, 0).Format("2006-01-02"))
text
2026-03-03
2026-10-01
AddDate 的规则是先把月份加上去,得到「2 月 31 日」,再把溢出的天数顺延。2026 年 2 月只有 28 天,31 - 28 = 3,于是变成 3 月 3 日。
做「下个月同一天扣费」「会员续一个月」时,这个行为基本不是你想要的。业务上一般要「下个月的最后一天封顶」,得自己写:
go
func addMonthsClamp(t time.Time, n int) time.Time {
y, m, d := t.Date()
first := time.Date(y, m+time.Month(n), 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
lastDay := first.AddDate(0, 1, -1).Day()
if d > lastDay {
d = lastDay
}
return time.Date(first.Year(), first.Month(), d, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
}
// addMonthsClamp(jan31, 1) => 2026-02-28
time.Date 本身允许月份越界(比如 13 月会进到下一年),所以先算出目标月的 1 号,再取那个月的最后一天比较,最稳。
坑四:格式化模板写错了不会报错
go
now := time.Date(2026, 10, 5, 14, 7, 9, 0, sh)
fmt.Println(now.Format("YYYY-MM-DD"))
fmt.Println(now.Format("2006-01-02 03:04"))
fmt.Println(now.Format("2006-01-01"))
text
YYYY-MM-DD
2026-10-05 02:07
2026-10-10
三个都没报错:
YYYY-MM-DD不是 Go 的模板,原样输出;03是 12 小时制,下午两点变成了02,24 小时制要写15;2006-01-01里第二个01也是月份,日期位置输出了「10」。
Go 用固定的参考时间 2006-01-02 15:04:05 当模板,写错一个数字就是另一个含义。项目里最好统一用常量,time.DateTime(Go 1.20 起)就是 "2006-01-02 15:04:05",time.DateOnly 是 "2006-01-02"。
坑五:JSON 序列化只认 RFC 3339
go
b, _ := json.Marshal(struct{ T time.Time }{now})
fmt.Println(string(b))
var bad struct{ T time.Time }
err := json.Unmarshal([]byte(`{"T":"2026-10-05 14:07:09"}`), &bad)
fmt.Println(err)
text
{"T":"2026-10-05T14:07:09+08:00"}
parsing time "2026-10-05 14:07:09" as "2006-01-02T15:04:05Z07:00": cannot parse " 14:07:09" as "T"
前端传 2026-10-05 14:07:09 这种格式,直接反序列化到 time.Time 会失败。要么前端改成 ISO 格式,要么自定义类型实现 UnmarshalJSON。
还有一个小细节:序列化再反序列化回来,back.T == now 是 false,Equal 是 true。原来的时区是 Asia/Shanghai,回来的是 Local,又绕回了坑一。
坑六:Truncate(24h) 拿不到「今天零点」
go
tt := time.Date(2026, 10, 5, 3, 0, 0, 0, sh)
fmt.Println(tt.Truncate(24 * time.Hour))
text
2026-10-04 08:00:00 +0800 CST
想要 10 月 5 日零点,拿到的是 10 月 4 日早上 8 点。Truncate 是按「从零时刻起的绝对时间」取整的,跟时区无关,算出来的是 UTC 的零点,换成北京时间就是早上 8 点。
本地日期的零点要这样写:
go
y, m, d := tt.Date()
midnight := time.Date(y, m, d, 0, 0, 0, 0, tt.Location())
坑七:零值不是 1970 年
go
var z time.Time
fmt.Println(z.Unix(), z.IsZero(), time.Unix(0, 0).IsZero())
text
-62135596800 true false
time.Time 的零值是公元 1 年 1 月 1 日,Unix() 是一个很大的负数。反过来,time.Unix(0, 0)(1970 年)不是零值。
数据库里存 0 表示「未设置」,读出来转成 time.Unix(0, 0),再用 IsZero() 判断就判断不出来。两种「空」要统一成一种。
坑八:「加一天」和「加 24 小时」不一样
go
ny, _ := time.LoadLocation("America/New_York")
a := time.Date(2026, 11, 1, 0, 0, 0, 0, ny)
fmt.Println(a.AddDate(0, 0, 1).Sub(a))
fmt.Println(a.Add(24 * time.Hour))
text
25h0m0s
2026-11-01 23:00:00 -0500 EST
纽约 2026 年 11 月 1 日结束夏令时,这一天有 25 个小时。AddDate(0, 0, 1) 是日历上的下一天零点,实际过了 25 小时;Add(24h) 是物理上的 24 小时,落在当天晚上 11 点。
国内服务器不涉及夏令时,但业务有海外用户时,「每天」到底是日历日还是 24 小时,要想清楚。
坑九:Duration 的除法和类型转换
go
d := 90 * time.Minute
fmt.Println(d.Hours(), int(d.Hours()), d/time.Hour)
n := 5
fmt.Println(time.Duration(n)*time.Second, time.Duration(n))
text
1.5 1 1ns
5s 5ns
d / time.Hour 的结果类型还是 Duration,值是 1,打印出来就成了 1ns,日志里看着像 bug。要小时数用 d.Hours(),要整数用 int(d / time.Hour)。
time.Duration(n) 是 n 纳秒,配置里读到一个 5,直接转成 Duration 当超时,就是 5 纳秒,请求立刻超时。一定要乘单位。
小结
| 坑 | 一句话 |
|---|---|
== |
比较时间用 Equal |
Parse |
没有时区就用 ParseInLocation |
AddDate 月份 |
月末会溢出到下个月 |
| 格式模板 | 写错不报错,用 time.DateTime 常量 |
| JSON | 只认 RFC 3339 |
Truncate |
按 UTC 取整,不是本地零点 |
| 零值 | 是公元 1 年,不是 1970 |
| 夏令时 | 加一天不等于加 24 小时 |
Duration |
除完还是 Duration,转换要乘单位 |
局限
- 输出都是在我本机 Go 1.25.0 上跑的。
Round(0)去掉单调时钟这个行为从 Go 1.9 就有,但更老的版本没有单调时钟,坑一的第一个例子会是true。 - 夏令时那条用的是纽约时区,依赖系统的时区数据库,容器镜像里没装 tzdata 的话
LoadLocation会直接报错,需要import _ "time/tzdata"。 - 坑三那个
addMonthsClamp只是示意,我只测了加 1 个月和 13 个月(含闰年 2028-01-31 → 2028-02-29),往前减月份的情况没测。
这篇是我在 forxi.cn 写在线小工具时顺手整理的笔记,后端用的是 Go,日期计算这块几乎每个工具都会碰到。