一、问题
1、问题涉及组件
xorm v1.3.2、go-sql-driver v1.6.0、mysql5.7
2、问题描述
在一次测试过程中,一个后台服务通过rpc调用一个业务服务更新数据库的时间,结果数据库的时间跟预期相差8小时,调用链路如下:
![](https://tech.qimao.com/content/images/2023/11/image.png)
数据表如下:
CREATE TABLE `test_datetime` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`tdatetime` datetime DEFAULT NULL,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `test_datetime` (`id`, `tdatetime`, `create_time`)
VALUES
(1, '2021-08-10 23:13:25', '2023-10-24 13:02:54');
代码已提出来,如下:
package main
import (
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
"xorm.io/xorm"
)
func main() {
dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&timeout=100s"
e, err := xorm.NewEngine("mysql", dsn)
if err != nil {
panic(err)
}
e.ShowSQL(true)
session := e.NewSession()
defer session.Close()
dt, _ := time.ParseInLocation("2006-01-02 15:04:05", "2021-08-17 23:13:25", time.Local)
data := map[string]interface{}{
"tdatetime": dt,
}
session.Table("test_datetime").Where("id=1").Update(data)
}
type testdatetime struct {
Id int64
Tdatetime time.Time
CreateTime time.Time
}
3、debug
1、先排查最容易的
首先通过日志排除dubbo-go问题,server端到端数据是正常的;同时抓包看到传输给mysql的时候,时间已经不符合预期(2021-08-17 23:13:25),如下图
![](https://tech.qimao.com/content/images/2023/11/image-1.png)
那么就是xorm、go-sql-driver的问题了,那么我们一步一步看,先看xorm组件有没有对时间进行处理,看一下这个update方法:
func (session *Session) Update(bean interface{}, condiBean ...interface{}) (int64, error) {
......
var err error
isMap := t.Kind() == reflect.Map
isStruct := t.Kind() == reflect.Struct
if isStruct {
if err := session.statement.SetRefBean(bean); err != nil {
return 0, err
}
if len(session.statement.TableName()) == 0 {
return 0, ErrTableNotFound
}
if session.statement.ColumnStr() == "" {
colNames, args, err = session.statement.BuildUpdates(v, false, false,
false, false, true)
} else {
colNames, args, err = session.genUpdateColumns(bean)
}
if err != nil {
return 0, err
}
} else if isMap {
colNames = make([]string, 0)
args = make([]interface{}, 0)
bValue := reflect.Indirect(reflect.ValueOf(bean))
for _, v := range bValue.MapKeys() {
colNames = append(colNames, session.engine.Quote(v.String())+" = ?")
args = append(args, bValue.MapIndex(v).Interface())
}
} else {
return 0, ErrParamsType
}
......
}
可以看到,这里判断更新参数是map的时候,只是把参数进行了拼接,然后经过一步一步调试,这个args一层一层向下传递,一直到调用go-sql-driver的方法,参数一直都是正确的,那么肯定就是这个包对时间进行了处理:
![](https://tech.qimao.com/content/images/2023/11/image-2.png)
我们继续看看,这个时间是什么时候变更的 mysql源码地址
func (stmt *mysqlStmt) writeExecutePacket(args []driver.Value) error {
......
for i, arg := range args {
......
switch arg.(type) {
......
case time.Time:
paramTypes[i+i] = byte(fieldTypeString)
paramTypes[i+i+1] = 0x00
var a [64]byte
var b = a[:0]
if v.IsZero() {
b = append(b, "0000-00-00"...)
} else {
b, err = appendDateTime(b, v.In(mc.cfg.Loc))
if err != nil {
return err
}
}
paramValues = appendLengthEncodedInteger(paramValues,
uint64(len(b)),
)
paramValues = append(paramValues, b...)
......
}
......
}
func appendDateTime(buf []byte, t time.Time) ([]byte, error) {
year, month, day := t.Date()
hour, min, sec := t.Clock()
nsec := t.Nanosecond()
if year < 1 || year > 9999 {
return buf, errors.New("year is not in the range [1, 9999]: " + strconv.Itoa(year)) // use errors.New instead of fmt.Errorf to avoid year escape to heap
}
year100 := year / 100
year1 := year % 100
var localBuf [len("2006-01-02T15:04:05.999999999")]byte // does not escape
localBuf[0], localBuf[1], localBuf[2], localBuf[3] = digits10[year100], digits01[year100], digits10[year1], digits01[year1]
localBuf[4] = '-'
localBuf[5], localBuf[6] = digits10[month], digits01[month]
localBuf[7] = '-'
localBuf[8], localBuf[9] = digits10[day], digits01[day]
if hour == 0 && min == 0 && sec == 0 && nsec == 0 {
return append(buf, localBuf[:10]...), nil
}
localBuf[10] = ' '
localBuf[11], localBuf[12] = digits10[hour], digits01[hour]
localBuf[13] = ':'
localBuf[14], localBuf[15] = digits10[min], digits01[min]
localBuf[16] = ':'
localBuf[17], localBuf[18] = digits10[sec], digits01[sec]
if nsec == 0 {
return append(buf, localBuf[:19]...), nil
}
nsec100000000 := nsec / 100000000
nsec1000000 := (nsec / 1000000) % 100
nsec10000 := (nsec / 10000) % 100
nsec100 := (nsec / 100) % 100
nsec1 := nsec % 100
localBuf[19] = '.'
// milli second
localBuf[20], localBuf[21], localBuf[22] =
digits01[nsec100000000], digits10[nsec1000000], digits01[nsec1000000]
// micro second
localBuf[23], localBuf[24], localBuf[25] =
digits10[nsec10000], digits01[nsec10000], digits10[nsec100]
// nano second
localBuf[26], localBuf[27], localBuf[28] =
digits01[nsec100], digits10[nsec1], digits01[nsec1]
// trim trailing zeros
n := len(localBuf)
for n > 0 && localBuf[n-1] == '0' {
n--
}
return append(buf, localBuf[:n]...), nil
}
这里循环处理了传入的参数,当是时间类型时,我们可以看到这里会转换而且转了时区(v.In(mc.cfg.Loc)),程序跑到这里时区已经发生了变化,如下图:
![](https://tech.qimao.com/content/images/2023/11/image-3.png)
那么就是这个mc.cfg.Loc参数的原因,回看我们的dsn信息(dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&timeout=100s"),是没有设置这个参数的,那么go-sql-driver是如何处理的,代码如下:
// ParseDSN parses the DSN string to a Config
func ParseDSN(dsn string) (cfg *Config, err error) {
// New config with some default values
cfg = NewConfig()
......
}
// NewConfig creates a new Config and sets default values.
func NewConfig() *Config {
return &Config{
Collation: defaultCollation,
Loc: time.UTC,
MaxAllowedPacket: defaultMaxAllowedPacket,
AllowNativePasswords: true,
CheckConnLiveness: true,
}
}
那么可以看到,如果没有配置参数这里默认会用UTC时区,这也就解释了这个时区问题出现的原因,回顾一下项目中还有一种更新数据的方法,就是通过结构体对象更新,代码如下:
package main
import (
"time"
_ "github.com/go-sql-driver/mysql"
"xorm.io/xorm"
)
func main() {
dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&timeout=100s"
e, err := xorm.NewEngine("mysql", dsn)
if err != nil {
panic(err)
}
e.ShowSQL(true)
session := e.NewSession()
defer session.Close()
dt, _ := time.ParseInLocation("2006-01-02 15:04:05", "2021-08-17 23:13:25", time.Local)
//data := map[string]interface{}{
// "tdatetime": dt,
//}
data := &testdatetime{
Tdatetime: dt,
}
session.Table("test_datetime").Where("id=1").Update(data)
//d := testdatetime{}
//session.Table("test_datetime").Get(&d)
//
//fmt.Println(d)
}
type testdatetime struct {
Id int64
Tdatetime time.Time
CreateTime time.Time
}
这种更新方式就不会有时区问题,还是通过以上步骤排查发现,是xorm在处理入参,判断如果是结构体,对其参数做了格式化,将time.Time格式化成了字符串,而xorm里面用的默认时区是time.Local,也就解释了通过map和struct更新time.Time类型时区问题的原因。
所以其实更新时间类型时,最好传递格式化好的字符串,这样是稳妥的方式,也省着这些第三方包去这样或者那样的处理。
说到这里,我们再看一下mysql常用时间类型。
二、mysql时间类型
聊完了上面的问题,顺便再聊一下我们常用的几种时间类型,date、datetime、timestamp。
1、介绍
date类型占用3bytes,存储时间范围'1000-01-01' to '9999-12-31'。
datetime类型占用8bytes(5bytes + 3bytes fractional seconds storage),存储时间范围'1000-01-01 00:00:00' to '9999-12-31 23:59:59',以下是源码:
/**
@page datetime_and_date_low_level_rep DATETIME and DATE
| Bits | Field | Value |
| ----: | :---- | :---- |
| 1 | sign |(used when on disk) |
| 17 | year*13+month |(year 0-9999, month 0-12) |
| 5 | day |(0-31)|
| 5 | hour |(0-23)|
| 6 | minute |(0-59)|
| 6 | second |(0-59)|
| 24 | microseconds |(0-999999)|
Total: 64 bits = 8 bytes
@verbatim
Format: SYYYYYYY.YYYYYYYY.YYdddddh.hhhhmmmm.mmssssss.ffffffff.ffffffff.ffffffff
@endverbatim
*/
/**
Convert datetime to packed numeric datetime representation.
@param my_time The value to convert.
@return Packed numeric representation of my_time.
*/
longlong TIME_to_longlong_datetime_packed(const MYSQL_TIME &my_time) {
const longlong ymd = ((my_time.year * 13 + my_time.month) << 5) | my_time.day;
const longlong hms =
(my_time.hour << 12) | (my_time.minute << 6) | my_time.second;
const longlong tmp =
my_packed_time_make(((ymd << 17) | hms), my_time.second_part);
assert(!check_datetime_range(my_time)); /* Make sure no overflow */
return my_time.neg ? -tmp : tmp;
}
timestamp类型占用4bytes,如果需要更高精度,则需要额外的存储空间(4 bytes + fractional seconds storage),存储时间范围'1970-01-01 00:00:01' UTC to '2038-01-19 03:14:07' UTC,可以看到它时带着时区的,也就是说只有timestamp是一个确定的时间,其他的时间其实算是一种浮动的时间。
- 时间戳在进行存储时,先根据当前时区转换成UTC,再转换成int类型进行存储
- 时间戳在进行读取时,先将int类型转换为UTC,再转换为当前时区
由此也可以看出timestamp类型相对于其他数据类型,处理过程中需要更多的cpu时间片。
以下是对fractional seconds的占用空间的补充。
Fractional Seconds Precision | Storage Required |
---|---|
0 | 0 bytes |
1, 2 | 1 byte |
3, 4 | 2 bytes |
5, 6 | 3 bytes |
2、默认值
在sql_mode非严格模式并且不限制NO_ZERO_DATE时,如果我们此时插入一个不合法的0值,那么mysql会转换成一个恰当的'zero'值 '0000-00-00 00:00:00'。
3、实践
CREATE TABLE `test_datetime` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`tdatetime` datetime DEFAULT NULL,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
insert into test_datetime (tdatetime,create_time) value(0,0);
结果符合预期:
![](https://tech.qimao.com/content/images/2023/11/image-4.png)
插入完数据,我们在看一下,程序读取时会发生什么
func main() {
dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&timeout=100s&parseTime=true&loc=Local"
e, err := xorm.NewEngine("mysql", dsn)
if err != nil {
panic(err)
}
e.ShowSQL(true)
session := e.NewSession()
defer session.Close()
data := testdatetimeStr{}
session.Table("test_datetime").Get(&data)
fmt.Println(data)
}
type testdatetimeStr struct {
Id int64
Tdatetime string
CreateTime time.Time
}
// parseTime=true&loc=Local
// 结果:{1 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0805 LMT}
// parseTime=true
// 结果:{1 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0805 LMT}
可以看到无论是否加了loc=Local,Tdatetime的值都是'0001-01-01 00:00:00 +0000 UTC',CreateTime的值都是'0001-01-01 00:00:00 +0805 LMT',这两个字段的值是不一样的。
我们看一下原因:go-sql-driver的源码如下,
func (rows *textRows) readRow(dest []driver.Value) error {
......
for i := range dest {
// Read bytes and convert to string
dest[i], isNull, n, err = readLengthEncodedString(data[pos:])
pos += n
if err == nil {
if !isNull {
if !mc.parseTime {
continue
} else {
switch rows.rs.columns[i].fieldType {
case fieldTypeTimestamp, fieldTypeDateTime,
fieldTypeDate, fieldTypeNewDate:
dest[i], err = parseDateTime(
dest[i].([]byte),
mc.cfg.Loc,
)
if err == nil {
continue
}
default:
continue
}
}
} else {
dest[i] = nil
continue
}
}
......
}
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"
if string(b) == base[:len(b)] {
return time.Time{}, nil
}
year, err := parseByteYear(b)
if err != nil {
return time.Time{}, err
}
if year <= 0 {
year = 1
}
if b[4] != '-' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[4])
}
m, err := parseByte2Digits(b[5], b[6])
if err != nil {
return time.Time{}, err
}
if m <= 0 {
m = 1
}
month := time.Month(m)
if b[7] != '-' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[7])
}
day, err := parseByte2Digits(b[8], b[9])
if err != nil {
return time.Time{}, err
}
if day <= 0 {
day = 1
}
if len(b) == 10 {
return time.Date(year, month, day, 0, 0, 0, 0, loc), nil
}
if b[10] != ' ' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[10])
}
hour, err := parseByte2Digits(b[11], b[12])
if err != nil {
return time.Time{}, err
}
if b[13] != ':' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[13])
}
min, err := parseByte2Digits(b[14], b[15])
if err != nil {
return time.Time{}, err
}
if b[16] != ':' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[16])
}
sec, err := parseByte2Digits(b[17], b[18])
if err != nil {
return time.Time{}, err
}
if len(b) == 19 {
return time.Date(year, month, day, hour, min, sec, 0, loc), nil
}
if b[19] != '.' {
return time.Time{}, fmt.Errorf("bad value for field: `%c`", b[19])
}
nsec, err := parseByteNanoSec(b[20:])
if err != nil {
return time.Time{}, err
}
return time.Date(year, month, day, hour, min, sec, nsec, loc), nil
default:
return time.Time{}, fmt.Errorf("invalid time bytes: %s", b)
}
}
这里因为parseTime=true,那么当是时间类型字段时,会走到parseDateTime,这里面当判断是一个非法时间,程序默认返回time.Time{},也就是go中时间类型的ZERO值,对于这两个时间字段,go-sql-driver都会给上层返回'0001-01-01 00:00:00 +0000 UTC',那么对于create_time字段就是xorm里的处理方式不一样导致,这样就会有一个问题,在使用time.IsZero判断时间是否为空的时候,create_time这个字段就会返回false,因为时区不是0时区。
xorm代码如下:
// AsTime converts interface as time
func AsTime(src interface{}, dbLoc *time.Location, uiLoc *time.Location) (*time.Time, error) {
switch t := src.(type) {
......
case *time.Time:
z, _ := t.Zone()
if len(z) == 0 || t.Year() == 0 || t.Location().String() != dbLoc.String() {
tm := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(),
t.Minute(), t.Second(), t.Nanosecond(), dbLoc).In(uiLoc)
return &tm, nil
}
tm := t.In(uiLoc)
return &tm, nil
case time.Time:
z, _ := t.Zone()
if len(z) == 0 || t.Year() == 0 || t.Location().String() != dbLoc.String() {
tm := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(),
t.Minute(), t.Second(), t.Nanosecond(), dbLoc).In(uiLoc)
return &tm, nil
}
tm := t.In(uiLoc)
return &tm, nil
......
}
return nil, fmt.Errorf("unsupported value %#v as time", src)
}
可以看到这里直接将时间的值赋值到了time.Date,然后给了一个时区,这个时区时与数据库的时区一致的。
看到这里,go-sql-driver对时区的处理可能不是太好,如果没有加loc参数指定的时候,是不是默认使用Local,或者使用mysql的时区会好一点,不知道开发者会不会改进这一点。
4、注意的点
我们在定义数据库时间字段时,通常是存到秒,例如 datetime/timestamp -> 2023-10-30 13:47:03,那么如果此时我们给数据库插入一个这样的时间'2023-10-30 23:59:59.5'或'2023-10-30 23:59:59.49'会发生什么,这里直接给出结果,'2023-10-30 23:59:59.5' -> '2023-10-31 00:00:00','2023-10-30 23:59:59.49' -> '2023-10-30 23:59:59',其实mysql会对这样的数据进行四舍五入。
三、时区
1、介绍
时区主要是为了解决同一个时刻,全球各地在时间上的不同表现形式,举个例子:北京时间早上6点,那位于0时区的英国,还是前一天的22点。
我们在调试程序或者程序日志里,经常看到 '2023-10-31 01:00:00 +0800 CST'、'2023-10-30 14:55:01.011+0800','2023-10-31 00:00:00 +0000 UTC',这些时间用的都是UTC偏移量表示的,其中'+0800',表示比世界协调时间(UTC,零时区的时间),多8小时,后面的'CST'表示'China Standard Time',时区的字母缩写形式,还有“EST、WST、PST”等。
UTC偏移量用以下形式表示:±[hh]:[mm]、±[hh][mm]、或者±[hh]。
对于时间的表示也有很多形式,RFC3339, RFC822, RFC822Z, RFC1123, 和 RFC1123Z,其中RFC3339是新的协议。这个协议中除了解释时间格式,还给出了闰年、闰秒的规则。
闰年:
int leap_year(int year)
{
return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
}
闰秒:语法元素time second可能在闰秒发生的月份结束时具有值“60”——到目前为止:六月(XXXX-06-30T23:59:60Z)或十二月(XXXX-12-31T23:59:60Z);闰秒表见附录D。也可以减去闰秒,此时时间秒的最大值为“58”。在所有其他时间,时间秒的最大值为“59”。此外,在“Z”以外的时区中,闰秒点会随着时区偏移而移动(因此它在全球范围内同时发生)。
闰秒实际上是为适应地球自转的脚步而对国际原子时的人为增减。依据国际地球自转服务组织对国际原子时与世界时的监测数据,当两者之差达到0.9秒时,该机构就向全世界发布公告,在下一个6月或12月最后一天的最后一分钟,实施正闰秒或负闰秒,闰秒之前也引起过一些事故(2012闰秒变更时全球重大事故),闰秒让互联网企业如鲠在喉,也在讨论是不是可以取消。
2、go如何处理闰秒
我们先看一下go里面时间是如何存储的:
type Time struct {
// wall and ext encode the wall time seconds, wall time nanoseconds,
// and optional monotonic clock reading in nanoseconds.
//
// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
// The nanoseconds field is in the range [0, 999999999].
// If the hasMonotonic bit is 0, then the 33-bit field must be zero
// and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext.
// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
// unsigned wall seconds since Jan 1 year 1885, and ext holds a
// signed 64-bit monotonic clock reading, nanoseconds since process start.
wall uint64
ext int64
// loc specifies the Location that should be used to
// determine the minute, hour, month, day, and year
// that correspond to this Time.
// The nil location means UTC.
// All UTC times are represented with loc==nil, never loc==&utcLoc.
loc *Location
}
wall与ext中的数据存储方式如下,其中hasMonotonic=1时,ext是单调递增的:
![](https://tech.qimao.com/content/images/2023/11/image-5.png)
其中1885算是go语言时间的纪元,ntp的纪元是1990年,其实go设计时在这个时间之前会好一些,致于选择1885年1月1日,通常是为了表示一个特殊事件,这里没深究。
如下我们再看一下Now()时间函数的实现:
// Monotonic times are reported as offsets from startNano.
// We initialize startNano to runtimeNano() - 1 so that on systems where
// monotonic time resolution is fairly low (e.g. Windows 2008
// which appears to have a default resolution of 15ms),
// we avoid ever reporting a monotonic time of 0.
// (Callers may want to use 0 as "time not set".)
var startNano int64 = runtimeNano() - 1
// Now returns the current local time.
func Now() Time {
sec, nsec, mono := now()
mono -= startNano
sec += unixToInternal - minWall
if uint64(sec)>>33 != 0 {
return Time{uint64(nsec), sec + minWall, Local}
}
return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}
可以看到,这里uint64(sec)>>33 != 0的判断,翻译过来就是判断当前时间与2157年的比较,大于2157最高位0的方式记录,小与2157最高位1的发方式记录。而 2157 其实是 1885 + (2^33-1)/365/24/3600,源码里是用秒来计算的。
闰秒其实导致的问题就是,后面的时间减前面的时间会出现负数。那么我们看一下go中时间差:
// Sub returns the duration t-u. If the result exceeds the maximum (or minimum)
// value that can be stored in a Duration, the maximum (or minimum) duration
// will be returned.
// To compute t-d for a duration d, use t.Add(-d).
func (t Time) Sub(u Time) Duration {
if t.wall&u.wall&hasMonotonic != 0 {
te := t.ext
ue := u.ext
d := Duration(te - ue)
if d < 0 && te > ue {
return maxDuration // t - u is positive out of range
}
if d > 0 && te < ue {
return minDuration // t - u is negative out of range
}
return d
}
d := Duration(t.sec()-u.sec())*Second + Duration(t.nsec()-u.nsec())
// Check for overflow or underflow.
switch {
case u.Add(d).Equal(t):
return d // d is correct
case t.Before(u):
return minDuration // t - u is negative out of range
default:
return maxDuration // t - u is positive out of range
}
}
可以看到hasMonotonic=1时,时间差值是用这个ext来计算的,由于ext是单调递增的,所以通过这样的方式可以避免出现负数的情况。
对于这个问题,又会想到我们常说的基于时间的雪花算法,在出现时钟回拨时会出现重复id,那么我们是不是也可以减少对时间的依赖,在系统启动的时候取到当时的时间,基于这个时间做累加,不过这样的话,我们id中存放的时间意义就不大了,是不是也可以定时的获取系统时间,来更新我们的基准时间。
四、mysql是如何处理粘包的
在抓包的过程中,对一个问题很感兴趣,mysql是如何处理粘包的。
1、介绍
在抓包的时候,突然想到,对于tcp的粘包,mysql协议是如何处理的呢?出于好奇心,盘一下。
对于粘包问题,大家熟知就是读的时候先给一个长度,告诉程序后面一共有多少内容,那么我们翻一下mysql的文档,
![](https://tech.qimao.com/content/images/2023/11/image-6.png)
可以看到,包构成是3byte载荷长度(0x22=34字节,小端序),1byte序列号,后面就是载荷。那载荷最大是2^24-1(16M),那如果发的数据包超过16M了呢?
![](https://tech.qimao.com/content/images/2023/11/image-7.png)
文档中提到,如果大于16M,那么会再次发一个数据包,直到发完为止。
2、那么每次真的能发16M么
先给出结论:不能。
1)mysql默认max_allowed_packet是4M,表示每次一个sql传输的数据不能超过4M。
2)go-sql-driver里面也有限制,默认配置与mysql一致是4M,不过对于预处理语句和普通sql的限制有一点差别,具体如下:
开始测试,表结构如下:
CREATE TABLE `test_max_packet` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`text` longtext COLLATE utf8mb4_unicode_ci,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
测试了两种插入数据的方式,一种预处理方式,一种直接拼接。代码如下:
func main() {
dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&timeout=100s"
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
f, err := os.OpenFile("./str.log", os.O_CREATE|os.O_RDWR, os.FileMode(0777))
if err != nil {
panic(err)
}
n := 2 << 25
b := make([]byte, n)
f.Read(b)
// 一种预处理方式,一种直接拼接
res, err := db.Exec("insert into test_max_packet (`text`) value(?)", b)
//res, err := db.Exec(`-- insert into test_max_packet (text) value(` + string(b) + `)`)
if err != nil {
panic(err)
}
fmt.Println(res.RowsAffected())
}
预处理语句:go-sql-driver包是这样处理的
如果超过了maxAllowedPacket,会对包大小进行切割,每次发送maxAllowedPacket - 1,代码如下:
func (stmt *mysqlStmt) writeCommandLongData(paramID int, arg []byte) error {
maxLen := stmt.mc.maxAllowedPacket - 1
pktLen := maxLen
// After the header (bytes 0-3) follows before the data:
// 1 byte command
// 4 bytes stmtID
// 2 bytes paramID
const dataOffset = 1 + 4 + 2
// Cannot use the write buffer since
// a) the buffer is too small
// b) it is in use
data := make([]byte, 4+1+4+2+len(arg))
copy(data[4+dataOffset:], arg)
for argLen := len(arg); argLen > 0; argLen -= pktLen - dataOffset {
......
}
.....
}
对于普通语句: 则是直接限制,代码如下:
func (mc *mysqlConn) writeCommandPacketStr(command byte, arg string) error {
// Reset Packet Sequence
mc.sequence = 0
pktLen := 1 + len(arg)
data, err := mc.buf.takeBuffer(pktLen + 4)
if err != nil {
// cannot take the buffer. Something must be wrong with the connection
errLog.Print(err)
return errBadConnNoWrite
}
// Add command byte
data[4] = command
// Add arg
copy(data[5:], arg)
// Send CMD packet
return mc.writePacket(data)
}
func (mc *mysqlConn) writePacket(data []byte) error {
pktLen := len(data) - 4
if pktLen > mc.maxAllowedPacket {
return ErrPktTooLarge
}
......
}
那么,我们增加大包传输大小的时候,就需要修改mysql的默认值和go-sql-driver的默认值,这样才能达到我们想要的效果。
五、总结
go-sql-driver时区的默认配置是零时区,xorm默认的是本地时区,那么在DSN的配置中,加上loc=Local,让go-sql-driver与xorm使用相同的时区,这样就能避免我们在使用time.Time传递参数时,各个模块时间转换不一致的情况出现。
对于时间类型的选取,timestamp存储的时间范围有限,占用空间也与datetime差不多,都具有很好的可读性,datetime则相对更有优势。对于默认值,在sql_mode非严格模式并且不限制NO_ZERO_DATE时,可以参考mysql处理非法值的方式,用'0000-00-00 00:00:00'来表示。
对于大数据量传输的时候,需要修改go-sql-driver的maxAllowedPacket和mysql的max_allowed_packet参数的配置,这样不会遇到坑,能达到我们预期的效果。
参考: