Skip to content
Go
time — 时间处理

time — 时间处理

time 包提供了时间测量、格式化和定时功能。它是 Go 中最常用的包之一,但也是最容易写出 bug 的包之一(时区、格式字符串、Duration 溢出、Timer 语义)。


1. 核心类型

类型 含义 示例
time.Time 一个时间点(纳秒精度,带时区) 2024-01-15 10:30:00 +08:00
time.Duration 时间段(纳秒精度,int64) 5s, 100ms, 2h30m
time.Location 时区 Asia/Shanghai, UTC
*time.Timer 一次性定时器 time.NewTimer(2*time.Second)
*time.Ticker 周期性打点器 time.NewTicker(500*time.Millisecond)

2. 深入原理:Go 的时间格式化为什么是 2006-01-02 15:04:05?

Go 使用固定的参考时间来表示格式:

Mon Jan 2 15:04:05 MST 2006

也就是 01/02 03:04:05PM ‘06 -0700(1 月 2 日 15:04:05,2006 年,时区 -0700)。这串数字按顺序是 1 2 3 4 5 6 7(月/日/时/分/秒/年)。这个设计意味着你不需要记 %Y %m %d 之类的符号,只要写出参考时间的对应位置即可:

now := time.Now()
now.Format("2006-01-02")          // → 2024-01-15
now.Format("15:04:05")            // → 10:30:00
now.Format("2006年01月02日")      // → 2024年01月15日
now.Format("Mon Jan 2 15:04:05")  // → Mon Jan 15 10:30:00

// 常用的预定义格式
now.Format(time.RFC3339)    // 2006-01-02T15:04:05Z07:00
now.Format(time.DateTime)   // 2006-01-02 15:04:05(Go 1.20+)
now.Format(time.RFC3339Nano)// 带纳秒、且去尾零

注意:time.RFC3339 是带时区的完整表示,适用于 API;但 encoding/json 对 time.Time 的序列化用的是 RFC3339Nano——同样是"标准格式",两者对尾随零的处理不同(RFC3339Nano 会去掉小数点后的零),对时间戳精度敏感的系统要注意。


3. Duration — 时间段的正确使用

// 创建 Duration
d := 10 * time.Second
d = time.Duration(500) * time.Millisecond  // 先转成 Duration 再乘

// 常量(纳秒基准)
time.Nanosecond   // 1
time.Microsecond  // 1e3
time.Millisecond  // 1e6
time.Second       // 1e9
time.Minute       // 6e10
time.Hour         // 3.6e12

配置里读出来的整数(秒数、毫秒数)想当 Duration 用时,一定要乘以单位常量:time.Duration(cfg.Timeout) 把"5"当成 5 纳秒,是新手高频 bug;正确写法 time.Duration(cfg.Timeout) * time.Second,或者从 YAML/JSON 直接解析 "5s" 字符串。

Duration 的 JSON 序列化

time.Duration 的 JSON 序列化是一个经典坑:它的底层是 int64,所以 Marshal 出来是纳秒数字,Unmarshal 也只接受数字,"5s" 这种字符串会直接报错。常见做法是定义包装类型:

type Duration time.Duration

func (d Duration) MarshalJSON() ([]byte, error) {
    return json.Marshal(time.Duration(d).String()) // "5s"
}

func (d *Duration) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    dur, err := time.ParseDuration(s)
    if err != nil {
        return err
    }
    *d = Duration(dur)
    return nil
}

这也是很多框架(如 go-zero、各种 YAML 配置库)都自带 Duration 包装类型的原因。

注意:Duration 最大约 292 年(int64 纳秒)。用原始 int64 参与乘法运算时可能溢出且不报错,最终得到负数或荒谬值——先转 time.Duration 再乘单位常量,让编译器和 ParseDuration 帮你把关。


4. 时区 — 最多坑的部分

4.1 三个关键认知

// 认知 1: time.Now() 返回的时间带本地时区信息
now := time.Now()
fmt.Println(now)  // 2024-01-15 10:30:00 +08:00 CST

// 认知 2: time.Parse 默认返回 UTC(除非格式中有时区信息)
t, _ := time.Parse("2006-01-02 15:04:05", "2024-01-15 10:30:00")
fmt.Println(t) // 2024-01-15 10:30:00 +0000 UTC ← 注意!

// 认知 3: 正确做法是使用 ParseInLocation
loc, _ := time.LoadLocation("Asia/Shanghai")
t, _ = time.ParseInLocation("2006-01-02 15:04:05", "2024-01-15 10:30:00", loc)
fmt.Println(t) // 2024-01-15 10:30:00 +0800 CST

顶级陷阱:time.Parse 不指定时区时默认为 UTC,这几乎不是你想要的——国内业务里"10:30"几乎总是北京时间。解析不带时区信息的字符串,始终用 ParseInLocation。

4.2 时区转换

// In(loc) 返回"同一时刻"在不同时区的表示
shanghai, _ := time.LoadLocation("Asia/Shanghai")
nyc, _ := time.LoadLocation("America/New_York")

t := time.Date(2024, 1, 15, 12, 0, 0, 0, shanghai)
fmt.Println(t.In(nyc)) // 2024-01-14 23:00:00 -0500 EST
// 同一时刻,纽约还是前一天晚上

In 不改变时刻本身,只改变"显示用的挂钟时间"。想改成本地挂钟时间而不改时区,要用 Add——两者语义完全不同。

4.3 时区数据库

Go 使用 IANA 时区数据库。跨平台注意事项:

  • Linux/macOS:通常依赖系统的 zoneinfo(/usr/share/zoneinfo)。
  • Windows:Go 1.15+ 内置了时区数据,LoadLocation 无需额外配置。
  • Docker 精简镜像 / scratch:没有系统 zoneinfo 时 LoadLocation("Asia/Shanghai") 会报错——要么装 tzdata,要么 import _ "time/tzdata" 把时区库编进二进制(约增加几百 KB),要么挂载宿主机的 zoneinfo 目录。alpine 基础镜像忘装 tzdata 导致服务启动失败,是容器化 Go 服务的常见事故。

5. 时间计算与判断

5.1 时间加减

now := time.Now()

// 加法(返回新值,不修改原值——Time 是不可变值类型)
tomorrow := now.Add(24 * time.Hour)

// AddDate — 处理日历意义上的年月日
nextMonth := now.AddDate(0, 1, 0)  // 加 1 个月

// 时间差
diff := tomorrow.Sub(now) // 返回 Duration

// 截断/舍入(基于绝对时间轴)
now.Truncate(time.Hour)  // 截断到整点
now.Round(time.Hour)     // 四舍五入到整点

Add 加的是物理时间(24 小时就是 86400 秒),AddDate 加的是日历时间(跨夏令时的一天可能是 23 或 25 小时)。定时任务和"每天 9 点"这类需求要用 AddDate,否则夏令时地区会漂移一小时。

陷阱:AddDate 会先做字面加法再规范化。1 月 31 日 AddDate(0, 1, 0) 得到"2 月 31 日",再溢出到 3 月。具体溢到哪天取决于当年 2 月天数:闰年(如 2024)2 月有 29 天,溢出 2 天 → 3 月 2 日;平年(如 2025)2 月 28 天,溢出 3 天 → 3 月 3 日。这和"想要当月最后一天"的预期完全不同。做"上个月的今天"这类逻辑,要么先 Truncate 到月首再加,要么用专门处理日历边界的库。

5.2 时间比较

t1 := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 1, 15, 20, 0, 0, 0, time.FixedZone("CST", 8*3600))

// Before / After / Compare — 比较时刻先后,忽略时区表示差异
t1.Before(t2)              // false
t1.Equal(t2)               // true —— 同一时刻
fmt.Println(t1.Compare(t2)) // 0 —— Go 1.20+,返回 -1/0/+1,适合排序

关键认知:== 和 Equal() 的区别很大:

  • t1 == t2 比较结构体的每个字段(墙钟、单调钟、时区指针)。两个不同时区但相同时刻的时间用 == 比较返回 false;更隐蔽的是,time.Now() 与由它派生、做过 In()/Truncate() 的值之间 == 也可能因为单调时钟被剥离而不等。
  • t1.Equal(t2) 比较绝对时刻,忽略时区表示,这才是"同一个时间点"的正确判断。
  • 永远用 Equal/Before/After/Compare 比较时间,不要用 ==。

5.3 零值判断:IsZero

var t time.Time          // 零值 = January 1, year 1, 00:00:00 UTC
fmt.Println(t.IsZero())  // true —— 还未被赋值

t2 := time.Now()
fmt.Println(t2.IsZero()) // false

// 常见用法:可选时间字段用零值表示"未设置"
type Event struct {
    Title     string
    StartTime time.Time
    EndTime   time.Time
}

func (e Event) HasSchedule() bool {
    return !e.StartTime.IsZero() && !e.EndTime.IsZero()
}

time.Time{} 的 IsZero 为 true;time.Unix(0, 0)(1970-01-01 UTC)不是零值,IsZero 为 false。数据库里 NULL 时间扫进 time.Time 常得到零值,用 IsZero 判缺省比和 time.Time{} 做 == 更稳。

5.4 其他判断方法

// 判断是否 UTC:Time 没有 IsUTC() 方法,比较 Location
t := time.Now().UTC()
fmt.Println(t.Location() == time.UTC) // true

// IsDST — 是否处于夏令时
loc, _ := time.LoadLocation("America/New_York")
summer := time.Date(2024, 7, 1, 12, 0, 0, 0, loc)
fmt.Println(summer.IsDST()) // true

// YearDay — 一年中的第几天
fmt.Println(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).YearDay()) // 32

// Weekday — 星期几(从 Sunday 开始)
weekday := time.Now().Weekday() // time.Monday 等
fmt.Println(weekday)            // Monday

// Unix / UnixMilli / UnixMicro / UnixNano — 时间戳
fmt.Println(time.Now().Unix())      // 秒
fmt.Println(time.Now().UnixMilli()) // 毫秒
fmt.Println(time.Now().UnixMicro()) // 微秒
fmt.Println(time.Now().UnixNano())  // 纳秒

注意:time.Time 上没有 IsUTC() 方法(一些资料会误写)。判断 UTC 就写 t.Location() == time.UTC。另外 Unix() 会向下取整丢掉不足一秒的部分,纳秒级比较请用 UnixNano,且注意 int64 纳秒在 2262 年之后会溢出。


6. Timer 和 Ticker

Timer:一次性

// 方式 1:创建 Timer
timer := time.NewTimer(2 * time.Second)
<-timer.C
timer.Stop()

// 方式 2:After(不能 Stop,适合一次性等待)
<-time.After(2 * time.Second)

// 方式 3:AfterFunc(触发后执行函数)
tf := time.AfterFunc(2*time.Second, func() {
    fmt.Println("fired")
})
defer tf.Stop() // 可取消;注意函数体可能已在别的 goroutine 跑起来

Go 1.23+ 语义变化:不再有"陈年旧值"

Go 1.23 重做了 Timer/Ticker:底层通道变为同步(无缓冲),且 Reset/Stop 之后保证不会再收到该 timer 此前已发出的值。这意味着两件事:

  1. 旧代码里"Stop 之后排空 timer.C“的防脏值套路不再需要(而且写错了反而会阻塞)。
  2. 没被 Stop 的 Timer 也能被 GC 回收了,time.After 在 select 循环里的泄漏问题大幅缓解——但循环里每轮都新建 timer 仍有分配开销,热路径还是复用 Reset 更划算。

注意:Go 1.27 移除了 GODEBUG=asynctimerchan=1 回退开关——1.23 引入的同步语义从此是唯一行为。如果你的代码库还在用"Stop 后排空"的老写法,升级到 1.27 前先检查:if !timer.Stop() { <-timer.C } 中的 <-timer.C 在 timer 已触发时会正常排空,但在尚未触发时 Stop() 返回 true 不会走排空分支——真正危险的是反过来无脑 <-timer.C,会一直阻塞到下一次触发。

Ticker:周期性

ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop() // 不 Stop,ticker 及其 goroutine 要等 GC 才能回收

for {
    select {
    case t := <-ticker.C:
        fmt.Println("tick at", t)
    case <-ctx.Done():
        return
    }
}

Ticker 的一个特性要知道:如果消费不够快,通道里不会堆积无限多个 tick——按时间轴"错过就错过”,最多留一个未读值。适合打点,不适合保证每个周期都被处理;要精确补发就得自己记上次执行时间。


7. 经典应用:超时控制

// 模式 1:context + timeout(首选——能一路透传到下游调用)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := doWork(ctx)

// 模式 2:select + After(一次性等待,简洁)
select {
case result := <-workCh:
    fmt.Println("done:", result)
case <-time.After(3 * time.Second):
    fmt.Println("timeout")
}

// 模式 3:Timer + Reset(循环场景,高效;Go 1.23+ 不需要排空)
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
for {
    select {
    case result := <-workCh:
        timer.Reset(3 * time.Second) // 1.23+:直接 Reset,不会有旧值残留
    case <-timer.C:
        fmt.Println("timeout")
        return
    }
}

// 模式 4:time.Sleep 不可中断——select + After 才是可取消的等待
select {
case <-time.After(2 * time.Second):
    // 到期
case <-ctx.Done():
    return ctx.Err()
}

模式 1 是生产首选,因为 ctx 能取消正在进行的 IO(HTTP 请求、数据库查询);模式 2/3 只是"放弃等待",被等待的 goroutine 并不会因此停下。


8. 常见陷阱汇总

陷阱 说明 正确做法
time.Parse 默认 UTC 解析无时区字符串得到 UTC 时间 用 ParseInLocation
AddDate 月末溢出 1月31日+1个月 → 闰年 3月2日 / 平年 3月3日 先归到月首,或用日历库
== 比较两个 Time 时区/单调钟字段参与比较,同时刻也可能不等 用 Equal()/Compare()
time.After 在循环中 每轮新建 timer,分配开销大 复用 Timer.Reset(1.23+ 语义安全)
老写法"Stop 后排空" 1.23+ 不需要,1.27 起无回退开关 直接 Reset,删掉排空代码
time.Sleep 不能中断 sleep 期间 goroutine 不响应取消 select + time.After + ctx.Done()
defer cancel() 忘记 Context 泄漏子 goroutine lint(lostcancel)能查
整数当 Duration 用 time.Duration(5) 是 5 纳秒 乘单位常量,或解析 "5s"
Docker 精简镜像缺时区 LoadLocation 报错 装 tzdata / import _ "time/tzdata"
误用不存在的 IsUTC() 编译失败 t.Location() == time.UTC

9. 实战建议

  • 对外一律显式带时区。API 返回时间用 RFC3339(或毫秒时间戳),入参用 ParseInLocation 解析;内部计算统一转 UTC,展示层再 In(loc)。混用本地时间存储、UTC 比较,是报表对不上账的根源。
  • 超时永远交给 context。HTTP client、DB、下游 RPC 都应透传 ctx;“函数执行超过 N 秒"这类需求在 Go 里无法安全强杀,只能靠被调用方自觉检查 ctx.Done()。
  • 升级 Go 1.23+ 后把 Timer 排空套路删掉;升到 1.27 前用 go vet + 全文搜 timer.Stop() 确认没有依赖旧异步语义的代码。
  • 定时任务用 time.Ticker 或 AfterFunc 就够;跨进程、要持久化调度的场景交给 asynq/cron 类库,别用裸 goroutine 自己造。