前一段时间,引入了第三方库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 Location
var 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&...¶mN=valueN]
parseTime默认为false,把mysql中的 DATE、DATETIME、TIMESTAMP
转为golang中的[]byte类型
设置为true,将会转为golang中的 time.Time 类型
loc默认为UTC,表示转换DATE、DATETIME、TIMESTAMP 为 time.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,程序运行起来,到底是哪个时区,谁能弄清楚?是不是增加了维护成本?所以要辩证性看源码。
推荐阅读