一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

go-mssqldbでタイムゾーンが常にUTCになる

f:id:ryo-utsunomiya:20190614171536p:plain:w320

こんにちは。宿泊事業本部の宇都宮です。

一休では、基幹データベースにSQL Serverを使用しています。また、Goアプリケーションでは、go-mssqldbというライブラリを使用して、データベースとのやりとりを行っています。

このgo-mssqldbには、タイムゾーンに関して厄介な挙動があります。タイトルにもあるように、タイムゾーンが常にUTCになってしまうのです。本記事では、go-mssqldbのタイムゾーン関係の振る舞いと、go-mssqldbを使いつつ正しくタイムゾーンを扱うための対処法を紹介します。

go-mssqldbのタイムゾーン問題

go-mssqldbのタイムゾーン問題は、以下のコードで再現できます。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"
    "time"

    // blank import必須
    _ "github.com/denisenkom/go-mssqldb"
)

func main() {
    conn, err := sql.Open("sqlserver", "sqlserver://user:password@dbhost:1433?ApplicationIntent=ReadWrite&MultiSubnetFailover=Yes&database=DB")
    if err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    row := conn.QueryRow(`
select
    GETDATE()
`)

    var dbnow time.Time
    if err := row.Scan(&dbnow); err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    now := time.Now()
    after1Hour := now.Add(1 * time.Hour)

    fmt.Println(after1Hour.After(now))
    fmt.Println(after1Hour.After(dbnow))

    fmt.Println(now)
    fmt.Println(after1Hour)
    fmt.Println(dbnow)
}

dbnowにはDBの現在時刻から取得した時刻が、nowにはサーバの現在時刻が入ります。after1Hourはサーバの現在時刻から1時間後です。したがって、 after1Hour.After(now)after1Hour.After(dbnow) は、DBとサーバの時計が1時間以上ずれていない限り、trueになるはずです。

しかし、DBとサーバの両方のタイムゾーンがJST(+09:00)の状態でこのコードを実行すると、 after1Hour.After(now) はtrue、after1Hour.After(dbnow)はfalseになります。

原因は、このコードの出力を見ると分かります。

f:id:ryo-utsunomiya:20190823155940p:plain:w320

dbnow は日時は同じ(2019-08-23 15:54)ですが、タイムゾーンがUTCになっています。UTCでJSTと表面上の日時が同じと言うことは、実質9時間後の日時ということです。そのため、after1Hour.After(dbnow)はfalseになってしまうのです。

タイムゾーン問題の対処方法

go用の mysqlドライバ のように、Data Source NameにDBサーバのロケーションを指定できる機能があればよいのですが、go-mssqldbにはそのような機能はありません。

この問題を解決しようとしているissueやプルリクエストはいくつか見つかりますが、近いうちに取り込まれそう、というステータスではありません。

そこで、ライブラリ側の解決を待つのではなく、利用者の側で対処する必要があります。

最も根本的な対応は、datetimeoffset型の使用です。datetimeoffset型はタイムゾーンも保持しているため、日時の保存にこの型を使っておけば、go-mssqldbを使っていても問題なくタイムゾーンを扱えます。

すでにdatetime型で日時を保存していて、datetimeoffset型での保存が難しい場合は、TODATETIMEOFFSET関数を使ってデータを取得する際に型変換しましょう。

   row := conn.QueryRow(`
select
    GETDATE()
  , TODATETIMEOFFSET(GETDATE(), '+09:00')
`)

    var dbnow time.Time
    var dbnowoffset time.Time
    if err := row.Scan(&dbnow, &dbnowoffset); err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    now := time.Now()
    after1Hour := now.Add(1 * time.Hour)

    fmt.Println(after1Hour.After(now))
    fmt.Println(after1Hour.After(dbnow))
    fmt.Println(after1Hour.After(dbnowoffset))

    fmt.Println(now)
    fmt.Println(after1Hour)
    fmt.Println(dbnow)
    fmt.Println(dbnowoffset)

dbnowoffset は+0900(JST)になっているため、after1Hour.After(dbnowoffset) はtrueになります。意図したとおりの動作といえます。

f:id:ryo-utsunomiya:20190823161158p:plain:w320