GO程序中的时区问题

4,929 阅读9分钟

前言

此前一直觉得时区就是个小问题,并没有特意去注意过。直到在最近的工作中,反而因为时区的问题搞得我迷迷糊糊的。几番碰壁后,决定亲自探究下程序中的时间类型在存储、读取与比较中到底具有怎样的行为。本文以GO语言和mysql作为示例,不熟悉GO的朋友可以直接跳至结论,思考下在自己熟悉的语言中是否表现相同。

准备工作

一切从简,直接创建一个极简单的表,并插入一条记录,接下来就研究这个updated_at字段:

-- 建表
CREATE TABLE time_test (
	id INT NOT NULL AUTO_INCREMENT,
	updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
	PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- 插入一条晚上11点的数据
INSERT INTO time_test(updatedAt) VALUES("2021-01-22 23:00:00");

从数据库读取时间

现在我们数据库中有了一条记录,那么在程序语言中直接读入这个时间字段会怎样呢? 是2021-01-22 23:00:00吗?

运行如下程序,观察结果:

本文所有实例程序为了简便,错误处理从简,全扔给它👉_

package main

import (
	"database/sql"
	"fmt"
	"time"

	// driver
	_ "github.com/go-sql-driver/mysql"
)

var localMysqlDSN = "root:rootroot@tcp(127.0.0.1:3306)/hello?parseTime=true"
func main() {
	db, _ := sql.Open("mysql", localMysqlDSN)
	defer db.Close()
	var (
		id int64
		updatedAt time.Time
	)
	rows, _ := db.Query("SELECT * FROM time_test")
	for rows.Next(){
		_ = rows.Scan(&id, &updatedAt)
		fmt.Printf("id: %v  updatedAt:%v\n", id, updatedAt)
	}
  timeNow := time.Now()
	fmt.Printf("timeNow: %v\n", timeNow)
}

从图中可以看到,从数据库中读取出来后是2021-01-22 23:00:00没错,但是,后面有个时区UTC,这是世界协调时间零时区的时间!而我们直接打印的当前时间是CST时区,即中国标准时间(China Standard Time),CST时间是经过UTC时间+8个小时后的时间。所以现在(我运行程序的时候)记录中的数据2021-01-22 23:00:00还是个未来时间。

瞅一眼,打印出的现在的时间timeNow, 跟电脑上的时间一致。所以程序中时间,默认会以电脑也就是你的系统时区为默认时区。为了验证这一点,修改电脑的时区为太平洋时间(美国和加拿大), 然后运行程序:

可以看到打印出的当前时间变了,比UTC时间少8个小时。证明程序中的时间默认会以操作系统中的时区为默认时区, 某些时间操作除外(见后文)

所以,用docker时在这方面踩过坑的请举爪。

向数据库写入时间

在程序中将当前时间写入数据库,然后又读取出来:

package main

import (
	"database/sql"
	"fmt"
	"time"

	// driver
	_ "github.com/go-sql-driver/mysql"
)

var localMysqlDSN = "root:rootroot@tcp(127.0.0.1:3306)/hello?parseTime=true"
func main() {
	db, _ := sql.Open("mysql", localMysqlDSN)
	defer db.Close()
	
	timeNow := time.Now()
	fmt.Printf("timeNow: %v\n", timeNow)
	
	db.Exec("INSERT INTO time_test(updated_at) VALUES(?)",timeNow)
	
	var (
		id int64
		updatedAt time.Time
	)
	rows, _ := db.Query("SELECT * FROM time_test")
	for rows.Next(){
		_ = rows.Scan(&id, &updatedAt)
		fmt.Printf("id: %v  updatedAt:%v\n", id, updatedAt)
	}
}

可以看出,打印的当前时间为符合我们直觉的CST时间,存入数据库后mysql会自动将其转为UTC时间进行存储。当我们再次读取出来时,读取的也是减了8个小时的UTC时间。

向数据库写入时区时间

上面的过程中,程序使用了默认的系统时区,现在我们让当前时间主动加载不同的时区,然后分别存入数据库,是否存储的值是一样的呢?

package main

import (
	"database/sql"
	"fmt"
	"time"

	// driver
	_ "github.com/go-sql-driver/mysql"
)

var localMysqlDSN = "root:rootroot@tcp(127.0.0.1:3306)/hello?parseTime=true"
func main() {
	db, _ := sql.Open("mysql", localMysqlDSN)
	defer db.Close()
	
	cstSh, _ := time.LoadLocation("Asia/Shanghai")
	usNY, _ := time.LoadLocation("America/New_York")
	timeNowCN := time.Now().In(cstSh)
	timeNowNY := time.Now().In(usNY)
	fmt.Printf("timeNowCN: %v\n", timeNowCN)
	fmt.Printf("timeNowNY: %v\n", timeNowNY)
	
	db.Exec("INSERT INTO time_test(updated_at) VALUES(?),(?);",timeNowCN, timeNowNY)
	
	var (
		id int64
		updatedAt time.Time
	)
	rows, _ := db.Query("SELECT * FROM time_test")
	for rows.Next(){
		_ = rows.Scan(&id, &updatedAt)
		fmt.Printf("id: %v  updatedAt:%v\n", id, updatedAt)
	}
}

图中可以看出,尽管America/New_York时区要比UTC时间少5个小时,但无论是上海时区还是其他时区的时间,在存入mysql时,都自动转换为了UTC时间。所以同一时间点不同时区的 时间在存入数据库时,存储值都是一样的UTC时间

获取某个时间当日零点

记得准备工作中插入的记录2021-01-22 23:00:00吗,这是有意安排的一个特殊时间,因为一旦处理不当掉入坑里,你就会把它处理为今天(2021年1月22日) !先运行如下程序:

package main

import (
	"database/sql"
	"fmt"
	"time"

	// driver
	_ "github.com/go-sql-driver/mysql"
)

var localMysqlDSN = "root:rootroot@tcp(127.0.0.1:3306)/hello?parseTime=true"
func main() {
	db, _ := sql.Open("mysql", localMysqlDSN)
	defer db.Close()
	
	var (
		id int64
		updatedAt time.Time
	)
  // 读取数据库第一行: updateAt = 2021-01-22 23:00:00 UTC
	row := db.QueryRow("SELECT * FROM time_test")
	_ = row.Scan(&id, &updatedAt)
	fmt.Printf("id: %v  updatedAt:%v\n", id, updatedAt)

	cstSh, _ := time.LoadLocation("Asia/Shanghai")

	updatedAtStr := updatedAt.Format("2006-01-02") // 2021-01-22
	updatedAtStrSh := updatedAt.In(cstSh).Format("2006-01-02") // 2021-01-23

	updatedDate1, _ := time.Parse("2006-01-02", updatedAtStr) // 2021-01-22 00:00:00 UTC
	updatedDate2, _ := time.Parse("2006-01-02", updatedAtStrSh) // 2021-01-23 00:00:00 UTC

	updatedDateSh1, _ := time.ParseInLocation("2006-01-02", updatedAtStr, cstSh) // 2021-01-22 00:00:00 CST 上海
	updatedDateSh2, _ := time.ParseInLocation("2006-01-02", updatedAtStrSh, cstSh) // 2021-01-23 00:00:00 CST 上海
	
	fmt.Println("updatedDate1:", updatedDate1)
	fmt.Println("updatedDate2:", updatedDate2)
	fmt.Println("updatedDateSh1:", updatedDateSh1)
	fmt.Println("updatedDateSh2:", updatedDateSh2)
}

这里的逻辑是一个2x2的问题,即 格式化时间为字符串时以及解析时间为字符串时 分别加载时区与否。为了便于一眼就看明白,列个表格:

格式化时间为字符串解析字符串为时间结果
不带时区不带时区2021-01-22 00:00:00 UTC
带时区不带时区2021-01-23 00:00:00 UTC
不带时区带时区2021-01-22 00:00:00 CST
带时区带时区2021-01-23 00:00:00 CST ✔

这里的不带时区是指不主动加载时区,其实默认的是UTC时区。

首先分析下正确的结果,2021-01-22 23:00:00在数据库中是UTC时间,要计算当日零点时间,以UTC时区来看,很明显就是当天2021-01-22 00:00:00。但是在中国,我们的项目运行基本都要求北京时间,所以,首先要转化为中国标准时间CST,加8个小时就是2021-01-23 07:00:00 CST,再计算其当日零点应该为2021-01-23 00:00:00 CST。所以在go程序中格式化、解析时间时都要加载时区。同时可以看出go程序的如下行为:

  • Format() 视时区而定,传哪个时区的时间值,就格式化为哪个时区的字符串;
  • Parse()解析字符串时间时默认UTC时区,与系统时区无关;

时间比较

前面分别讨论了时间在读写、格式化、解析时时区的表现。那么时间在进行比较时,会受时区影响吗?需要将比较的时间转换到同一个时区吗?

现在依然从数据库中获取2021-01-22 23:00:00这条记录(注意是UTC时间),然后分别获得其不同时区下的今日零点时间来进行比较:

package main

import (
	"database/sql"
	"fmt"
	"time"

	// driver
	_ "github.com/go-sql-driver/mysql"
)

var localMysqlDSN = "root:rootroot@tcp(127.0.0.1:3306)/hello?parseTime=true"
func main() {
	db, _ := sql.Open("mysql", localMysqlDSN)
	defer db.Close()
	
	var (
		id int64
		updatedAt time.Time
	)
	row := db.QueryRow("SELECT * FROM time_test")
	_ = row.Scan(&id, &updatedAt)
	fmt.Printf("id: %v  updatedAt:%v\n", id, updatedAt)

	cstSh, _ := time.LoadLocation("Asia/Shanghai")
	usNY, _ := time.LoadLocation("America/New_York")
	updatedAtCN :=updatedAt.In(cstSh)
	updatedAtNY :=updatedAt.In(usNY)
	fmt.Printf("updatedAtCN: %v\n", updatedAtCN)
	fmt.Printf("updatedAtNY: %v\n", updatedAtNY)

	// 两个时区今日0点
	todayDateStrCN := updatedAtCN.Format("2006-01-02")
	todayDateStrNY := updatedAtNY.Format("2006-01-02")
	todayZeroTimeCN, _ := time.ParseInLocation("2006-01-02", todayDateStrCN, cstSh) //上海时区
	todayZeroTimeNY, _ := time.ParseInLocation("2006-01-02", todayDateStrNY, usNY) // 纽约时区

	fmt.Println("todayZeroTimeCN:", todayZeroTimeCN)
	fmt.Println("todayZeroTimeNY:", todayZeroTimeNY)
	fmt.Println("updatedAt is before China-today  :", updatedAt.Before(todayZeroTimeCN))
	fmt.Println("updatedAt is before NewYork-today:", updatedAt.Before(todayZeroTimeNY))
	fmt.Println("updatedAt is after China-today   :", updatedAt.After(todayZeroTimeCN))
	fmt.Println("updatedAt is after NewYork-today :", updatedAt.After(todayZeroTimeNY))
}

updatedAt在不同时区下,一个为1月23日,一个为1月22日,但肯定都要晚于当日零点。从比较结果可以看出,在不同时区下进行比较都成立,说明程序中时间的比较是时区无关的(其实比较的是时间戳)。所以不同时区的时间可以直接进行比较。

结论

时间的读写

这里结论的前提是我们并没有对数据库进行任何时区相关的设置,比如mongo有时区感应tz_aware配置。在此前提下,可以得出:

  1. 除特定操作外,程序中的时间默认为操作系统时区,典型的如time.Now()
  2. 无论哪个时区的时间写入数据库时,都会转换为相应的UTC时间;
  3. 时间在读取时,存入的是UTC时间,读取的就是UTC时间;

时间的格式化与解析

  1. 时间在格式化时随时区而定,传递的是哪个时区的时间值,就格式化为哪个时区的字符串;
  2. 时间字符串在解析时,默认为UTC时区,与系统时区无关;
  3. 所以时间在格式化时、解析时都要加载时区;

时间的比较

  1. 时间的比较与时区无关,不同时区的时间可以直接进行比较,实际比较的是时间戳。

思考—判断两个日期是否是同一天

下面这段代码,是我在此前的工作中写的。假设系统的时间已经设置为了Asia/Shanghai时区,你觉得这个处理对吗?

// IsToday 判断统计日期是否是今天
// statTIme 是从数据库获取然后作为参数传递
var cstSh, _ = time.LoadLocation("Asia/Shanghai") //加载时区

func IsToday(statTime time.Time) bool {
	statTimeStr := statTime.Format("2006-01-02")
	todayTimeStr := time.Now().Format("2006-01-02")
	statDate, err := time.ParseInLocation("2006-01-02", statTimeStr, cstSh)
	if err != nil {
		log.Fatalln(err)
	}
	todayDate, err := time.ParseInLocation("2006-01-02", todayTimeStr, cstSh)
	if err != nil {
		log.Fatalln(err)
	}
	if statDate.Equal(todayDate) {
		return true
	}
	return false
}