當 go-sql-driver 遇到 mysql timestamp 的離奇 bug
對于 Go CURD Boy 來說,
github.com/go-sql-driver/mysql
這個庫都不會陌生。
或許有些人可能沒太留意,直接就復制粘貼了 import。
比如我們使用 gorm 的時候,如果不加
_ "github.com/go-sql-driver/mysql"
的話,就會報:
panic: sql: unknown driver "mysql" (forgotten import?)
。
基本上 Go 的 CURD 都離不開這個特別重要的庫。
不過最近在使用 go-sql-driver/mysql 查詢 mysql 的時候,就出現(xiàn)一個很有意思的 bug, 這里分享出來給大家看看。
Demo 準備
數(shù)據(jù)庫準備
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 這個類型。
現(xiàn)在插入一條數(shù)據(jù),然后查看剛插入的數(shù)據(jù)的值。
insert?into?Test1?values?(1,?'2022-01-01?00:00:00')
查看下 msyql 當前的時區(qū):
?show?VARIABLES?like?'%time_zone%';
結(jié)果是:
| Variable_name | Value |
|---|---|
| system_time_zone | CST |
| time_zone | +08:00 |
接下來使用 mysql unix_timestamp 查看 create_time 的時間戳
SELECT?unix_timestamp(create_time)?from?Test1?where?id?=?1;
結(jié)果是:
| unix_timestamp(create_time) |
|---|
| 1640966400 |
使用 go-sql-driver 讀取 create_time 的值
package?main
import?(
?"database/sql"
?"fmt"
?_?"github.com/go-sql-driver/mysql"
?"time"
)
func?main()?{
??var?user?=?"user"
??var?pwd?=?"password"
??var?dbName?=?"dbname"
??dsn?:=?fmt.Sprintf("%s:%s@tcp(localhost:3306)/%s?timeout=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())
??}
}
我們運行程序會輸出下面的結(jié)果:
2022-01-01?00:00:00?+0000?UTC
1640995200
你發(fā)現(xiàn)問題所在了嗎, 可能順著看下來不太直觀,我用一張圖把結(jié)果粘在一塊就看清楚了。

看圖中紅色箭頭指向的兩個結(jié)果,用 go-sql-driver 讀取的結(jié)果和在 mysql 中用 unix_timestamp 獲取的結(jié)果明顯是不一樣的。
通過截圖可以看出來,現(xiàn)在 create_time 在數(shù)據(jù)庫的 2022-01-01 00:00:00 是東八區(qū)的時間,也就是北京時間,這個時間對應的時間戳就是 1640966400. 但是 go 程序讀出來的卻不是, go-sql-driver 讀取到 1640995200 是什么呢?其實細心的小伙伴已經(jīng)看出來了,這是 0 時區(qū)的 2022-01-01 00:00:00。
再直接點,mysql 的 create_time 是 2022-01-01 00:00:00 +008,而讀取到的是 2022-01-01 00:00:00 +000,他倆壓根就不是一個值。
基本能看出來 bug 是如何發(fā)生的了。下面接著剖析下 go-sql-driver 源碼,看看是哪里導致的結(jié)果的錯誤。
go-sq-driver 源碼
就不貼詳細的源碼了,只貼關(guān)鍵的路徑,有興趣的小伙伴可以順著這個路徑去看。

我們詳細關(guān)注調(diào)用路徑中紅色的兩個方塊的內(nèi)存中的值。

//?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 把數(shù)據(jù)庫讀出來的 create_time timestamp 值當做一個字符串,然后按照 mysql timestamp 的標準格式 "0000-00-00 00:00:00.000000" 去解析,分別得到 year, month, day, hour, min, sec。最后依賴傳入 time.Location 值,調(diào)用 go 系統(tǒng)庫 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 時區(qū)。

讀取數(shù)值不一致的原因分析
再回頭看開頭的程序,我們初始化 go-sql-driver 的 dsn 是 user:password@tcp(localhost:3306)/dbname?timeout=100s&parseTime=true&interpolateParams=true. 該 dsn 里面并不包含 loc 信息,所以 go-sql-driver 使用了默認的 UTC 時區(qū)。然后解析從 mysql 中獲取的 timestamp 字段了,也就用默認的 UTC 時區(qū)去生成 Date,結(jié)果也就錯了。
其實問題的主要原因就是:go-sql-driver 并沒有按照數(shù)據(jù)庫的時區(qū)去解析 timestamp 字段,而且依賴了開發(fā)者生成的 dsn 傳入的 loc。當開發(fā)者傳入的 loc 和 數(shù)據(jù)庫的 time_zone 不匹配的時候,所有的 timestamp 字段都會解析錯誤。
有些人可能有疑問,如果 go-sql-driver 為什么不直接使用 mysql 的時區(qū)去解析 timestamp 呢,提了一個 issue,看官方如何回復吧。
結(jié)論
所以說,現(xiàn)階段使用 go-sql-driver 的時候一定要特別注意,生成的 dsn 字符串一定要和數(shù)據(jù)的時區(qū)保持一致,不然的話就會導致 timestamp 字段解析錯誤。
參考文檔
- [The DATE, DATETIME, and TIMESTAMP Types] https://dev.mysql.com/doc/refman/8.0/en/datetime.html
- [mysql的timestamp會存在時區(qū)問題?] https://juejin.cn/post/7007044908250824741
- [is this is go-sql-driver bug] https://github.com/go-sql-driver/mysql/issues/1379
推薦閱讀
我為大家整理了一份 從入門到進階的Go學習資料禮包 ,包含學習建議:入門看什么,進階看什么。 關(guān)注公眾號 「polarisxu」,回復? ebook ?獲取;還可以回復「進群」,和數(shù)萬 Gopher 交流學習。
