文|郝洪范
京东技术专家
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"
的具体源码了,只贴要害的门路。
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-dri…。
PART. 3 最初论断
在 MySQL 中读写 timestamp 类型数据时,有如下注意事项:
- 默认约定:写入 MySQL 工夫时,把以后时区的工夫转换为 UTC + 00:00(世界标准时区)的值,读取后在前端展现时再次进行转换;
- 如果不违心应用默认约定,在现阶段应用 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 半年布局