时间处理:掌握 Go 的 time 包

时间处理:掌握 Go 的 time 包 时间是程序中最常用也是最容易出错的概念之一。时区转换、格式化、时间计算、定时任务……每一个都是坑。幸运的是,Go 的 time 包设计得非常优雅和实用。今天我们就来全面学习它。

时间处理:掌握 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,可能会加载失败。解决方案:

  1. 使用包含 tzdable 的镜像(如 golang:alpine 需要安装 tzdata)
  2. 使用 time.FixedZone 手动定义固定偏移量时区
  3. 使用 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.After1 次否否一次性的超时控制
time.NewTimer1 次是是需要取消/重设的单次延迟
time.AfterFunc1 次是(返回 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,如何处理?

  1. 在 Dockerfile 中安装 tzdata(Alpine:apk add --no-cache tzdata)
  2. 设置环境变量 TZ=Asia/Shanghai
  3. 在代码中使用 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 可能引发竞态条件。

最佳实践

  1. 总是设置明确的时区:涉及用户交互的时间都与时区有关,不要依赖默认的 time.Local
  2. 存储时间用 UTC:数据库和网络传输中始终使用 UTC,在展示时转换为用户时区
  3. 使用 time.ParseInLocation 解析本地时间:避免默认 UTC 造成的时区偏移陷阱
  4. 优先复用 time.NewTimer 而不是 time.After:高频场景下后者的定时器无法 GC
  5. 定时器停止后必须排空通道:timer.Stop() 返回 false 时,<-timer.C 可能已经就绪
  6. 比较时间用 Before/After/Equal:不要用 </>/== 直接操作
  7. 使用 time.DateTime 等新常量:Go 1.20+ 提供了预定义常用格式,减少硬编码错误
  8. monotonic 时间是好朋友:Since、Until 等内置方法已经帮你处理了系统时间跳变的问题

延伸阅读

如果你对 Go 的时间处理、并发编程和系统编程感兴趣,推荐继续阅读:

练习时间

  1. 写一个程序,计算你出生到今天一共活了多少天
  2. 实现一个函数,判断给定日期是否是工作日(考虑法定节假日扩展)
  3. 创建一个基于 time.Ticker 的心跳检测器,每 5 秒检测一次服务健康状态
  4. 写一个程序,显示世界主要城市(纽约、伦敦、东京、悉尼)的当前时间

我们下篇见!

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练