xorm与go-sql-driver的时区问题

一、问题

1、问题涉及组件

xorm v1.3.2、go-sql-driver v1.6.0、mysql5.7

2、问题描述

在一次测试过程中,一个后台服务通过rpc调用一个业务服务更新数据库的时间,结果数据库的时间跟预期相差8小时,调用链路如下:

数据表如下:

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),如下图

那么就是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的方法,参数一直都是正确的,那么肯定就是这个包对时间进行了处理:

我们继续看看,这个时间是什么时候变更的 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)),程序跑到这里时区已经发生了变化,如下图:

那么就是这个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);

结果符合预期:

插入完数据,我们在看一下,程序读取时会发生什么

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是单调递增的:

其中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的文档

可以看到,包构成是3byte载荷长度(0x22=34字节,小端序),1byte序列号,后面就是载荷。那载荷最大是2^24-1(16M),那如果发的数据包超过16M了呢?

文档中提到,如果大于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参数的配置,这样不会遇到坑,能达到我们预期的效果。

参考:

展示评论