1. “时间”这个词的两种含义:时刻和时长
当程序员说“时间”时,往往同时指两个不同的含义。第一个是具体的时刻: “现在”、 “2026-01-16 10:30”。第二个是时长: “等 2 秒”、 “操作花了 150 毫秒”。
如果把这两个含义混到一个类型里,错误就会变得非常……有创造性。Go 预先解决了这个问题:时刻存放在 time.Time 中,而时长存放在 time.Duration 中。
你可以把 time.Time 想象成尺子上的一个点,而把 time.Duration 想象成一段长度。点可以沿着长度移动,得到一个新的点。但“把两个点相加”是没有意义的,所以编译器不会让你做这种蠢事(或者至少会让你费一番功夫)。
这里有个小表格,用来固定一下含义:
| 想表达什么 | Go 中的类型 | 现实中的例子 | 代码中的例子 |
|---|---|---|---|
| 时刻(时间上的一点) | |
“现在”、 “12:00” | |
| 时长(区间) | |
“2 秒”、 “15 分钟” | |
2. time.Time:时刻
如果你之前把日期当作字符串来处理(“2026-01-16”),那你已经知道这有多方便……直到你需要比较日期、加一天,或者处理时区。time.Time 类型是对某个时刻的正常、结构化表示。
它可以比较、格式化、与另一个时刻相减并得到时长,而且完全不需要永无止境地手工解析字符串。字符串适合输出或交换,但不适合作为程序内部的“事实来源”。
获取 time.Time 最常见的方式是调用 time.Now()。这就是系统时钟上的“当前时刻”。它非常适合用来测量时长、记录“何时开始”、保存“何时创建”等等。
小例子:打印 time.Time 并查看类型
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
fmt.Printf("now=%v\n", now)
fmt.Printf("type=%T\n", now) // type=time.Time
}
注意:fmt 能把 time.Time 打印得“像人话”,但这还不是“可控格式”。现在更重要的是记住:now 不是字符串,而是一个独立的类型。
小例子:手动创建一个时刻
有时需要创建一个固定时刻。例如在测试或演示中。这时可以用 time.Date。为了不深入讨论时区和本地化,我们使用 time.UTC。
package main
import (
"fmt"
"time"
)
func main() {
t := time.Date(2026, 1, 16, 10, 30, 0, 0, time.UTC)
fmt.Println(t) // 2026-01-16 10:30:00 +0000 UTC
}
time.Time 的零值:Go 如何表达“时间未设置”
Go 中很多类型都有有意义的默认值。对于 int,默认值是 0;对于 string,默认值是 "";对于 bool,默认值是 false。time.Time 也有零值——这是“零时刻”。
在真实场景里,它几乎从不表示“真实日期”,而是表示“值未设置”。要检查“时间是否已设置”,通常使用 t.IsZero()。
package main
import (
"fmt"
"time"
)
func main() {
var t time.Time
fmt.Println(t.IsZero()) // true
fmt.Println(t) // 0001-01-01 00:00:00 +0000 UTC
}
重要的是: “0001 年”看起来像“某个日期”,但在大多数业务场景里,它其实就是“空”的标记。这对数据模型来说非常方便。
示例:带有 DoneAt 并通过 IsZero() 检查的任务
让我们开始谨慎地构建一个假想的任务跟踪应用(先不涉及文件、HTTP 和数据库——只处理数据)。逻辑很简单:如果 DoneAt 是零值,那么任务尚未完成。
package main
import (
"fmt"
"time"
)
type Task struct {
Title string
DoneAt time.Time
}
func main() {
t := Task{Title: "read about time.Time"}
fmt.Println(t.DoneAt.IsZero()) // true
}
3. time.Duration:时长、单位与新手陷阱
time.Duration 表示时长:10 毫秒、2 秒、3 分钟、1 小时。它内部是纳秒数,但你几乎永远不应该用“纳秒”去思考。
相反,Go 提供了单位常量:time.Millisecond、time.Second、time.Minute、time.Hour。正确的写法几乎总是“数字 * 单位”。
小例子:正确的时长(以及打印)
package main
import (
"fmt"
"time"
)
func main() {
d := 150 * time.Millisecond
fmt.Println(d) // 150ms
fmt.Printf("%T\n", d) // time.Duration
}
新手最经典的 bug:想把“5 秒”写成 time.Duration(5)。这不是 5 秒,而是 5 纳秒。也就是说,大概就是“眨个眼就过去了”。
小例子:time.Duration(5) 的陷阱
package main
import (
"fmt"
"time"
)
func main() {
a := time.Duration(5)
b := 5 * time.Second
fmt.Println(a) // 5ns
fmt.Println(b) // 5s
}
这个陷阱最好记成“注意力测试”:如果你看到 time.Duration(数字),那几乎肯定是错的(或者是很少见的情况,作者确实想要纳秒,并且愿意为此负责)。
4. time.Time 和 time.Duration 如何组成“正确的时间数学”
我们需要固定住基本的语义数学:时刻可以加上时长进行偏移,而两个时刻之间的差就是时长。这是之后一切逻辑的基础。
对时刻做偏移用 Add。两个时刻的差用 Sub。这里要注意:Sub 返回的是 time.Duration,不是一个普通数字。也就是说,Go 会强迫你不要忘记单位。
小例子:Add 和 Sub
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
end := start.Add(2 * time.Second)
elapsed := end.Sub(start)
fmt.Println(elapsed) // 2s
}
如果你试图把两个 time.Time 相加(比如 start + end),编译器会拦住你。这是一种很有用的“防止你在数学上发挥创造力”的保护。
简单示意
time.Time — point: ●
time.Duration — segment: ─────
t.Add(d) — shift: ● + ───── = ●
t2.Sub(t1) — difference: ● - ● = ─────
5. 单调时间:为什么在测量时长时它很重要
看起来测量时长很简单:先取 start := time.Now(),再取一次 time.Now(),相减就知道“过去了多久”。但现实中系统时钟可能会变化:NTP 同步、管理员手动调整时间、闰秒、虚拟机迁移,或者操作系统自己觉得“我更懂现在是什么时间”。
对日历来说这没问题,但对时长测量来说就很糟糕。
因此,Go 有“单调时间”这个概念:它是一个内部计数器,不依赖“真实时钟”显示什么。从 Go 1.9 开始,time 包会在 Time 值内部透明地跟踪单调时间,因此两个 Time 之间的时长计算能够抵抗系统时钟调整。
尤其是经典写法 start := time.Now(); ...; elapsed := time.Since(start),即使在这些变化下也能正常工作。
需要理解边界:单调部分是用来测量时长的,而“墙上时钟时间”(wall clock)是用来打印日期、保存“创建于 10:30”,以及展示给用户看的。
小例子:通过 time.Since 测量时长
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
time.Sleep(20 * time.Millisecond)
fmt.Println(time.Since(start)) // ~20ms (plus or minus)
}
为什么我们更喜欢 time.Since(start),而不是 time.Now().Sub(start)?因为它读起来像一句话(“自 start 以来过去了多久”),而且是更地道的测量方式。
还有一个有用的直觉:单调部分不是为了“精确日期”,而是为了“过去了多久”。因此,当你把 time.Time 格式化成字符串或保存成文本时,单调部分不会被保存下来。这不是 bug,而是设计:字符串 “2026-01-16 10:30” 不应该包含来自处理器的隐藏计数器。
应用小片段:CreatedAt 和任务“年龄”
在真实应用中,经常需要保存“对象是什么时候创建的”。对任务来说这很自然:CreatedAt time.Time。这样我们就可以向用户展示“任务是 3 分钟前创建的”或者“这个任务已经挂了 2 天”。
让我们给 Task 模型做一个小扩展:添加 CreatedAt,并计算“自创建以来经过了多久”。注意,我们返回并打印的正是 time.Duration,而不是 int,这样就不会丢失单位含义。
package main
import (
"fmt"
"time"
)
type Task struct {
Title string
CreatedAt time.Time
}
func main() {
t := Task{Title: "deal with time", CreatedAt: time.Now()}
time.Sleep(10 * time.Millisecond)
fmt.Println(time.Since(t.CreatedAt)) // ~10ms
}
现在再加上 DoneAt 和一个清晰的“任务是否完成”检查,用 IsZero() 来判断。这即使在还没有数据库和 REST API 的情况下,也会显得相当“像样”。
package main
import "time"
type Task struct {
Title string
CreatedAt time.Time
DoneAt time.Time
}
func (t Task) IsDone() bool {
return !t.DoneAt.IsZero()
}
这里要注意,IsZero() 是你在模型里的最佳朋友。它比直接比较 t.DoneAt == time.Time{} 更容易理解(当然也能这么写,但读起来更累,尤其对新手来说)。
6. 使用 time.Time 和 time.Duration 时的典型错误
错误 1:写 time.Duration(5) 却指望它表示“5 秒”。
这是最常见的问题之一,因为代码看起来很合理。实际上它是 5 纳秒。正确形式通常是:5 * time.Second、250 * time.Millisecond、3 * time.Minute。如果你看到 time.Duration(数字),请重新确认作者是不是确实想要纳秒。
错误 2:把时刻存成字符串,觉得“这样更简单”。
当你只是打印日期时,确实看起来更简单。但只要你需要比较、加区间、计算差值或检查“值是否已设置”,字符串就会变成痛点。请把时刻存成 time.Time,字符串只用于输出或交换,而且要有明确格式。
错误 3:试图“把两个时刻相加”或者“用不合适的类型减去时长”。
如果你发现自己想做类似 t1 + t2 的事情,请停下来问自己:现实中这是什么意思?时刻 + 时刻没有意义。时刻 + 时长有意义。时长 + 时长有意义。两个时刻的差值则是时长。
错误 4:不使用 IsZero(),却把 0001-01-01... 当成真实日期。
time.Time 的零值打印出来看上去像一个日期,新手可能会把它当成“非常古老的任务”。通常它其实表示“未设置”。在数据模型里,“时间可能不存在”这件事最适合用零值 + IsZero() 来表达。
错误 5:用“墙上时钟”测量时长,然后对奇怪结果感到惊讶。
如果你手动写 end := time.Now(); elapsed := end.Sub(start),通常也没问题。但如果系统时间被调整,就可能出现意外效果。惯用的测量方式是 start := time.Now(); ...; elapsed := time.Since(start)。这个模式正是为了支持单调时间的理念而设计的,也旨在对时钟校正保持稳定。
GO TO FULL VERSION