时间处理:掌握 Go 的 time 包
时间是程序中最常用也是最容易出错的概念之一。时区转换、格式化、时间计算、定时任务……每一个都是坑。一个看似简单的时间比较可能跨不过时区这道坎,一次不经意的格式化字符串写错一个字符,就会导致解析失败。幸运的是,Go 的 time 包设计得相当优雅和实用,完全基于一个明确且一致的时间模型。今天我们就来全面系统学习它。
时间的表示:time.Time
在 Go 中,所有时间用 time.Time 类型表示。它包含了从公元前 1 年到公元 9999 年的任何时间点,精度达到纳秒级。
package main
import (
"fmt"
"time"
)
func main() {
// 当前时间
now := time.Now()
fmt.Println("当前时间:", now)
// 输出示例: 2021-03-06 10:15:00.123456789 +0800 CST m=+0.000000001
// 访问时间组件
fmt.Println("年:", now.Year()) // 2021
fmt.Println("月:", now.Month()) // March
fmt.Println("日:", now.Day()) // 6
fmt.Println("时:", now.Hour()) // 10
fmt.Println("分:", now.Minute()) // 15
fmt.Println("秒:", now.Second()) // 0
fmt.Println("纳秒:", now.Nanosecond()) // 123456789
// 星期几
fmt.Println("星期:", now.Weekday()) // Saturday
// Unix 时间戳
fmt.Println("Unix 秒:", now.Unix()) // 1614996900
fmt.Println("Unix 毫秒:", now.UnixMilli()) // 1614996900123
fmt.Println("Unix 纳秒:", now.UnixNano()) // 1614996900123456789
}
time.Time 是一个结构体,内部包含秒级和纳秒级的时间信息,以及时区信息。最重要的设计决策是:time.Time 的零值意味着公元 1 年 1 月 1 日 00:00:00 UTC。
var zeroTime time.Time
fmt.Println(zeroTime) // 0001-01-01 00:00:00 +0000 UTC
fmt.Println(zeroTime.IsZero()) // true
IsZero() 常用来判断一个 time.Time 变量是否被显式赋值过,在处理数据库 NULL 值时特别有用。
创建特定时间
time.Date
// 创建指定时间(本地时区)
t1 := time.Date(2021, time.March, 6, 10, 15, 0, 0, time.Local)
fmt.Println(t1) // 2021-03-06 10:15:00 +0800 CST
// 创建指定时间(UTC)
t2 := time.Date(2021, time.March, 6, 10, 15, 0, 0, time.UTC)
fmt.Println(t2) // 2021-03-06 10:15:00 +0000 UTC
// 创建指定时间(自定义时区)
loc, _ := time.LoadLocation("America/New_York")
t3 := time.Date(2021, time.March, 6, 10, 15, 0, 0, loc)
fmt.Println(t3) // 2021-03-06 10:15:00 -0500 EST
注意:
- 月份用
time.January到time.December表示(也可以用数字 1-12) - 时区参数很重要,相同的年月日时分秒在不同时区代表不同的瞬间
time.Local会尝试加载系统时区配置
通过解析字符串创建时间
// 解析标准格式
t, err := time.Parse(time.RFC3339, "2021-03-06T10:15:00+08:00")
if err != nil {
log.Fatal(err)
}
fmt.Println(t)
// 注意:time.Parse 默认使用 UTC
// 如果输入字符串没有时区信息,结果时间是 UTC
时间格式化:神秘的参考时间
Go 的时间格式化非常独特,它不用 YYYY-MM-DD 这种占位符,而是用一个固定的参考时间:
Mon Jan 2 15:04:05 MST 2006 (即 Unix 时间 1136239445)
这个数字组合可以这样记:
- 1(日)
- 2(小时的 12 小时制标记)
- 3(月)
- 4(分)
- 5(秒)
- 6(年)
- -7(时区偏移)
now := time.Now()
// 常用格式
fmt.Println(now.Format("2006-01-02")) // 2021-03-06
fmt.Println(now.Format("2006-01-02 15:04:05")) // 2021-03-06 10:15:00
fmt.Println(now.Format("2006/01/02 15:04")) // 2021/03/06 10:15
fmt.Println(now.Format("Jan 2, 2006")) // Mar 6, 2021
fmt.Println(now.Format("Monday, January 2, 2006")) // Saturday, March 6, 2021
fmt.Println(now.Format("3:04 PM")) // 10:15 AM
fmt.Println(now.Format("2006-01-02T15:04:05Z07:00")) // RFC3339
// 预定义常量
fmt.Println(now.Format(time.RFC3339)) // 2021-03-06T10:15:00+08:00
fmt.Println(now.Format(time.RFC1123)) // Sat, 06 Mar 2021 10:15:00 CST
fmt.Println(now.Format(time.Kitchen)) // 10:15AM
fmt.Println(now.Format(time.Stamp)) // Mar 6 10:15:00
Go 1.20 新增了几个非常实用的时间格式常量,终于不用硬编码常见格式了:
now := time.Now()
fmt.Println(now.Format(time.DateTime)) // 2006-01-02 15:04:05
fmt.Println(now.Format(time.DateOnly)) // 2006-01-02
fmt.Println(now.Format(time.TimeOnly)) // 15:04:05
解析时间字符串
// 解析时间字符串(使用 UTC 作为默认时区)
timeStr := "2021-03-06 10:15:00"
t, err := time.Parse("2006-01-02 15:04:05", timeStr)
if err != nil {
log.Fatal("解析失败:", err)
}
fmt.Println(t) // 2021-03-06 10:15:00 +0000 UTC
// 解析带时区的时间
timeStr2 := "2021-03-06T10:15:00+08:00"
t2, err := time.Parse(time.RFC3339, timeStr2)
if err != nil {
log.Fatal("解析失败:", err)
}
fmt.Println(t2) // 2021-03-06 10:15:00 +0800 +0800
⚠️ 陷阱:time.Parse 默认使用 UTC 作为参考时区。如果你解析的是不带时区信息的本地时间字符串(如 "2021-03-06 10:15:00"),而你想把它当作北京时间处理,应该使用 time.ParseInLocation:
loc, _ := time.LoadLocation("Asia/Shanghai")
t, _ := time.ParseInLocation("2006-01-02 15:04:05", "2021-03-06 10:15:00", loc)
fmt.Println(t) // 2021-03-06 10:15:00 +0800 CST
这一点如果不注意,数据库中存储的"早上 8 点"在解析后会变成"早上 8 点 UTC",在用户界面显示时就变成了下午 4 点,非常恼人。
时间计算
时间的加减
now := time.Now()
// 加时间
tomorrow := now.Add(24 * time.Hour)
nextWeek := now.AddDate(0, 0, 7) // 年、月、日
nextMonth := now.AddDate(0, 1, 0)
nextYear := now.AddDate(1, 0, 0)
fmt.Println("明天:", tomorrow)
fmt.Println("下周:", nextWeek)
fmt.Println("下月:", nextMonth)
fmt.Println("明年:", nextYear)
// 减时间
yesterday := now.Add(-24 * time.Hour)
lastWeek := now.AddDate(0, 0, -7)
fmt.Println("昨天:", yesterday)
fmt.Println("上周:", lastWeek)
AddDate 的月计算遵循"溢出规则":AddDate(0, 1, 0) 在 1 月 31 对 1 天加上 1 个月会得到 2 月 28/29 日(平年/闰年)。
时间差计算
start := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2021, 3, 6, 10, 15, 0, 0, time.UTC)
// 计算时间差
duration := end.Sub(start)
fmt.Println("时间差:", duration) // 1546h15m0s
fmt.Println("小时数:", duration.Hours()) // 1546.25
fmt.Println("分钟数:", duration.Minutes()) // 92775
fmt.Println("秒数:", duration.Seconds()) // 5566500
// 检查时间先后
fmt.Println("end 在 start 之后:", end.After(start)) // true
fmt.Println("start 在 end 之前:", start.Before(end)) // true
fmt.Println("两个时间相等:", start.Equal(end)) // false
时间比较始终使用 Before、After、Equal 方法,不要直接用 == 或 < 比较。虽然 Go 允许比较两个 time.Time,但它比较的是时区调整后的实际瞬间,对大多数开发者来说,显式方法更安全。
时间的截断和舍入
now := time.Now()
// 截断到小时(向下取整)
truncated := now.Truncate(time.Hour)
fmt.Println("截断到小时:", truncated)
// 截断到最近的一天
truncatedDay := now.Truncate(24 * time.Hour)
fmt.Println("截断到天:", truncatedDay)
// 舍入到最近的 30 分钟
rounded := now.Round(30 * time.Minute)
fmt.Println("舍入到30分钟:", rounded)
Truncate 和 Round 在日志归档、统计聚合等场景中非常有用。例如把访问日志按小时聚合时,就需要先 Truncate 到小时边界。
时区处理
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
fmt.Println("本地时间:", now)
// 转换到 UTC
utc := now.UTC()
fmt.Println("UTC 时间:", utc)
// 加载时区
shanghai, _ := time.LoadLocation("Asia/Shanghai")
newYork, _ := time.LoadLocation("America/New_York")
london, _ := time.LoadLocation("Europe/London")
tokyo, _ := time.LoadLocation("Asia/Tokyo")
// 转换时区
fmt.Println("上海:", now.In(shanghai))
fmt.Println("纽约:", now.In(newYork))
fmt.Println("伦敦:", now.In(london))
fmt.Println("东京:", now.In(tokyo))
// 获取时区名称和偏移
name, offset := now.Zone()
fmt.Printf("时区: %s, 偏移: %d 秒\n", name, offset)
}
处理夏令时
// 纽约在夏季使用 EDT(UTC-4),冬季使用 EST(UTC-5)
loc, _ := time.LoadLocation("America/New_York")
winter := time.Date(2021, 1, 15, 12, 0, 0, 0, loc)
summer := time.Date(2021, 7, 15, 12, 0, 0, 0, loc)
fmt.Println("冬季:", winter) // 2021-01-15 12:00:00 -0500 EST
fmt.Println("夏季:", summer) // 2021-07-15 12:00:00 -0400 EDT
Go 的时区处理会自动识别夏令时变化,这对处理美国、欧洲等使用夏令时地区的数据至关重要。
⚠️ 重要:time.LoadLocation 默认从系统 IANA 时区数据库加载。在容器化部署中,如果基础镜像没有包含 tzdata,可能会加载失败。解决方案:
- 使用包含 tzdable 的镜像(如
golang:alpine需要安装tzdata) - 使用
time.FixedZone手动定义固定偏移量时区 - 使用
embed嵌入时区数据库
Duration:时间间隔
time.Duration 表示两个时间点之间的间隔,底层是 int64,单位是纳秒。
package main
import (
"fmt"
"time"
)
func main() {
// 创建 Duration
d1 := 5 * time.Second
d2 := 100 * time.Millisecond
d3 := 2 * time.Hour
d4 := time.Hour + 30*time.Minute + 45*time.Second
fmt.Println(d1) // 5s
fmt.Println(d2) // 100ms
fmt.Println(d3) // 2h0m0s
fmt.Println(d4) // 1h30m45s
// 转换单位
fmt.Println(d3.Hours()) // 2
fmt.Println(d3.Minutes()) // 120
fmt.Println(d3.Seconds()) // 7200
fmt.Println(d3.Milliseconds()) // 7200000
// Duration 计算
sum := d1 + d2
diff := d3 - d1
multiple := d1 * 2
divided := d3 / 2
fmt.Println("和:", sum)
fmt.Println("差:", diff)
fmt.Println("倍数:", multiple)
fmt.Println("除法:", divided)
// 解析 Duration 字符串
d5, _ := time.ParseDuration("1h30m")
d6, _ := time.ParseDuration("2.5h")
d7, _ := time.ParseDuration("100ms")
d8, _ := time.ParseDuration("2h30m10s")
fmt.Println(d5) // 1h30m0s
fmt.Println(d6) // 2h30m0s
fmt.Println(d7) // 100ms
fmt.Println(d8) // 2h30m10s
}
time.Duration 支持加减乘除,这使得时间间隔的算术运算非常直观。⚠️ 注意:Duration 不能直接用 int 加减,因为 time.Second 等常量是 Duration 类型,需要将整数乘上相应单位:5 * time.Second 是正确的,5 + time.Second 会编译失败。
定时器
time.Sleep
最简单的等待:
fmt.Println("开始")
time.Sleep(2 * time.Second)
fmt.Println("2秒后")
time.Sleep 会让当前 goroutine 进入休眠状态,出让 CPU。注意在测试中使用 Sleep 要慎重,它会让测试变慢。
time.Timer
一次性定时器:
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个 2 秒后触发的定时器
timer := time.NewTimer(2 * time.Second)
fmt.Println("等待定时器...")
<-timer.C
fmt.Println("定时器触发!")
// 停止定时器(如果还没触发)
timer2 := time.NewTimer(5 * time.Second)
if !timer2.Stop() {
<-timer2.C // 确保通道被排空
}
// 重置定时器
timer2.Reset(1 * time.Second)
<-timer2.C
fmt.Println("重置后的定时器触发!")
}
⚠️ Stop 的陷阱:timer.Stop() 返回 false 表示定时器已经触发(或已经停止)。如果已经触发,需要排空 timer.C 通道,否则在下一次 Reset 后可能会收到旧的信号。这是 Timer 使用中常见的内存泄漏或逻辑错误来源。
time.After
time.After 是 time.NewTimer 的简化版:
fmt.Println("开始等待")
<-time.After(2 * time.Second)
fmt.Println("2秒后")
常用于超时控制:
select {
case result := <-doSomething():
fmt.Println("完成:", result)
case <-time.After(5 * time.Second):
fmt.Println("超时!")
}
⚠️ 内存警告:time.After 创建的 Timer 在触发前不会被垃圾回收。如果在高频率循环中使用 time.After 创建大量定时器,可能造成内存泄漏。这种情况下应使用 time.NewTimer 并手动重置复用。
time.Ticker
周期性定时器:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
done := make(chan bool)
go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
fmt.Println("Ticker 触发:", t.Format("15:04:05"))
}
}
}()
// 运行 5 秒后停止
time.Sleep(5 * time.Second)
close(done)
fmt.Println("Ticker 已停止")
}
time.Tick
time.Tick 是 time.NewTicker 的简化版,但无法停止,只适合用在不会结束的 goroutine 中:
for t := range time.Tick(1 * time.Second) {
fmt.Println(t)
}
⚠️ 不要滥用 time.Tick:每次调用 time.Tick 都会创建一个新的 Ticker,且无法停止。在循环或短生命周期的函数中使用它会导致资源泄漏。
定时器函数示例
time.AfterFunc
在指定时间后执行某个函数:
time.AfterFunc(2*time.Second, func() {
fmt.Println("2秒后执行的任务")
})
// 还有 AfterFunc 返回的 Timer 可以取消
timer := time.AfterFunc(5*time.Second, func() {
fmt.Println("这行不会打印")
})
timer.Stop()
package main
import (
"fmt"
"time"
)
func main() {
// AfterFunc 演示
var t *time.Timer
t = time.AfterFunc(2*time.Second, func() {
fmt.Println("2秒后执行")
})
t.Stop() // 取消执行
// 重新设置
t.Reset(1 * time.Second)
time.Sleep(2 * time.Second)
}
实战:倒计时程序
package main
import (
"fmt"
"time"
)
func countdown(seconds int) {
fmt.Printf("倒计时开始!%d 秒后发射...\n\n", seconds)
start := time.Now()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for remaining := seconds; remaining > 0; remaining-- {
<-ticker.C
elapsed := time.Since(start)
fmt.Printf("\r剩余: %2d 秒 | 已过去: %v", remaining, elapsed.Round(time.Second))
}
fmt.Println("\n\n发射!")
}
func main() {
countdown(10)
}
实战:Session 过期管理器
用于 Web 服务端 Session 管理的完整示例:
package main
import (
"sync"
"time"
)
type Session struct {
UserID string
ExpiresAt time.Time
}
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
ticker *time.Ticker
stop chan bool
}
func NewSessionManager(cleanupInterval time.Duration) *SessionManager {
sm := &SessionManager{
sessions: make(map[string]*Session),
stop: make(chan bool),
}
// 启动定期清理过期 Session 的后台任务
sm.ticker = time.NewTicker(cleanupInterval)
go sm.cleanupLoop()
return sm
}
func (sm *SessionManager) cleanupLoop() {
for {
select {
case <-sm.ticker.C:
sm.cleanup()
case <-sm.stop:
sm.ticker.Stop()
return
}
}
}
func (sm *SessionManager) cleanup() {
sm.mu.Lock()
defer sm.mu.Unlock()
now := time.Now()
for id, session := range sm.sessions {
if now.After(session.ExpiresAt) {
delete(sm.sessions, id)
}
}
}
func (sm *SessionManager) Create(userID string, duration time.Duration) string {
sessionID := generateSessionID()
sm.mu.Lock()
sm.sessions[sessionID] = &Session{
UserID: userID,
ExpiresAt: time.Now().Add(duration),
}
sm.mu.Unlock()
return sessionID
}
func (sm *SessionManager) Get(sessionID string) (*Session, bool) {
sm.mu.RLock()
session, ok := sm.sessions[sessionID]
sm.mu.RUnlock()
if !ok || time.Now().After(session.ExpiresAt) {
return nil, false
}
return session, true
}
func (sm *SessionManager) Stop() {
close(sm.stop)
}
func generateSessionID() string {
// 简化实现
return time.Now().Format("20060102150405") + string(rune(time.Now().Nanosecond()))
}
实战:性能计时器
package main
import (
"fmt"
"time"
)
type Timer struct {
start time.Time
name string
}
func NewTimer(name string) *Timer {
return &Timer{start: time.Now(), name: name}
}
func (t *Timer) Elapsed() time.Duration {
return time.Since(t.start)
}
func (t *Timer) Stop() {
fmt.Printf("%s 耗时: %v\n", t.name, t.Elapsed())
}
// usage with defer
func slowFunction() {
defer NewTimer("slowFunction").Stop()
time.Sleep(2 * time.Second)
}
// 并发安全的计时器
type ConcurrentTimer struct {
timers map[string]time.Duration
mu sync.Mutex
}
func (ct *ConcurrentTimer) Record(name string, elapsed time.Duration) {
ct.mu.Lock()
ct.timers[name] += elapsed
ct.mu.Unlock()
}
Timer/Ticker 对比与选择
| 工具 | 执行次数 | 可取消 | 可重置 | 适用场景 |
|---|---|---|---|---|
time.After | 1 次 | 否 | 否 | 一次性的超时控制 |
time.NewTimer | 1 次 | 是 | 是 | 需要取消/重设的单次延迟 |
time.AfterFunc | 1 次 | 是(返回 Timer) | 是(通过 Timer) | 延迟执行回调函数 |
time.Tick | 无限次 | 否 | 否 | 不会停止的长期定时任务 |
time.NewTicker | 无限次 | 是 | 是 | 需要停止/重置的周期性任务 |
常见问题 FAQ
Q1: time.Parse 和 time.ParseInLocation 有什么区别?time.Parse 默认将无时区信息的时间解析为 UTC;time.ParseInLocation 允许你指定一个时区。如果你解析的是本地日志中的时间戳,务必使用 time.ParseInLocation,否则时区会发生偏移。
Q2: 为什么 time.Now() 返回的时间包含 m=+0.000000001 这种奇怪的字段?
这是 Go runtime 内部的 monotonic 时间戳,用于精确计算时间间隔(不受系统时间调整影响)。这个值在普通使用中不需要关心,Go 会自动处理。
Q3: 容器中的时区总是 UTC,如何处理?
- 在 Dockerfile 中安装 tzdata(Alpine:
apk add --no-cache tzdata) - 设置环境变量
TZ=Asia/Shanghai - 在代码中使用
time.LoadLocation之前先os.Setenv("TZ", "Asia/Shanghai")
Q4: 为什么 time.Sleep(1) 不是睡眠 1 秒?time.Sleep 的参数是 time.Duration,必须带上单位。正确写法是 time.Sleep(1 * time.Second)。直接传入 1 只会睡眠 1 纳秒。
Q5: 如何在 Go 中实现 cron 定时任务?
标准库没有内置 cron,但社区有很多优秀的库:robfig/cron/v3 是最流行的选择。底层实现通常基于 time.NewTicker + 时间匹配逻辑。
Q6: time.Truncate 和 time.Round 在负 duration 时有什么行为?Truncate 向零值方向截断,Round 四舍五入到最近的 duration 倍数。对于负数时间,Truncate(-1 * time.Hour) 的行为需要特别注意——文档建议始终使用正数 duration。
Q7: 两个 time.Time 能用 == 比较吗?
可以,但 == 比较的是时区+时间的完全等价。如果两个时间代表同一瞬间但时区不同,== 会返回 false。推荐使用 Equal() 方法,它会先统一调整为 UTC 再比较。
Q8: Go 的 time 包线程安全吗?time.Time 是值类型,本身是线程安全的;但 time.Timer 和 time.Ticker 必须在同一个 goroutine 中操作,跨 goroutine Reset/Stop 可能引发竞态条件。
最佳实践
- 总是设置明确的时区:涉及用户交互的时间都与时区有关,不要依赖默认的
time.Local - 存储时间用 UTC:数据库和网络传输中始终使用 UTC,在展示时转换为用户时区
- 使用
time.ParseInLocation解析本地时间:避免默认 UTC 造成的时区偏移陷阱 - 优先复用
time.NewTimer而不是time.After:高频场景下后者的定时器无法 GC - 定时器停止后必须排空通道:
timer.Stop()返回false时,<-timer.C可能已经就绪 - 比较时间用
Before/After/Equal:不要用</>/==直接操作 - 使用
time.DateTime等新常量:Go 1.20+ 提供了预定义常用格式,减少硬编码错误 - monotonic 时间是好朋友:
Since、Until等内置方法已经帮你处理了系统时间跳变的问题
延伸阅读
如果你对 Go 的时间处理、并发编程和系统编程感兴趣,推荐继续阅读:
- Go 函数与多返回值 — 掌握 defer 的底层原理解释
- Go 并发编程:Goroutine — Timer、Ticker 与 goroutine 的完美协作
- Go channel 详解 — for-select 与 time.After 的超时模式
- Go context 详解 — 带超时的请求取消与 time 包深度结合
- Go 错误处理 — 解析时间错误(如未知 Location)的优雅处理
- Go 文件操作 — 日志文件按时间切分的实战技巧
- Go 网络编程 — HTTP timeout 的底层原理
练习时间
- 写一个程序,计算你出生到今天一共活了多少天
- 实现一个函数,判断给定日期是否是工作日(考虑法定节假日扩展)
- 创建一个基于
time.Ticker的心跳检测器,每 5 秒检测一次服务健康状态 - 写一个程序,显示世界主要城市(纽约、伦敦、东京、悉尼)的当前时间
我们下篇见!
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。