🕰️

go-sql-driver/mysqlと日時データ型とタイムゾーン

2023/09/18に公開

Goでアプリケーションを開発する際に、RDBMSにMySQLを採用した場合はドライバーの実装としてgo-sql-driver/mysqlを利用します。この記事ではこのドライバーを利用するにあたって知っておきたい日時データ型やタイムゾーン関連の設定について解説します。なお、記載の内容はgo-sql-driver/mysql@v1.7.1、MySQL8.1の実装を元にしています。

go-sql-driver/mysqlの設定について

go-sql-driver/mysqlのMySQLの日時データ型の扱い及びタイムゾーンに関係する設定は、主にparseTimeloctime_zone(connectionAttributes)の3つです。

parseTime

parseTimeは一部の日時データ型をgo-sql-driver/mysql、つまりドライバーのレイヤでtime.Timeに変換するかどうかを設定する項目です。どの型が変換され、どの型が変換されないかについてを表にまとめると以下のようになります。

カラムの型 parseTime=false parseTime=true
DATE []byte time.Time
DATETIME []byte time.Time
TIMESTAMP []byte time.Time
YEAR []byteまたはint64 []byteまたはint64
TIME []byte []byte

YEARについてはプリペアードステートメントの利用有無によって型が変わります。ただし、後述するdatabase/sqlのレイヤで変換処理が行われるケースが多いのでそこまで気にしなくて良いのと、v1.8からは常にint64に変換されるように変更されるようです。この辺りの仕組みや変更の理由はgo-mysqlでany型へのScan()が使いやすくなりますで詳しく解説されているので一読をお勧めします。

変換処理の実装箇所

parseTimeを指定した場合の変換処理はMySQLからのレスポンスをパースする処理の中の2種類のreadRowで実装されています。

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L736

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L1173

2種類の実装があるのは、go-sql-driver/mysqlはMySQLとの通信を行うにあたってテキストプロトコルとバイナリプロトコルを使い分けているからで、先述したプリペアードステートメントの利用有無によってどちらのプロトコルを使うかが決まっています(有:バイナリプロトコル, 無: テキストプロトコル)。

これらのメソッドの中でparseTimeのtrue/falseで処理が分岐しており、trueの場合にparseDateTime及びparseBinaryDateTimeによって[]byteがtime.Timeに変換されています。

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L784-L797

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L1313-L1314

database/sqlによる変換

この記事の趣旨とは少しずれますが、データの変換が行われるもう一つの場所があるのでここで述べておきます。

上で見たようにparseTime=falseだとカラムがDATE型等であった場合にはドライバーは[]byteを返しますし、YEAR型は[]byteかint64を返したりと、アプリケーション側でハンドリングするのが大変そうですが、実際にはドライバーを呼び出しているdatabase/sqlのレイヤである程度変換されるため、アプリケーション側で変換する手間が発生することは少ないです。具体的には、database/sqlでデータを取得する際に利用するRow.Scan, Rows.Scanはドライバーから渡された値の型と、Scanに指定した入れ物の型を見てよしなにデータの変換を行ってくれています。

この変換処理はconvertAssignRowsにて定義されています。
https://github.com/golang/go/blob/release-branch.go1.21/src/database/sql/convert.go#L220

例えば、DATE型の[]byteを*stringで受ける場合は以下の部分で変換されます。
https://github.com/golang/go/blob/release-branch.go1.21/src/database/sql/convert.go#L244-L251

また、YEAR型の[]byteやint64を*intで受ける場合は以下の部分で変換されます。
https://github.com/golang/go/blob/release-branch.go1.21/src/database/sql/convert.go#L430-L441

loc

locはtime.Timeのタイムゾーンをgo-sql-driver/mysql内部でどのように扱うかについて設定する項目です。読み込みと書き込みの際に処理が挟まるので、それぞれ分けて説明します。

読み込み

MySQLからtime.Timeに変換する際に、locで指定したタイムゾーンで日時が解釈されます。例えば、DATETIME型に2023-09-18: 03:45:10という値が入っていた場合は、locがUTCであれば2023-09-18T03:45:10Zと解釈し、locがAsia/Tokyoであれば2023-09-18T03:45:10+09:00と解釈します。

これらは、parseTimeの変換処理の実装箇所で出てきたparseDateTime及びparseBinaryDateTimeの中で行われています。

https://github.com/go-sql-driver/mysql/blob/v1.7.1/utils.go#L108

https://github.com/go-sql-driver/mysql/blob/v1.7.1/utils.go#L230

どちらもMySQLから送られてきた値をパースしてyear, month, day, hour, minutes, secondsに当たる部分を取得したのちに、Date関数にそれらの値を指定してtime.Timeを作成していますが、その際にloc引数にlocをそのまま渡しています。

書き込み

書き込む値としてtime.Timeが渡されてきた場合に、Time.Inメソッドを用いてそのtime.Timeのタイムゾーンをlocで指定したものに切り替えます。その後、この処理を行ったtime.Timeに対してTime.DateTime.Clock等を呼び出して、YYYY-MM-DD HH:MM:SS等の形式にした上でMySQLに送ります。

上記の説明だけだとわかりづらいと思うので、実際の値とRFC3339の表現を用いて説明すると、アプリケーションから2023-09-18T03:45:10+09:00がドライバーに渡された場合に、locがUTCだとTime.Inの処理によって2023-09-17T18:45:10Zになります。そして、MySQLへはタイムゾーンの情報を除いた値がカラムの型に応じて送られます。例えば、DATETIME型であれば、2023-09-17 18:45:10となります。

これらは、MySQLに送信するパケットを作成しているwriteExecutePacketの中で行われています。

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L909

以下の場所でlocを指定してTime.Inを呼び出した上でappendDateTimeに渡し

https://github.com/go-sql-driver/mysql/blob/v1.7.1/packets.go#L1109-L1128

このappendDateTimeがMySQLに送る値を作成しています。

https://github.com/go-sql-driver/mysql/blob/master/utils.go#L268

locによってタイムゾーンを変換する際の副次的効果

このlocによるタイムゾーンの変換を利用している場合に、一部のORMが出力するデバッグログのSQLと実際にMySQLで実行されているSQLに差異が生じる可能性があります。例えばGORMはこれに該当します(参考: GORM で出力されるログの SQL と実行される SQL が違った話)。このような現象が発生するのは、ドライバー層で変換を行っているため、それより上の層で出力されるログは変換前の値を参照することになるからです。

time_zone(connectionAttributes)

この設定に関しては上記の2つとはやや毛色が異なります。というのも、parseTimeとlocはgo-sql-driver/mysqlに閉じていますが、この設定はMySQLとのセッションそのものに影響を与えます。

まず、READMEを見るとtime_zoneはConnection attributesに分類され、これについては以下の説明がなされています。

Connection attributes are key-value pairs that application programs can pass to the server at connect time.
https://github.com/go-sql-driver/mysql#connectionattributes

実際に何が起きるかというと、例えばtime_zone=Asia/Tokyoという指定をしたとすると、MySQLに接続したのちに SET time_zone = Asia/Tokyoというクエリが実行されます。これによって当該セッションではタイムゾーンがAsia/Tokyoに設定されます。

MySQLのタイムゾーンの影響範囲については、下記の公式ドキュメントに記載されています。

The session time zone setting affects display and storage of time values that are zone-sensitive. This includes the values displayed by functions such as NOW() or CURTIME(), and values stored in and retrieved from TIMESTAMP columns. Values for TIMESTAMP columns are converted from the session time zone to UTC for storage, and from UTC to the session time zone for retrieval.

The session time zone setting does not affect values displayed by functions such as UTC_TIMESTAMP() or values in DATE, TIME, or DATETIME columns. Nor are values in those data types stored in UTC; the time zone applies for them only when converting from TIMESTAMP values. If you want locale-specific arithmetic for DATE, TIME, or DATETIME values, convert them to UTC, perform the arithmetic, and then convert back.
https://dev.mysql.com/doc/refman/8.1/en/time-zone-support.html

この設定に関してはMySQLのmy.cnfや起動時のオプション等を直接いじれないケース以外ではあまり利用しないと思います。AWSやGCPでMySQLを利用する場合でもmy.cnfや起動時のオプション等はいじれなくとも、time_zoneを指定できる方法があるので、そちらを利用するのが良いでしょう。

どう設定すれば良いのか

長々と各種設定の説明をしましたが、基本的には以下で問題ないと思います。

  • parseTimeはtrue
  • locに指定するタイムゾーンとMySQLのタイムゾーンを一致させる

後者についてはこれらがずれてしまっていると、例えばTIMESTAMP型に内部的に保存されている値は通常UTCで解釈すべき値ですが、JSTとして解釈しないといけない値が入ってしまっているといった事態が発生します。このような状態に一回なってしまうと後から修正するのは困難です。アプリケーションのみからデータを読み書きする場合はそこまで問題になりませんが、created_at, updated_atで良くやる処理やデータ連携の際に色々と辛くなります。

Discussion