atexitで終了させるスレッドはdaemonにしよう

なにかの処理をバックグラウンドスレッドで実行して、アプリケーション終了時にその処理を止めたいことがあります。 たとえばOpenTelemetryのトレースやログを送信するためにスレッドが使われていますが、それらは終了時にバッファリングしているデータを送信してから終了します。

コマンドラインアプリケーションでは、main関数の終了時にバックグラウンドスレッドの終了を呼び出して待つことができます。

# th_main.py
import threading
import time

alive = True


def bgmain():
    while alive:
        print("hello")
        time.sleep(1)


th = threading.Thread(target=bgmain)
th.start()


def main():
    global alive
    try:
        while True:
            time.sleep(1)
    finally:
        alive = False
        th.join()


main()

実行例:

$ python3 th_main.py
hello
hello
^CTraceback (most recent call last):
  File "/Users/inada-n/work/th_main.py", line 27, in <module>
    main()
    ~~~~^^
  File "/Users/inada-n/work/th_main.py", line 21, in main
    time.sleep(1)
    ~~~~~~~~~~^^^
KeyboardInterrupt

しかし、wsgiアプリケーションのようにmain関数が無い場合があります。そのような場合に終了処理を実装するための標準ライブラリとして提供されているのがatexitモジュールです。次のようにすればバックグラウンドスレッドを止められそうに見えます。

# th_atexit.py
import threading
import time
import atexit

alive = True


def bgmain():
    while alive:
        print("hello")
        time.sleep(1)


th = threading.Thread(target=bgmain)
th.start()


@atexit.register
def on_exit():
    global alive
    print("atexit")
    alive = False
    th.join()


def main():
    while True:
        time.sleep(1)


main()

しかし、このプログラムを止めるためにはCtrl-Cを2回押す必要がありました。コマンドラインプログラムだから停止できましたが、wsgiアプリケーションだとしたら強制的に終了されるまで待ち続けることになります。

$ python3 th_atexit.py
hello
hello
hello
^CTraceback (most recent call last):
  File "/Users/inada-n/work/th_atexit.py", line 32, in <module>
    main()
    ~~~~^^
  File "/Users/inada-n/work/th_atexit.py", line 29, in main
    time.sleep(1)
    ~~~~~~~~~~^^^
KeyboardInterrupt
hello
hello
^CTraceback (most recent call last):
  File "/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py", line 1542, in _shutdown
    _thread_shutdown()
KeyboardInterrupt:
atexit

ateixit が表示されているのが二回目のCtrl-Cの後、最後であることがわかります。 これはatexitが呼ばれるタイミングの問題です。

Py_Finalize(PyRuntimeState *runtime)

    // Block some operations.
    tstate->interp->finalizing = 1;


    // Wrap up existing "threading"-module-created, non-daemon threads.
    wait_for_thread_shutdown(tstate);


    // Make any remaining pending calls.
    _Py_FinishPendingCalls(tstate);


    /* The interpreter is still entirely intact at this point, and the
     * exit funcs may be relying on that.  In particular, if some thread
     * or exit func is still waiting to do an import, the import machinery
     * expects Py_IsInitialized() to return true.  So don't say the
     * runtime is uninitialized until after the exit funcs have run.
     * Note that Threading.py uses an exit func to do a join on all the
     * threads created thru it, so this also protects pending imports in
     * the threads created via Threading.
     */


    _PyAtExit_Call(tstate->interp);

atexitが呼ばれるのは非daemonスレッドが全て終了してからだということがわかります。

Pythonのドキュメントにはdaemonスレッドについてこう書かれています。

スレッドには "デーモンスレッド (daemon thread)" であるというフラグを立てられます。 このフラグには、残っているスレッドがデーモンスレッドだけになった時に Python プログラム全体を終了させるという意味があります。フラグの初期値はスレッドを生成したスレッドから継承します。フラグの値は daemon プロパティまたは daemon コンストラクタ引数を通して設定できます。

注釈 デーモンスレッドは終了時にいきなり停止されます。デーモンスレッドで使われたリソース (開いているファイル、データベースのトランザクションなど) は適切に解放されないかもしれません。きちんと (gracefully) スレッドを停止したい場合は、スレッドを非デーモンスレッドにして、Event のような適切なシグナル送信機構を使用してください。

https://docs.python.org/ja/3.12/library/threading.html#thread-objects

このドキュメントを読むと、アプリケーション終了時にきちんと終了させるスレッドはデーモンスレッドにするべきでないということになります。しかし、atexitから終了処理を実行する場合はデーモンスレッドにする必要があるので注意が必要です。

先ほどのプログラムをデーモンスレッドに修正してみましょう。

# th_atexit2.py
import threading
import time
import atexit

alive = True


def bgmain():
    while alive:
        print("hello")
        time.sleep(1)


th = threading.Thread(target=bgmain, daemon=True)  # daemonスレッドに
th.start()


@atexit.register
def on_exit():
    global alive
    print("atexit")
    alive = False
    th.join()


def main():
    while True:
        time.sleep(1)


main()
$ python3 th_atexit2.py
hello
hello
hello
^CTraceback (most recent call last):
  File "/Users/inada-n/work/th_atexit2.py", line 32, in <module>
    main()
    ~~~~^^
  File "/Users/inada-n/work/th_atexit2.py", line 29, in main
    time.sleep(1)
    ~~~~~~~~~~^^^
KeyboardInterrupt
atexit

きちんと1回のCtrl-Cでatexitが呼ばれて終了しました。

しかし、たとえば標準ライブラリの concurrent.futures.ThreadPoolExecutor はdamonスレッドを使っていません。 そのため、 executor.shutdown(wait=True) を atexit から呼び出すことができません。

この問題は現在コア開発者で話し合いが進められていますが、まだ解決のためのAPIは実装されていません。 今は非デーモンスレッドをバックグラウンドで使うライブラリにモンキーパッチを当てていくか、WSGIサーバーが提供する独自APIを使う必要があります。

例えば uwsgi には uwsgi.atexit というAPIがあり、これに登録された処理は Py_Finalize() を呼び出す前に実行されるので ThreadPoolExecutorを止めることができます。

9/2追記: ThreadPoolExecutorはatexit実行前に終了待ちされるので、 executor.shutdown() などを atexit から実行する必要はありません。問題になるのはバックグラウンドスレッドで動き続けている処理を止めるためにatexitを使う場合だけで、ThreadPoolExecutorの一般的な使い方であるそれなりの時間で終わる処理をバックグラウンドで実行するだけであれば問題ありません。次の補足記事を参照してください。

methane.hatenablog.jp

コンテナのPythonからMySQLにzstd圧縮を有効にして接続する

mysql-connector-python を使えば簡単なのですが、あえて mysqlclient を使う場合の話です。

まず、Pythonの公式DockerイメージはDebianベースになっていますが、Debianではデフォルトではapt-getでMySQLをインストールできません。 (Debian sid では MySQL が復活していますが、stableになるには時間がかかります)

代わりにmariadbのクライアントライブラリ(libmariadb)はインストールできるのですが、MariaDBはzstd圧縮に対応しておらず、そのせいかクライアントライブラリはzstd圧縮に対応しているもののDebian bookwormのパッケージには入っていないので使えません。

なので標準の apt-get だけでzstd圧縮を使う方法がありません。代わりにライブラリをインストールする必要があります。

まず MySQL なんですが、8.0からMySQL Connector/Cの配布を止めてしまっていて、MySQLを入れろというふうになっていて面倒です。 また apt レポジトリも公開してくれているのですが、 i386amd64 はあるのに arm64 がないのでそれも面倒です。

次に MariaDB ですが、次のダウンロードページを見てみると、Debian用のバイナリをarm64とamd64でバイナリパッケージを tar.gz で提供してくれています。

https://mariadb.com/downloads/connectors/

これを使ってmysqlclientを用意してみます。

FROM python:3.13

# 先にデフォルトのlibmariadbを消しておく。 python:3.13-slim の場合は省略可能
RUN apt-get -y purge libmariadb3 libmariadb-dev

# MariaDB Connector/C をインストールする
WORKDIR /usr/local
ADD https://dlm.mariadb.com/4234446/Connectors/c/connector-c-3.4.5/mariadb-connector-c-3.4.5-debian-bookworm-aarch64.tar.gz /usr/local/mariadb-connector.tar.gz
# AMD64の場合はこっち
# ADD https://dlm.mariadb.com/4234464/Connectors/c/connector-c-3.4.5/mariadb-connector-c-3.4.5-debian-bookworm-amd64.tar.gz /usr/local/mariadb-connector.tar.gz

RUN tar xf mariadb-connector.tar.gz --strip-components=1 && rm mariadb-connector.tar.gz

# /usr/local/lib/mariadb の下のライブラリはデフォルトで見つけてくれないので、 libmariadb.so(.3) へのsymlink を ld.so.conf で指定されているディレクトリに作る
# または下にある方法で rpath を設定しても良い
WORKDIR /usr/local/lib
RUN ln -s mariadb/libmariadb.so* . && ldconfig

# mysqlclient をインストール
WORKDIR /home
RUN pip install -v mysqlclient

# rpath を設定する場合
#RUN MYSQLCLIENT_CFLAGS=$(/usr/local/bin/mariadb_config --cflags) \
#    MYSQLCLIENT_LDFLAGS="$(/usr/local/bin/mariadb_config --libs) -Wl,-rpath=/usr/local/lib/mariadb" \
#    pip install -v mysqlclient

これで zstd 接続ができているかを確認しましょう。

$ docker run --network host --name mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:8.0

$ docker build -t hoge .
$ docker run --network host -it hoge bash

root@colima:/home# ldd /usr/local/lib/python3.13/site-packages/MySQLdb/_mysql.cpython-313-aarch64-linux-gnu.so
        linux-vdso.so.1 (0x0000fc6873027000)
        libmariadb.so.3 => /usr/local/lib/libmariadb.so.3 (0x0000fc6872f40000)
        libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000fc6872d90000)
        libssl.so.3 => /lib/aarch64-linux-gnu/libssl.so.3 (0x0000fc6872cd0000)
        libcrypto.so.3 => /lib/aarch64-linux-gnu/libcrypto.so.3 (0x0000fc6872800000)
        /lib/ld-linux-aarch64.so.1 (0x0000fc6872fea000)

root@colima:/home# python
Python 3.13.3 (main, Apr  9 2025, 02:20:29) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import MySQLdb
>>> con = MySQLdb.connect(host="127.0.0.1", user="root", password="my-secret-pw", compress=True)
>>> cur = con.cursor()
>>> cur.execute('SHOW STATUS LIKE "Compress%"')
3
>>> for v in cur:
...     print(v)
...
('Compression', 'ON')
('Compression_algorithm', 'zstd')
('Compression_level', '3')
>>>

Go 1.24 の testing.B.Loop() が便利

Benchmark関数を書くとき、重い初期化処理が必要になるケースがある。 例えばデータベースから大量のデータを読み込むベンチマークを書きたい場合、まずはその大きいデータを持ったテーブルを作る必要がある。

Goのベンチマーク関数は、普通は実行時間がターゲット(デフォルトで1秒)に近づくように b.N を変えながら複数回呼び出される。

func BenchmarkBigData(b *testing.B) {  // 複数回呼び出される
    // 重いセットアップ

    b.ResetTimer()
    for range b.N {
        // 測りたいコード
    }
    b.StopTimer()

    // クリーンアップ
}

このようなコードでは複数回「重いセットアップ」「クリーンアップ」がよばれてしまうので、1秒のベンチマークのためのセットアップ&クリーンアップ時間が数十秒かかる可能性が出てくる。

これを避けるために今まで使っていたのは b.Run() を使う方法だ。 b.Run() を呼び出したら、呼び出した側のベンチマーク関数は1度しか実行されなくなり、b.Run() に渡したベンチマーク関数が複数回よばれる。

func BenchmarkBigData(b *testing.B) {  // 1回だけ呼び出される
    // 重いセットアップ

    b.Run("go", func(b *testing.B) {  // 複数回呼び出される
        for range b.N {
            // 測りたいコード
        }
    })

    // クリーンアップ
}

ただ、ベンチマークを実行するときに "BenchmarkBigData/go" みたいにサブテスト名が追加されてしまうのがダサくなってしまう。 Go 1.24 で追加された b.Loop() を使えばサブテストが不要になる。 ただし b.Run() から for b.Loop() に切り替える時は、 b.ResetTimer() や b.StopTimer() を忘れないこと。

func BenchmarkBigData(b *testing.B) {  // 1回だけ呼び出される
    // 重いセットアップ

    b.ResetTimer()
    for b.Loop() {
        // 測りたいコード
    }
    b.StopTimer()

    // クリーンアップ
}

Rows.Scan() に渡す変数はループ外で宣言した方が速い

go-mysql-driver のアロケーションを調査していて気づいた小ネタ。

--- a/benchmark_test.go
+++ b/benchmark_test.go
@@ -423,9 +423,9 @@ func BenchmarkReceiveMassiveRows(b *testing.B) {
                                b.Errorf("failed to select: %v", err)
                                return
                        }
+                       var i int
+                       var s sql.RawBytes
                        for rows.Next() {
-                               var i int
-                               var s sql.RawBytes
                                err = rows.Scan(&i, &s)
                                if err != nil {
                                        b.Errorf("failed to scan: %v", err)

こう言うふうに、Scanに渡す変数は rows.Next() ループの外で宣言した方がいい。

rows.Scan() に &i のようにアドレスを渡しているが、Scan() に渡された変数はエスケープしていると判断される。 C言語などではこのアドレスがScan()を呼び出した後に利用されないことをプログラマが保証するのでコンパイラはスタックに変数を宣言できるが、Goではエスケープするならヒープアロケーションしてしまう。

ループの外で宣言することで、ヒープアロケーションをループごとではなく1度だけに限定できる。

MySQLとMariaDBで接続collationが決定される方法の違い

MySQL 8.0がutf8mb4のデフォルトcollationを utf8mb4_0900_ai_ci に変更したことは有名です。 MariaDBも11.5からデフォルトcollationを変更したのですが、 utf8mb4_0900_ai_ci ではなく utf8mb4_uca1400_ai_ci になりました。

この2つのcollationについてはMySQLMariaDBの識者に任せて、私はMySQLMariaDBで接続のcollation (collation_connection) がどう設定されるかを調べてみました。

MySQL 8

MySQL 8.4のクライアントからサーバーに --default-character-set=utf8mb4 を指定して接続すると、 collation_connectionutf8mb4_0900_ai_ci になります。 utf8mb4のデフォルトcollationが utf8mb4_0900_ai_ci になっていて、クライアントは utf8mb4 のデフォルトcollationのcollation idをハンドシェイクパケットで送信しています。

$ docker run --network host -it mysql:8.4 mysql -h 127.0.0.1 --default-character-set=utf8mb4
...
Server version: 8.4.5 MySQL Community Server - GPL
...

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

mysql> SELECT * FROM information_schema.COLLATIONS WHERE CHARACTER_SET_NAME='utf8mb4' AND ID<256;
+------------------------+--------------------+-----+------------+-------------+---------+---------------+
| COLLATION_NAME         | CHARACTER_SET_NAME | ID  | IS_DEFAULT | IS_COMPILED | SORTLEN | PAD_ATTRIBUTE |
+------------------------+--------------------+-----+------------+-------------+---------+---------------+
| utf8mb4_general_ci     | utf8mb4            |  45 |            | Yes         |       1 | PAD SPACE     |
| utf8mb4_bin            | utf8mb4            |  46 |            | Yes         |       1 | PAD SPACE     |
...
| utf8mb4_0900_ai_ci     | utf8mb4            | 255 | Yes        | Yes         |       0 | NO PAD        |
+------------------------+--------------------+-----+------------+-------------+---------+---------------+
27 rows in set (0.01 sec)

ただし、MySQL 8.4同士でも注意が必要なケースがあります。

デフォルトのcharsetがutf8mb4になっていても、 --default-charcter-set=utf8mb4 を指定しないと utf8mb4 にならないことがあります。たとえばmysql:8.4のコンテナのmysqlを使うとこうなります。

$ docker run --rm --network host -it mysql:8.4 mysql  -h 127.0.0.1
mysql> show variables like '%_connection';
+--------------------------+-------------------+
| Variable_name            | Value             |
+--------------------------+-------------------+
| character_set_connection | latin1            |
| collation_connection     | latin1_swedish_ci |
+--------------------------+-------------------+
2 rows in set (0.00 sec)

これはmysqlクライアントが、 --default-character-set が指定されていないときにlocaleからcharsetを選択するためです。例えば環境変数 LANG=C.UTF-8 を設定すると utf8mb4 が使われます。

$ docker run --rm --network host -it --env LANG=C.UTF-8 mysql:8.4 mysql  -h 127.0.0.1
...
mysql> show variables like '%_connection';
+--------------------------+--------------------+
| Variable_name            | Value              |
+--------------------------+--------------------+
| character_set_connection | utf8mb4            |
| collation_connection     | utf8mb4_0900_ai_ci |
+--------------------------+--------------------+
2 rows in set (0.01 sec)

もう一つ注意する必要があるのがビルド時のオプションです。Homebrewのmysql-clientは -DDEFAULT_COLLATION=utf8mb4_general_ci をつけてビルドされています。(mysqlではこのオプションは消されているのでmysql-clientでも消すPRを作っています。)

mysqlが選択したcharsetが、DEFAULT_COLLATIONのcharsetと一致している場合、charsetのデフォルトのcollationではなく DEFAULT_COLLATIONが使われます。

https://github.com/mysql/mysql-server/blob/ff05628a530696bc6851ba6540ac250c7a059aa7/sql-common/client.cc#L3954-L3958

そのためHomebrewのmysql-clientのmysqlコマンドを使うと utf8mb4_general_ci が使われます。utf8mb4のデフォルトcollationが変わっている訳では無いので、 SET NAMES utf8mb4 を実行すると utf8mb4_0900_ai_ci になります。

$ /opt/homebrew/opt/mysql-client@8.4/bin/mysql -h 127.0.0.1 -uroot --default-character-set=utf8mb4
...

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

mysql> set names utf8mb4;
Query OK, 0 rows affected (0.00 sec)

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

MariaDB

MariaDBは組み込みのutf8mb4のデフォルトcollationをMySQL 5.7時代の utf8mb4_general_ci にしたまま、追加の character_set_collations というシステム変数でutf8mb4のデフォルトcollationを変更しています。試しにこの変数を空にして information_schema.COLLATIONS を見てみると utf8mb4_general_ci がデフォルトであることが分かります。

# $ docker run --rm --name mariadb --network host --env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 mariadb:latest --character_set_collations=''

mysql> SELECT * FROM information_schema.COLLATIONS WHERE CHARACTER_SET_NAME='utf8mb4' AND ID<256;
+------------------------------+--------------------+------+------------+-------------+---------+---------------------------------------+
| COLLATION_NAME               | CHARACTER_SET_NAME | ID   | IS_DEFAULT | IS_COMPILED | SORTLEN | COMMENT                               |
+------------------------------+--------------------+------+------------+-------------+---------+---------------------------------------+
| utf8mb4_general_ci           | utf8mb4            |   45 | Yes        | Yes         |       1 | UTF-8 Unicode                         |
| utf8mb4_bin                  | utf8mb4            |   46 |            | Yes         |       1 | UTF-8 Unicode                         |
| utf8mb4_unicode_ci           | utf8mb4            |  224 |            | Yes         |       8 |                                       |
...
+------------------------------+--------------------+------+------------+-------------+---------+---------------------------------------+
27 rows in set (0.00 sec)

今度はデフォルトの設定でMariaDBを起動してみると、 information_schema.COLLATIONS からデフォルトのcollationが消えました。 character_set_collations 変数を確認するとUnicode系のcharsetに対して *_uca1400_ai_ci をデフォルトのcollationとして設定していることが分かります。

# $ docker run --rm --name mariadb --network host --env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 mariadb:latest

mysql> SELECT * FROM information_schema.COLLATIONS WHERE CHARACTER_SET_NAME='utf8mb4' AND IS_DEFAULT='Yes';
Empty set (0.00 sec)

mysql> SELECT * FROM information_schema.COLLATIONS WHERE CHARACTER_SET_NAME='utf8mb3' AND IS_DEFAULT='Yes';
Empty set (0.00 sec)

mysql> select @@character_set_collations;
+-----------------------------------------------------------------------------------------------------------------------------------------+
| @@character_set_collations                                                                                                              |
+-----------------------------------------------------------------------------------------------------------------------------------------+
| utf8mb3=utf8mb3_uca1400_ai_ci,ucs2=ucs2_uca1400_ai_ci,utf8mb4=utf8mb4_uca1400_ai_ci,utf16=utf16_uca1400_ai_ci,utf32=utf32_uca1400_ai_ci |
+-----------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

character_set_collations システム変数は MariaDB 11.2.1 で追加されましたが、 uca1400_ai_ci をデフォルトにしたのは MariaDB 11.5 からです。

MariaDBcollation_connection が設定されるまでの流れは次のようになります。

  • mariadbクライアントはmysqlと違い DEFAULT_COLLATION によるcollationの上書きをしないので、 --default-character-set=utf8mb4 を指定すると utf8mb4_general_ci のID(45)をハンドシェイクパケットで送信します。
  • mariadbサーバーは、 utf8mb4_general_ci のIDを受け取ると、これが utf8mb4 のデフォルトのcollationであり、なおかつ character_set_collationsutf8mb4=utf8mb4_uca1400_ai_ci が設定されているので、 utf8mb4_uca1400_ai_ci を使います。

相互接続

--default-character-set=utf8mb4 を指定して接続するときの、MySQL/MariaDB間やバージョン間の挙動をまとめます。

MySQLへの接続

MySQLサーバーは(オプションで無効化されていなければ)ハンドシェイクパケットで指定されたcollationを優先します。

mariadbクライアントからMySQLに接続すると、ハンドシェイクパケットで指定された utf8mb4_general_ci が利用されます。 これは MySQL 5.7 のクライアントから接続された場合も同じです。またHomebrew の mysql-client, mysql-client@8.4, mysql-client@8.0 なども、 -DDEFAULT_COLLATION=utf8mb4_general_ci をつけてビルドされているので utf8mb4_general_ci が利用されます。(近い将来に修正されるかもしれません。)

# $ docker run --rm --name mysql --network host -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql:8.4

# mysql -> mysqld では utf8mb4_0900_ai_ci
$ /opt/homebrew/Cellar/mysql@8.4/8.4.5/bin/mysql -uroot -h127.0.0.1 --default-character-set=utf8mb4 -e 'SELECT @@collation_connection'
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_0900_ai_ci     |
+------------------------+

# mariadb -> mysqld では utf8mb4_general_ci.
# SET NAMES utf8mb4 で utf8mb4_0900_ai_ci になる
$ /opt/homebrew/Cellar/mariadb/11.7.2/bin/mariadb -uroot -h127.0.0.1 --default-character-set=utf8mb4
Server version: 8.4.5 MySQL Community Server - GPL

MySQL [(none)]> SELECT @@collation_connection;
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_general_ci     |
+------------------------+
1 row in set (0.008 sec)

MySQL [(none)]> SET NAMES utf8mb4;
Query OK, 0 rows affected (0.005 sec)

MySQL [(none)]> SELECT @@collation_connection;
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_0900_ai_ci     |
+------------------------+
1 row in set (0.005 sec)


# Homebrewのmysql-client -> mysqld でも utf8mb4_general_ci
$ /opt/homebrew/opt/mysql-client@8.4/bin/mysql -uroot -h127.0.0.1 --default-character-set=utf8mb4 -e 'SELECT @@collation_connection'
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_general_ci     |
+------------------------+

MariaDBへの接続

MariaDBサーバーはハンドシェイクパケットで utf8mb4_general_ci が指定されていると、 character_set_collations で指定されたcollation(デフォルトでは utf8mb4_uca1400_ai_ci)を利用します。 ただし、ハンドシェイクパケットで指定されたcollationがそのcharsetのデフォルトcollationでなければ、 character_set_collations は無視されます。

MySQL 8.xのクライアントは、(ビルド時にDEFAULT_COLLATIONが指定されていなければ) utf8mb4のデフォルトcollationである utf8mb4_0900_ai_ci をハンドシェイクパケットで指定しますが、これはMariaDBでは utf8mb4 のデフォルトcollationではないので character_set_collations は無視され、そのまま collation_connection に設定されます。

# $ docker run --rm --name mariadb --network host --env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 mariadb:latest

# mariadb -> mariadb では utf8mb4_uca1400_ai_ci
$ /opt/homebrew/Cellar/mariadb/11.7.2/bin/mariadb -uroot -h127.0.0.1 --default-character-set=utf8mb4 -e 'SELECT @@collation_connection'
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_uca1400_ai_ci  |
+------------------------+


# mysql -> mariadb では utf8mb4_0900_ai_ci
$ /opt/homebrew/Cellar/mysql@8.4/8.4.5/bin/mysql -uroot -h127.0.0.1 --default-character-set=utf8mb4 -e 'SELECT @@collation_connection'
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_0900_ai_ci     |
+------------------------+

# SET NAMES utf8mb4 すると utf8mb4_uca1400_ai_ci になる。
$ /opt/homebrew/opt/mysql-client@8.4/bin/mysql -uroot -h127.0.0.1 --default-character-set=utf8mb4

mysql> SET NAMES utf8mb4;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@collation_connection;
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_uca1400_ai_ci  |
+------------------------+
1 row in set (0.01 sec)


# Homebrewのmysql-client -> mariadb では utf8mb4_uca1400_ai_ci (近い将来修正される可能性あり)
$ /opt/homebrew/opt/mysql-client@8.4/bin/mysql -uroot -h127.0.0.1 --default-character-set=utf8mb4 -e 'SELECT @@collation_connection'
+------------------------+
| @@collation_connection |
+------------------------+
| utf8mb4_uca1400_ai_ci  |
+------------------------+

まとめ

collation_connection は実際に影響するケースは少ない変数ですが、だからこそ稀にハマった場合に原因がなかなかわからない事があります。

クライアント側では charset はエスケープ処理に関係するものの、collationは全く使いません。 そのため、私がメンテナンスしている go-sql-drivers/mysql, mysqlclient, PyMySQL では、サーバーのデフォルトのcollationを優先するように、接続後に SET NAMES utf8mb4 を実行するようになっています。

ほかの接続ライブラリでも、例えばPHPのmysqliにある MYSQLI_INIT_COMMAND のように新規接続時に実行するコマンドを指定できる場合は、 SET NAMES utf8mb4 を実行することをお勧めします。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。