mita2 database life

主にMySQLに関するメモです

MySQL go-sql-driver/mysql ドライバで意図せず utf8mb4_0900_ai_ci が使われるケース

Background

MySQL 8.0 で utf8mb4のデフォルトの COLLATION (照合順序) が utf8mb4_general_ci から utf8mb4_0900_ai_ci に変更されました。 COLLATE を指定せず、SET NAMES <CHARSET> を実行すると、そのキャラクタセットのデフォルトの collation が利用されます。

つまり、SET NAMES utf8mb4 の結果、5.7 まではクライアントのCOLLATIONとして utf8mb4_general_ci が使われますが、8.0 からは utf8mb4_0900_ai_ci が使われます。

-- MySQL 5.7 だと utf8mb4_general_ci
mysql> SET NAMES utf8mb4;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@SESSION.collation_connection;
+--------------------------------+
| @@SESSION.collation_connection |
+--------------------------------+
| utf8mb4_general_ci             |
+--------------------------------+
1 row in set (0.00 sec)

-- MySQL 8.0 だと utf8mb4_0900_ai_ci
mysql> SET NAMES utf8mb4;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@SESSION.collation_connection;
+--------------------------------+
| @@SESSION.collation_connection |
+--------------------------------+
| utf8mb4_0900_ai_ci             |
+--------------------------------+
1 row in set (0.00 sec)

-- COLLATION を明示的に指定が必要
mysql> SET NAMES utf8mb4 COLLATE utf8mb4_general_ci;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@SESSION.collation_connection;
+--------------------------------+
| @@SESSION.collation_connection |
+--------------------------------+
| utf8mb4_general_ci             |
+--------------------------------+
1 row in set (0.00 sec)

MySQL 8.0 にバージョンアップした際に、意図せず utf8mb4_0900_ai_ci が使われてしまうケースがないか調査しました。 ドライバは go-sql-driver/mysql のみを確認しています。他のドライバは挙動が異なると思います。

前提

default_collation_for_utf8mb4 を変更せず、skip_character_set_client_handshake も有効化できない環境を前提としています。 これらのパラメータが変更できる場合は、サーバサイドの設定で影響を回避可能なはずです(確かめてませんが)。

まとめ

パターン ドライバのオプション 影響有無
指定なし 影響なし
collation のみ指定 collation=utf8mb4_general_ci 影響なし
charset のみ指定 charset=utf8mb4 影響あり
charset と collation を両方指定 & go-sql-driver/mysql v1.8 以降 charset=utf8mb4&collation=utf8mb4_general_ci 影響なし
charset と collation を両方指定 & go-sql-driver/mysql v1.8 より前 charset=utf8mb4&collation=utf8mb4_general_ci 影響あり

詳しい説明

charset も collation も指定していないケース (影響なし)

go-sql-driver/mysql のデフォルト collation は utf8mb4_general_ci のため utf8mb4_general_ci が暗黙的に利用されます。collation のみを指定したケースと同様の動作です。 SET NAMES は実行されません。

charset のみ指定 (影響あり)

冒頭で、記載した COLLATE 指定なしの SET NAMES utf8mb4 が実行されます。 MySQLサーバの utf8mb4 のデフォルト collation が変更された影響を受けます。

charset と collation を両方指定

go-sql-driver/mysql が v1.8 より前 (影響あり)

v1.8 より前では、charset と collation の両方指定した場合 (charset=utf8mb4&collation=utf8mb4_general_ci)、以下のように、作用します。

  1. ハンドシェイク時に utf8mb4_general_ci を指定し接続
  2. 接続後、COLLATE 無しで、SET NAMES utf8mb4 を改めて実行(結果として、ハンドシェイク時の collation は無視される)

これにより、MySQLサーバの utf8mb4 のデフォルト collation が変更された影響を受けます。 明示的に両方指定するのが一番確実だと思いがちですが、実際は期待と異なった挙動をするようです。

v1.8 より前では、charset とcollationがちぐはぐな組み合わせでも接続可能です (v1.8 以降ではError 1253 (42000): COLLATION ~ is not valid for CHARACTER SET ~)。 SET NAMES する際に、ドライバのオプションに指定した collation が使われないからです。

charset=latin1&collation=utf8mb4_general_ci だと、SET NAMES latin1 が実行され、最終的に利用される COLLATION は latin1 のデフォルトである latin1_swedish_ci になります。

このような動作をすることから、v1.7 のドキュメントには charset は指定せず、collation だけを指定するように記載があります。

Usage of the charset parameter is discouraged because it issues additional queries to the server. Unless you need the fallback behavior, please use collation instead.

go-sql-driver/mysql が v1.8 以降(影響なし)

v1.8.0 (2024/03/09 Release) で、charset と collation を両方指定した場合、COLLATEを含めて、SET NAMES <CHARSET> COLLATE <COLLATION> が実行されるようになりました。v1.8.0 以降 では問題は発生しなくなっています。

Use SET NAMES charset COLLATE collation. by @methane in #1437 https://github.com/go-sql-driver/mysql/releases

methane.hatenablog.jp

両方指定している場合は、v.1.8.0 へバージョンアップしてから MySQL 8.0 へ移行する必要があります。

参考資料

sejima さんもこのあたりについて詳しく調査されています。

labs.gree.jp

結果一覧

検証用のコード

package main

import (
    "database/sql"
    "fmt"

    _ "github.com/go-sql-driver/mysql"
)

var (
    scope, vname, vvalue *string
    masterConfig = "root:Password@(192.168.0.100:3306)/"
    parameters = []map[string]string{
        {},
        {"charset": "utf8mb4"},
        {"collation": "utf8mb4_unicode_ci"},
        {"collation": "utf8mb4_general_ci"},
        {"charset": "utf8mb4", "collation": "utf8mb4_unicode_ci"},
        {"charset": "utf8mb4", "collation": "utf8mb4_general_ci"},
        {"charset": "latin1"},
        {"charset": "latin1", "collation": "latin1_bin"},
        /* {"charset": "latin1", "collation": "utf8mb4_unicode_ci"}, */
    }
)

func main() {
    for i, m := range parameters {
        dsn := ""

        if charset, ok := m["charset"]; ok {
            if collation, ok := m["collation"]; ok {
                dsn = masterConfig + "?charset=" + charset + "&collation=" + collation
            } else {
                dsn = masterConfig + "?charset=" + charset
            }
        } else {
            if collation, ok := m["collation"]; ok {
                dsn = masterConfig + "?collation=" + collation
            } else { // nothing
                dsn = masterConfig
            }
        }

        fmt.Printf("%d. charset = %s, collation = %s\n", i+1, m["charset"], m["collation"])

        db, err := sql.Open("mysql", dsn)
        if err != nil {
            panic(err)
        }
        defer db.Close()
        q := fmt.Sprintf(`SELECT /* %d. charset = %s, collation = %s */ 'session', VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.session_variables WHERE VARIABLE_NAME = 'collation_connection'
                                  UNION ALL
                                  SELECT 'global ', VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_variables WHERE VARIABLE_NAME IN ('collation_connection', 'collation_server', 'default_collation_for_utf8mb4')`, i+1, m["charset"], m["collation"])

        rows, err := db.Query(q)
        if err != nil {
            panic(err)
        }
        defer rows.Close()

        for rows.Next() {
            if err := rows.Scan(&scope, &vname, &vvalue); err != nil {
                panic(err)
            }
            fmt.Printf("%s\t%s\t%s\n", *scope, *vname, *vvalue)
        }
    }

}