MySQL道普請便り

第261回MySQLアップグレードで顕在化したINTと空文字のバグ挙動

MySQL 8.0のサポートが終了し、移行先として8.4を利用するケースは今後ますます増えていくと思います。すでに本番環境で8.0系を運用していたユーザーの多くが、サポート終了を受けてバージョンアップを完了させているのではないでしょうか。

MySQLのアップグレードでは、⁠昨日まで問題なく動いていた処理が、アップグレード後に突然エラーになる」という状況に出会うことがあります。筆者も今回、まさにその典型のようなケースに遭遇しました。本稿では、アップグレード作業の途中で発覚したMySQL 8.0.28、および8.0.29のみに存在していた特定のバグを紹介します。

実害そのものは大きなものではありませんでしたが、切り分けの過程で得た気づきは多く、振り返ってみるとバージョンアップ作業における「盲点」のようなポイントがいくつも含まれていました。⁠道普請」らしく、今回のケースを簡単にまとめておこうと思います。

なお、本件のバグが再現するのはMySQL 8.0.28および8.0.29という特定のバージョンに限られます。これらのバージョンを使用していない方は安心して読んでいただければと思います。

始まり

きっかけは、あるアプリケーションをMySQL 8.0.28から8.4に載せ替えたことでした。比較的シンプルな構造のテーブルに対してデータを挿入する処理があり、8.0.28では特に問題なく動いていました。しかし、アップグレード後のテストで、一部のINSERTがエラーを返すことに気付きます。

ERROR 1366 (HY000): Incorrect integer value: '' for column 'id' at row 1

内容としては「整数として不正な値が渡されている」という典型的な1366エラーです。テーブル定義を確認すると、該当カラムは確かにINT型として宣言されています。

MySQLをある程度使ったことがある方であれば、この種のエラーを見るとまずstrictモード、特にstrict_trans_tablesの設定を確認すると思います。INTカラムに空文字列を渡した場合、strict_trans_tablesが有効であればエラーになります。一方で、無効な場合には0へ暗黙的に変換されます。一般的には、そのように理解されているはずです。

ところが今回のケースでは、こうした理解と異なる挙動が実際に発生していました。

strictモードを確認

もし8.0.28の環境でエラーにならず通っていたのであれば、最初に疑うべきはSQL_MODE、特にSTRICT_TRANS_TABLESが有効かどうかです。そこでまず、8.0.28と8.4.5の両方でSQL_MODEを確認しました。

mysql8.0.28> SELECT @@SQL_MODE;
+-----------------------------------------------------------------------------------------------------------------------+
| @@SQL_MODE                                                                                                            |
+-----------------------------------------------------------------------------------------------------------------------+
| ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION |
+-----------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
mysql8.4.5> SELECT @@SQL_MODE;
+-----------------------------------------------------------------------------------------------------------------------+
| @@SQL_MODE                                                                                                            |
+-----------------------------------------------------------------------------------------------------------------------+
| ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION |
+-----------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

結果として、8.0.28でも8.4.5でもSTRICT_TRANS_TABLESは有効でした。両環境で@@SQL_MODEは同一で、少なくともこの点に設定差はありません。にもかかわらず、8.0.28では成功していたINSERTが8.4.5ではエラーになる。この時点で、原因は設定ではなくバージョンによる挙動差にあると考えるのが自然ですが、まだ決め手には欠けていました。

そこで、当時のログを頼りに原因を特定しようと、同じINSERTを手元で再現してみることにしました。問題となっていたSQLを参考にしつつ8.0.28 環境で実行します。

mysql8.0.28> CREATE TABLE t (
    ->   id INT
    -> );
Query OK, 0 rows affected (0.01 sec)

mysql8.0.28> INSERT INTO t VALUES ('');
ERROR 1366 (HY000): Incorrect integer value: '' for column 'id' at row 1

8.0.28でも、INTカラムに対する空文字のINSERTは期待どおり1366エラーになります。strictモードの動作は少なくともこの経路では正しいと言ってよさそうです。

一方で、アプリケーションログ上は同等のINSERTが成功したように見える記録が残っていました。⁠以前は通っていたはずのINSERTが手動で実行すると通らない⁠⁠。この食い違いから、再現方法か前提に見落としがあるのではないかと感じ、一度立ち止まって整理し直すことにしました。

再現方法を確認

そこで思い出したのが、実際にエラーが起きた環境では、アプリケーションがSQLを文字列でそのまま投げているわけではない、という点でした。実際には、フレームワークが内部的に PREPARED STATEMENTを利用しています。手元で再現を試みた際には、素のINSERT文をそのまま実行していたため、アプリケーション環境とは実行経路が異なっていたことになります。言い換えると、PREPARED STATEMENTを使った場合にのみ問題が起きている可能性が考えられました。

そこで改めて、PREPARED STATEMENTを使ったINSERTを試してみることにしました。空文字をパラメータとしてバインドして実行すると、8.4.5では予想どおり1366エラーになります。しかし8.0.28ではエラーにならず、整数カラムに0が挿入されてしまいました。さらに確認のために8.0.29でも同じ手順を試したところ、同様に0が挿入される挙動を再現できました。STRICT_TRANS_TABLESが有効でありながら、空文字が整数として解釈されず、エラーも出ず、静かに0に変換される─⁠─ようやく現象の輪郭がはっきりしてきた瞬間でした。

mysql8.0.28> CREATE TABLE t (
    ->   id INT
    -> );
Query OK, 0 rows affected (0.01 sec)

mysql8.0.28> INSERT INTO t VALUES ('');
ERROR 1366 (HY000): Incorrect integer value: '' for column 'id' at row 1

mysql8.0.28> PREPARE stmt FROM 'INSERT INTO t VALUES (?)';
Query OK, 0 rows affected (0.00 sec)
Statement prepared

mysql8.0.28> SET @a = '';
Query OK, 0 rows affected (0.01 sec)

mysql8.0.28> EXECUTE stmt USING @a;
Query OK, 1 row affected (0.00 sec)

mysql8.0.28> SELECT * FROM t;
+------+
| id   |
+------+
|    0 |
+------+
1 row in set (0.01 sec)

同様のバグを調査

ここまで来ると、次に確認すべきなのは「これはすでに既知のバグとして報告されていないかどうか」です。そこで、バージョンと現象のキーワードを手掛かりに調べていくと、今回の挙動とよく似た報告としてBug#107399を見つけました。

内容を読んでみると、

  • 対象は MySQL 8.0.28および8.0.29の一部バージョンであること
  • PREPARED STATEMENT経由で空文字を数値型に渡した場合に、数値変換時のバリデーションが正しく行われないこと
  • STRICT_TRANS_TABLESが有効であってもエラーにならず、値0として挿入されてしまうこと

といった点が挙げられており、今回手元で確認した挙動と一致していました。

また、MySQL 8.0.30でこの問題が修正され、それ以降のバージョン(8.4系を含む)では、PREPARED STATEMENT経由であってもstrictのチェックが正しく動作し、空文字は1366エラーとして拒否されるようになっていることも確認できました。今回8.4へのアップグレードで表面化したのは、言い換えると「8.0.28/29側の挙動が本来とは異なっていた」ことが、後になって露見した形だったと言えそうです。

まとめ

今回の事例は影響範囲こそ限定的でしたが、PREPARED STATEMENTと素のSQLの挙動差、そしてstrictモードの動作が特定のバージョンで想定と異なる形になる場合があることを改めて実感する機会になりました。とりわけ、手動での再現では問題が表に出ず、アプリケーションの実行経路でのみ挙動が変わるケースは、切り分けに時間がかかります。

今後もアップグレード時には、機能追加や非推奨化だけでなく、今回のような細かな、そして盲点になりやすい挙動差にも目を向け、PREPARED STATEMENTを含めた実行経路でのテストを習慣化していきたいところです。

おすすめ記事

記事・ニュース一覧