从一次 PR 经历谈谈 Go 和 MySQL 的时区问题
2023-5-17 08:54:6 Author: Go语言中文网(查看原文) 阅读量:19 收藏

前一段时间,引入了第三方库https://github.com/dolthub/go-mysql-server来进行mysql的单测,它是一个纯go实现的mysql server端,使用它可以去除fake test对mysql环境/docker环境的依赖,实测可以提升运行速度50%以上。实际测试的过程中,发现它会改变datetime类型字段的时区值,导致时区被改的诡异现象。当我们用mysql-cli连上go-mysql-server后,设置当前时区为东八区,就会出现下面的诡异现象。

mysql> create table test (  `sale_end` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '停售时间');Query OK, 0 rows affected (0.00 sec)
mysql> insert into test (sale_end) values('2023-05-09 09:00:00 +0800 CST');Query OK, 1 row affected (0.00 sec)
mysql> select * from test;+---------------------+| sale_end |+---------------------+| 2023-05-09 01:00:00 |+---------------------+1 row in set (0.00 sec)

分析了下https://github.com/dolthub/go-mysql-server的源码后发现go-mysql-server会解析datetime类型的字符串转换为time.Time, 但是它解析的时候用的时区是UTC,于是就导致了上述问题。所以我想到的办法是在go-mysql-server启动的时候设置TZ环境变量,也就是服务器时区为东八区,解析的时候使用time.ParseInLocation来解析,因为我们单测和go-mysql-server,运行在同一一个进程中,就能解决上述时区问题。

但是提交单测的时候发现golang是不允许修改时区的,比如下面的例子:

func TestTimeZone(t *testing.T) {  os.Setenv("TZ", "Asia/Shanghai")  spew.Dump(time.Parse("2006-01-02 15:04:05", "1970-01-01 00:00:00")) //1970-01-01 00:00:00 +0000 UTC  spew.Dump(time.Local)  spew.Dump(time.ParseInLocation("2006-01-02 15:04:05", "1970-01-01 00:00:00", time.Local)) //1970-01-01 00:00:00 +0800 CST  loc, _ := time.LoadLocation("UTC")  spew.Dump(time.Date(1970, 1, 1, 0, 0, 1, 0, loc))        //(time.Time) 1970-01-01 00:00:01 +0000 UTC  spew.Dump(time.Date(1970, 1, 1, 0, 0, 1, 0, time.UTC))   //(time.Time) 1970-01-01 00:00:01 +0000 UTC  spew.Dump(time.Date(1970, 1, 1, 0, 0, 1, 0, time.Local)) //(time.Time) 1970-01-01 00:00:01 +0800 CST  os.Setenv("TZ", "UTC")                                   //修改tz没有用  spew.Dump(time.Date(1970, 1, 1, 0, 0, 1, 0, time.Local)) //(time.Time) 1970-01-01 00:00:01 +0800 CST  os.Setenv("TZ", "Asia/Shanghai")  spew.Dump(time.Date(1970, 1, 1, 0, 0, 1, 0, time.Local)) //(time.Time) 1970-01-01 00:00:01 +0800 CST}

可以看到 os.Setenv("TZ", "UTC")  ,其实是不生效的,为什么呢?我们看下源码

var localLoc Locationvar localOnce sync.Once
func (l *Location) get() *Location { if l == nil { return &utcLoc } if l == &localLoc { localOnce.Do(initLocal) } return l}
func initLocal() { // consult $TZ to find the time zone to use. // no $TZ means use the system default /etc/localtime. // $TZ="" means use UTC. // $TZ="foo" or $TZ=":foo" if foo is an absolute path, then the file pointed // by foo will be used to initialize timezone; otherwise, file // /usr/share/zoneinfo/foo will be used.
tz, ok := syscall.Getenv("TZ")

可以看到通过环境变量TZ,来更改当前时区信息是个单例,也就意味着,一个程序在运行期间,只有最早的那一次才生效。

var utcLoc = Location{name: "UTC"}
// Local represents the system's local time zone.// On Unix systems, Local consults the TZ environment// variable to find the time zone to use. No TZ means// use the system default /etc/localtime.// TZ="" means use UTC.// TZ="foo" means use file foo in the system timezone directory.var Local *Location = &localLoc

并且,我们的time.Local变量也默认是赋值为UTC的。怎么解决这个问题呢,我们可以单独声明一个包,初始化时区信息,并且在程序启动的最开始引入

package tzinit
import ( "os" "time")
func init() { os.Setenv("TZ", "UTC") time.Local = time.UTC}
import (  _ "learn/time/time_zone/tz"

这样就能修改时区了。于是我就可以愉快地改单测了。然后顺利提交了我的pr:https://github.com/dolthub/go-mysql-server/pull/1733。

        但是提交后go-mysql-server的作者和我交流了下mysql时区的问题。这里我们也可以复习下mysql的关于时间的处理标准,以及golang mysql client的处理逻辑。

        从mysql的的官方文档https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html,我们可以知道mysql是这样处理时间相关字段的

The session time zone setting does not affect values displayed by functions such as UTC_TIMESTAMP() or values in DATE, TIME, or DATETIME columns. Nor are values in those data types stored in UTC; the time zone applies for them only when converting from TIMESTAMP values. If you  want locale-specific arithmetic for DATE, TIME, or  DATETIME values, convert them to UTC, perform the  arithmetic, and then convert back.

简单翻译下:mysql server 在接收到sql语句的时候除了TIMESTAMP类型的列会按照服务器时区进行解析,然后转换成UTC时间戳存储外,其它类型的列,比如DATE, TIME, or DATETIME会原样存储,UTC_TIMESTAMP() 函数的执行也不受服务器时区的影响,这些字段的时区都是client的具体逻辑决定的,对于mysql-server来说,这些字段是黑盒,原样按照字符串存储,并不会解析。

但是go-mysql-server在实现的时候,用UTC时区解析了上述字段,也就出现了前面奇怪的问题,更明确的对比可以看下下面的例子

mysql-server:

mysql> set time_zone='+08:00';Query OK, 1 row affected (0.00 sec)
mysql> insert into test (sale_end) values('2023-05-09 09:00:00+08:00');Query OK, 1 row affected (0.01 sec)
mysql> select * from test;+---------------------+| sale_end |+---------------------+| 2023-05-09 09:00:00 |+---------------------+1 row in set (0.00 sec)

set time_zone='+00:00';Query OK, 0 rows affected (0.00 sec)mysql> insert into test (sale_end) values('2023-05-09 09:00:00+08:00');Query OK, 1 row affected (0.00 sec)select * from test;+---------------------+| sale_end |+---------------------+| 2023-05-09 01:00:00 |+---------------------+1 row in set (0.00 sec)

go-mysql-server:

mysql> set time_zone='+08:00';Query OK, 1 row affected (0.00 sec)
mysql> insert into test (sale_end) values('2023-05-09 09:00:00 +0800 CST');Query OK, 1 row affected (0.01 sec)
mysql> select * from test;+---------------------+| sale_end |+---------------------+| 2023-05-09 01:00:00 |+---------------------+1 row in set (0.00 sec)
set time_zone='+00:00';Query OK, 1 row affected (0.00 sec)
mysql> insert into test (sale_end) values('2023-05-09 09:00:00 +0800 CST');Query OK, 1 row affected (0.00 sec)
mysql> select * from test;+---------------------+| sale_end |+---------------------+| 2023-05-09 01:00:00 |+---------------------+1 row in set (0.00 sec)

所以我给的建议是服务器端用time.ParseInLocation解析时间,这样服务器和client的解析规则一样,就不会有问题。

说完服务器时区问题,我们讨论下go-mysql-client的时区是如何处理的。

mysql有两个时区概念全局时区和会话时区,对应变量如下:

global.time_zone: mysql服务设置的时区session.time_zone: 此次连接的设置时区,一般就是global.time_zone,上面返回的SYSTEM代表取系统时区,也就是东八区,默认会从TZ变量来取。

        客户端在DSN参数上可以加两个变量parseTime和loc[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]

parseTime默认为false,把mysql中的 DATE、DATETIME、TIMESTAMP 转为golang中的[]byte类型设置为true,将会转为golang中的 time.Time 类型
loc默认为UTC,表示转换DATEDATETIMETIMESTAMPtime.Time 时所使用的时区,设置成Local,则与系统设置的时区一致如果想要设置成中国时区可以设置成 Asia/Shanghai

更多的时区可以参考 /usr/share/zoneinfo/ 或者$GOROOT/lib/time/zoneinfo.zip。在实际的使用中,我们往往会配置成 parseTime=true 和 loc=Local,这样避免了手动转换DATE、DATETIME、TIMESTAMP。

因为我们一般会把loc设置成系统的东八区,所以会有前文的问题。总结下:很多细节问题,虽然看上去没啥技术难度,并且很反人类,比如golang中途改TZ环境变量不生效,比如mysql-server的时间处理方式如此复杂。但是从软件的可维护性上来思考,这样做确实可以将整个系统复杂度降低,提升可维护性。假如golang任何地方改TZ环境变量马上生效,一个初学者,在一个进程中,多次设置了TZ,程序运行起来,到底是哪个时区,谁能弄清楚?是不是增加了维护成本?所以要辩证性看源码。


推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651454359&idx=1&sn=41bb59e43fba509a4471075e7f1f243e&chksm=80bb2565b7ccac7360d17b4e432b6e25c839685d5d1f965b1c3d41bfffa9e31f7e73db3dda62#rd
如有侵权请联系:admin#unsafe.sh