从一次pr经历谈谈golang和mysql的时区问题

2023-09-06 19:26:48 浏览数 (1)

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

代码语言:javascript复制
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是不允许修改时区的,比如下面的例子:

代码语言:javascript复制
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") ,其实是不生效的,为什么呢?我们看下源码

代码语言:javascript复制
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,来更改当前时区信息是个单例,也就意味着,一个程序在运行期间,只有最早的那一次才生效。

代码语言:javascript复制
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的。怎么解决这个问题呢,我们可以单独声明一个包,初始化时区信息,并且在程序启动的最开始引入

代码语言:javascript复制
package tzinit

import (
  "os"
  "time"
)

func init() {
  os.Setenv("TZ", "UTC")
  time.Local = time.UTC
}
代码语言:javascript复制
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是这样处理时间相关字段的

代码语言:javascript复制
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:

代码语言:javascript复制
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:

代码语言:javascript复制
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有两个时区概念全局时区和会话时区,对应变量如下:

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

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

代码语言:javascript复制
parseTime默认为false,把mysql中的 DATE、DATETIME、TIMESTAMP 
转为golang中的[]byte类型
设置为true,将会转为golang中的 time.Time 类型
代码语言:javascript复制
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,程序运行起来,到底是哪个时区,谁能弄清楚?是不是增加了维护成本?所以要辩证性看源码。

0 人点赞