時間型別和時間戳

schaepher發表於2021-01-29

Unix 時間戳以及日期表示方法

Unix 時間戳表示的是從世界標準時間(UTC,Coordinated Universal Time)的 1970 年 1 月 1 日 0 時 0 分 0 秒開始的偏移量。

全球共有 24 個時區,分為東西各 12 時區。所有地區在使用同一個時間戳的基礎上,根據當地時區調整時間的表示。

現在比較常見的日期和時間的表示標準是 ISO8601,或者在其基礎上更加標準化的 RFC3339。

舉個例子,北京時間 2021 年 1 月 28 日 0 時 0 分 0 秒用 RFC3339 表示為:2021-01-28T00:00:00+08:00

+08:00 表示東 8 區,2021-01-28T00:00:00 表示這個時區的人所看到的時間。加號如果改為減號,則表示西時區。

比較特殊的是 UTC 時區,可以表示為 2006-01-02T15:04:05+00:00,但通常簡化為 2006-01-02T15:04:05Z

在使用的時候,應當根據時區調整時間的展示。例如 1611792000 可以表示為 2021-01-28T00:00:00Z 或者 2021-01-28T08:00:00+08:00

日期和時間的解析

不同的資料來源很可能使用不同的時間表示方法。根據是否可讀分成兩類:

  • 用數字表示的時間戳
  • 用字串表示的年月日時分秒

數字型別就不詳細說明。

字串又根據是否有時區分為兩類:

  • 2021-01-28 00:00:00 沒有包含時區資訊
  • 2021-01-28T08:00:00+08:00 包含了時區資訊

在解析沒有包含時區資訊的字串時,通常要由程式設計師指定時區,否則預設為 UTC 時區。如果附帶時區,那就可以不用另外指定。

例如 Golang 的時間庫,就有兩個方法:

  • Parse(layout, value string)
  • ParseInLocation(layout, value string, loc *Location)

在解析的時候,會先根據年月日時分秒計算出一個整數。接著看 value 是否包含時區資訊。

如果 value 包含時區,那麼就會給解析後的整數加一個偏移量,這個偏移量由時區與 UTC 時區之間的位置關係決定。

如果 value 不包含時區資訊,Parse 會將其設定為 UTC 時區,ParseInLocation 會根據傳入的時區調整解析出來的整數,並將時區設定為傳入的時區。

日期和時間的儲存

和解析時一樣,儲存日期和時間的方式有多種。

例如 Golang 的 Time :

type Time struct {
	wall uint64
	ext  int64
	loc *Location  // 位置。用於調整時間的表示。
}

Golang 儲存的不是 Unix 時間戳,但是會根據情況將其轉換為時間戳。對於 loc 的修改不會對 Unix 時間戳產生影響,只會影響時間的展示形式。

MongoDB 使用的 bson.Date 使用 int64 儲存從 1970 年 1 月 1 日以來的毫秒數。

MySQL 使用 DATETIME 型別儲存不包含時區的年月日時分秒,查詢時以 YYYY-MM-DD HH:MM:SS 的形式展示。也可以用四個位元組的 TIMESTAMP 型別儲存 Unix 時間戳。

時間戳的問題

以前在儲存時間戳的時候,通常都使用四個位元組,也就是 32 位的有符號整數儲存。

把二進位制的 01111111 11111111 11111111 11111111 轉化為十進位制後得到 2147483647,再轉化為北京時間得到 2038-01-19 11:14:07

這就表示 32 位整數最多隻能儲存到 2038 年的時間,因此被稱為 “2038 年問題”。

比較新的一些專案會通過各種方式解決這個問題,通常是使用 64 位整數來儲存時間戳。但使用方式各有不同。

例如 Golang 使用了兩個 64 位整數來儲存。其中無法符號整數 wall,第一位表示是否有單調時間。

  • 如果為 1,則表示有單調時間。
    wall 的 2~34 位儲存自 1885 年 1 月 1 日 0 時 0 分 0 秒以來的秒數,35~64 位儲存納秒數。
    有符號的 64 位整數 ext 儲存從程式啟動以來的納秒數(單調時間)。
  • 如果為 0,則表示沒有單調時間。
    wall 的 2~64 不儲存時間。
    有符號的 64 位整數 ext 儲存從 0001 年 1 月 1 日 0 時 0 分 0 秒以來的秒數。

MongoDB 則是使用 int64 儲存從 1970 年 1 月 1 日以來的 UTC 毫秒數。

MySQL 沒有解決 TIMESTAMP 型別的問題,它始終是四個位元組。因此如果要解決這個問題,最好使用 DATETIME。但是 DATETIME 也有問題,它沒法儲存時區。不過大多數應用都無需考慮時區問題,無需擔心。

時間的展示

資料庫都預設使用 UTC。如果不加以處理,儲存到資料庫的時間就會展示為與本地實際展示的時間不一致的形式。

例如 MongoDB 儲存的是從 1970 年 1 月 1 日以來的 UTC 毫秒數,像 Navicat 這種工具,會用 UTC 的形式展示時間。這樣其他時區的人看起來就會不習慣。

而 MySQL 就更難處理了,DATETIME 不帶時區。

解決這個問題有三種思路:

  1. 修改資料庫配置,改成本地時區
    MongoDB 這樣設定不會有影響,仍然儲存的是毫秒數。只是在展示的時候會使用配置的時區格式化字串。
    MySQL 這樣設定後,會對 NOW() 這種函式的結果產生影響。不會對 SQL 語句中直接寫 0000-00-00 00:00:00 的情況產生影響。
  2. 查詢的時候將其重新轉換為本地時區
    有三種:
    • 為資料庫連線會話設定時區。同上,只是在會話級別產生影響。
      MySQL 會有影響,如果不同地方的會話設定不同時區,又使用了 NOW(),得到的結果不一致。
    • 在程式碼上做一層包裝,用於調整時區。
      MongoDB 沒啥影響,畢竟儲存的是毫秒數。只是展示的時候做個調整。
      MySQL 可以始終儲存為 UTC 形式,然後要展示的時候,用程式碼把時間格式化為本地時區的形式。
    • 為資料庫表建立 view,在 view 裡面處理時區
      例如 MongoDB:
      db.createView("view_name","collection_name",[
          {
              $addFields: {
                  date: {
                      $dateToString: {
                          date: "$date",
                          format: "%Y-%m-%dT%H:%M:%S+08:00",
                          timezone: "+08:00"
                      }
                  }
              }
          }
      ]);
      
      addFields 會覆蓋同名的欄位。上面的語句會將原先的 date 欄位的值以新的格式展示。
  3. 儲存的時候建立一個年月日時分秒和本地展示時間一致的 UTC 時間
    這會改變資料庫儲存的時間戳,使得時間戳與實際時間戳不一致。對 MongoDB 會產生影響。
    不過 MySQL 的 DATETIME 不是用時間戳,所以只要格式化到 SQL 語句的時間形式是本地時區的就行了。只是如果出現跨時區的使用者、資料、開發人員,處理起來就比較麻煩。

具體例項

Golang MongoDB 庫

MongoDB 的官方庫在儲存的時候,會使用 UTC 的時間戳。但在查詢的時候,會判斷是否設定了使用本地時間展示。如果沒有設定按本地時間展示,則會將 Time 設定為 UTC 時區。

if !tc.UseLocalTimeZone {
    timeVal = timeVal.UTC()
}

如何事先配置好?

builder := bsoncodec.NewRegistryBuilder()

// 註冊預設的編碼和解碼器
bsoncodec.DefaultValueEncoders{}.RegisterDefaultEncoders(builder)
bsoncodec.DefaultValueDecoders{}.RegisterDefaultDecoders(builder)

// 註冊時間解碼器
tTime := reflect.TypeOf(time.Time{})
tCodec := bsoncodec.NewTimeCodec(bsonoptions.TimeCodec().SetUseLocalTimeZone(true))
registry := builder.RegisterTypeDecoder(tTime, tCodec).Build()

client, err := mongo.NewClient(options.Client().ApplyURI(uri), options.Client().SetRegistry(registry))

MongoDB 使用的 bson.Date 使用 int64 儲存 1970 年 1 月 1 日以來的毫秒數。從 MongoDB 查出來的也是這個資料。

如果 decode 的時候指定了儲存結果的結構體的時間欄位的型別,如 time.Time。則會將 int64 轉化為 time.Time。如果不指定,則返回 int64。

可見 MongoDB 官方庫使用的是第二種思路。

Golang MySQL 驅動的例項

https://github.com/go-sql-driver/mysql#loc

需要在連線的時候設定。dsn 裡面帶上 loc 引數。

在解析查詢結果中的 DateTime 型別的時候,會將位元組轉換為字串形式。這個字串形式最長的情況是 0000-00-00 00:00:00.0000000。驅動會根據實際長度解析。

MySQL 驅動的做法是,如果 dsn 有帶 loc 引數,那麼在解析年月日時分秒和毫秒後,以這些資料和時區建立 time.Time。即 time.Date(y, mo, d, h, mi, s, t.Nanosecond(), loc)

而在 insert 操作時,會將 time.Time 設定為指定的時區。v.In(mc.cfg.Loc).AppendFormat(b, timeFormat),這裡的 v 就是我們 Insert 的型別為 time.Time 的值。

可見 MySQL 驅動使用的是第三種思路。

相關文章