這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
1. 前言
時間包括時間值和時區, 沒有包含時區資訊的時間是不完整的、有歧義的. 和外界傳遞或解析時間資料時, 應當像HTTP協議或unix-timestamp那樣, 使用沒有時區歧義的格式, 如果使用某些沒有包含時區的非標準的時間表示格式(如yyyy-mm-dd HH:MM:SS), 是有隱患的, 因為解析時會使用情境的預設設定, 如系統時區, 資料庫預設時區可能引發事故. 確保伺服器系統、資料庫、應用程式使用統一的時區, 如果因為一些曆史原因, 應用程式各自保持著不同時區, 那麼編程時要小心檢查代碼, 知道時間資料在使用不同時區的程式之間交換時的行為. 第三節會詳細解釋go程式在不同情境下time.Time的行為.
2. Time的資料結構
go1.9之前, time.Time的定義為
type Time struct {// sec gives the number of seconds elapsed since// January 1, year 1 00:00:00 UTC.sec int64// nsec specifies a non-negative nanosecond// offset within the second named by Seconds.// It must be in the range [0, 999999999].nsec int32// 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}
sec表示從公元1年1月1日00:00:00UTC到要表示的整數秒數, nsec表示餘下的納秒數, loc表示時區. sec和nsec處理沒有歧義的時間值, loc處理位移量.
因為2017年閏一秒, 國際時鐘調整, Go程式兩次取time.Now()相減的時間差得到了意料之外的負數, 導致cloudFlare的CDN服務中斷, 詳見https://blog.cloudflare.com/how-and-why-the-leap-second-affected-cloudflare-dns/, go1.9在不影響已有應用代碼的情況下修改了time.Time的實現. go1.9的time.Time定義為
// A Time represents an instant in time with nanosecond precision.//// Programs using times should typically store and pass them as values,// not pointers. That is, time variables and struct fields should be of// type time.Time, not *time.Time.//// A Time value can be used by multiple goroutines simultaneously except// that the methods GobDecode, UnmarshalBinary, UnmarshalJSON and// UnmarshalText are not concurrency-safe.//// Time instants can be compared using the Before, After, and Equal methods.// The Sub method subtracts two instants, producing a Duration.// The Add method adds a Time and a Duration, producing a Time.//// The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.// As this time is unlikely to come up in practice, the IsZero method gives// a simple way of detecting a time that has not been initialized explicitly.//// Each Time has associated with it a Location, consulted when computing the// presentation form of the time, such as in the Format, Hour, and Year methods.// The methods Local, UTC, and In return a Time with a specific location.// Changing the location in this way changes only the presentation; it does not// change the instant in time being denoted and therefore does not affect the// computations described in earlier paragraphs.//// Note that the Go == operator compares not just the time instant but also the// Location and the monotonic clock reading. Therefore, Time values should not// be used as map or database keys without first guaranteeing that the// identical Location has been set for all values, which can be achieved// through use of the UTC or Local method, and that the monotonic clock reading// has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)// to t == u, since t.Equal uses the most accurate comparison available and// correctly handles the case when only one of its arguments has a monotonic// clock reading.//// In addition to the required “wall clock” reading, a Time may contain an optional// reading of the current process's monotonic clock, to provide additional precision// for comparison or subtraction.// See the “Monotonic Clocks” section in the package documentation for details.//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 uint64ext 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}
3. time的行為
構造時間-擷取現在時間-time.Now(), time.Now()使用本地時間, time.Local即本地時區, 取決於啟動並執行系統內容設定, 優先取”TZ”這個環境變數, 然後取/etc/localtime, 都取不到就用UTC兜底.
func Now() Time {sec, nsec := now()return Time{sec + unixToInternal, nsec, Local}}
構造時間-擷取某一時區的現在時間-time.Now().In(), Time結構體的In()
方法僅設定loc, 不會改變時間值. 特別地, 如果是擷取現在的UTC時間, 可以使用Time.Now().UTC().
時區不能為nil. time包中只有兩個時區變數time.Local和time.UTC. 其他時區變數有兩種方法取得, 一個是通過time.LoadLocation函數根據時區名字載入, 時區名字見IANA Time Zone database, LoadLocation首先尋找系統zoneinfo, 然後尋找$GOROOT/lib/time/zoneinfo.zip
.另一個是在知道時區名字和位移量的情況下直接調用time.FixedZone("$zonename", $offsetSecond)
構造一個Location對象.
// In returns t with the location information set to loc.//// In panics if loc is nil.func (t Time) In(loc *Location) Time {if loc == nil {panic("time: missing Location in call to Time.In")}t.setLoc(loc)return t}// LoadLocation returns the Location with the given name.//// If the name is "" or "UTC", LoadLocation returns UTC.// If the name is "Local", LoadLocation returns Local.//// Otherwise, the name is taken to be a location name corresponding to a file// in the IANA Time Zone database, such as "America/New_York".//// The time zone database needed by LoadLocation may not be// present on all systems, especially non-Unix systems.// LoadLocation looks in the directory or uncompressed zip file// named by the ZONEINFO environment variable, if any, then looks in// known installation locations on Unix systems,// and finally looks in $GOROOT/lib/time/zoneinfo.zip.func LoadLocation(name string) (*Location, error) {if name == "" || name == "UTC" {return UTC, nil}if name == "Local" {return Local, nil}if zoneinfo != "" {if z, err := loadZoneFile(zoneinfo, name); err == nil {z.name = namereturn z, nil}}return loadLocation(name)}
構造時間-手動構造時間-time.Date(), 傳入年元日時分秒納秒和時區變數Location構造一個時間. 得到的是指定location的時間.
func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {if loc == nil {panic("time: missing Location in call to Date")}.....}
- 構造時間-從unix時間戳記中構造時間, time.Unix(), 傳入秒和納秒構造.
序列化還原序列化時間-文本和JSON, fmt.Sprintf,fmt.SScanf, json.Marshal, json.Unmarshal時的, 使用的時間格式均包含時區資訊, 序列化使用RFC3339Nano()”2006-01-02T15:04:05.999999999Z07:00”, 還原序列化使用RFC3339()”2006-01-02T15:04:05Z07:00”, 還原序列化沒有納秒值也可以正常序列化成功.
// String returns the time formatted using the format string//"2006-01-02 15:04:05.999999999 -0700 MST"func (t Time) String() string {return t.Format("2006-01-02 15:04:05.999999999 -0700 MST")}// MarshalJSON implements the json.Marshaler interface.// The time is a quoted string in RFC 3339 format, with sub-second precision added if present.func (t Time) MarshalJSON() ([]byte, error) {if y := t.Year(); y < 0 || y >= 10000 {// RFC 3339 is clear that years are 4 digits exactly.// See golang.org/issue/4556#c15 for more discussion.return nil, errors.New("Time.MarshalJSON: year outside of range [0,9999]")}b := make([]byte, 0, len(RFC3339Nano)+2)b = append(b, '"')b = t.AppendFormat(b, RFC3339Nano)b = append(b, '"')return b, nil}// UnmarshalJSON implements the json.Unmarshaler interface.// The time is expected to be a quoted string in RFC 3339 format.func (t *Time) UnmarshalJSON(data []byte) error {// Ignore null, like in the main JSON package.if string(data) == "null" {return nil}// Fractional seconds are handled implicitly by Parse.var err error*t, err = Parse(`"`+RFC3339+`"`, string(data))return err}
序列化還原序列化時間-HTTP協議中的date, 統一GMT, 代碼位於net/http/server.go:878
// TimeFormat is the time format to use when generating times in HTTP// headers. It is like time.RFC1123 but hard-codes GMT as the time// zone. The time being formatted must be in UTC for Format to// generate the correct format.//// For parsing this time format, see ParseTime.const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
序列化還原序列化時間-time.Format("$layout")
, time.Parse("$layout","$value")
, time.ParseInLocation("$layout","$value","$Location")
time.Format("$layout")
格式化時間時, 時區會參與計算. 調time.Time的Year()Month()Day()等擷取年月日等時時區會參與計算, 得到一個使用位移量修正過的正確的時間字串, 若$layout
有指定顯示時區, 那麼時區資訊會體現在格式化後的時間字串中. 如果$layout
沒有指定顯示時區, 那麼字串只有時間沒有時區, 時區是隱含的, time.Time對象中的時區.
time.Parse("$layout","$value")
, 若$layout
有指定顯示時區, 那麼時區資訊會體現在格式化後的time.Time對象. 如果$layout
沒有指定顯示時區, 那麼使用會認為這是一個UTC時間, 時區是UTC.
time.ParseInLocation("$layout","$value","$Location")
使用傳參的時區解析時間, 建議用這個, 沒有歧義.
// Parse parses a formatted string and returns the time value it represents.// The layout defines the format by showing how the reference time,// defined to be//Mon Jan 2 15:04:05 -0700 MST 2006// would be interpreted if it were the value; it serves as an example of// the input format. The same interpretation will then be made to the// input string.//// Predefined layouts ANSIC, UnixDate, RFC3339 and others describe standard// and convenient representations of the reference time. For more information// about the formats and the definition of the reference time, see the// documentation for ANSIC and the other constants defined by this package.// Also, the executable example for time.Format demonstrates the working// of the layout string in detail and is a good reference.//// Elements omitted from the value are assumed to be zero or, when// zero is impossible, one, so parsing "3:04pm" returns the time// corresponding to Jan 1, year 0, 15:04:00 UTC (note that because the year is// 0, this time is before the zero Time).// Years must be in the range 0000..9999. The day of the week is checked// for syntax but it is otherwise ignored.//// In the absence of a time zone indicator, Parse returns a time in UTC.//// When parsing a time with a zone offset like -0700, if the offset corresponds// to a time zone used by the current location (Local), then Parse uses that// location and zone in the returned time. Otherwise it records the time as// being in a fabricated location with time fixed at the given zone offset.//// No checking is done that the day of the month is within the month's// valid dates; any one- or two-digit value is accepted. For example// February 31 and even February 99 are valid dates, specifying dates// in March and May. This behavior is consistent with time.Date.//// When parsing a time with a zone abbreviation like MST, if the zone abbreviation// has a defined offset in the current location, then that offset is used.// The zone abbreviation "UTC" is recognized as UTC regardless of location.// If the zone abbreviation is unknown, Parse records the time as being// in a fabricated location with the given zone abbreviation and a zero offset.// This choice means that such a time can be parsed and reformatted with the// same layout losslessly, but the exact instant used in the representation will// differ by the actual zone offset. To avoid such problems, prefer time layouts// that use a numeric zone offset, or use ParseInLocation.func Parse(layout, value string) (Time, error) {return parse(layout, value, UTC, Local)}// ParseInLocation is like Parse but differs in two important ways.// First, in the absence of time zone information, Parse interprets a time as UTC;// ParseInLocation interprets the time as in the given location.// Second, when given a zone offset or abbreviation, Parse tries to match it// against the Local location; ParseInLocation uses the given location.func ParseInLocation(layout, value string, loc *Location) (Time, error) {return parse(layout, value, loc, loc)}func parse(layout, value string, defaultLocation, local *Location) (Time, error) {.....}
序列化還原序列化時間-go-sql-driver/mysql中的時間處理.
MySQL驅動解析時間的前提是連接字串加了parseTime和loc, 如果parseTime為false, 會把mysql的date類型變成[]byte/string自行處理, parseTime為true才處理時間, loc指定MySQL中儲存時間資料的時區, 如果沒有指定loc, 用UTC. 序列化和還原序列化均使用連接字串中的設定的loc, SQL語句中的time.Time類型的參數的時區資訊如果和loc不同, 則會調用t.In(loc)
方法轉時區.
解析連接字串的代碼位於parseDSNParams函數https://github.com/go-sql-driver/mysql/blob/master/dsn.go#L467-L490
// Time Locationcase "loc":if value, err = url.QueryUnescape(value); err != nil {return}cfg.Loc, err = time.LoadLocation(value)if err != nil {return}// time.Time parsingcase "parseTime":var isBool boolcfg.ParseTime, isBool = readBool(value)if !isBool {return errors.New("invalid bool value: " + value)}
解析SQL語句中time.Time類型的參數的代碼位於mysqlConn.interpolateParams方法https://github.com/go-sql-driver/mysql/blob/master/connection.go#L230-L273
case time.Time:if v.IsZero() {buf = append(buf, "'0000-00-00'"...)} else {v := v.In(mc.cfg.Loc)v = v.Add(time.Nanosecond * 500) // To round under microsecondyear := v.Year()year100 := year / 100year1 := year % 100month := v.Month()day := v.Day()hour := v.Hour()minute := v.Minute()second := v.Second()micro := v.Nanosecond() / 1000buf = append(buf, []byte{'\'',digits10[year100], digits01[year100],digits10[year1], digits01[year1],'-',digits10[month], digits01[month],'-',digits10[day], digits01[day],' ',digits10[hour], digits01[hour],':',digits10[minute], digits01[minute],':',digits10[second], digits01[second],}...)if micro != 0 {micro10000 := micro / 10000micro100 := micro / 100 % 100micro1 := micro % 100buf = append(buf, []byte{'.',digits10[micro10000], digits01[micro10000],digits10[micro100], digits01[micro100],digits10[micro1], digits01[micro1],}...)}buf = append(buf, '\'')}
從MySQL資料流中解析時間的代碼位於textRows.readRow方法https://github.com/go-sql-driver/mysql/blob/master/packets.go#L772-L777, 注意只要MySQL連接字串設定了parseTime=true, 就會解析時間, 不管你是用string還是time.Time接收的.
if !isNull {if !mc.parseTime {continue} else {switch rows.rs.columns[i].fieldType {case fieldTypeTimestamp, fieldTypeDateTime,fieldTypeDate, fieldTypeNewDate:dest[i], err = parseDateTime(string(dest[i].([]byte)),mc.cfg.Loc,)if err == nil {continue}default:continue}}}
4. time時區處理不當案例
有個服務頻繁使用最新匯率, 所以緩衝了最新匯率對象, 匯率對象的到期時間設為第二天北京時間零點, 匯率到期則從資料庫中去最新匯率, 設定到期時間的代碼如下:
var startTime string = time.Now().UTC().Add(8 * time.Hour).Format("2006-01-02")tm2, _ := time.Parse("2006-01-02", startTime)lastTime = tm2.Unix() + 24*60*60
這段代碼使用了time.Parse, 如果時間格式中沒有指定時區, 那麼會得到使用本地時區下的第二天零點, 伺服器時區設定為UTC0, 於是匯率緩衝在UTC零點即北京時間八點才更新.
公用庫中有一個GetBjTime()方法, 注釋寫著將伺服器UTC轉成北京時間, 代碼如下
// 原版func GetBjTime() time.Time {// 將伺服器UTC轉成北京時間uTime := time.Now().UTC()dur, _ := time.ParseDuration("+8h")return uTime.Add(dur)}// 改func GetBjTime() time.Time {// 將伺服器UTC轉成北京時間uTime := time.Now()return uTime.In(time.FixedZone("CST", 8*60*60))}
同事用這個方法將得到的time.Time參與計算, 發現多了8個小時. 覺得有問題, 同事和我討論了之後, 我們得出結論後就大意地直接把原有函數改了, 我們都沒有意識到這是個非常危險操作, 只所以危險是因為這個函數已經在很多服務的代碼裡用著(要穩!不能亂動公用庫!!!). 之前用這個函數是因為老Java項目運行在時區為東八區的系統上, 大量代碼使用東八區時間, 但資料庫MySQL時區設定為UTC, go項目也運行在UTC時區. 也就是說, Java項目在把時區為UTC資料庫當做是東八區來用, Java程式往MySQL寫東八區的時間字串, 在sequel軟體中看錶內容時雖然字串是一樣的, 但其實內部是UTC的時間, go代碼的mysql連接字串中loc選項為空白, 就會使用UTC時區去解析資料, 拿到的資料會多八個小時. 例如Java代碼往mysql插入一條”2017-10-29 22:00:00”資料本意是東八區2017年10月29日22點, 但在MySQL內部看來, 這是UTC的2017年10月29日22點, 換算成東八區時間為2017年10月30日6點, 如果其它程式解析時認為時間資料是MySQL的UTC時區, 那麼會得到一個錯誤的時間. 所以才會在GO中要往Java代碼建立的表寫入資料時用time.Now().UTC().Add(time.Hour*8)
直接相加八小時使得Java項目行為一致, 拿UTC的資料庫存東八區時間.
後面想想, 面對這種資料庫中有時區不一致資料的情況, 在沒有辦法統一UTC時區的情況下, 應當使用MySQL時間字串而不是time.Time來傳遞以避免時區隱含轉換問題, 寫入時參數傳string類型的時間字串, 解析時先拿到時間字串, 然後自行判斷建表時這個欄位用的是東八區的時間字串還是UTC時間字串進行time.ParseInLocation得到時間對象, MySQL連接字串的parseTime選項要設定為false. 比如我想在MySQL中存東八區的目前時間, SQL參數用Format後的字串而不是傳time.Time, 原版的time.Now().UTC().Add(time.Hour*8).Format("2006-01-02 15:04:05")
和修改的time.Now().In(time.FixedZone("CST", 8*60*60))
的輸出將是一樣, 但後者是正確的東八區現在時間. 原版的GetBjTime()返回time.Time可能用GetBeijingNowTimeString返回string更能體現本意吧.
5. 時間有關的標準