Go 时间格式化为什么用 2006-01-02:time.Format/Parse 的参考时间、时区与解析踩坑

Go 时间格式化为什么用 2006-01-02:time.Format/Parse 的参考时间、时区与解析踩坑

第一次用 Go 格式化时间的人几乎都会愣一下:别的语言里格式化字符串是 yyyy-MM-dd HH:mm:ss 这种占位符,到了 Go 变成了 2006-01-02 15:04:05。你甚至会怀疑是不是抄错了------为什么是这么一串看起来像随机日期的数字?

这不是随机的,而且一旦你搞错一个数字,格式化出来的结果会静默地不对,编译器一句警告都不给。这篇文章把 Go 时间格式化的设计、正确写法,以及最容易翻车的时区和解析问题一次讲清。

那串魔数到底是什么

Go 没有用 yyyyMM 这类抽象占位符,而是用一个具体的参考时间当模板。这个参考时间是固定的:

复制代码
2006-01-02 15:04:05

把它按「1 2 3 4 5 6 7」的顺序念一遍就懂了:

  • 01 → 月(Month,第 1)
  • 02 → 日(Day,第 2)
  • 15 → 时(Hour,第 3,24 小时制,注意是 15 不是 03)
  • 04 → 分(Minute,第 4)
  • 05 → 秒(Second,第 5)
  • 2006 → 年(Year,第 6)
  • -07 → 时区偏移(Zone,第 7)

也就是这个参考时间正好是 01/02 03:04:05 PM '06 -0700(美式记法 1、2、3、4、5、6、7),Go 团队用它当助记。你要什么格式,就用这个参考时间「摆」成那个样子。想要 2024/03/08,模板就写 2006/01/02;想要 08-03-2024,就写 02-01-2006

go 复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.Date(2024, 3, 8, 15, 4, 5, 0, time.Local)

	fmt.Println(t.Format("2006-01-02 15:04:05")) // 2024-03-08 15:04:05
	fmt.Println(t.Format("2006/01/02"))          // 2024/03/08
	fmt.Println(t.Format("15:04"))               // 15:04
	fmt.Println(t.Format("2006年01月02日"))       // 2024年03月08日
}

关键就一句:模板里出现哪个魔数,对应位置就填哪个字段;魔数以外的字符(横杠、斜杠、汉字)原样输出。

最高频的翻车:小时用错、月和分搞反

新手最容易踩的两个坑,编译器完全不报错,结果却是错的。

坑一:24 小时制要用 15,不是 03。 参考时间里 15 代表 24 小时制的小时,03 代表 12 小时制。如果你想要 24 小时制却写了 03:

go 复制代码
t := time.Date(2024, 3, 8, 20, 30, 0, 0, time.Local) // 晚上 8 点半
fmt.Println(t.Format("2006-01-02 03:04:05")) // 2024-03-08 08:30:00  ← 20 点被输出成 08!
fmt.Println(t.Format("2006-01-02 15:04:05")) // 2024-03-08 20:30:00  ← 正确

03(12 小时制)时通常要配 PM 才有意义:03:04:05 PM。想要不带前导零的 12 小时制用 3

坑二:月是 01,分是 04,别搞反。 01 是月、04 是分。写 04-02 想表示「月-日」是错的,那会输出「分-日」:

go 复制代码
t := time.Date(2024, 3, 8, 15, 45, 0, 0, time.Local)
fmt.Println(t.Format("01-02")) // 03-08  月-日,正确
fmt.Println(t.Format("04-02")) // 45-08  分-日,几乎肯定不是你要的

记牢那句口诀「1 月 2 日 3 时 4 分 5 秒 6 年」,月在时之前(1、2、3),分在时之后(4)。

常用的格式占位速查

除了主力的那几个,还有些常用变体:

go 复制代码
t := time.Date(2024, 3, 8, 9, 4, 5, 123456789, time.Local)

t.Format("2006-1-2")            // 2024-3-8      不带前导零(1、2 而非 01、02)
t.Format("Jan 2, 2006")         // Mar 8, 2024   月份英文缩写
t.Format("January")             // March         月份英文全名
t.Format("Mon")                 // Fri           星期缩写
t.Format("Monday")              // Friday        星期全名
t.Format("15:04:05.000")        // 09:04:05.123  毫秒(补零到 3 位)
t.Format("15:04:05.999")        // 09:04:05.123  毫秒(尾部零省略)
t.Format("2006-01-02T15:04:05Z07:00") // RFC3339 带时区

标准库还预定义了一批常量,能用就别手写:

go 复制代码
t.Format(time.RFC3339)  // 2024-03-08T09:04:05+08:00
t.Format(time.DateTime) // 2024-03-08 09:04:05   (Go 1.20+)
t.Format(time.DateOnly) // 2024-03-08            (Go 1.20+)
t.Format(time.TimeOnly) // 09:04:05              (Go 1.20+)

time.DateTime/DateOnly/TimeOnly 是 Go 1.20 加的,正好对应最常用的三种格式,直接用比手敲 2006-01-02 15:04:05 更不容易打错。

解析:time.Parse 和它的时区陷阱

time.Parse(layout, value)Format 的逆操作,layout 用同一套魔数描述 value 的格式:

go 复制代码
t, err := time.Parse("2006-01-02 15:04:05", "2024-03-08 15:04:05")
if err != nil {
	panic(err)
}
fmt.Println(t) // 2024-03-08 15:04:05 +0000 UTC

注意输出末尾的 +0000 UTC------这是第一个大坑:如果 layout 里没有时区信息,time.Parse 会把这个时间当成 UTC,而不是你本地时区。上面字符串是「2024-03-08 15:04:05」,你以为是北京时间下午 3 点,Go 却理解成 UTC 的下午 3 点,差了 8 小时。

如果这个时间其实是本地时间,要用 time.ParseInLocation 显式指定时区:

go 复制代码
loc, _ := time.LoadLocation("Asia/Shanghai")
t, _ := time.ParseInLocation("2006-01-02 15:04:05", "2024-03-08 15:04:05", loc)
fmt.Println(t)       // 2024-03-08 15:04:05 +0800 CST
fmt.Println(t.UTC()) // 2024-03-08 07:04:05 +0000 UTC   正确换算

这个坑在处理用户输入、数据库里的「无时区时间戳」时极其常见。规则记死:字符串里不带时区偏移,就必须用 ParseInLocation 告诉 Go 它是哪个时区的时间,否则一律被当 UTC。

第二个坑:layout 和 value 的格式必须严格对齐,包括分隔符和位数。差一个字符就报错:

go 复制代码
_, err := time.Parse("2006-01-02", "2024/03/08")
fmt.Println(err) // parsing time "2024/03/08" as "2006-01-02": cannot parse "/03/08" ...

_, err = time.Parse("2006-01-02 15:04:05", "2024-03-08") // value 缺了时间部分
fmt.Println(err) // ... cannot parse "" as "15"

所以拿到一个时间字符串,先看清它到底长什么样,layout 一字不差地照着摆。

时区加载的一个部署坑

time.LoadLocation("Asia/Shanghai") 依赖系统的时区数据库(通常在 /usr/share/zoneinfo)。在精简的容器镜像里(比如 scratchalpine 没装 tzdata),这个调用会失败:

go 复制代码
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
	// alpine 上常见:unknown time zone Asia/Shanghai
	log.Fatal(err)
}

两种解法。一是镜像里装上时区数据(alpineapk add tzdata);二是让 Go 自带一份------从 Go 1.15 起,匿名导入 time/tzdata 就会把时区库编译进二进制,不再依赖系统文件:

go 复制代码
import _ "time/tzdata" // 时区数据打进二进制,容器里也能 LoadLocation

代价是二进制体积增加几百 KB,但换来「到处都能加载时区」的确定性,对容器化部署很值。

一个能直接用的小工具

把常见需求封装一下,顺手贴一个「解析本地时间字符串并转成 UTC 存储」的函数,这是后端存时间的推荐姿势(存 UTC、展示时再转本地):

go 复制代码
// ParseLocalToUTC 把「无时区的本地时间字符串」解析成 UTC 时间。
// 例:输入 "2024-03-08 15:04:05" + "Asia/Shanghai",返回对应的 UTC time.Time。
func ParseLocalToUTC(s, zone string) (time.Time, error) {
	loc, err := time.LoadLocation(zone)
	if err != nil {
		return time.Time{}, err
	}
	// 用 ParseInLocation 避免被当成 UTC,再转 UTC 落库
	t, err := time.ParseInLocation(time.DateTime, s, loc)
	if err != nil {
		return time.Time{}, err
	}
	return t.UTC(), nil
}

存 UTC 的好处是:不管服务器部署在哪个时区、用户在哪个时区,底层时间点是唯一确定的,展示时再 t.In(loc).Format(...) 转回去即可,不会出现「跨时区各显示各的」这种脏数据。

小结

  • Go 用参考时间 2006-01-02 15:04:05 当格式模板,按 1 月 2 日 3 时 4 分 5 秒 6 年记:月=01、日=02、时(24 制)=15、分=04、秒=05、年=2006。
  • 两个静默错误:24 小时制要用 15(用 03 会把 20 点显示成 08),月是 01、分是 04,别搞反。
  • 优先用标准库常量 time.DateTime/DateOnly/TimeOnly/RFC3339,少手敲少出错。
  • time.Parse 遇到不带时区 的字符串会当成 UTC;要按本地时区解析必须用 time.ParseInLocation 并传 *time.Location
  • 精简容器里 LoadLocation 会因缺 tzdata 失败,匿名导入 _ "time/tzdata" 把时区库编进二进制最省心。

一句话记忆:格式化认「1234567」口诀,解析认「不带时区就是 UTC」这条铁律,存时间一律存 UTC、展示再转本地。

相关推荐
Dream Cosmos1 小时前
C++ 多态下篇:虚函数表、动态绑定与多态底层原理
开发语言·c++
jyOverQ4 小时前
RabbitMQ 消息积压怎么办?Prefetch、消费者并发与扩容
分布式·后端·rabbitmq
2601_962071579 小时前
【Java报错已解决】org.springframework.beans.factory.BeanCreationException
java·开发语言
淡海水10 小时前
07-04-并发-ConcurrentBag-T-工作窃取WorkStealing算法
开发语言·算法·c#·bag·concurrent·workstealing
CS_Zero11 小时前
C语言sizeof是函数吗?
c语言·开发语言
东风破_11 小时前
从跨域到 WebSocket:前端跨域方案、SSE 与双向实时通信详解
前端·后端
-今昭-12 小时前
《V2V 迁移实战:将 VMware 虚拟机转为 OpenStack 可用 Glance qcow2 镜像》
开发语言·python
行百里er13 小时前
Gateway 上别硬塞 MVC:DeferredImportSelector 看菜下锅
spring boot·后端·开源
名字还没想好☜13 小时前
Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法
后端