そろそろMySQLのutf8について一言いっとくか

MySQLのutf8 charsetは、やれ「罠」だの「絵文字が入らなくて使えない」だの「utf8という名前はutf8mb4の別名にすべき」だの、散々な言われようでディスられてかわいそうな charset なんだけど、というか主に私がそう言ってる気もするんだけど、そろそろ utf8mb3 のエイリアスとしての utf8 は消え去ろうとしてるみたいなので、ここでちょっと勝手にフォローしておく。

UTF-8 エンコーディングの RFC は RFC3629で、ここで UTF-8 は最大4バイトと書かれている。 しかし、この RFC3629 の前のRFC2279では6バイトだった。 RFC3629 の日付は 2003/11 なので、つまり 2003/11 よりも前は UTF-8 の1文字のバイト数は最大6バイトだったのだ(少なくともRFC上では)。

MySQL が Unicode に対応したのはバージョン 4.1 からで、開発版4.1.0のリリースは 2003/04/03、正式版4.1.7のリリースは 2004/10/23 だった。すくなくとも開発版リリース時点では UTF-8 の理論的な最大長は6バイトだった。 理論的に最大6バイトと言っても、当時に実際にそんなに文字が割り当てられてるわけはなく、UTF-8対応しようとした時に6バイトに対応するのは無駄であるし、それよりも小さいバイト数に対応しようとするのは自然だと思う。

Unicode は当初2バイトにすべての文字を納めるという計画だったし、UTF-8は1文字が可変長なのでそのままではプログラムで扱いづらいため、内部的には2バイトの固定長で扱われることが多かった(たぶん今でも多いと思う)。 2バイトで表現できる範囲(U+0000〜U+FFFF)をUTF-8で表すと最大3バイトになる。UTF-8の最大バイト数を3バイトにするというのは当時は妥当な判断だったと思う。

たとえば Windows が UTF-8で4バイトになる文字(U+10000〜の文字)に対応したのは、2006年リリースの Windows Vista からだ。 Windows Vista が出るまでは世間で4バイトUTF-8文字なんて実質使えなかったのである。

utf8が最大3バイトなのは妥当だったとして、MySQL が4バイトUTF-8に対応したのは 5.5 (2010/12 リリース)で、Windows Vista の4年後なので、まあちょっと遅い気がしないでもない。

なお、utf8mb4 に対応した現在でも、テーブル名やカラム名には4バイト文字は使えない。絵文字テーブル名や絵文字カラム名を使いたかったみなさん、残念でした。

ところで、先般リリースされたMySQL 8.0 では utf8 を使用するととうとう warning が出るようになるまで地位が下がってきた。 将来 utf8 は utf8mb3 のエイリアスではなく utf8mb4 のエイリアスになるらしい。

mysql> create table t (hoge varchar(10)) charset utf8;
Query OK, 0 rows affected, 1 warning (0.22 sec)

Warning (Code 3719): 'utf8' is currently an alias for the character set UTF8MB3, 
which will be replaced by UTF8MB4 in a future release. Please consider using 
UTF8MB4 in order to be unambiguous.

ところが、utf8mb3 でテーブル作っても、show create table すると utf8 で出力しやがるんですよ。

mysql> show create table t\G
*************************** 1. row ***************************
       Table: t
Create Table: CREATE TABLE `t` (
  `hoge` varchar(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.02 sec)

なので、mysqldump してロードしてみると、

% sudo mysqldump -uroot test t > /tmp/dump.sql
% sudo mysql -uroot --show-warnings test < /tmp/dump.sql
Warning (Code 3719): 'utf8' is currently an alias for the character set UTF8MB3, 
which will be replaced by UTF8MB4 in a future release. Please consider using 
UTF8MB4 in order to be unambiguous.

ちょ、おま… ダンプしてロードするだけで warning て…。

まだ中途半端なようですな…。

MySQLのsjisとcp932の違い

今さらですけど、自分でもちゃんと把握してなかったので調べてみました。

MySQLのCharsetのうちシフトJIS系のものはsjisとcp932の二つあります。

どちらもコードの範囲は次のように同じです。

1バイト文字 0x00-0x7F, 0xA1-0xDF
2バイト文字の1バイト目 0x81-0x9F, 0xE0-0xFC
2バイト文字の2バイト目 0x40-0x7E, 0x80-0xFC

違いは文字集合です。1バイト文字はどちらも同じ(ASCII + JIS X 0201 カナ)ですが、2バイト文字はsjisはJIS X 0208 で、cp932はWindows-31Jです。

sjisに含まれていない文字

cp932はsjisよりも文字が多く、丸囲み数字(「①」「②」「③」等)、ローマ数字(「Ⅰ」「Ⅱ」「Ⅲ」等)、組文字(「㍉」「㌍」「㍻」等)、その他「彅」「髙」等の JIS X 0208 には入ってない文字が含まれています。

同じコードで異なる文字

上記のように基本的にはsjisに文字を追加したものがcp932なのですが、同じコードで異なる文字が割り当てられているものがあります。

「\」「~」「∥」「-」「¢」「£」「¬」の7文字です。

sjisだけまたはcp932だけを使用して処理している場合には、特に何も問題にならないのですが、charsetを変換する場合に問題になります。

sjisの「~」はcp932には存在しないし、cp932の「~」はsjisには存在しません。つまりsjisとcp932の相互に変換しようとしたときに問題になります。なおutf8mb4には両方とも存在するので、utf8mb4に変換することはできます。

どうしてこんなことになったのか興味がある人はWikipediaの日本語環境でのUnicodeの諸問題を見ましょう。

利用者定義領域(外字領域)

cp932にはsjisには無かった利用者定義領域(外字領域)が1880文字分あります(0xF040〜F9FC)。

MySQLではsjis,cp932のコードの範囲であれば文字が割り当てられていないコードの文字も使用することができますが、他のcharsetに変換することはできません。

Unicodeにも利用者定義領域と同様の私用領域が定められていて、このうちの1880文字がcp932の利用者定義領域とマッピングされています。

つまりcp932の0xF040〜F9FCの文字はutf8mb4には変換できますが、sjisの同じ領域の文字は変換することができません。


ということで、以上をMySQL上で確かめてみます。

まず sjis, cp932 の範囲の文字を含んだファイルを作ります。今回はRubyで次のように作りました。

((0x81..0x9f).to_a + (0xe0..0xfc).to_a).each do |c1|
  ((0x40..0x7e).to_a + (0x80..0xfc).to_a).each do |c2|
    code = format "%02X%02X", c1, c2
    s = [c1, c2].pack('C*').b
    puts [code, s].join("\t")
  end
end

これを sjis.rb として保存して、次のように実行すると sjis.txt が作られます。

% ruby sjis.rb > /tmp/sjis.txt

MySQLでsjisとcp932それぞれのテーブルを作り、sjis.txtをロードします。

mysql> create table sjis (code varchar(255) ascii primary key, c varchar(255)) charset sjis;
mysql> create table cp932 (code varchar(255) ascii primary key, c varchar(255)) charset cp932;
mysql> load data local infile '/tmp/sjis.txt' into table sjis charset sjis;
mysql> load data local infile '/tmp/sjis.txt' into table cp932 charset cp932;

sjis と cp932 をそれぞれ utf8mb4 に変換して、違うコードになった文字を出力します。

mysql> select sjis.code, sjis.c sjis, cp932.c cp932 from sjis join cp932 using (code) where hex(convert(sjis.c using utf8mb4))!=hex(convert(cp932.c using utf8mb4));
+------+------+-------+
| code | sjis | cp932 |
+------+------+-------+
| 815F | \    | \    |
| 8160 | 〜   | ~    |
| 8161 | ‖    | ∥     |
| 817C | −    | -    |
| 8191 | ¢    | ¢    |
| 8192 | £    | £    |
| 81CA | ¬    | ¬    |
| 8740 | ?    | ①     |
| 8741 | ?    | ②     |
| 8742 | ?    | ③     |
| 8743 | ?    | ④     |
...

出力結果は長いので https://gist.github.com/tmtm/c0d325ae74e43740f7ffa3d0dccb0bb4 に置いておきました。

815F,8160等のようにsjisとcp932の両方に文字があるものは、sjisとcp932の相互に変換できない文字です。utf8mb4には対応する文字が存在しているので、utf8mb4に変換することはできています。

?となってるのは、utf8mb4に変換することができなかった文字です。コードに文字が割り当てられていないことを意味しています。

MySQL Innovation Day Tokyo で MySQL 8 の文字コードについて話した

MySQL Innovation Day Tokyo に参加して10分ほど喋ってきました。

ひさびさに巨大サキラちゃん登場。本人曰く5年間ほど倉庫に隠れてたそうです。

f:id:tmtms:20180523175014j:plain

昼ごはんは今半のすき焼弁当でした。豪華!

f:id:tmtms:20180523121113j:plain f:id:tmtms:20180523121135j:plain

会場の様子。百数十人で満員でした f:id:tmtms:20180523152737j:plain

以下わたしの発表内容。スライドはこちら https://tmtm.github.io/mysql-innovation-tokyo/


MySQL恒例「RCとはいったい…」案件

utf8の指定でwarningが出るようになった

mysql> set names utf8;
Query OK, 0 rows affected, 1 warning (0.00 sec)

Warning (Code 3719): 'utf8' is currently an alias for the character set UTF8MB3, which will be replaced by UTF8MB4 in a future release. Please consider using UTF8MB4 in order to be unambiguous.

今は utf8 は utf8mb3 の別名だけどどうやら将来 utf8mb4 の別名になるらしく、そのためのwarningらしいんだけど、RC版では出力されなかったのがGA版で出力されるようになったのはいいんだろうか。

なお、utf8mb3 を使えという割には、参照時には utf8 と表示されるのがイマイチ。show create table の結果をそのまま使うと warning が出るという…。

mysql> create table xx (id char(10)) charset utf8mb3;
Query OK, 0 rows affected (0.08 sec)

mysql> show create table xx\G
*************************** 1. row ***************************
       Table: xx
Create Table: CREATE TABLE `xx` (
  `id` char(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql> CREATE TABLE `xx` (
    ->   `id` char(10) DEFAULT NULL
    -> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Query OK, 0 rows affected, 1 warning (0.19 sec)

Warning (Code 3719): 'utf8' is currently an alias for the character set UTF8MB3, which will be replaced by UTF8MB4 in a future release. Please consider using UTF8MB4 in order to be unambiguous.

mysqlshのプロンプトが派手になった

プロンプトの「MySQL」の文字色がロゴの色と同じ。凝りすぎでは…。

f:id:tmtms:20180524223644p:plain

パラメータ比較

MySQLのバージョン間のパラメータを比較できるページを作りました。よろしければどうぞ。

https://tmtm.github.io/mysql-params/?vers=5.7.22,8.0.11&diff=true

RC版とGA版の比較はこちら。RCとはいったい…。

https://tmtm.github.io/mysql-params/?vers=8.0.4,8.0.11&diff=true

文字コードまわり

5.7ではデフォルトのcharsetはlatin1(ISO8859-1)だったのが8.0ではutf8mb4(Unicode)になった。

デフォルトのままで日本語や絵文字が使えるようになったのはよい。

utf8mb4 charset のcollation(照合規則)のうち日本語環境で使われそうなものは次の通り:

  • utf8mb4_bin
  • utf8mb4_general_ci (5.7 デフォルト)
  • utf8mb4_unicode_ci
  • utf8mb4_unicode_520_ci
  • utf8mb4_0900_ai_ci (8.0 デフォルト)
  • utf8mb4_0900_as_ci
  • utf8mb4_0900_as_cs
  • utf8mb4_ja_0900_as_cs
  • utf8mb4_ja_0900_as_cs_ks

強調されてる下の5つが8.0で増えたもの。

utf8mb4の次の文字の意味:

bin コードのまま
general MySQL独自規則
unicode Unicode 4.0.0
unicode_0520 Unicode 5.2.0
0900 Unicode 9.0.0
ja_0900 Unicode 9.0.0 + 日本語

その次の文字の意味:

ai Accent Insensitive アクセント違いは同じ文字
as Accent Sensitive アクセント違いは異なる文字
ci Case Insensitive 大文字小文字は同じ文字
cs Case Sensitive 大文字小文字は異なる文字
ks Kana Sensitive カタカナひらがなは異なる文字

デフォルトCollationの違い

  • 5.7: utf8mb4_general_ci

    • Aa, ,
    • 🍣🍺
  • 8.0: utf8mb4_0900_ai_ci

    • Aa, ,
    • 🍣🍺

Charset=utf8mb4 を指定しただけでは 5.7 と 8.0 で動きが異なるので注意

新しいCollationたち

utf8mb4_0900_ai_ci

  • アクセントの違いを区別しない

    • ははぱぱばば
  • 大文字小文字を区別しない

    • びょういんびよういん
    • いろはイロハ
    • 株式会社

ci/cs でカタカナや組文字も影響うけるらしい。

utf8mb4_0900_as_ci

  • アクセントの違いを区別する

    • ははぱぱばば
  • 大文字小文字を区別しない

    • びょういんびよういん
    • いろはイロハ
    • 株式会社

utf8mb4_0900_as_cs

  • アクセントの違いを区別する

    • ははぱぱばば
  • 大文字小文字を区別する

    • びょういんびよういん
    • いろはイロハ
    • 株式会社

(時間の都合で発表では以下は省略した)

全部の文字が区別されるんなら utf8mb4_bin と何が違う? 合成文字の扱いが異なる。

「が」と「か」+「゛」の比較

utf8mb4_0900_as_cs では一致:

mysql> set @a='が' collate utf8mb4_0900_as_cs;
mysql> set @b=concat('か', _utf8mb4 0xE38299 collate utf8mb4_0900_as_cs);
mysql> select @a,@b,hex(@a),hex(@b),@a=@b;
+------+--------+---------+--------------+-------+
| @a   | @b     | hex(@a) | hex(@b)      | @a=@b |
+------+--------+---------+--------------+-------+
| が   | が   | E3818C  | E3818BE38299 |     1 |
+------+--------+---------+--------------+-------+

utf8mb4_bin では不一致:

mysql> set @a='が' collate utf8mb4_bin;
mysql> set @b=concat('か', _utf8mb4 0xE38299 collate utf8mb4_bin);
mysql> select @a,@b,hex(@a),hex(@b),@a=@b;
+------+--------+---------+--------------+-------+
| @a   | @b     | hex(@a) | hex(@b)      | @a=@b |
+------+--------+---------+--------------+-------+
| が   | が   | E3818C  | E3818BE38299 |     0 |
+------+--------+---------+--------------+-------+

異体字セレクタも同様。

utf8mb4_ja_0900_as_cs

  • アクセントの違いを区別する

    • ははぱぱばば
  • 大文字小文字を区別する

    • びょういんびよういん
    • 株式会社
  • カタカナひらがなを区別しない

    • いろはイロハ

utf8mb4_ja_0900_as_cs_ks

  • アクセントの違いを区別する

    • ははぱぱばば
  • 大文字小文字を区別する

    • びょういんびよういん
    • 株式会社
  • カタカナひらがなを区別する

    • いろはイロハ

全部の文字が区別されるなら utf8mb4_0900_as_cs と同じ??

utf8mb4_0900 と utf8mb4_ja_0900 の違い

ja_0900 ではJISコード順にソートされる。

mysql> select hex(s),s from ja order by s;
+--------+------+
| hex(s) | s    |
+--------+------+
| E4BA9C | 亜   |
| E4BC8A | 伊   |
| E99BA8 | 雨   |
| E6A084 | 栄   |
| E5A5A5 | 奥   |
+--------+------+

長音記号は前の文字の母音に依存する。

mysql> select s from ja order by s;
+--------+
| s      |
+--------+
| あー   |
| ああ   |
| あい   |
| いあ   |
| いー   |
| いい   |
| うあ   |
| うい   |
| うー   |
+--------+

凝りすぎ!? でもこれはMySQL固有の特殊な実装じゃなくて、ちゃんとUnicodeの規約通り。 日本語辞書順ともいうらしい。

でも自分はつかわないかなー。

utf8mb4_0900_as_csが一番無難かも。 合成文字等の Unicode の特殊な事情を考慮しなくていいのであれば utf8mb4_binでもいいかも。たぶんその方が速いんじゃないかと。

昔からMySQLを使ってる人は大文字小文字を区別しない方がうれしいかもしれない。その場合は utf8mb4*ci の中から選択する感じで。

ちゃんと各collationの特徴を知って適切なものを使いましょう。


サキラちゃんと。

f:id:tmtms:20180523175456j:plain

MySQLパラメータ比較

Vue.js の勉強をしようと思ってMySQLのバージョン間のパラメータを比較できるページを作ってみました。

MySQL Parameters

やってることは、あらかじめ mysqld --no-defaults -v --help の出力からパラメータの名前と値を JSON にしておいて、それを表示しているだけです。

環境によってデフォルト値が動的に変わるようなパラメータもあるのですべて信用できるわけではないですけど、まあ参考くらいにはなるかなと。

自分が 5.7 と 8.0 の比較を見てみて気づいたのは、

  • basedir のデフォルト値が mysqld の実行パスから動的に生成されるようになった。

  • date-format, datetime-format なんてパラメータが今まであったの知らなかった。

  • へー query-cache まわりはパラメータ自体なくなったのか…。

とかとか。

本当はこわいMySQLプロトコル

11/28 に Haskell で MySQL の Xプロトコルを実装したという話が聴ける Club MySQL というイベントがあったので参加してきました。

clubmysql.connpass.com

MySQLのプロトコルの話ということで、平日の夜とは言え東京で参加者9人(発表者含む)というマニアックな集まりでした。

自分も1年前に Ruby で MySQL Xプロトコルを実装していたのですが、このツイートを最後に中断していたのでした。

今回話を聞いて、無理に謎条件式文字列をパースするんじゃなくて、処理系で書きやすいように書けばいいんだという方式に目からウロコでした。

もしかしたらまたRubyでの実装を再開するかもしれません。

で、このイベントで私も喋ってきました。本当は前座で話す予定だったんですが、到着が遅れてしまったので合間の休憩時間で話すことになりました。スイマセン。

www.slideshare.net

以下がしゃべった内容です。


MySQLの参照系のクエリのパケットはこんな感じです。クエリの応答としてフィールド数と、フィールド情報と、レコードデータが返されます。

f:id:tmtms:20171130224159p:plain

更新系のクエリはもっと簡単です。クエリの応答として更新の結果情報が返されます。

f:id:tmtms:20171130224203p:plain

クエリをパースするのはサーバーなので、クライアントはクエリが参照か更新かは知りません。

クライアントはクエリの応答のパケットでクエリが参照だったか更新だったかを知ります。

ところで、LOAD DATA LOCAL INFILE という特殊なクエリがあって、これはローカルにあるファイルをテーブルにロードします。

が、クエリをパースするのはサーバーなので、クエリ中のファイル名もサーバーから返してもらいます。

f:id:tmtms:20171130224205p:plain

ということは、クライアントから送ったクエリで指定したファイル名とは異なるファイル名がサーバーから指定されても、クライアントはそれを信じてサーバーから指定されたファイルのデータを送ってしまいます。

当日は、クライアントとサーバーの間のプロキシとして動くようなテキトーに作ったプログラムを使って、LOAD DATA LOCAL INFILE のときだけファイル名を差し替えるデモをしました。

f:id:tmtms:20171130224208p:plain

この場合は、テーブルに登録されたデータを見れば自分が意図したものではないことがわかります。

クエリをパースするのはサーバー側ということは、INSERT, UPDATE 等の更新系のクエリを発行した時に LOAD DATA LOCAL INFILE だと偽ってクライアントにファイルを送らせることもできるわけです。

f:id:tmtms:20171130224211p:plain

この場合はテーブルに登録されるのはクライアントが発行したクエリ通りのデータなので、プロキシによってファイルの内容が取られてしまったことはわかりません。

こわい!

対策としては、

  • 基本的に信頼できない MySQL サーバーには繋がない。

  • サーバーは信頼できるんだけど、途中のネットワークが信頼できない場合はSSL接続を使う。

  • クライアントライブラリで LOAD DATA LOCAL INFILE を使わないというフラグを設定する。

mysql コマンドの場合は --local-infile=false を指定できます。この場合はサーバーからファイルを送るように指示されてもクライアントライブラリが不正なパケットとして振る舞います。

MySQL の max_connections, table_open_cache, open_files_limit の関係

昔書いた記事を久々に見てみたら何書いてあるかさっぱりわからなかったので、最新情報で書き直してみます。

tmtms.hatenablog.com

以下は MySQL 5.6, 5.7 について書かれています。

MySQL の max_connectoins, table_open_cache, open_files_limit パラメータは相互に依存して動的に値が設定されます。

パラメータ名 デフォルト値 最小値 最大値
max_connections 151 1 100000
table_open_cache 2000 1 524288
open_files_limit 5000 0 4G

わからないと言われたので追記。

my.cnfやコマンドラインオプション等で設定された値(またはデフォルト値)を初期値として、open_files_limit → max_connections → table_open_cache の順に値が決定します。

open_files_limit

open_files_limit は次の計算結果の最大の値になります。

  1. 10 + max_connections + table_open_cache * 2
  2. max_connections * 5
  3. open_files_limit に指定された値(未指定時は 5000)
  4. 現在の getrlimit(2) の RLIMIT_NOFILE の値 (ulimit -n の値)

ただし、setrlimit(2) で RLIMIT_NOFILE にこの値を設定できない場合(おそらく root で起動されなかった場合)、open_files_limit は現在の RLIMIT_NOFILE の値になり、次の警告メッセージを出力します。

open_files_limit が未指定の場合:

Changed limits: max_open_files: XXX (requested YYY)

open_files_limit に値が設定されていた場合:

Could not increase number of max_open_files to more than XXX (request: YYY)

max_connections

max_connections が open_files_limit - 810 よりも大きい場合は、open_files_limit -810 の値になります。

table_open_cache

(open_files_limit - 10 - max_connections) / 2 (この値が 400 よりも小さい場合は 400) よりも table_open_cache が大きい場合、次の警告メッセージを出力して、table_open_cache がその値に変更されます。

Changed limits: table_open_cache: XXX (requested YYY)

open_files_limit = 100 を指定した場合、open_files_limit は 10 + max_connections + table_open_cache * 2 が採用されて 4161 になります。

ulimit -n が 1024 である一般ユーザーでは、setrlimit(2) で RLIMIT_NOFILE を増加させることはできないので、open_files_limit は 1024 になり、table_open_cache が 431 になります。次の警告メッセージが出力されます。

[Warning] Changed limits: max_open_files: 1024 (requested 5000)
[Warning] Changed limits: table_open_cache: 431 (requested 2000)

この状況では max_connections も指定した値にならないことがあります。

max_connections=1000 を指定した場合、max_connections=214, table_open_cache=400 になります。

max_connections = open_files_limit(1024)-810 = 214
table_open_cache = (open_files_limit(1024)-10-max_connections(214))/2 = 400

Ubuntuの罠

Ubuntu で mysql-server をインストールすると my.cnf で max_connections を設定しても指定した値になりません。

/etc/mysql/my.cnfmax-connections = 1024 と記述して起動した mysql では次のようになります。

mysql> select * from performance_schema.global_variables where variable_name in ('max_connections','table_open_cache','open_files_limit');
+------------------+----------------+
| VARIABLE_NAME    | VARIABLE_VALUE |
+------------------+----------------+
| max_connections  | 214            |
| open_files_limit | 1024           |
| table_open_cache | 400            |
+------------------+----------------+

これは mysqld が root ではなく mysql ユーザーで起動されるようになっているためです。

/lib/systemd/system/mysql.service

[Service]
User=mysql
Group=mysql
PermissionsStartOnly=true
ExecStartPre=/usr/share/mysql/mysql-systemd-start pre
ExecStart=/usr/sbin/mysqld
ExecStartPost=/usr/share/mysql/mysql-systemd-start post

なんでこんな設定になっているのかわかりませんが、root で起動するようにすれば普通に設定されるようになります。 RuntimeDirectoryMode=1777 を設定しているのは、/var/run/mysqld/ が root の 755 で作られると、mysqld が pid や socket ファイルを作成できないためです。

# systemctl edit mysql
[Service]
User=root
Group=root
RuntimeDirectoryMode=1777
# systemctl restart mysql
mysql> select * from performance_schema.global_variables where variable_name in ('max_connections','table_open_cache','open_files_limit');
+------------------+----------------+
| VARIABLE_NAME    | VARIABLE_VALUE |
+------------------+----------------+
| max_connections  | 1024           |
| open_files_limit | 5120           |
| table_open_cache | 2000           |
+------------------+----------------+

追記

はてブで指摘されましたが、どうやら systemd は LimitNOFILE で最大ファイルディスクリプタ数を設定できるようです。

User=root にしなくても、LimitNOFILE で適切な値を設定すればいいみたいです。

# systemctl edit mysql
[Service]
LimitNOFILE=65535
mysql> select * from performance_schema.global_variables where variable_name in ('max_connections','table_open_cache','open_files_limit');
+------------------+----------------+
| VARIABLE_NAME    | VARIABLE_VALUE |
+------------------+----------------+
| max_connections  | 1024           |
| open_files_limit | 65535          |
| table_open_cache | 2000           |
+------------------+----------------+

MySQLの日本語コレーション

4月にMySQLの日本語コレーションについて語り合う場に呼ばれていろいろ話を聞いてきました。すぐにブログを書こうと思ったんですが、はや2ヶ月経過…。

ときどき、自分がMySQLの文字コードに関して発表する際に、次のようなスライドをいれてるんですが、

https://image.slidesharecdn.com/mysql-2017-170520113534/95/mysql-2017-55-638.jpg

MySQL 8.0 でとうとう日本語コレーションが入ることになったのに、なんか期待してたのと違いました。

https://image.slidesharecdn.com/mysql-2017-170520113534/95/mysql-2017-58-638.jpg

で、その辺の話を聞きました(2ヶ月も経ってるのでうろ覚え)。

Q. わざわざ日本語ロケール作るんだったら日本人が扱いやすいロケールにしてほしい

utf8mb4_ja_0900_as_csはMySQLが独自に考えたものではない。Unicode規格に従っている。過去にいろいろ独自にやって失敗してきてるので、もう独自にやるのは避けたい。

ai(accent insensitive)で「ハ」=「パ」=「バ」になるのも、ci(case insensitive)で「や」=「ゃ」になるのもUnicodeに従っている。

Q. ja_0900_as_cs0900_as_cs と何が違うの?

utf8mb4_ja_0900_as_cs は日本語固有の規則に従う。CLDR参照。

長音記号「ー」の順序が前の字によって異なる。たとえば「アー」は「アイ」よりも前だが「ウー」は「ウイ」よりも後。

そんな凝ったことしてたのか…。

その他

コレーションは xml ファイルを置くことで独自に定義できるので、標準のコレーションが気に入らないのなら自分で定義すればいい。

マルチバイト文字はリコンパイルしないと組み込めないと思ってましたが、それは文字セットの話でコレーションについてはコンパイル不要とのことでした。

ちゃんとマニュアルにも書いてありました。10.4.4 Unicode 文字セットへの UCA 照合順序の追加

ja_0900_as_cs はひらがなとカタカナを区別しないが、区別したい場合のために ja_0900_as_cs_ks というコレーションを作ろうとしている。

うーん、個人的にはそこまで区別したいんだったら utf8mb4_bin でいいかな…。


MySQLは標準(Unicode)に従ってるだけなので、独自におかしなことをしているわけではないということでした。

そしてUnicodeの日本語の照合順序はJIS X 4061が元なので、日本語の扱いがおかしいと日本人がMySQLに対して言うのは「お前がゆーな!」状態でした。申し訳ありませんでした!

あと最近知ったのですが、ja_0900_as_cs の場合は、漢字の順番もちゃんとJIS順になってました。 試してみると、UTF-8の文字コード順ではなくJISコード順(音読みの順)になっていることがわかります。

mysql> select hex(s),s from ja_0900_as_cs order by s;
+--------+------+
| hex(s) | s    |
+--------+------+
| E4BA9C | 亜   |
| E4BC8A | 伊   |
| E99BA8 | 雨   |
| E6A084 | 栄   |
| E5A5A5 | 奥   |
+--------+------+

業界によっては実は結構嬉しいのかもしれません。

MySQL 5.7が何も言わずに起動できなかったのでメモ

MySQL 5.6と同じ方法でMySQL 5.7を起動しようとしたら何も言わずに黙って終了してしまって少しだけハマったのでメモ。

MySQL 5.6では次のようにして起動してました。

# /usr/local/mysql-5.6/bin/mysqld --no-defaults --user=mysql
  --basedir=/usr/local/mysql-5.6 --skip-networking --socket=/tmp/mysql56.sock
  --log-error=/tmp/my56.err > /tmp/my56.err 2>&1

log-error をつけていても、最初の数行が標準エラー出力に出ちゃうので、log-error と同じファイルにリダイレクトするようにしていました。

同じようにしてMySQL 5.7を起動してみたら、すぐに終了してしまって、しかもエラーを何も出力しません。

# /usr/local/mysql-5.7/bin/mysqld --no-defaults --user=mysql
  --basedir=/usr/local/mysql-5.7 --skip-networking --socket=/tmp/mysql57.sock
  --log-error=/tmp/my57.err > /tmp/my57.err 2>&1

結論からいうと、エラー出力のリダイレクトが不要でした。5.6 と異なり、リダイレクトしなくても余計な出力がされることはありませんでした。

log-error と同じファイルにリダイレクトすることで、ファイルの所有者が root になり、mysql ユーザー権限でエラーファイルに書き込みできず、エラーを出力することもできなくて終了していたのでした。

5.6の時は log-error のファイルオープン時のユーザーはまだ mysql になる前の root だからオープンできたけど、5.7ではファイルオープン時に既に mysql ユーザーになっているからファイルのオープンに失敗しているってことだと思います。ソースを見て確認したわけではないですけど、たぶん。

MySQLユーザ会会 in 長野 を開催しました

5/13(土)に「MySQLユーザ会会 in 長野」を開催しました。

nseg.connpass.com

「会」が2つあるのは仕様です。「MySQLユーザ会」の会合で「MySQLユーザ会会」です。

開催経緯はこんなかんじです。

NSEG(長野ソフトウェア技術者グループ)というのがあって、毎月何かしらやってるんですが、その87回目イベントとして開催しました。


昼前に長野駅について、駅ビルのGitHubにめっちゃコミットしてるMen↓の近くでボーっとしてたら

@RKajiyama さんに遭遇したので、一緒に昼飯を食うことにしました。

他県から長野に来た人とお昼を食べるならやっぱり蕎麦かな…と思ったのですが、あまり長野駅近辺の蕎麦屋を知らなかったので、前に美味しいという噂を聞いたことがあった「ぼっち」という蕎麦屋に行きました。 満席でキューに並んでる途中で @mamy1326 さんも合流。 席につくまでも席についてからも結構待たされたのですが、美味しかったです。

主催者なのに遅れ気味に会場についたら、ほとんどの人はもう来ていました。 ドタキャンなし無断欠席なしで、さすが地方開催イベント(というか事前登録無しで来た人もいた)。発表者含めて参加者18名でした。

ツイートを Togetterにまとめました。


MySQLとは - @sakaik

RDBMSとは? MySQLとは? MySQLのバージョン、歴史等について話していただきました。

AWSでサクッとMySQL環境を立ち上げるデモもありました。

MySQL 8 - @RKajiyama

MySQLの中の人として、MySQLの次バージョンである MySQL 8 について話していただきました。

CTEとかWindow関数とかが実装されて、RDBMSとしてどんどんマトモになっていきますね。MySQLなのに。

MySQLの14年物のバグ #199 が直るらしいです。

yoku0825を支える技術 - @yoku0825

yoku0825とは「GMOペパボの福利厚生」で「はてなの外部API」で「bot」だそうです。

GTID, Multi Thread Slave, Defer Table Index, HANDLER ステートメント, PMM 等についての話がありました。

Bug #2 の紹介がありました。 Closed になってるのに 8.0 でバグが再発したらしいです。

Transactd PHP ORM - @bizstationcorp

TransactdはMySQLのHanderインタフェースを直接叩くことができるAPIを提供するプラグインで、Transactd PHP ORMはそのAPIを使用したPHPのORMです。

既存のORMのAPIとほぼ同じで、省メモリで高速とのこと。

Transactd も PHP ORM もオープンソースでGitHubにあります。

RubyにもTransactd APIがあるので、頑張ればORMを作れそうです。

MySQLの文字コード事情 - @tmtms

2月のMySQL Casualの発表の使いまわしです。MySQL 8 の話も少し追加しました。


懇親会は「酔来処」14名参加でした。

参加者のみなさま、発表者のみなさま、ありがとうございました。

1年に1回くらいは長野で開催したいですね。 次は松本でやるのもいいかなぁと思ったりしてます。自分は土地勘ないので誰か仕切ってくれる人がいれば…。

Unicode Collation Algorithm

文字コードは面白いね! わーい! たのしー!

🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾

MySQL で utf8mb4_unicode_ci コレーションを使用した時に「🍣」=「🍺」や「ハ」=「パ」になる問題があります。

この utf8mb4_unicode_ci ってなんぞや?と思ってマニュアルを見てみると、

MySQL は、http://www.unicode.org/reports/tr10/ で説明している Unicode 照合順序アルゴリズム (UCA) に従って xxx_unicode_ci 照合順序を実装します。照合順序は、バージョン 4.0.0 UCA 重みキー (http://www.unicode.org/Public/UCA/4.0.0/allkeys-4.0.0.txt) を使用します。

https://dev.mysql.com/doc/refman/5.6/ja/charset-unicode-sets.html

とあります。

Unicode には Unicode Collation Algorithm (UCA) という標準があり、MySQL の utf8mb4_unicode_ci は UCA のバージョン 4.0.0 を使用しています。

UCAのドキュメントをちゃんと読んだわけではないので以下の説明はテキトーです。

各文字の比較レベルを定義したテーブルは Default Unicode Collation Element Table (DUCET)と呼ばれて UCA のバージョン毎に提供されています。

UCA 4.0.0 の DUCET の中味はこんな感じです。

0061  ; [.0E33.0020.0002.0061] # LATIN SMALL LETTER A
FF41  ; [.0E33.0020.0003.FF41] # FULLWIDTH LATIN SMALL LETTER A; QQK
0363  ; [.0E33.0020.0004.0363] # COMBINING LATIN SMALL LETTER A; QQK
249C  ; [*0288.0020.0004.249C][.0E33.0020.0004.249C][*0289.0020.001F.249C] # PARENTHESIZED LATIN SMALL LETTER A; QQKN
1D41A ; [.0E33.0020.0005.1D41A] # MATHEMATICAL BOLD SMALL A; QQK

左端の16進数はUnicodeのコードポイントを表し、その次の [ ] で括られた4つの16進数は文字の比較レベルを表します。

レベルは左から順に次のようになっています。

L1 Base characters 基本文字
L2 Accents アクセント
L3 Case/Variants 大文字小文字/異体字
L4 Punctuation 句読点(?)

いくつか抜粋してみます。左に文字をつけました。

a 0061 ; [.0E33.0020.0002.0061] # LATIN SMALL LETTER A
FF41 ; [.0E33.0020.0003.FF41] # FULLWIDTH LATIN SMALL LETTER A; QQK
24D0 ; [.0E33.0020.0006.24D0] # CIRCLED LATIN SMALL LETTER A; QQK
A 0041 ; [.0E33.0020.0008.0041] # LATIN CAPITAL LETTER A
FF21 ; [.0E33.0020.0009.FF21] # FULLWIDTH LATIN CAPITAL LETTER A; QQK
å 00E5 ; [.0E33.0020.0002.0061][.0000.0043.0002.030A] # LATIN SMALL LETTER A WITH RING ABOVE; QQCM
Å 00C5 ; [.0E33.0020.0008.0041][.0000.0043.0002.030A] # LATIN CAPITAL LETTER A WITH RING ABOVE; QQCM
b 0062 ; [.0E4A.0020.0002.0062] # LATIN SMALL LETTER B
FF42 ; [.0E4A.0020.0003.FF42] # FULLWIDTH LATIN SMALL LETTER B; QQK
B 0042 ; [.0E4A.0020.0008.0042] # LATIN CAPITAL LETTER B

「a」っぽい文字は L1=0E33 で「b」っぽい文字は L1=0E4a になっています。

Å」は複数のレベルを持ち、1個目のレベルは「A」とまったく同じで、2個目のレベルは合成文字用の「˚」です。 NFD正規化された状態(?)でレベルが表されます。

L1 や L1+L2 で比較すると「a」「」「A」「」は同じ文字として扱われます。 L1+L2+L3 で比較すると異なる文字として扱われます。

文字の比較にどのレベルまで使用するかはアプリ次第で、MySQL の utf8mb4_unicode_ci では L1 しか使用していません。 そのため、英字は大文字/小文字/全角/半角は区別されません。

は=ぱ=ば=ハ=パ=バ

で、問題の「は」「ぱ」「ば」「ハ」「パ」「バ」ですが、次のようになっています。 濁点/半濁点つきの文字は正規化されて、清音文字+濁点文字の2つのレベルの組み合わせで表されてます。

306F ; [.1E6B.0020.000E.306F] # HIRAGANA LETTER HA
3071 ; [.1E6B.0020.000E.306F][.0000.0141.0002.309A] # HIRAGANA LETTER PA; QQCM
3070 ; [.1E6B.0020.000E.306F][.0000.0140.0002.3099] # HIRAGANA LETTER BA; QQCM
30CF ; [.1E6B.0020.0011.30CF] # KATAKANA LETTER HA
30D1 ; [.1E6B.0020.0011.30CF][.0000.0141.0002.309A] # KATAKANA LETTER PA; QQCM
30D0 ; [.1E6B.0020.0011.30CF][.0000.0140.0002.3099] # KATAKANA LETTER BA; QQCM

これらの文字は L1 レベルでは同じレベルなので、L1 でしか使用しない MySQL の utf8mb4_unicode_ci では区別されないことになります。

「は」「ぱ」「ば」だけでなく「か」「が」や「さ」「ざ」も区別されません。

日本語としては、清音、濁音、半濁音をそれぞれ区別するのが自然ですが、Unicode の標準の規則にしたがった Case insensitive だと区別できません。

utf8mb4_japanese_ci の登場に期待したいところです。

🍣=🍺

絵文字の比較はまた事情が異なります。DUCET には絵文字は定義されていないのです。実は漢字も定義されていません。

UCA では DUCET に定義されていない文字の扱い方も定めています。(7.1.3)

AAAA = BASE + (CP >> 15);
BBBB = (CP & 0x7FFF) | 0x8000;
CP => [.AAAA.0020.0002.][.BBBB.0000.0000.]

BASE:
FB40 CJK Ideograph
FB80 CJK Ideograph Extension A/B
FBC0 Any other code point

「漢」という文字のCP(Code point)はU+6F22なので、[.FB40.0020.0002.][.EF22.0000.0000] となります。 この2つのレベルを組みわせて使用します。

mysql> SELECT HEX(WEIGHT_STRING('漢'));
+---------------------------+
| HEX(WEIGHT_STRING('漢'))  |
+---------------------------+
| FB40EF22                  |
+---------------------------+

同じように「🍣」と「🍺」の値を求めると 「🍣」(U+1F363)は FBC3F363となり、「🍺」(U+1F37A)はFBC3F37Aとなるので、区別できるはずです。

ところが MySQL の utf8mb4_unicode_ci では、絵文字についてはそれに従わず、FFFD にしてしまっています。

一般的な照合順序の補助文字の場合、重みは 0xfffd REPLACEMENT CHARACTER の重みです。UCA 4.0.0 照合順序の補助文字の場合、照合重みは 0xfffd です。

https://dev.mysql.com/doc/refman/5.6/ja/charset-unicode-sets.html

mysql> SELECT HEX(WEIGHT_STRING('🍣'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FFFD                    |
+-------------------------+

つまり、utf8mb4_unicode_ci で 🍣=🍺 となるのは Unicode のせいではなく、MySQL の問題です。

なお、utf8mb4_unicode_520_ci ではちゃんと計算された値を使用しています。

mysql> SET NAMES utf8mb4 COLLATE utf8mb4_unicode_520_ci;
mysql> SELECT HEX(WEIGHT_STRING('🍣'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FBC3F363                |
+-------------------------+
mysql> SELECT HEX(WEIGHT_STRING('🍺'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FBC3F37A                |
+-------------------------+

MySQLの文字コード事情

この前 MySQL Casual に登壇して、「MySQLの文字コード事情」と称して発表してきました。

終電の都合で途中退席しましたが楽しかったです。また機会があれば参加したいです。

発表スライドはこちら

www.slideshare.net

以下、補足のような何か。

「Charset≒エンコーディング (MySQLに限らない)」

英語版のWikipediaでもcharsetCharacter encoding にリダイレクトされます。

自分がcharsetという用語に出会ったのはおそらくメールのContent-Typeヘッダが初めてだったと思います。 今ではメールだけではなくHTTPのヘッダでも使用されています。

なお、CharsetはInternet Assigned Numbers Authority(IANA)という組織で管理されています。http://www.iana.org/assignments/character-sets/character-sets.xhtml

ujis

MySQLで日本語を使用できるようになった時にEUC-JPエンコーディングのcharsetの名前をujisとしてしまったのは自分です。ゴメンナサイ。

いや、今でこそEUC-JPの方がメジャーでujisなんてもう見かけないんですけど、1998年当時はeucJPとujisのどちらも使われたんですよ。(あと ujis と sjis で韻を踏んでいていいかなーとか…)

EUCは Extended Unix Code の略で、ujisは Unixized JIS の略です。ググってみたら ujis の方が EUC-JP よりも歴史は古いようですね。

「歴史的には、まず「日本語EUC」の元 (ujis) があって、その工夫を I18N 的枠組に拡張したものが EUC」

http://naruse.hateblo.jp/entry/20090308/1236517235

「ふつうはutf8mb4」

とスライドには書きましたが、cp932を使うメリットも無いことはないです。

UTF-8は日本語の文字は普通3バイトですがCP932は2バイトです。 つまりディスクやメモリの消費量がUTF-8に比べて2/3で済むということです。

扱える文字が Windows-31J の範囲の文字だけで良くて、少しでも資源を節約したいのであれば、cp932を使用するのもいいかもしれません。

🍣=🍺問題とCollation

伝統的にMySQLは標準で大文字小文字を区別しないので、ci(Case Insensitive)を使いたくなってしまうのですけど、PostgreSQL とかは普通に大文字小文字は別の文字扱いだし、実は MySQL も utf8mb4_bin でも全然問題ないのかもしれません。

utf8mb4_bin であれば🍣=🍺問題も、ハハ=パパ問題も発生しませんし。

‘🍣’=‘🍺’=‘�’

MySQLで同じ文字とみなされるかどうかは WEIGHT_STRING() という関数の戻り値が同じかどうかで確かめられます。

SET NAMES utf8mb4 COLLATE utf8mb4_general_ci;
SELECT HEX(WEIGHT_STRING('🍣'));  -- => FFFD
SELECT HEX(WEIGHT_STRING('🍺'));  -- => FFFD
SELECT HEX(WEIGHT_STRING('�'));  -- => FFFD

SET NAMES utf8mb4 COLLATE utf8mb4_unicode_520_ci;
SELECT HEX(WEIGHT_STRING('🍣'));  -- => FBC3F363
SELECT HEX(WEIGHT_STRING('🍺'));  -- => FBC3F37A

SET NAMES utf8mb4 COLLATE utf8mb4_bin;
SELECT HEX(WEIGHT_STRING('🍣'));  -- => 01F363
SELECT HEX(WEIGHT_STRING('🍺'));  -- => 01F37A

utf8mb4_bin の場合は Unicode のコードポイントがそのまま使用されるようです。

「'パ'=‘パ'」と「'パ’ LIKE ‘パ'」

半角カナの「パ」は「ハ」と「゚」の二文字で構成されていますが、unicode_ci では一文字の「パ」と一致します。

SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SELECT 'パ'='パ'; -- => 1

ですが、LIKE では一致しません。

SELECT 'パ' LIKE 'パ'; -- => 0

SQL 標準では、LIKE は文字ごとに一致を実行するため、= 比較演算子とは異なる結果が生成される可能性があります。

https://dev.mysql.com/doc/refman/5.6/ja/string-comparison-functions.html#operator_like

…ということのようです。

Unicode Collation Algorithm

そのうち書きたい(書くとは言ってない)。

書いた

MySQL で utf8 と utf8mb4 の混在で起きること

MySQL を UTF-8 で使おうと思ってハマりがちなのは charset utf8 を指定してしまうことです。

MySQL の UTF-8 には歴史的事情により utf8 と utf8mb4 の二つあります。

UTF-8 は1バイト〜4バイトで1文字が構成される文字コードですが、MySQL の utf8 は4バイト文字を扱うことができません。ハマりたくなければ utf8mb4 を使いましょう。

utf8 を使ってしまった場合に4バイト文字がどのように扱われるか、自分でもうろ覚えだったのでメモしておきます。

登録

接続が utf8mb4 でカラムが utf8mb4

あたりまえですが、そのまま登録されます。

mysql> insert into utf8mb4 (c) values ('美味しい🍣と🍺');
mysql> select * from utf8mb4;
+-------------------------+
| c                       |
+-------------------------+
| 美味しい🍣と🍺              |
+-------------------------+

接続が utf8 でカラムが utf8mb4

4バイト文字が「????」になります。

mysql> insert into utf8mb4 (c) values ('美味しい🍣と🍺');
Warning (Code 1300): Invalid utf8 character string: 'F09F8D'
Warning (Code 1366): Incorrect string value: '\xF0\x9F\x8D\xA3\xE3\x81...' for column 'c' at row 1
mysql> select * from utf8mb4;
+-------------------------+
| c                       |
+-------------------------+
| 美味しい????と????      |
+-------------------------+

utf8 の接続から送られてくるデータでは4バイト文字は不正な4バイトデータなので、4つの「?」に置き換えられます。

なお、sql_mode の設定によってはエラーになります。MySQL 5.7 のデフォルトではエラーになります。安全ですね。

mysql> insert into utf8mb4 (c) values ('美味しい🍣と🍺');
ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x8D\xA3\xE3\x81...' for column 'c' at row 1

接続が utf8mb4 でカラムが utf8

4バイト文字が「?」になります。

mysql> insert into utf8 (c) values ('美味しい🍣と🍺');
Warning (Code 1366): Incorrect string value: '\xF0\x9F\x8D\xA3\xE3\x81...' for column 'c' at row 1
mysql> select * from utf8;
+-------------------+
| c                 |
+-------------------+
| 美味しい?と?      |
+-------------------+

utf8mb4 の接続上は4バイト文字は正しい1文字ですが、utf8 に対応する文字がないため、1つの「?」に置き換えられます。

sql_mode の設定によってエラーになるのは同上です。

接続が utf8 でカラムが utf8

4バイト文字が現れるとそこで文字列が切られてしまいます!

mysql> insert into utf8 (c) values ('美味しい🍣と🍺');
Warning (Code 1300): Invalid utf8 character string: 'F09F8D'
Warning (Code 1366): Incorrect string value: '\xF0\x9F\x8D\xA3\xE3\x81...' for column 'c' at row 1
mysql> select * from utf8;
+--------------+
| c            |
+--------------+
| 美味しい     |
+--------------+

utf8 の接続で4バイト文字は4バイトの不正データなので「????」になっても良さそうなのですが、MySQL の気持ちはよくわかりません。 接続もカラムも utf8 なので文字コードの変換が行われず、そのまま登録しようとしたらおかしな文字があったからそこで打ち切り…ということなのかもしれません。

まあ、これも sql_mode をちゃんと設定しておけばいいのですけど…。

参照

utf8mb4 接続で参照

あたりまえですが、そのまま参照できます。

mysql> select * from utf8mb4;
+-------------------------+
| c                       |
+-------------------------+
| 美味しい🍣と🍺              |
+-------------------------+

utf8 接続で参照

4バイト文字は「?」に置換されます。

mysql> select * from utf8mb4;
+-------------------+
| c                 |
+-------------------+
| 美味しい?と?      |
+-------------------+

文字「?」そのものが入っているのか、参照時に置換されたのかは HEX() 関数で確認できます。

mysql> select right(c,1),hex(right(c,1)) from utf8mb4;
+------------+-----------------+
| right(c,1) | hex(right(c,1)) |
+------------+-----------------+
| ?          | F09F8DBA        |
+------------+-----------------+

右端1文字の文字コードを16進で出力すると「?」の文字コード 3F ではなく「🍺」の F09F8DBA になっていることがわかります。

MySQL X Protocol を解析してみる

前回 MySQL X Protocol で使用している Protobuf について書きましたが、それだけでは MySQL のプロトコルは解析できません。

TCP を流れるデータは区切りがないので、書き込み側が Protobuf データをただ垂れ流しても、読み込む側がどう読んで良いのかわかりません。

書き込むデータの大きさと、書き込む Protobuf データの型を相手に伝える必要があります。

MySQL X Protocol のパケットは次のようになっているようです。

┌────┬───────────────
│size(4) │type(1) + Protobuf(size-1)
└────┴───────────────

最初の4バイト(リトルエンディアン)で続くデータ部のサイズを示します。 データ部の先頭1バイトは Protobuf データの型を示します。

Protobuf データの型は、クライアントから送るデータは ClientMessages::Type で、サーバーから送るデータは ServerMessages::Type に enum で定義されています。

TCP 上を流れるデータの形式がわかったので、あとは、どの型のデータがどのタイミングでサーバー/クライアントのどちらから送られるかがわかればいいです。

ドキュメント https://dev.mysql.com/doc/internals/en/x-protocol.html もありますが、実際に mysqlsh の通信を見てみるのが手っ取り早いかもしれません。

次のような MySQL X Protocol を中継するプログラムを作って動かしてみました。

require 'mysqlx.pb'
require 'socket'

ClientMessage = {}
Mysqlx::ClientMessages::Type.constants.each do |c|
  v = Mysqlx::ClientMessages::Type.const_get(c)
  if v.is_a? Protobuf::Enum
    ClientMessage[v.to_i] = c
  end
end

ServerMessage = {}
Mysqlx::ServerMessages::Type.constants.each do |c|
  v = Mysqlx::ServerMessages::Type.const_get(c)
  if v.is_a? Protobuf::Enum
    ServerMessage[v.to_i] = c
  end
end

localport, host, port = ARGV

def relay(r, w, from)
  while true
    head = r.read(5)
    break unless head && head.length == 5
    size, type = head.unpack('VC')
    if from == :client
      puts "C: #{ClientMessage[type] || type}"
    else
      puts "S: #{ServerMessage[type] || type}"
    end
    data = r.read(size-1)
    break unless data && data.length == size-1
    w.write(head + data)
  end
rescue => e
  p e
end

Socket.tcp_server_loop(localport) do |client, _addrinfo|
  server = TCPSocket.new(host, port)
  Thread.new(client) do |_client|
    relay(_client, server, :client)
  end
  Thread.new(client) do |_client|
    relay(server, _client, :server)
  end
end

33061 ポートで待ち受けて 127.0.0.1 の 33060 に中継するように動かします。

% ruby -I. ./mysqlx-relay.rb 33061 127.0.0.1 33060

別の端末から mysqlsh を次のように起動します。

% mysqlsh --uri mysql://hoge@127.0.0.1:33061/test --sql
Creating a Node Session to hoge@127.0.0.1:33061/test
Enter password: 
Default schema `test` accessible through db.

mysql-sql> プロンプトが出るまでのパケット。結構多い…。

C: CON_CAPABILITIES_GET
S: CONN_CAPABILITIES
C: SESS_AUTHENTICATE_START
S: SESS_AUTHENTICATE_CONTINUE
C: SESS_AUTHENTICATE_CONTINUE
S: NOTICE
S: SESS_AUTHENTICATE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: SQL_STMT_EXECUTE_OK

SELECT したり、

mysql-sql> SELECT * FROM t;
C: SQL_STMT_EXECUTE
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_COLUMN_META_DATA
S: RESULTSET_ROW
S: RESULTSET_ROW
S: RESULTSET_ROW
S: RESULTSET_FETCH_DONE
S: NOTICE
S: SQL_STMT_EXECUTE_OK

INSERT したり、

mysql-sql> INSERT INTO t (id, value) VALUES (1, 'abc'),(2,'def');
C: SQL_STMT_EXECUTE
S: NOTICE
S: NOTICE
S: SQL_STMT_EXECUTE_OK

いい感じに動いてるようなので、あとは色々試してみます。

RubyからProtobufを使う

MySQL 5.7.12 から追加された X Protocol は Protobuf というのを使ってるらしいです。 Protobuf というのをそこで初めて知ったので、とりあえず Ruby から Protobuf を利用する方法を調べてみました。

Protobuf はデータ構造をバイト列にエンコードしたり、その逆にバイト列をデータ構造にデコードしたりするライブラリのようです。

Ubuntu で protobuf を使うには、protobuf-compiler パッケージをインストールします。

% sudo apt-get install protobuf-compiler

Ruby から Protobuf を使うには、protobuf gem をインストールします。

% gem install protobuf

データ構造は .proto という拡張子のファイルで定義するようです。

MySQL 5.7.12 では rapid/plugin/x/protocol ディレクトリに置かれていました。

% cd mysql-5.7.12/rapid/plugin/x/protocol
% ls
mysqlx.proto             mysqlx_expect.proto     mysqlx_session.proto
mysqlx_connection.proto  mysqlx_expr.proto       mysqlx_sql.proto
mysqlx_crud.proto        mysqlx_notice.proto
mysqlx_datatypes.proto   mysqlx_resultset.proto

Ruby 用にコンパイル(?)します。

% mkdir /tmp/x
% protoc -I . --ruby_out /tmp/x mysqlx.proto
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_resultset.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_session.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_sql.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_connection.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_expect.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_crud.proto" which is not used.
[libprotobuf WARNING google/protobuf/descriptor.cc:5411] Warning: Unused import: "mysqlx.proto" imports "mysqlx_notice.proto" which is not used.
Suppress tag warning output with PB_NO_TAG_WARNINGS=1.
[WARN] .Mysqlx.Datatypes.Scalar object should have 9 tags (1..9), but found 8 tags.
[WARN] .ColumnMetaData.FieldType object should have 18 tags (1..18), but found 11 tags.
[WARN] .Mysqlx.Crud.Find object should have 10 tags (2..11), but found 9 tags.
[WARN] .SessionStateChanged.Parameter object should have 11 tags (1..11), but found 10 tags.
[WARN] .ClientMessages.Type object should have 25 tags (1..25), but found 14 tags.
[WARN] .ServerMessages.Type object should have 19 tags (0..18), but found 13 tags.

いくつか Warning が出てますが、よくわからないので無視します。

/tmp/x に .proto に対応する .pb.rb ファイルが出来ました。

% cd /tmp/x
% ls
mysqlx.pb.rb             mysqlx_expect.pb.rb     mysqlx_session.pb.rb
mysqlx_connection.pb.rb  mysqlx_expr.pb.rb       mysqlx_sql.pb.rb
mysqlx_crud.pb.rb        mysqlx_notice.pb.rb
mysqlx_datatypes.pb.rb   mysqlx_resultset.pb.rb

mysql.x.pb.rb 中にある Mysqlx::Error で試してみます。

module Mysqlx
...
  class Error < ::Protobuf::Message
    optional ::Mysqlx::Error::Severity, :severity, 1, :default => ::Mysqlx::Error::Severity::ERROR
    required :uint32, :code, 2
    required :string, :sql_state, 4
    required :string, :msg, 3
  end
end
require "mysqlx.pb"

e1 = Mysqlx::Error.new(code: 123, sql_state: "XXXXX", msg: "hoge")
s = e.encode  # => "\x10{\x1A\x04hoge\"\x05XXXXX"

e2 = Mysqlx::Error.decode(s)
e2.severity   # => #<Protobuf::Enum(Mysqlx::Error::Severity)::ERROR=0>
e2.code       # => 123
e2.sql_state  # => "XXXXX"
e2.msg        # => "hoge"

Mysqlx::Error オブジェクトがバイト列にエンコード(シリアライズ)されて、バイト列からオブジェクトにデコード(デシリアライズ)されたことがわかります。

Mysqlx::Error は severity が optional で、その他の code, sql_state, msg が required とされています。

required メンバーを指定せずにシリアライズするとエラーになります。

e = Mysqlx::Error.new(code: 123)
e.encode  # => Required field Mysqlx::Error#msg does not have a value. (Protobuf::SerializationError)

また、定義と異なる型を設定しようとしてもエラーになります。

e = Mysqlx::Error.new
e.code = 123456789    # => Ok
e.code = 12345678901  # => Unacceptable value 12345678901 for field code of type Protobuf::Field::Uint32Field (TypeError)

Mysqlx::Error クラスは Protobuf::Message の継承クラスですが、Protobuf::Message は上記のように型チェックのある構造体のように使えます。

プロトコルのためのデータ構造なので、厳密に型をチェックしているのですね。

とりあえずここまで。

MySQL Shell / X DevAPI / X Protocol

MySQL 5.7.12 で突如登場した MySQL Shell とか X DevAPI とか X Protocol とかが面白そうだったので調べてみました。

Document Store とかも同じ文脈で語られてて、それぞれの用語が何を表してるのかややこしかったので、まずその辺から。

X Protocol

mysqlx プラグインを使用することで追加されるサーバー/クライアントプロトコル。ポート番号は 33060。

詳細→ https://dev.mysql.com/doc/internals/en/x-protocol.html

X DevAPI

各プログラミング言語用の新しいAPI。Document Store用のAPIも含む。今のところ、MySQL Shell JavaScript, MySQL Shell Python, Java, .Net, Node.js 用の API がある。X Protocol を使用。

詳細→ https://dev.mysql.com/doc/x-devapi-userguide/en/

MySQL Shell

X DevAPI が組み込まれた JavaScript / Python の対話型コマンドラインツール。

詳細→ https://dev.mysql.com/doc/refman/5.7/en/mysql-shell.html

Document Store

MySQLをドキュメントデータベースとして使う方法。 内部的には、JSON型の doc という名前の1カラムだけを持つテーブル(実際には _id という自動生成カラムもある)を作って、すべてのデータをJSONで突っ込むことでドキュメントデータベースとして使用している。

詳細→ https://dev.mysql.com/doc/refman/5.7/en/document-store.html

ということで MySQL Shell を使ってみます。

MySQL 5.7.12 のインストール

自分は Xubuntu を使っていて apt-get で MySQL をインストールすると 5.7.12 が入るので、お手軽だと思ったんですけど、これは mysqlx プラグインを含んでいないため使えませんでした。ここ http://dev.mysql.com/downloads/mysql/ から適当にインストールします。

個人的に MySQL の rpm や deb パッケージは信用してないので(my.cnf を /usr に置いたりする)、tarball からインストールします。

# cd /usr/local
# tar xf /tmp/mysql-5.7.12-linux-glibc2.5-x86_64.tar.gz
# ln -s mysql-5.7.12-linux-glibc2.5-x86_64 mysql
# cd mysql
# ./bin/mysqld --no-defaults --initialize
...
2016-05-10T02:24:00.559906Z 1 [Note] A temporary password is generated for root@localhost: krJ&LWZFv3:Q
# chown -R mysql:mysql .
# ./bin/mysqld --no-defaults --user=mysql --log-error=/tmp/mysql.err --daemonize >> /tmp/mysql.err 2>&1

mysqld --initialize の最後の行にパスワードが表示されるのでメモっときます。 いちいち --no-defaults をつけてるのは、Ubuntu の MySQL の設定を読まないようにするためです。 本当はちゃんと my.cnf を書けばいいんですけど、お試しなのでこのままで。

% mysql -uroot -p
Enter password: krJ&LWZFv3:Q
mysql>

この状態で何かクエリを実行しようとすると、

ERROR 1820 (HY000): You must reset your password using ALTER USER statement before executing this statement.

って怒られるので、ぶつぶつ言いながらパスワードを設定します。

mysql> set password = 'abcdefg';

テスト用のデータベースを作成し、mysqlx プラグインを有効にしておきます。

mysql> create database test;
mysql> install plugin mysqlx soname 'mysqlx.so';

MySQL Shell のインストール

MySQL Shell は http://dev.mysql.com/downloads/shell/ から持ってきます。

残念ながらバイナリ tarball は無いみたいなので、deb をダウンロードして入れました。

# dpkg -i mysql-shell_1.0.3-1ubuntu15.10_amd64.deb

MySQL Shell を使う

コマンド名は mysqlsh です。

% mysqlsh --sql --js --user=root test
Creating an X Session to root@localhost:33060
Enter password:
...
mysql-js>

URI形式でも接続できます。

% mysqlsh --sql --js --uri root@localhost/test

コマンドラインでパスワードを指定することもできます。

% mysqlsh --sql --js --user=root --password=abcdefg test
% mysqlsh --sql --js --uri root:abcdefg@localhost/test

--sql」がないと、後で出てくる「session.sql()」が何故か使えないので指定しています。

起動後は対話型 JavaScript として動きます。何故か日本語は入力できませんでした。

mysql-js> function fib(n) { if (n < 2) return n; else return fib(n-1) + fib(n-2); }
mysql-js> fib(10)
55

複数行でも記述できます。「...」に対して何も入力せずに改行すると複数行入力の終了とみなされます。

mysql-js> function fib(n) {
      ... if (n < 2)
      ...   return n;
      ... else
      ...   return fib(n-1) + fib(n-2);
      ... }
      ...
mysql-js> fib(10)
55

session, db という変数が接続とデータベースを表しているようです。

mysql-js> session
<NodeSession:root@localhost:33060/test>
mysql-js> db
<Schema:test>

普通にSQLとして使ってみます。

mysql-js> session.sql("CREATE TABLE hoge (id INT, str VARCHAR(32))")

mysql-js> db.tables
{
    "hoge": <Table:hoge>
}
mysql-js> db.hoge.insert(['id', 'str']).values(123, 'abc')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.hoge.insert(['id', 'str']).values(456, 'def').values(789, 'ghi')
Query OK, 2 items affected (0.05 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+-----+
| id  | str |
+-----+-----+
| 123 | abc |
| 456 | def |
| 789 | ghi |
+-----+-----+
3 rows in set (0.00 sec)

mysql-js> db.hoge.select(['id', 'str']).where('id=456')
+-----+-----+
| id  | str |
+-----+-----+
| 456 | def |
+-----+-----+
1 row in set (0.01 sec)

mysql-js> db.hoge.update().set('str', 'hoge').where('id = 456')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+------+
| id  | str  |
+-----+------+
| 123 | abc  |
| 456 | hoge |
| 789 | ghi  |
+-----+------+
3 rows in set (0.00 sec)

mysql-js> db.hoge.delete().where('id=456');
Query OK, 1 item affected (0.04 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+-----+
| id  | str |
+-----+-----+
| 123 | abc |
| 789 | ghi |
+-----+-----+
2 rows in set (0.00 sec)

mysql-js> db.hoge.delete()
Query OK, 2 items affected (0.03 sec)

mysql-js> db.hoge.select(['id', 'str'])
Empty set (0.00 sec)

Document Store として使ってみます。 ドキュメントを格納するテーブルは Collection となり普通のテーブルとしては扱われません。

mysql-js> db.createCollection('fuga')
<Collection:fuga>
mysql-js> db.tables
{
    "hoge": <Table:hoge>
}
mysql-js> db.collections
{
    "fuga": <Collection:fuga>
}

実体は普通にテーブルです。_id は生成カラムなので、実質 JSON の doc カラムひとつだけのテーブルです。

mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| fuga           |
| hoge           |
+----------------+
2 rows in set (0.00 sec)

mysql> show create table fuga\G
*************************** 1. row ***************************
       Table: fuga
Create Table: CREATE TABLE `fuga` (
  `doc` json DEFAULT NULL,
  `_id` varchar(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,'$._id'))) STORED NOT NULL,
  UNIQUE KEY `_id` (`_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

どうやらテーブル構造がポイントらしく、hoge テーブルに同じ型の doc と _id カラムを追加すると、Collection として扱われます。

mysql> ALTER TABLE hoge ADD doc json,
    -> add _id varchar(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,'$._id')))
    -> STORED NOT NULL UNIQUE;

テーブル構造はキャッシュされてるようなので、再接続してから確認します。

mysql-js> \connect root:abcdefg@localhost/test
Closing old connection...
Creating an X Session to root@localhost:33060/test
Default schema `test` accessible through db.

mysql-js> db.tables
{

}
mysql-js> db.collections
{
    "fuga": <Collection:fuga>, 
    "hoge": <Collection:hoge>
}
mysql-js> 

Collection として使ってみます。

mysql-js> db.fuga.add({abc: 123})
Query OK, 1 item affected (0.05 sec)

mysql-js> db.fuga.add([{name: 'tmtms'}, {text: 'AIUEO'}])
Query OK, 2 items affected (0.04 sec)

mysql-js> db.fuga.find()
[
    {
        "_id": "682d6ac3b616e6111432022c3a710274",
        "name": "tmtms"
    },
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123
    },
    {
        "_id": "e82f6ac3b616e6111432022c3a710274",
        "text": "AIUEO"
    }
]
3 documents in set (0.00 sec)

mysql-js> db.fuga.find('abc=123')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.modify('abc=123').set('xyz', 789)
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find('abc=123')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123,
        "xyz": 789
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.modify('abc=123').unset('abc')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find('xyz=789')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "xyz": 789
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.remove('xyz=789')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find()
[
    {
        "_id": "682d6ac3b616e6111432022c3a710274",
        "name": "tmtms"
    },
    {
        "_id": "e82f6ac3b616e6111432022c3a710274",
        "text": "AIUEO"
    }
]
2 documents in set (0.01 sec)

おわりに

5.6 以降強化され続けている JSON 機能を使ってドキュメントデータベースのように使える API を用意し、そのためのプロトコルも作成したって感じでしょうか。

プロトコルはパイプライン処理ができるようになっていたりして、それ単体で見ても面白そうなので、そのうちちゃんと調べてみたいです。