一个 go-sql-driver 的离奇 bug

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

图片

文|郝洪范

京东技术专家

Seata-go 项目共同发起人

微服务底层技术的探索与研究。

本文 3482 字 阅读 7 分钟

对于 Go CURD Boy 来说相信 github.com/go-sql-driver/mysql 这个库都不会陌生。基本上 Go 的 CURD 都离不开这个特别重要的库。我们在开发 Seata-go 时也使用了这个库。不过最近在使用 go-sql-driver/mysql 查询 MySQL 的时候就出现一个很有意思的 bug, 觉得有必要分享出来以防止后来者再次踩坑。

PART. 1 问题详述

为了说明问题这里不详述 Seata-go 的相关代码用一个单独的 demo 把问题详细描述清楚。

1.1 环境准备

在一个 MySQL 实例上准备如下环境

CREATE TABLE `Test1` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP,  
-PRIMARY KEY (`id`)) ENGINE=InnoDB AUTO_INCREMENT=101 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

从这个 SQL 语句中可以看出来 create_time 是 timestamp 类型这里要特别留意 timestamp 这个类型。

现在插入一条数据然后查看刚插入的数据的值。

insert into Test1 values (1, '2022-01-01 00:00:00')

查看下 MySQL 当前的时区。请记好相关值草蛇灰线伏笔于此。

 show VARIABLES like '%time_zone%';

查询结果

图片

接下来使用 MySQL unix_timestamp 查看 create_time 的时间戳

SELECT unix_timestamp(create_time) from Test1 where id = 1;

查询结果

图片

1.2 测试程序

有如下 demo 程序示例使用 go-sql-driver 读取 create_time 的值

package main

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

    _ "github.com/go-sql-driver/mysql"
    )
    
func main() {
var user = "user" 
var pwd = "password" 
var dbName = "dbname"

  dsn := fmt.Sprintf("%s:%s@tcp(localhost:3306)/%stimeout=100s&parseTime=true&interpolateParams=true", user, pwd, dbName)  
  db, err := sql.Open("mysql", dsn)
  if err != nil { 
  panic(err) 
  } 
  
  defer db.Close()
  rows, err := db.Query("select create_time 
  from Test1 limit 1") 
  if err != nil {  
  panic(err)  
  }  
  
  for rows.Next() {  
  t := time.Time{}   
  rows.Scan(&t)  
  fmt.Println(t)   
  fmt.Println(t.Unix())  }}

我们运行个程序会输出下面的结果:

2022-01-01 00:00:00 +0000 UTC1640995200

1.3 问题详述

发现问题所在了吗有图如下把结果放在一块可以详细说明问题。

图片

图中红色箭头指向的两个结果用 go-sql-driver 读取的结果和在 MySQL 中用 unix_timestamp 获取的结果明显是不一样的。

PART. 2 问题探案

1.3 小节中最后示图可以看出数据库中 create_time  的值 2022-01-01 00:00:00 是东八区的时间也就是北京时间这个时间对应的时间戳就是 1640966400 。但是 go-sql-driver 示例程序读出来的却是 1640995200  这是什么值这是 0 时区的 2022-01-01 00:00:00

对问题的直白描述就是MySQL 的 create_time 是 2022-01-01 00:00:00 +008 而读取到的是 2022-01-01 00:00:00 +000 他俩压根就不是一个值。

基本能看出来 bug 是如何发生的了。那就需要剖析下 go-sql-driver 源码追查问题的根源。

2.1 go-sq-driver 源码分析

这里就不粘贴 "github.com/go-sql-driver/mysql" 的详细源码了只贴关键的路径。

image.png

Debug 的时候详细关注调用路径中红色的两个方块的内存中的值。

图片

// https://github.com/go-sql-driver/mysql/blob/master/packets.go#L788-
L798
func (rows *textRows) readRow(dest []driver.Value) error {

  // ... 
  
  // Parse time field  
  switch rows.rs.columns[i].fieldType
  { 
  case fieldTypeTimestamp, 
  fieldTypeDateTime,  
  fieldTypeDate,  
  fieldTypeNewDate:   
  if dest[i], err = parseDateTime(dest[i].([]byte), mc.cfg.Loc);
  err != nil {      return err    }  }}
func parseDateTime(b []byte, loc *time.Location) (time.Time, error) {  const base = "0000-00-00 00:00:00.000000"  switch len(b) {  case 10, 19, 21, 22, 23, 24, 25, 26: // up to "YYYY-MM-DD HH:MM:SS.MMMMMM"
    year, err := parseByteYear(b)
    month := time.Month(m)
    day, err := parseByte2Digits(b[8], b[9])
    hour, err := parseByte2Digits(b[11], b[12])
    min, err := parseByte2Digits(b[14], b[15])
    sec, err := parseByte2Digits(b[17], b[18])
    // https://github.com/go-sql-driver/mysql/blob/master/utils.go#L166-L168    if len(b) == 19 {      return time.Date(year, month, day, hour, min, sec, 0, loc), nil    }  }}

从这里基本上就能明白go-sql-driver 把数据库读出来的 create_time timestamp 值当做一个字符串然后按照 MySQL timestamp 的标准格式 “0000-00-00 00:00:00.000000” 去解析分别得到 year, month, day, hour, min, sec。最后依赖传入 time.Location 值调用 Go 系统库 time.Date() 再去生成对应的值。

这里表面看起来没有问题其实这里严重依赖了传入的 time.Location。这个 time.Location 是如何得到的呢进一步阅读源码可以明显的看出来是通过解析传入的 DSN 的 Loc 获取。

其中关键代码是https://github.com/go-sql-driver/mysql/blob/master/dsn.go#L467-L474

图片

如果传入的 DSN 串不带 Loc 时Loc 就是默认的 UTC 时区。

图片

2.2 抽丝剥茧

回头看开头的程序初始化 go-sql-driver 的 DSN 是 user:password@tcp(localhost:3306)/dbname?timeout=100s&parseTime=true&interpolateParams=true该 DSN 里面并不包含 Loc 信息go-sql-driver 用使用了默认的 UTC 时区。然后解析从 MySQL 中获取的 timestamp 字段了也就用默认的 UTC 时区去生成 Date结果也就错了。

因此问题的主要原因是go-sql-driver 并没有按照数据库的时区去解析 timestamp 字段而且依赖了开发者生成的 DSN 传入的 Loc。当开发者传入的 Loc 和数据库的 time_Zone 不匹配的时候所有的 timestamp 字段都会解析错误。

有些人可能有疑问如果 go-sql-driver 为什么不直接使用 MySQL 的时区去解析 timestamp 呢

我们已经提了一个 issue商讨更好的解决方案https://github.com/go-sql-driver/mysql/issues/1379

PART. 3 最后结论

在 MySQL 中读写 timestamp 类型数据时有如下注意事项

  1. 默认约定写入 MySQL 时间时把当前时区的时间转换为 UTC + 00:00世界标准时区的值读取后在前端展示时再次进行转换

  2. 如果不愿意使用默认约定在现阶段使用 go-sql-driver 的时候一定要特别注意需要在 DSN 字符串加上 “loc=true&time_zone=*” , 和数据的时区保持一致不然的话就会导致 timestamp 字段解析错误。

| 参考文档 |

《The date, datetime, and timestamp Types》

https://dev.mysql.com/doc/refman/8.0/en/datetime.html

《MySQL 的 timestamp 会存在时区问题》

https://juejin.cn/post/7007044908250824741

《Feature request: Fetch connection time_zone automatically》

https://github.com/go-sql-driver/mysql/issues/1379

社区讨论群

细节处见真章

Seata-go 社区认认真真做开源

做对用户负责任的高质量的项目。

了解更多…

Seata Star 一下✨
https://github.com/seata/seata-go

本周推荐阅读

Seata AT 模式代码级详解

蚂蚁集团境外站点 Seata 实践与探索

Seata 多语言体系建设

Seata-php 半年规划

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: go