暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

          前一段时间,引入了第三方库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&...&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,程序运行起来,到底是哪个时区,谁能弄清楚?是不是增加了维护成本?所以要辩证性看源码。

                          文章转载自golang算法架构leetcode技术php,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论