CentOS 7 の mysql コマンドでの日本語入力

某所で CentOS 7 で mysql コマンドで日本語入力ができないという話を見かけた。 mysql> プロンプトで日本語を入力しようとしても確定すると消えてしまうらしい。 自分は Ubuntu で普通に入力できてるので調べてみた。

結論からいうと、これはロケールの問題で locale -a コマンドの出力中に ja_JP.utf8 があって、ロケール(環境変数 LC_ALL, LC_CTYPE, LANG 等の値)が ja_JP.UTF-8 になっていれば問題ない。

もう一つ。CentOS 7 で絵文字を入力すると、絵文字ではなく \U+1F37A みたいに表示される。表示だけの問題なので動作上は問題ない。

これは OS の glibc のバージョンの問題ぽいので CentOS 7 ではどうしようもなさそう。

以下調査内容。

MySQL は mysql-8.0.22-linux-glibc2.12-x86_64.tar.xz を使用。

% docker run --name centos7 -h centos7 -it centos:7 bash
[root@centos7 /]# yum install -y libaio numactl
[root@centos7 /]# useradd -rm mysql
[root@centos7 /]# cd /usr/local
[root@centos7 local]# tar xf /tmp/mysql-8.0.22-linux-glibc2.12-x86_64.tar.xz
[root@centos7 local]# ln -s mysql-8.0.22-linux-glibc2.12-x86_64 mysql
[root@centos7 local]# ./mysql/bin/mysqld --user=mysql --initialize-insecure
[root@centos7 local]# ./mysql/bin/mysqld --user=mysql --daemonize
[root@centos7 local]# ./mysql/bin/mysql
mysql>
↑ここで日本語を入力しても確定すると消えてしまう

locale -a の結果に ja が無い。

[root@centos7 local]# locale -a | grep ja

ロケールを追加。

[root@centos7 local]# localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
[root@centos7 local]# locale -a | grep ja
ja_JP.utf8
[root@centos7 local]# export LC_ALL=ja_JP.UTF-8
[root@centos7 local]# ./mysql/bin/mysql
mysql> あいうえお
↑入力できた

よく考えたら UTF-8 でさえあれば別に ja である必要はなかったので、ロケールを追加しなくても最初から存在してる en_US.UTF-8 でも良かった。

[root@centos7 local]# export LC_ALL=en_US.UTF-8
[root@centos7 local]# ./mysql/bin/mysql
mysql> あいうえお
↑入力できた

まあ日本語を使うなら ja_JP.UTF-8 にしといた方がいいような気はする。縁起物なので。

日本語が入力できるようになったけど、🍣🍺 などの絵文字を入力すると \U+1F363\U+1F37A になってしまう。MySQL の動作的には問題ない。

mysql> select '\U+1F363';
+------+
| ?    |
+------+
| 🍣     |
+------+

\U+1F363 の後にカーソルがある状態で左に移動すると \U+1F363 の先頭の \ に移動するので、行編集上は \U+1F363 が1文字として扱われていることがわかる。

行編集ライブラリの readline の問題かと思ったけど、どうやら readline ではなく互換ライブラリの libedit を使っているらしい。

静的リンクされているので、ldd ではわからなかったが、strings で判明。 readline は $HOME/.inputrc を使うが libedit は $HOME/.editrc を使うため。

[root@centos7 local]# ldd mysql/bin/mysql
    linux-vdso.so.1 =>  (0x00007ffd64d2e000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fca79068000)
    librt.so.1 => /lib64/librt.so.1 (0x00007fca78e60000)
    libcrypto.so.1.1 => /usr/local/mysql/bin/../lib/private/libcrypto.so.1.1 (0x00007fca789af000)
    libssl.so.1.1 => /usr/local/mysql/bin/../lib/private/libssl.so.1.1 (0x00007fca7871f000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007fca7851b000)
    libresolv.so.2 => /lib64/libresolv.so.2 (0x00007fca78301000)
    libtinfo.so.5 => /lib64/libtinfo.so.5 (0x00007fca780d7000)
    libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fca77dd0000)
    libm.so.6 => /lib64/libm.so.6 (0x00007fca77ace000)
    libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fca778b8000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fca774ea000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fca79284000)

[root@centos7 local]# strings ./mysql/bin/mysql | grep inputrc
[root@centos7 local]# strings ./mysql/bin/mysql | grep editrc
/.editrc

libedit のソースをテキトーに grep したらそれっぽい処理を発見: https://salsa.debian.org/debian/libedit/-/blob/8fb436cf/src/chartype.c#L270

iswprint() が偽になったらこのような動きになるっぽい: https://salsa.debian.org/debian/libedit/-/blob/8fb436cf/src/chartype.c#L335

C でテキトーなサンプルを作って確認してみる。

#include <stdio.h>
#include <locale.h>
#include <wchar.h>
#include <wctype.h>

int main(int argc, char *argv[])
{
  setlocale(LC_CTYPE, "");
  wint_t wc;
  while ((wc = getwc(stdin)) != WEOF) {
    printf("%d\n", iswprint(wc));
  }
  return 0;
}
[root@centos7 tmp]# gcc test.c
[root@centos7 tmp]# echo -n aあ🍣 | LC_ALL=ja_JP.UTF-8 ./a.out
16384  ← 「a」は真
1      ← 「あ」は真
0      ← 「🍣」は偽

ちなみに Ubuntu 20.10 で試すとこうなる。

% echo -n aあ🍣 | LC_ALL=ja_JP.UTF-8 ./a.out
16384  ← 「a」は真
1      ← 「あ」は真
1      ← 「🍣」は真

iswprint() は glibc の関数で、CentOS 7 の glibc は 2.17、Ubuntu 20.10 は 2.32 なのでおそらく glibc が古いせいだと思う。

というわけでこれについては CentOS 7 では諦めるのがよさそう。なお CentOS 8 では問題なかった。

MySQL 8.0.22 DNS SRV レコードサポート

MySQL 8.0.22 の新機能で DNS SRV レコードのサポートというのがあったので試してみた。 https://dev.mysql.com/doc/refman/8.0/en/connecting-using-dns-srv.html

MySQLサーバー3台 (a.example.com, b.example.com, c.example.com)とそれに接続するためのクライアントの計4台を docker-compose で作成する。

Dockerfile

FROM ubuntu
RUN apt update
RUN apt install -y mysql-client libmysqlclient-dev gcc unbound bind9-dnsutils
RUN rm -f /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
ENTRYPOINT /entrypoint.sh

docker-compose.yml

services:
  client:
    build: .
    hostname: client
    volumes:
      - ./resolv.conf:/etc/resolv.conf
      - ./unbound-example.conf:/etc/unbound/unbound.conf.d/example.conf
      - .:/work
    networks:
      test:
        ipv4_address: 192.168.100.100
  a:
    image: mysql:8.0.22
    hostname: a
    networks:
      test:
        ipv4_address: 192.168.100.101
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=1
    entrypoint: bash -c '/entrypoint.sh mysqld & sleep infinity'
  b:
    image: mysql:8.0.22
    hostname: b
    networks:
      test:
        ipv4_address: 192.168.100.102
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=1
    entrypoint: bash -c '/entrypoint.sh mysqld & sleep infinity'
  c:
    image: mysql:8.0.22
    hostname: c
    networks:
      test:
        ipv4_address: 192.168.100.103
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=1
    entrypoint: bash -c '/entrypoint.sh mysqld & sleep infinity'

networks:
  test:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 192.168.100.0/24

unbound-example.conf

server:
        interface: 127.0.0.1
        local-zone: "example.com." static
        local-data: "a.example.com. IN A 192.168.100.101"
        local-data: "b.example.com. IN A 192.168.100.102"
        local-data: "c.example.com. IN A 192.168.100.103"
        local-data: "_mysql._tcp.example.com. IN SRV 1 0 3306 a.example.com"
        local-data: "_mysql._tcp.example.com. IN SRV 2 0 3306 b.example.com"
        local-data: "_mysql._tcp.example.com. IN SRV 3 0 3306 c.example.com"

resolv.conf

nameserver 127.0.0.1

entrypoint.sh

#!/bin/bash
unbound
sleep infinity

以上のファイルを同じディレクトリに置いて docker-compose を実行する。

% docker-compose up -d
Creating network "m_test" with driver "bridge"
Creating m_c_1      ... done
Creating m_a_1      ... done
Creating m_client_1 ... done
Creating m_b_1      ... done

% docker-compose exec client bash

現状はこんな感じ。_mysql._tcp.examlpe.com の SRV レコードの値は優先度が高い順に a.example.com, b.example.com, c.example.com

root@client:/# dig _mysql._tcp.example.com srv +short
1 0 3306 a.example.com.
2 0 3306 b.example.com.
3 0 3306 c.example.com.

何回実行しても最優先の a に繋がる。

root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
a
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
a
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
a

a の mysqld を落とすと b に繋がる。

root@client:/# mysql -h a.example.com -e shutdown
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
b

b を落とすと c に繋がり、c も落とすとエラーになる。

root@client:/# mysql -h b.example.com -e shutdown
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c
root@client:/# mysql -h c.example.com -e shutdown
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
ERROR 2003 (HY000): Can't connect to MySQL server on 'c.example.com' (111)

次に優先度は同一にして Weight(分散割合)を変更してみる。

unbound-example.conf を次のように変更する。

server:
        interface: 127.0.0.1
        local-zone: "example.com." static
        local-data: "a.example.com. IN A 192.168.100.101"
        local-data: "b.example.com. IN A 192.168.100.102"
        local-data: "c.example.com. IN A 192.168.100.103"
        local-data: "_mysql._tcp.example.com. IN SRV 1 10 3306 a.example.com"
        local-data: "_mysql._tcp.example.com. IN SRV 1 20 3306 b.example.com"
        local-data: "_mysql._tcp.example.com. IN SRV 1 30 3306 c.example.com"
% docker-compose down
Stopping m_client_1 ... done
Stopping m_a_1      ... done
Stopping m_b_1      ... done
Stopping m_c_1      ... done
Removing m_client_1 ... done
Removing m_a_1      ... done
Removing m_b_1      ... done
Removing m_c_1      ... done
Removing network m_test

% docker-compose up -d
Creating network "m_test" with driver "bridge"
Creating m_client_1 ... done
Creating m_b_1      ... done
Creating m_c_1      ... done
Creating m_a_1      ... done

% docker-compose exec client bash

root@client:/# dig _mysql._tcp.example.com srv +short
1 10 3306 a.example.com.
1 20 3306 b.example.com.
1 30 3306 c.example.com.

何回実行しても c に繋がる。

root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c
root@client:/# mysql -s -N --dns-srv-name _mysql._tcp.example.com -e 'select @@hostname'
c

SRV レコードのどれに接続するのかはプログラムが制御するんだけど、mysql コマンドは実行する度にプロセスが異なるし、前回何に繋いだかなんて覚えてないので、毎回 Weight が一番高い c が選択されるということなんだろう。

というわけで C API で簡単なプログラムを書いてみた。

#include <stdio.h>
#include <string.h>
#include <mysql/mysql.h>

int main(int argc, char *argv[])
{
  MYSQL my;

  mysql_init(&my);
  for (int i = 0; i < 30; i++) {
    if (mysql_real_connect_dns_srv(&my, "_mysql._tcp.example.com", "root", NULL, NULL, 0) == NULL)
      goto error;
    if (mysql_real_query(&my, "select @@hostname", 17) != 0)
      goto error;
    MYSQL_RES *res;
    if ((res = mysql_store_result(&my)) == NULL)
      goto error;
    MYSQL_ROW row;
    while ((row = mysql_fetch_row(res))) {
      unsigned long *lengths;
      lengths = mysql_fetch_lengths(res);
      printf("%.*s\n", (int)lengths[0], row[0]);
    }
    mysql_close(&my);
  }
  exit(0);

error:
  puts(mysql_error(&my));
  exit(1);
}

同じプロセス内で30回接続している。

root@client:/work# gcc test.c -lmysqlclient
root@client:/work# ./a.out
c
b
c
c
c
b
b
c
b
c
b
c
b
b
c
c
c
c
a
c
a
b
a
c
a
b
a
a
c
b
root@client:/work# ./a.out | sort | uniq -c
      6 a
     10 b
     14 c

なんとなく Weight に応じた割合で接続先が選ばれてる感じになった。


SRV レコードの話。

SRV レコードは MX レコードを汎用化したようなもので、レコード名の先頭はサービスとプロトコル(TCP, UDP)。

RFC6186では次のような例が示されてる。

_submission._tcp SRV 0 1 587 mail.example.com.
_imap._tcp       SRV 0 1 143 imap.example.com.
_imaps._tcp      SRV 0 1 993 imap.example.com.
_pop3._tcp       SRV 0 1 110 pop3.example.com.
_pop3s._tcp      SRV 0 1 995 pop3.example.com.

SRV レコードに対応したメールアプリは example.com というドメイン名さえ知っていれば SMTP/POP/IMAP の接続先を DNS で得ることができる。

というような感じなんで、MySQL の場合はレコード名の先頭は _mysql._tcp 固定なのでわざわざ指定する必要はないはずなんだけど、なんで指定させてるんだろう。mysql_real_connect_dns_srv() 関数の中で _mysql._tcp を先頭につければいいのに…。

MySQL 8.0.22 のプリペアドステートメントの非互換

MySQL 8.0.22 のリリースノートによるとプリペアドステートメントまわりに非互換があるらしい。

https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-22.html

For a prepared statement of the form SELECT expr1, expr2, ... FROM table ORDER BY ?, passing an integer value N for the parameter no longer causes ordering of the results by the Nth expression in the select list; the results are no longer ordered, as is expected with ORDER BY constant.

order by の引数をプレースホルダで与えても無視されるらしい。

試してみる。

8.0.21:

% docker run -p 13306:3306 --name mysql8021 -d -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql:8.0.21
% docker exec -it mysql8021 mysql
mysql> create database test;
mysql> use test;
mysql> create table t (id int auto_increment primary key, n int);
mysql> insert into t values (1,5),(2,4),(3,3),(4,2),(5,1);
mysql> select id, n from t order by 2;
+----+------+
| id | n    |
+----+------+
|  5 |    1 |
|  4 |    2 |
|  3 |    3 |
|  2 |    4 |
|  1 |    5 |
+----+------+
mysql> prepare st from "select id, n from t order by ?";
mysql> set @n=2;
mysql> execute st using @n;
+----+------+
| id | n    |
+----+------+
|  5 |    1 |
|  4 |    2 |
|  3 |    3 |
|  2 |    4 |
|  1 |    5 |
+----+------+

通常のクエリとプリペアドステートメントで同じ結果になる。

8.0.22:

% docker run -p 23306:3306 --name mysql8022 -d -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql:8.0.22
% docker exec -it mysql8022 mysql
mysql> create database test;
mysql> use test;
mysql> create table t (id int auto_increment primary key, n int);
mysql> insert into t values (1,5),(2,4),(3,3),(4,2),(5,1);
mysql> select id, n from t order by 2;
+----+------+
| id | n    |
+----+------+
|  5 |    1 |
|  4 |    2 |
|  3 |    3 |
|  2 |    4 |
|  1 |    5 |
+----+------+
mysql> prepare st from "select id, n from t order by ?";
mysql> set @n=2;
mysql> execute st using @n;
+----+------+
| id | n    |
+----+------+
|  1 |    5 |
|  2 |    4 |
|  3 |    3 |
|  4 |    2 |
|  5 |    1 |
+----+------+

プリペアドステートメントの order by が無視されてる。

んー、使えないなら使えないで変な結果を返すんじゃなくてエラーにして欲しいなぁ。

ところで、MySQL のプリペアドステートメントは、上記の prepare 命令を使う方法と C API の mysql_stmt_ で始まる関数を使う方法の二種類がある。 前者は通常のクエリと同じプロトコル(COM_QUERY)が使用されるが、後者は専用のプロトコル(COM_STMT_PREPARE, COM_STMT_EXECUTE等)が使用され、パラメータや結果はテキストではなくバイナリデータで送られる。

ということでバイナリの方でどうなるか気になったので試してみた。

とりあえず簡単に Ruby で。

require 'mysql2'
m = Mysql2::Client.new(host: "127.0.0.1", username: "root", database: "test")
s = m.prepare("select id, n from t order by ?")
s.execute(2).each do |*a|
  p a
end

8.0.21:

% MYSQL_TCP_PORT=13306 ruby test.rb
[{"id"=>5, "n"=>1}]
[{"id"=>4, "n"=>2}]
[{"id"=>3, "n"=>3}]
[{"id"=>2, "n"=>4}]
[{"id"=>1, "n"=>5}]

8.0.22:

% MYSQL_TCP_PORT=23306 ruby test.rb
(…応答なし…)

あれ…?

一応 C でも試してみる(チョーめんどくさい)。

#include <stdio.h>
#include <string.h>
#include <mysql/mysql.h>

int main(int argc, char *argv[])
{
  MYSQL my;
  MYSQL_STMT *st;
  MYSQL_BIND bind[1], bind2[2];
  memset(bind, 0, sizeof(bind));
  memset(bind2, 0, sizeof(bind2));

  mysql_init(&my);
  if (mysql_real_connect(&my, "127.0.0.1", "root", "", "test", 0, NULL, 0) == NULL)
    goto error;
  if ((st = mysql_stmt_init(&my)) == NULL)
    goto error;

  char *q = "select id,n from t order by ?";
  mysql_stmt_prepare(st, q, strlen(q));

  long n = 2;
  bind[0].buffer_type = MYSQL_TYPE_LONG;
  bind[0].buffer = &n;
  bind[0].length = NULL;
  bind[0].buffer_length = sizeof(n);
  bind[0].is_null = 0;

  if (mysql_stmt_bind_param(st, bind) != 0)
    goto stmt_error;
  if (mysql_stmt_execute(st) != 0)
    goto stmt_error;

  long id = 0;
  bind2[0].buffer_type = MYSQL_TYPE_LONG;
  bind2[0].buffer = &id;
  bind2[0].buffer_length = sizeof(id);
  bind2[0].length = NULL;
  bind2[1].buffer_type = MYSQL_TYPE_LONG;
  bind2[1].buffer = &n;
  bind2[1].buffer_length = sizeof(n);
  bind2[1].length = NULL;

  if (mysql_stmt_bind_result(st, bind2) != 0)
    goto stmt_error;
  if (mysql_stmt_store_result(st) != 0)
    goto stmt_error;

  while (mysql_stmt_fetch(st) == 0) {
    printf("%ld %ld\n", id, n);
  }
  exit(0);

error:
  puts(mysql_error(&my));
  exit(1);

stmt_error:
  puts(mysql_stmt_error(st));
  exit(1);
}
% gcc test.c -lmysqlclient

8.0.21:

% MYSQL_TCP_PORT=13306 ./a.out
5 1
4 2
3 3
2 4
1 5

8.0.22:

% MYSQL_TCP_PORT=23306 ./a.out
(…応答なし…)

やっぱり応答がない。バグなのかな。

まあ order by でプレースホルダなんて使わないから別にいいんだけど、気が向いたらバグレポ書こう。

続き

MySQL の接続まわりのエラー

MySQL で Host '〜' is blocked because of many connection errors が出るのってどういうときだっけ…とふと思ったので接続周りを調べてみた。

クライアントごとの連続接続失敗によるブロック

マニュアル https://dev.mysql.com/doc/refman/8.0/en/blocked-host.html にはこう書かれてる (DeepL翻訳)

システム変数 max_connect_errors の値は、何回連続して中断される接続要求を許可するかを決定します。max_connect_errors が接続に成功せずにリクエストに失敗した後、mysqld は何か問題がある (誰かが侵入しようとしているなど) と判断し、FLUSH HOSTS 文、Performance Schema host_cache テーブルを切り捨てる TRUNCATE TABLE 文、または mysqladmin flush-hosts コマンドを実行してホストキャッシュをフラッシュするまで、それ以上の接続からホストをブロックします。

一見すると認証に失敗した場合もこれに含まれるんじゃないかと読めるんだけど、実は認証失敗はこれには含まれないっぽい。 なので同じクライアントから何回失敗してもブロックされることはない。

max_connect_errors のデフォルト値は 100 (MySQL 5.6 より前は 10) なので、そんなに試すのは嫌なので 3 に設定しておく。

mysql> set global max_connect_errors=3

テキトーなクライアントから不正なパスワードで接続を試みてみる。

~% mysql -u test -h 192.168.68.1 -pinvalid 
ERROR 1045 (28000): Access denied for user 'test'@'hogehoge' (using password: YES)
~% mysql -u test -h 192.168.68.1 -pinvalid
ERROR 1045 (28000): Access denied for user 'test'@'hogehoge' (using password: YES)
~% mysql -u test -h 192.168.68.1 -pinvalid
ERROR 1045 (28000): Access denied for user 'test'@'hogehoge' (using password: YES)
~% mysql -u test -h 192.168.68.1 -pinvalid
ERROR 1045 (28000): Access denied for user 'test'@'hogehoge' (using password: YES)
~% mysql -u test -h 192.168.68.1 -pinvalid
ERROR 1045 (28000): Access denied for user 'test'@'hogehoge' (using password: YES)

5回やってもブロックされない。

performance_schema.host_cache を見ると COUNT_AUTHENTICATION_ERRORS には計上されている。

mysql> select * from host_cache\G
*************************** 1. row ***************************
                                        IP: 192.168.68.103
                                      HOST: hogehoge
                            HOST_VALIDATED: YES
                        SUM_CONNECT_ERRORS: 0
                 COUNT_HOST_BLOCKED_ERRORS: 0
           COUNT_NAMEINFO_TRANSIENT_ERRORS: 0
           COUNT_NAMEINFO_PERMANENT_ERRORS: 0
                       COUNT_FORMAT_ERRORS: 0
           COUNT_ADDRINFO_TRANSIENT_ERRORS: 0
           COUNT_ADDRINFO_PERMANENT_ERRORS: 0
                       COUNT_FCRDNS_ERRORS: 0
                     COUNT_HOST_ACL_ERRORS: 0
               COUNT_NO_AUTH_PLUGIN_ERRORS: 0
                  COUNT_AUTH_PLUGIN_ERRORS: 0
                    COUNT_HANDSHAKE_ERRORS: 0
                   COUNT_PROXY_USER_ERRORS: 0
               COUNT_PROXY_USER_ACL_ERRORS: 0
               COUNT_AUTHENTICATION_ERRORS: 5
                          COUNT_SSL_ERRORS: 0
         COUNT_MAX_USER_CONNECTIONS_ERRORS: 0
COUNT_MAX_USER_CONNECTIONS_PER_HOUR_ERRORS: 0
             COUNT_DEFAULT_DATABASE_ERRORS: 0
                 COUNT_INIT_CONNECT_ERRORS: 0
                        COUNT_LOCAL_ERRORS: 0
                      COUNT_UNKNOWN_ERRORS: 0
                                FIRST_SEEN: 2020-09-07 00:14:15
                                 LAST_SEEN: 2020-09-07 00:14:19
                          FIRST_ERROR_SEEN: 2020-09-07 00:14:15
                           LAST_ERROR_SEEN: 2020-09-07 00:14:19
1 row in set (0.00 sec)

じゃあブロックされるのはどういう時なの? っていうと、接続の初期プロトコルが完了しなかった場合らしい。

試しに 3306ポートに接続してすぐに切断するようなのを3回繰り返した後に、接続を試みるとブロックされる。

~% ruby -rsocket -e 'TCPSocket.new("192.168.68.103", 3306)'
~% ruby -rsocket -e 'TCPSocket.new("192.168.68.103", 3306)'
~% ruby -rsocket -e 'TCPSocket.new("192.168.68.103", 3306)'

~% mysql -u test -h 192.168.68.1 -pinvalid
ERROR 1129 (HY000): Host '192.168.68.103' is blocked because of many connection errors; unblock with 'mysqladmin flush-hosts'
~% 

host_cache はこんな感じ:

mysql> select * from host_cache\G
*************************** 1. row ***************************
                                        IP: 192.168.68.103
                                      HOST: hogehoge
                            HOST_VALIDATED: YES
                        SUM_CONNECT_ERRORS: 3
                 COUNT_HOST_BLOCKED_ERRORS: 1
           COUNT_NAMEINFO_TRANSIENT_ERRORS: 0
           COUNT_NAMEINFO_PERMANENT_ERRORS: 0
                       COUNT_FORMAT_ERRORS: 0
           COUNT_ADDRINFO_TRANSIENT_ERRORS: 0
           COUNT_ADDRINFO_PERMANENT_ERRORS: 0
                       COUNT_FCRDNS_ERRORS: 0
                     COUNT_HOST_ACL_ERRORS: 0
               COUNT_NO_AUTH_PLUGIN_ERRORS: 0
                  COUNT_AUTH_PLUGIN_ERRORS: 0
                    COUNT_HANDSHAKE_ERRORS: 3
                   COUNT_PROXY_USER_ERRORS: 0
               COUNT_PROXY_USER_ACL_ERRORS: 0
               COUNT_AUTHENTICATION_ERRORS: 0
                          COUNT_SSL_ERRORS: 0
         COUNT_MAX_USER_CONNECTIONS_ERRORS: 0
COUNT_MAX_USER_CONNECTIONS_PER_HOUR_ERRORS: 0
             COUNT_DEFAULT_DATABASE_ERRORS: 0
                 COUNT_INIT_CONNECT_ERRORS: 0
                        COUNT_LOCAL_ERRORS: 0
                      COUNT_UNKNOWN_ERRORS: 0
                                FIRST_SEEN: 2020-09-07 00:21:52
                                 LAST_SEEN: 2020-09-07 00:22:04
                          FIRST_ERROR_SEEN: 2020-09-07 00:21:52
                           LAST_ERROR_SEEN: 2020-09-07 00:22:04
1 row in set (0.00 sec)

接続してすぐに切断した数は SUM_CONNECT_ERRORSCOUNT_HANDSHAKE_ERRORS として計上されている。 SUM_CONNECT_ERRORSmax_connect_errors に達するとブロックするらしい。 軽くソースを眺めてみたところ、 SUM_CONNECT_ERRORSCOUNT_HANDSHAKE_ERRORS は同じ値になるように見える…けど違うかもしれない。

ブロックされた後に接続を試みた数は COUNT_HOST_BLOCKED_ERRORS として計上されている。

ところで、127.0.0.1::1 のようなループバックアドレスはブロック対象にはならない。 外部からの MySQL プロトコルに従わない怪しげな接続をブロックための仕組みのようで、ちゃんと MySQL プロトコルに従ってるような攻撃には無力。

[追記]

はてブのコメントより

MySQL の接続まわりのエラー - @tmtms のメモ

&quot;Host '〜' is blocked because of many connection errors&quot; こんなのあったんだ・・skip_name_resolve だと host_cache 自体が有効にならないから発生しない模様

2020/09/09 15:14
b.hatena.ne.jp

そうなんですよね。クライアントの名前解決をキャッシュする機構に乗っかってるんで、skip_name_resolve が有効だとそもそも発生しない。

実際には skip_name_resolve を設定している環境は多いと思う。名前解決しないぶん速いし。

なのでやっぱりこの仕組みがどれくらいセキュリティ的に意味があるのかは疑問だったりする。

ユーザー名ごとの連続認証失敗回数

今回これを調べてて知ったんだけど、ちゃんと MySQL プロトコルに従ってるけど認証失敗するような接続を連続で試みると、エラーが返るまでの時間が延びていくらしい。

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m0.060s
user    0m0.027s
sys     0m0.009s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m0.027s
user    0m0.012s
sys     0m0.009s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m0.042s
user    0m0.011s
sys     0m0.012s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m1.017s
user    0m0.000s
sys     0m0.013s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m2.036s
user    0m0.016s
sys     0m0.004s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m3.016s
user    0m0.004s
sys     0m0.008s

~% time mysql -u test -h 127.0.0.1
ERROR 1045 (28000): Access denied for user 'test'@'localhost' (using password: NO)

real    0m4.049s
user    0m0.017s
sys     0m0.014s

~% time mysql -u test -h 127.0.0.1 -p******** -e ''
mysql: [Warning] Using a password on the command line interface can be insecure.

real    0m5.027s
user    0m0.009s
sys     0m0.009s
~% 

3回目まではすぐにエラーが返るんだけど、4回目以降は1秒ずつ延びていく。正しいパスワードでも時間が掛かるので認証処理の前にスリープしてるっぽい。

これはホストブロックと違って、127.0.0.1 でも有効だし、どうやらクライアントじゃなくてユーザー名ごとに認証失敗回数を保持しているらしい。 同じクライアントであっても異なるユーザー名であればすぐにエラーが返るし、異なるクライアントからの接続でも同じユーザー名であれば失敗回数を保持している。MySQLに存在していないユーザー名でも効く。

クライアントAからtestというユーザー名で認証を5回失敗した後に、クライアントBから同じtestというユーザー名で接続しようとすると3秒待たされ、クライアントAからtest2というユーザー名で接続すると待たされない。

いつからだろう。前からこんな仕組みあったっけ…?

[追記]

Twitter で指摘していただいた。

connection_control プラグインがインストールされていたからだった。 MySQL Parameters のために全プラグインをインストールした環境で試してた…。

デフォルト状態ではこのプラグインはインストールされてないので、何回失敗しても遅延することはない。

Aborted_clients と Aborted_connects

ステータス変数に Aborted_clientsAboreted_connects というのがある。どう違うんだっけ…と毎回調べてすぐに忘れるのでメモ。

mysql> show status like 'aborted%';
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| Aborted_clients  | 0     |
| Aborted_connects | 0     |
+------------------+-------+
2 rows in set (0.00 sec)

まあ答えはマニュアル https://dev.mysql.com/doc/refman/8.0/en/communication-errors.html に書かれてるんだけど。

Aborted_clients は接続成功後に終了プロトコルなしでTCP接続が切断した場合にカウントされる。 たとえばクライアントプログラムが落ちたり、KILL で接続を強制切断したり、wait_timeout の時間クエリが何も発行されずに接続が切断された場合など。

Aborted_connects は接続が失敗した場合にカウントされる。 接続してすぐに切断したようなプロトコルが不正な場合や、認証に失敗した場合も含まれる。これは 127.0.0.1 からの接続でも働く。

MySQL Parameters - プラグインとコンポーネントを有効にして Variable も追加

MySQL Parameters というページを公開してたら

と言われたんだけど、mysqld --no-defaults --help --verbose でパラメータ一覧を取得してるんで、プラグインであるグループレプリケーションは組み込まれる前だから値が取れないんだな。

と思ってたら、--plugin-load というオプションでできることを教えてもらった。

せっかくなんでグループレプリケーションだけじゃなくて、可能なかぎりすべてのプラグインを組み込んでパラメータ一覧を出力するようにしてみた。

https://mysql-params.tmtms.net/mysqld/?vers=8.0.21

ちゃんとグループレプリケーション用のパラメータも表示されるようになった。 f:id:tmtms:20200817030857p:plain

でも MySQL にはプラグインじゃなくてコンポーネントと呼ばれる機構もある。プラグインとコンポーネントの違いはわかってない。コンポーネントは 8.0 で登場したものだから今後はプラグインよりもコンポーネントが使われるようになっていくのかも知れない。

で、このコンポーネントは --plugin-load みたいな感じで起動時に指定することはできないっぽい。

起動後に show variables で出力されるので、それの一覧を見れるようにしてみた。

https://mysql-params.tmtms.net/variable/?vers=8.0.21

初めは mysqld --help -v の結果と show variables の結果をマージして表示すればいいかと思ってたんだけど、比べてみたら片方にしかないものがあったり、値がビミョーに異なっていたりしたんで別のページを作った。

validate_password プラグインと validate_password コンポーネントの両方が表示されてて面白い。

f:id:tmtms:20200817031455p:plain

対応したおかげで MySQL の公式なイベントで紹介されたらしい 🎉

しかし後から拡張し続けてえらい汚くなってるんでそろそろ作り直したかったり。元々 Vue の勉強も兼ねて作り始めたやつなんで、今度は Vuetify を使って作ってみようかな。

Re: MySQL の NOW() と SYSDATE()

自分は全然気にしたことなかったんだけど、MySQL の NOW()SYSDATE() は異なるらしい。

sakaik.hateblo.jp

MySQL 8.0 のマニュアル (https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_sysdate) にも確かにちゃんと書かれてる。

SYSDATE() returns the time at which it executes. This differs from the behavior for NOW(), which returns a constant time that indicates the time at which the statement began to execute. (Within a stored function or trigger, NOW() returns the time at which the function or triggering statement began to execute.)

Google翻訳:

SYSDATE()は、それが実行された時刻を返します。 これは、ステートメントの実行が開始された時間を示す一定の時間を返すNOW()の動作とは異なります。 (ストアドファンクションまたはトリガー内で、NOW()は、ファンクションまたはトリガーステートメントが実行を開始した時刻を返します。)

複数行を返すクエリで試してみると、行ごとに値が異なる。

mysql> select now(), sysdate(), sleep(1) from (values row(1), row(2), row(3), row(4)) t;
+---------------------+---------------------+----------+
| now()               | sysdate()           | sleep(1) |
+---------------------+---------------------+----------+
| 2020-07-12 19:14:05 | 2020-07-12 19:14:05 |        0 |
| 2020-07-12 19:14:05 | 2020-07-12 19:14:06 |        0 |
| 2020-07-12 19:14:05 | 2020-07-12 19:14:07 |        0 |
| 2020-07-12 19:14:05 | 2020-07-12 19:14:08 |        0 |
+---------------------+---------------------+----------+
4 rows in set (4.00 sec)

へー、面白い。複数行を処理するようなクエリでは SYSDATE() は使わないほうが良さそうだな。

しかし、この挙動はいつからなんだろう。昔は NOW()SYSDATE() は同じだったはず。

4.1 の日本語マニュアル (https://downloads.mysql.com/docs/refman-4.1-ja.a4.pdf)には

SYSDATE() は NOW() のシノニム

と書かれている。ソースコード上も lex.h に1行だけ。

  { "SYSDATE",        SYM(NOW_SYM)},

5.0 のマニュアル (https://downloads.mysql.com/docs/refman-5.0-en.a4.pdf) に次のようにあった。

As of MySQL 5.0.12, SYSDATE() returns the time at which it executes. This differs from the behaviorfor NOW(), which returns a constant time that indicates the time at which the statement beganto execute. (Within a stored function or trigger, NOW() returns the time at which the function ortriggering statement began to execute.)

As of MySQL 5.0.13, the SYSDATE() function is no longer equivalent to NOW(). Implications are that SYSDATE() is not replication-safe because it is not affected by SET TIMESTAMP statements in thebinary log and is nondeterministic. To avoid this, you can start the server with the --sysdate-is-now option to cause SYSDATE() to be an alias for NOW().

へー、5.0 からなのか。最近だった。自分は知らないわけだ。

どうやら 5.0.12 か 5.0.13 のどちらかかららしい。どっちやねん。

「5.0 の途中から!?」と一瞬思ったけど、5.0 の GA は 5.0.15 なので、パッチレベルで振る舞いを変えるようなことはさすがに無かったようだ(昔はよかった)。

そして非互換なので、--sysdate-is-now オプションで振る舞いを変えることができるようだ(昔はよかった)。なお、このオプションは 8.0 でも存在する。

MySQL 8.0.20 を --sysdate-is-now つきで起動してさっきのクエリを試すと

mysql> select now(), sysdate(), sleep(1) from (values row(1), row(2), row(3), row(4)) t;
+---------------------+---------------------+----------+
| now()               | sysdate()           | sleep(1) |
+---------------------+---------------------+----------+
| 2020-07-12 20:11:15 | 2020-07-12 20:11:15 |        0 |
| 2020-07-12 20:11:15 | 2020-07-12 20:11:15 |        0 |
| 2020-07-12 20:11:15 | 2020-07-12 20:11:15 |        0 |
| 2020-07-12 20:11:15 | 2020-07-12 20:11:15 |        0 |
+---------------------+---------------------+----------+
4 rows in set (4.00 sec)

NOW() と同じになった。

これは mysqld 起動時のオプションだけど、システム変数ではないので SHOW VARIABLES では出てこない。たまにこういうのがある。 システム変数ではないので SET PERSIST でも設定できないけど、my.cnf に書くとちゃんと有効になる。

「MySQL徹底入門 第4版」が出るよ

「MySQL徹底入門 第4版」が 7/6 に発売される。🎉

www.shoeisha.co.jp

電子書籍は翔泳社の直販がDRMフリー(たぶん)だからオススメ。

著者用見本誌も届いたので、さすがにこれからやっぱり発売できませんでした!ってことにはならないと思う。

長かった。 本当は去年出る予定だったんだが、なんやかんやで今年になった(よくある)。

第3版が出たのが 2011年だから、実に9年ぶり!

偶然にも第3版と同じ 544ページなんだけど、1ページ辺りの文字数は増えているので情報密度は増しているはず(そしてその分価格も上がってる)。 (ただしくは552ページらしい。)

「MySQL徹底入門」は第n版が出るたびに、毎回ほとんどを書き下ろしてるし、複数人で書いてるんだけど、書いてる人も入れ替わるし担当する章も変わる面白い本。

自分は、今回は第5章「ユーザー管理」、第10章「データベースプログラミング」のRubyの節、第11章「文字コードと日本語環境」、第14章「逆引きMySQL辞典」のうちの2項目を書いた。 全部で544ページあるうちの、52ページ分。あれ? 1/10 にも満たないのか…。結構書いたような気がしたんだけど。というかみんな書きすぎなんだよ!

前の版からそうなんだけど、「MySQL徹底入門」は MySQL の入門書であって、SQL や RDB の入門書ではないということに注意。

一般的な SQL の話はほとんどないかわりに、MySQL固有の情報はふんだんに書かれている

また、今回は「MySQL 8.0 対応」という副題がついている。これを見て「MySQL 8.0 の新機能についても触れられてるんだなー」とか思っちゃいけない。 この本の中では、MySQL 8.0 よりも前のバージョンのことはほとんど考慮されていない。この本は MySQL 5.6 や 5.7 を使ってる人にはほとんど役に立たないだろう(言いすぎな気もする)。

まあでもみんな 8.0 を使ってるだろうから問題ないよね。


これを機に歴史を振り返ってみる。

初版 2001/1/1 384ページ 3200円

www.seshop.com

Amazon だと 2000/12/1 になってるな。

どっちが正しいんだろう。まあその辺で出たんだろう、きっと。

今手元に本が無いからわからないんだけど、自分は3割くらいは書いたような気がする。

2000年だからたぶん MySQL 3.23 あたりが対象と思われる。

2000年3月に「MySQLユーザ会」が発足したと思ったらわりとすぐに執筆依頼が来たという記憶がある。

詳しい経緯は忘れたけどメーリングリストで執筆者を募って「MySQLユーザ会」として執筆することになったんだと思う。

執筆者は「MySQLユーザ会」で、監修がソフトエージェンシーさんと自分だった。個人の名前として自分しか載らなかったのは良くなかったので、第2版からは全員の名前を並べるようになった。

なお、表紙が鳥なのは、MySQLユーザ会の略称である MyNA から、myna が英語で九官鳥などの鳥を表す単語だったから。

自分の名前が載った本が本屋に並んでるのを見るのは嬉しかった。

当時3歳の娘と本屋に行って本を見せて、

私「ほら、これはちちの本なんだよ」
娘「ふーん、なんで本屋に置いてあるの? 娘ちゃんの本はおうちにあるよ」
私「いや、ちちが書いた本なんだよ」
娘「へー、こんな綺麗な字が書けるんだね」

という会話をしたのも懐かしい思い出。

第2版 2006/1/23 416ページ 3480円

www.seshop.com

初版から5年後。 表紙にも書かれてるけど、MySQL 4.1 & 5.0 対応。

執筆者は、遠藤 俊裕、坂井 恵、館山 聖司、鶴長 鎮一、とみた まさひろ、班石 悦夫、松信 嘉範 (敬称略)。あいうえお順みたい。

メールによると、編集さんから 2003/6 に改訂しないかという話があったっぽい。その後のメールはロストしててよくわからない…。

MySQL 5.0 の GA版は 2005/10/19 リリースの 5.0.15 だから、基本は 4.1 で書いて、5.0 の目玉機能だけ追記したとかなのかなぁ。これも今手元に無いからわからない。

自分は1割くらいは書いたような気がする。

第3版 2011/8/25 544ページ 3480円

www.seshop.com

さらに5年後。MySQL 5.5 対応。

執筆者は、遠藤 俊裕、坂井 恵、館山 聖司、鶴長 鎮一、とみた まさひろ、班石 悦夫、松信 嘉範 (敬称略)。お、第2版とまったく同じだった。

2010/7 頃に編集さんから第3版を出さないかという話があった。

その後、章構成を変更しないで、5.5 対応だけするという方針になったっぽい。

ゆるゆると話が進んで、目次案が確定したのが 2011/2 頃。

レプリケーションの章が新たに追加されて、それを自分が書いたらしい。

Subversion とメーリングリストを使ってたっぽい。

自分は 5% くらいは書いたような気がする。 版を重ねる毎に順調に執筆割合が減ってきている。

第4版 2020/7/6 552ページ 3800円

www.shoeisha.co.jp

そして9年ぶりに第4版。

執筆者は、yoku0825、坂井 恵、鶴長 鎮一、とみたまさひろ、深町 日出海、福山 裕大、班石 悦夫、山﨑 由章(敬称略)。結構変わった。

8.0 対応。これまでに 5.6 とか 5.7 とかあったから、やっぱり途中にもうひとつ出てても良かったような感じ。

最初に話が出たのが 2018/10 頃。 2018/12 にみんなで集まってキックオフ。

今なら Subversion じゃなくて Git だろうということで、GitLab を使うことに。GitHub はプライベートリポジトリは有料だったので。

メーリングリストじゃなくてチャットにしようということで、Mattermost サーバーを立てた。今思えば Slack にしておいた方が良かったかもしれない。 Slack は無料プランだと過去のログが消えてしまうと思ってたので。実は管理者権限ならログを取り出せるらしい。

原稿の形式は Markdown で。

原稿が書けたら GitLab でマージリクエストを発行して、誰かがレビューしたらマージする…みたいな運用をしてたんだけど、これはやめといた方がよかったかもしれない。 というか、途中でやめて、書いたらどんどん master に突っ込むようにした。 みんなが Git や GitLab の使い方に慣れてるわけではなかったので。

Mattermost も章ごとにチャンネルを分けた方がよかったかも。一つのチャンネルでやってたんだけど、流れが速くなると見なきゃいけないメッセージが埋もれちゃうので。 Mattermost の設定がヘボくて(私のせいだ)、日本語で過去のメッセージを検索できなかったのも何気にいたかったかも。

自分の執筆は1割くらい。少し増えた。

2019/5 原稿締切、2019/8 頃出版… というのが当初の予定だったんだけど、実際には 2020/7 になった。 まあ予定は未定。ちゃんと出たんだからヨシッ!

mrubyudf : C を書かずに Ruby だけで MySQL の UDF を作る

昨日はmrubyでMySQLのUDFを作ってみたんだけど、関数毎にCのプログラムを書くのがめんどくさいので、簡単なツールを作ってみた。

github.com

詳しくは README 参照。

fib.rb と fib.spec をこんな感じで作っておいて、

fib.rb

LONG_LONG_MAX = 9223372036854775807

def fib(n)
  b = 1
  c = 0
  n.times do
    a, b = b, c
    c = a + b
    raise 'Overflow' if c > LONG_LONG_MAX
  end
  c
end

fib.spec

MrubyUdf.function do |f|
  f.name = 'fib'           # 関数名は fib
  f.return_type = Integer  # 戻り値は Integer
  f.arguments = [          # 引数は一つで型は Integer
    Integer
  ]
end
% mrubyudf fib.spec

とやれば fib.so が出来上がるはず。 コンパイルオプションとかは自分の環境決め打ちなのでうまくいかないかもしれない。

% sudo cp fib.so $(mysql_config --plugindir)
% mysql -uroot
mysql> create function fib returns int soname 'fib.so';
mysql> select fib(10);
+---------+
| fib(10) |
+---------+
|      55 |
+---------+

ところで、mruby のインスタンスは関数を使うクエリが発行されたタイミングで生成されて、クエリ終了時に破棄される。

つまり、クエリの実行中はグローバル変数が生存しているので、こんな感じにすると、

rownum.rb

$n = 0
def rownum()
  $n += 1
end

rownum.spec

MrubyUdf.function do |f|
  f.name = 'rownum'
  f.return_type = Integer
  f.arguments = []
end

クエリ結果に行番号をつけることができたりもする。面白いかも。

mysql> create function rownum returns int soname 'rownum.so';
mysql> select rownum(),character_set_name from information_schema.character_sets;
+----------+--------------------+
| rownum() | CHARACTER_SET_NAME |
+----------+--------------------+
|        1 | big5               |
|        2 | dec8               |
|        3 | cp850              |
|        4 | hp8                |
|        5 | koi8r              |
|        6 | latin1             |
|        7 | latin2             |
|        8 | swe7               |
|        9 | ascii              |
|       10 | ujis               |
|       11 | sjis               |
|       12 | hebrew             |
|       13 | tis620             |
|       14 | euckr              |
|       15 | koi8u              |
|       16 | gb2312             |
|       17 | greek              |
|       18 | cp1250             |
|       19 | gbk                |
|       20 | latin5             |
|       21 | armscii8           |
|       22 | utf8               |
|       23 | ucs2               |
|       24 | cp866              |
|       25 | keybcs2            |
|       26 | macce              |
|       27 | macroman           |
|       28 | cp852              |
|       29 | latin7             |
|       30 | cp1251             |
|       31 | utf16              |
|       32 | utf16le            |
|       33 | cp1256             |
|       34 | cp1257             |
|       35 | utf32              |
|       36 | binary             |
|       37 | geostd8            |
|       38 | cp932              |
|       39 | eucjpms            |
|       40 | gb18030            |
|       41 | utf8mb4            |
+----------+--------------------+

しかし、ツールを作ってはみたものの別に作りたい UDF は特に無いんだよな…。

RubyでMySQLのUDFを作る

昨日はCでMySQLのUDFを作ってみたんだけど、今日はRubyで作ってみる。Rubyと言ってもmrubyだけど。

mrubyは5年ぶりくらいに使ってみたんだけど、相変わらずドキュメントが少なくてなかなかつらい…。

まず mruby のビルド。MySQLのUDFは共有ライブラリにしないといけないので、パッチをあてて make。

% git clone git@github.com:mruby/mruby.git
% cd mruby
% patch -p1 < /path/to/mruby-shared.patch
% make

mruby-shared.patch はこれ

diff --git a/build_config.rb b/build_config.rb
index 254a28ce..310191e3 100644
--- a/build_config.rb
+++ b/build_config.rb
@@ -35,6 +35,10 @@ MRuby::Build.new do |conf|
   #   cc.compile_options = %Q[%{flags} -MMD -o "%{outfile}" -c "%{infile}"]
   # end
 
+  conf.cc do |cc|
+    cc.flags = '-fPIC'
+  end
+
   # mrbc settings
   # conf.mrbc do |mrbc|
   #   mrbc.compile_options = "-g -B%{funcname} -o-" # The -g option is required for line numbers
@@ -59,6 +63,11 @@ MRuby::Build.new do |conf|
   #   archiver.archive_options = 'rs "%{outfile}" %{objs}'
   # end
 
+  conf.archiver do |archiver|
+    archiver.command = 'gcc'
+    archiver.archive_options = '-shared -o %{outfile} %{objs}'
+  end
+
   # Parser generator settings
   # conf.yacc do |yacc|
   #   yacc.command = ENV['YACC'] || 'bison'
diff --git a/lib/mruby/build.rb b/lib/mruby/build.rb
index 8154b2b1..80f0b782 100644
--- a/lib/mruby/build.rb
+++ b/lib/mruby/build.rb
@@ -61,7 +61,7 @@ module MRuby
         if ENV['OS'] == 'Windows_NT'
           @exts = Exts.new('.o', '.exe', '.a')
         else
-          @exts = Exts.new('.o', '', '.a')
+          @exts = Exts.new('.o', '', '.so')
         end
 
         build_dir = build_dir || ENV['MRUBY_BUILD_DIR'] || "#{MRUBY_ROOT}/build"

関数のメインのRubyスクリプト fib.rb

LONG_LONG_MAX = 9223372036854775807

def fib(n)
  b = 1
  c = 0
  n.times do
    a, b = b, c
    c = a + b
    raise 'Overflow' if c > LONG_LONG_MAX
  end
  c
end

一応ちゃんと動くか確認

% /path/to/mruby/bin/mruby -r./fib.rb -e 'p fib(10)'
55
% /path/to/mruby/bin/mruby -r./fib.rb -e 'p fib(100)'
trace (most recent call last):
    [2] -e:1
    [1] -e:6:in fib
-e:9:in fib: Overflow (RuntimeError)

mrubyバイトコードにコンパイル。

% /path/to/mruby/bin/mrbc -Bfib_mrb fib.rb

バイトコードが埋め込まれた fib.c が出来る。内容はこんなの。

/* dumped in little endian order.
   use `mrbc -E` option for big endian CPU. */
#include <stdint.h>
#ifdef __cplusplus
extern const uint8_t fib_mrb[];
#endif
const uint8_t
#if defined __GNUC__
__attribute__((aligned(4)))
#elif defined _MSC_VER
__declspec(align(4))
#endif
fib_mrb[] = {
0x45,0x54,0x49,0x52,0x30,0x30,0x30,0x36,0x64,0x45,0x00,0x00,0x01,0x5f,0x4d,0x41,
0x54,0x5a,0x30,0x30,0x30,0x30,0x49,0x52,0x45,0x50,0x00,0x00,0x01,0x0e,0x30,0x30,
0x30,0x32,0x00,0x00,0x00,0x96,0x00,0x01,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x14,
0x02,0x01,0x00,0x1c,0x01,0x00,0x61,0x01,0x56,0x02,0x00,0x5d,0x01,0x01,0x0e,0x01,
0x01,0x37,0x01,0x67,0x00,0x00,0x00,0x01,0x01,0x00,0x13,0x39,0x32,0x32,0x33,0x33,
0x37,0x32,0x30,0x33,0x36,0x38,0x35,0x34,0x37,0x37,0x35,0x38,0x30,0x37,0x00,0x00,
0x00,0x02,0x00,0x0d,0x4c,0x4f,0x4e,0x47,0x5f,0x4c,0x4f,0x4e,0x47,0x5f,0x4d,0x41,
0x58,0x00,0x00,0x03,0x66,0x69,0x62,0x00,0x00,0x00,0x00,0x72,0x00,0x05,0x00,0x07,
0x00,0x01,0x00,0x00,0x00,0x14,0x00,0x00,0x33,0x04,0x00,0x00,0x07,0x03,0x06,0x04,
0x01,0x05,0x01,0x55,0x06,0x00,0x2f,0x05,0x00,0x00,0x37,0x04,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x01,0x00,0x05,0x74,0x69,0x6d,0x65,0x73,0x00,0x00,0x00,0x01,0x31,
0x00,0x03,0x00,0x06,0x00,0x00,0x00,0x00,0x00,0x3d,0x00,0x00,0x33,0x00,0x00,0x00,
0x1f,0x03,0x03,0x00,0x1f,0x04,0x04,0x00,0x01,0x02,0x03,0x20,0x04,0x03,0x00,0x01,
0x03,0x02,0x1f,0x04,0x03,0x00,0x3b,0x03,0x20,0x03,0x04,0x00,0x1f,0x03,0x04,0x00,
0x1b,0x04,0x00,0x44,0x03,0x23,0x03,0x00,0x39,0x10,0x03,0x4f,0x04,0x00,0x2e,0x03,
0x01,0x01,0x21,0x00,0x3b,0x0f,0x03,0x37,0x03,0x00,0x00,0x00,0x01,0x00,0x00,0x08,
0x4f,0x76,0x65,0x72,0x66,0x6c,0x6f,0x77,0x00,0x00,0x00,0x02,0x00,0x0d,0x4c,0x4f,
0x4e,0x47,0x5f,0x4c,0x4f,0x4e,0x47,0x5f,0x4d,0x41,0x58,0x00,0x00,0x05,0x72,0x61,
0x69,0x73,0x65,0x00,0x4c,0x56,0x41,0x52,0x00,0x00,0x00,0x33,0x00,0x00,0x00,0x05,
0x00,0x01,0x6e,0x00,0x01,0x26,0x00,0x01,0x62,0x00,0x01,0x63,0x00,0x01,0x61,0x00,
0x00,0x00,0x01,0x00,0x01,0x00,0x02,0x00,0x02,0x00,0x03,0x00,0x03,0x00,0x04,0x00,
0x01,0x00,0x01,0x00,0x04,0x00,0x02,0x45,0x4e,0x44,0x00,0x00,0x00,0x00,0x08,
};

MySQLのUDFからfibメソッドを実行するためのファイル fib_udf.c を作成。

#include <stdio.h>
#include <string.h>
#include "mruby.h"
#include "mruby/string.h"
#include "mruby/irep.h"
#include "mysql.h"

extern const uint8_t fib_mrb[];

bool fib_init(UDF_INIT *initid, UDF_ARGS *args, char *message)
{
  if (args->arg_count != 1) {  // 引数が1個じゃなければエラー
    strcpy(message, "fib() requires one argument");
    return true;
  }
  args->arg_type[0] = INT_RESULT; // 引数は整数に自動変換する

  mrb_state *mrb = mrb_open();
  if (!mrb) {  // mruby 初期化でエラーになった
    strcpy(message, "mrb_open() error");
    return true;
  }
  initid->ptr = (void *)mrb;
  mrb_load_irep(mrb, fib_mrb);
  if (mrb->exc) {  // mrb 実行でエラーになった
    // 例外オブジェクトを inspect
    mrb_value s = mrb_funcall(mrb, mrb_obj_value(mrb->exc), "inspect", 0);
    // C文字列化
    const char *cs = mrb_string_value_cstr(mrb, &s);
    // MySQLのエラーメッセージに設定
    strncpy(message, cs, MYSQL_ERRMSG_SIZE);
    // 標準エラー出力にも書き出し
    fprintf(stderr, "%s\n", cs);
    return true;
  }
  return false;
}

void fib_deinit(UDF_INIT *initid)
{
  mrb_state *mrb = (mrb_state *)initid->ptr;
  if (mrb) {
    mrb_close(mrb);
  }
}

long long fib(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error)
{
  mrb_state *mrb = (mrb_state *)initid->ptr;
  long long n = *((long long *)args->args[0]);
  // トップレベルの fib メソッド呼び出し
  mrb_value ret = mrb_funcall(mrb, mrb_top_self(mrb), "fib", 1, mrb_fixnum_value(n));
  if (mrb->exc) { // 例外が発生した
    // 例外オブジェクトを inspect
    mrb_value s = mrb_funcall(mrb, mrb_obj_value(mrb->exc), "inspect", 0);
    // 標準エラー出力に書き出し
    fprintf(stderr, "fib(%lld): %s\n", n, mrb_string_value_cstr(mrb, &s));
    // 戻り値は NULL
    *is_null = true;
    return 0;
  }
  // Integerオブジェクトを long long に変換して返す
  return mrb_fixnum(ret);
}

コンパイル。

LD_RUN_PATH=/path/to/mruby/build/host/lib gcc -shared -fPIC -I /usr/local/mysql/include -I /path/to/mruby/include fib_udf.c fib.c -L /path/to/mruby/build/host/lib -lmruby -lm -o fib.so

配置。既に同じ名前のファイルを使ってた場合は、mysql で drop function fib してからじゃないと、mysqld が落ちるので注意。

% sudo cp fib.so /usr/local/mysql/lib/plugin/

使う。

% mysql -uroot
mysql> create function fib returns integer soname 'fib.so';
Query OK, 0 rows affected (0.03 sec)
mysql> select fib(10);
+---------+
| fib(10) |
+---------+
|      55 |
+---------+
1 row in set (0.04 sec)

mysql> select fib(100);
+----------+
| fib(100) |
+----------+
|     NULL |
+----------+
1 row in set (0.03 sec)

オーバーフローが起きた場合はエラーログに出力される。

fib(100): Overflow (RuntimeError)

続く

MySQLのユーザー定義関数(UDF)

MySQLのユーザー定義関数(UDF)は大昔に作った記憶があるけど、最近作ってなかったので試しに作ってみたメモ。

関数の中身はなんでも良かったんだけど、フィボナッチ数を求める fib()を作ってみた。

ちゃんと知りたい場合は、マニュアルとかサンプルプログラムを見ましょう。

% gcc -shared -I /usr/local/mysql/include fib.c -o fib.so
% sudo cp fib.so /usr/local/mysql/lib/plugin/
% /usr/local/mysql/bin/mysql -uroot
mysql> create function fib returns integer soname 'fib.so';
Query OK, 0 rows affected (0.02 sec)
mysql> select n, fib(n) from (values row(1),row(2),row(3),row(4),row(5),row(6),row(7),row(8),row(9),row(10)) as n(n);
+----+--------+
| n  | fib(n) |
+----+--------+
|  1 |      1 |
|  2 |      1 |
|  3 |      2 |
|  4 |      3 |
|  5 |      5 |
|  6 |      8 |
|  7 |     13 |
|  8 |     21 |
|  9 |     34 |
| 10 |     55 |
+----+--------+
10 rows in set (0.01 sec)
// UDFの詳細はマニュアルとサンプルプログラムを参照
// https://dev.mysql.com/doc/refman/8.0/en/adding-functions.html
// https://github.com/mysql/mysql-server/blob/8.0/sql/udf_example.cc
//
// コンパイル方法
// gcc -shared -I /usr/local/mysql/include fib.c -o fib.so
// 作成された so ファイルを plugin-dir ディレクトリに置く
// 使用時:
// create function fib returns integer soname 'fib.so';
// select fib(10);
// 関数が不要になった場合は破棄できる:
// drop function fib;

#include <string.h>
#include "mysql.h"
#include "mysql/udf_registration_types.h"

// 初期化
// 関数を使用するクエリの実行前に呼ばれる
// initid  : 戻り値
// args    : 引数
// message : エラーメッセージ用バッファ。MYSQL_ERRMSG_SIZE バイト(8.0 では 512)
// 戻り値は、成功時には false、エラー時には true (変なの)
// 関数の戻り値は CREATE FUNCTION で指定される
bool fib_init(UDF_INIT *initid, UDF_ARGS *args, char *message)
{
  initid->maybe_null = true;   // 戻り値がNULLになり得る場合はtrue
  initid->decimals = 0;        // 戻り値が浮動小数点数の場合に小数点以下の桁数を指定
  initid->max_length = 21;     // 戻り値の最大文字数/最大桁数
  initid->ptr = NULL;          // この関数用に確保したメモリのポインタ等
  if (args->arg_count != 1) {  // 引数が1個じゃなければエラー
    strcpy(message, "fib() requires one argument");
    return true;
  }
  if (args->maybe_null[0]) { // 引数にNULLは許容しない
    strcpy(message, "fib() requires not NULL");
    return true;
  }
  if (args->arg_type[0] != INT_RESULT) { // 引数が整数でなければエラー
    strcpy(message, "fib() requires an integer argument");
    return true;
  }
  // 上のようにチェックする他に MySQL に自動変換させることもできる
  //args->arg_type[0] = INT_RESULT;

  // おまけ
  // args->attribues[0] と args->attribute_lengths[0] で 0番目の引数に与えた名前がわかる
  // fib(123) の場合は "123", fib(hoge) の場合は "hoge"

  return false;
}

// 終了
// initid->ptr にメモリを確保していた場合はここで解放する
void fib_deinit(UDF_INIT *initid)
{
}

// 実行
// initid  : ptr を使う場合以外は使用しない
// args    : 引数
// is_null : 戻り値がNULLになる場合 true を設定
// error   : エラー時に true を設定。ひとつのクエリ内ではそれ以降は評価されずにNULLが返る
long long fib(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error)
{
  long long n;
  n = *((long long *)args->args[0]);

  long long i, a, b, c;
  for (i = 0, b = 1, c = 0; i < n; i++) {
    a = b;
    b = c;
    c = a + b;
    if (c <= 0) {  // オーバーフロー時は NULL を返す
      *is_null = true;
      return 0;
    }
  }
  return c;
}

続く

MySQL 8.0.19 のオフセットつき日時リテラル

MySQL 8.0.19 のリリースノート にこんなのがあって、

MySQL now supports datetime literals with time zone offsets, such as '2019-12-11 10:40:30-05:00', '2003-04-14 03:30:00+10:00', and '2020-01-01 15:35:45+05:30'; these offsets are respected but not stored when inserting such values into TIMESTAMP and DATETIME columns; that is, offsets are not displayed when retrieving the values.

一瞬 DATETIME 型にタイムゾーンが来たか! と喜んだんだけど、よく読んだら違った。

日時リテラルにオフセットを指定できるようになっただけだった。

日本時間のサーバーで +09:00 を指定しても時刻はそのまま。当たり前。

mysql> SELECT CONVERT('2020-01-28 00:00:00+09:00', DATETIME);
+------------------------------------------------+
| CONVERT('2020-01-28 00:00:00+09:00', DATETIME) |
+------------------------------------------------+
| 2020-01-28 00:00:00                            |
+------------------------------------------------+

UTC(+00:00)を指定すると、日本時間に変換される。

mysql> SELECT CONVERT('2020-01-28 00:00:00+00:00', DATETIME);
+------------------------------------------------+
| CONVERT('2020-01-28 00:00:00+00:00', DATETIME) |
+------------------------------------------------+
| 2020-01-28 09:00:00                            |
+------------------------------------------------+

正確にはサーバーの時間じゃなくて、接続のタイムゾーンに依存する。デフォルトの time_zoneSYSTEM だからサーバーの時間になっていただけ。

接続のタイムゾーンを UTC にすると、UTC時間に変換される。

mysql> SET time_zone='+00:00';

mysql> SELECT CONVERT('2020-01-28 00:00:00+09:00', DATETIME);
+------------------------------------------------+
| CONVERT('2020-01-28 00:00:00+09:00', DATETIME) |
+------------------------------------------------+
| 2020-01-27 15:00:00                            |
+------------------------------------------------+

mysql> SELECT CONVERT('2020-01-28 00:00:00+00:00', DATETIME);
+------------------------------------------------+
| CONVERT('2020-01-28 00:00:00+00:00', DATETIME) |
+------------------------------------------------+
| 2020-01-28 00:00:00                            |
+------------------------------------------------+

DATETIME 型はタイムゾーンを保持しないので、格納したデータは接続のタイムゾーンを変更しても何も変わらない。残念。TIMESTAMP の方は変わる。

mysql> CREATE TABLE x (ts TIMESTAMP, dt DATETIME);

mysql> SET time_zone='+09:00';
mysql> INSERT INTO x (ts, dt) VALUES
    -> ('2020-01-28 00:00:00+09:00', '2020-01-28 00:00:00+09:00'),
    -> ('2020-01-28 00:00:00+00:00', '2020-01-28 00:00:00+00:00');
mysql> SELECT * FROM x;
+---------------------+---------------------+
| ts                  | dt                  |
+---------------------+---------------------+
| 2020-01-28 00:00:00 | 2020-01-28 00:00:00 |
| 2020-01-28 09:00:00 | 2020-01-28 09:00:00 |
+---------------------+---------------------+
mysql> SET time_zone='+00:00';
mysql> SELECT * FROM x;
+---------------------+---------------------+
| ts                  | dt                  |
+---------------------+---------------------+
| 2020-01-27 15:00:00 | 2020-01-28 00:00:00 |
| 2020-01-28 00:00:00 | 2020-01-28 09:00:00 |
+---------------------+---------------------+

おまけ: MySQL 8.0 での TIMESTAMP と DATETIME の比較

昔は TIMESTAMP はレコードの作成時刻や更新時刻を自動設定できるのに DATETIME ではできなかったんだけど、今は DATETIME でもできる。

TIMESTAMP DATETIME
上限 2038-01-19 03:14:07+00:00 9999-12-31 23:59:59+00:00
レコード作成時時刻を自動設定 可(デフォルトはオフ) 可(デフォルトはオフ)
レコードの更新時刻を自動設定 可(デフォルトはオフ) 可(デフォルトはオフ)
接続のタイムゾーンに時刻を変換 不可

ということで、DATETIME が時刻変換してくれるようになれば、TIMESTAMP を捨てされるのになーって感じ。

MySQL 8.0.19 で追加された TABLE と VALUES

1/13 に MySQL 8.0.19 がリリースされたんで、リリースノート を眺めてると気になるのがいくつか。

Important Change: MySQL now supports explicit table clauses and table value constructors according to the SQL standard. These have now been implemented, respectively, as the TABLE statement and the VALUES statement

TABLE と VALUES ステートメントが追加されたみたい。 さすがMySQL! パッチリリースで新しい構文を突っ込んでくるッ! そこにシビれる!あこがれるゥ!

TABLE

TABLE は、テーブルの中身全部を取り出す SELECT のようなものらしい。

TABLE tSELECT * FROM t と同じ意味らしい。WHERE や JOIN は使えない(ORDER BY と LIMIT はある)。

mysql> SELECT * FROM t;
+------+-------+
| id   | value |
+------+-------+
|    1 | abc   |
|    2 | xyz   |
+------+-------+
2 rows in set (0.01 sec)

mysql> TABLE t;
+------+-------+
| id   | value |
+------+-------+
|    1 | abc   |
|    2 | xyz   |
+------+-------+
2 rows in set (0.00 sec)

誰得なのかわからん…。サブクエリ中の SELECT を簡単に書けるようになるくらい?

VALUES

VALUES はレコードセットをリテラルで書けるらしい。 カラム名は column_N になる。

mysql> VALUES ROW(1,2,3), ROW(3,2,1);
+----------+----------+----------+
| column_0 | column_1 | column_2 |
+----------+----------+----------+
|        1 |        2 |        3 |
|        3 |        2 |        1 |
+----------+----------+----------+
2 rows in set (0.00 sec)

ROW が余計な気がする。PostgreSQL は ROW が要らなくて、INSERT の VALUES 句と同じように書けるっぽい。

VALUES にも ORDER BY と LIMIT はある。

mysql> VALUES ROW(1,2,3), ROW(3,2,1) ORDER BY column_2 LIMIT 1;
+----------+----------+----------+
| column_0 | column_1 | column_2 |
+----------+----------+----------+
|        3 |        2 |        1 |
+----------+----------+----------+
1 row in set (0.00 sec)

けど、ORDER BY を指定しない LIMIT は効かないみたい。バグなのかな…。

mysql> VALUES ROW(1,2,3), ROW(3,2,1) LIMIT 1;
+----------+----------+----------+
| column_0 | column_1 | column_2 |
+----------+----------+----------+
|        1 |        2 |        3 |
|        3 |        2 |        1 |
+----------+----------+----------+
2 rows in set (0.00 sec)

VALUES の方は嬉しいことがあるかもしれない。集計系のクエリで結果テーブルに表れない項目のレコードを足したりとか。

mysql> SELECT name, COUNT(*) cnt FROM t GROUP BY name;
+---------+-----+
| name    | cnt |
+---------+-----+
| 製品A   |   2 |
| 製品C   |   1 |
| 製品E   |   3 |
+---------+-----+
3 rows in set (0.00 sec)

mysql> SELECT name, IFNULL(cnt,0) AS cnt
 FROM (SELECT name, count(*) AS cnt FROM t GROUP BY name) AS a
 RIGHT JOIN (VALUES ROW('製品A'), ROW('製品B'), ROW('製品C'), ROW('製品D'), ROW('製品E')) AS b(name)
 USING (name);
+---------+-----+
| name    | cnt |
+---------+-----+
| 製品A   |   2 |
| 製品C   |   1 |
| 製品E   |   3 |
| 製品B   |   0 |
| 製品D   |   0 |
+---------+-----+
5 rows in set (0.00 sec)

しかし名前が TABLE と VALUES なのか…。動詞じゃないことに違和感。標準SQLらしいけど。

MySQL Parameters を拡張した

これは「MySQL アドベントカレンダー 2019」と「富士通クラウドテクノロジーズ アドベントカレンダー 2019」の4日目の記事です。

qiita.com

qiita.com

MySQL の各バージョン間のパラメーターの違いを比較できる MySQL Parameter というページを去年公開したんですが、それが何故か割と好評だったみたいで今年の4月に MySQL Community Contributor Award Program 2019 というのにノミネートされたりもしました。

blogs.oracle.com

それを独自ドメイン化したり、いろいろ追加したりしたので、それについて書きます。

独自ドメイン化

今まで GitHub Pages のデフォルトのドメイン https://tmtm.github.io/mysql-params で公開していたのですが、手持ちのドメインのサブドメインの https://mysql-params.tmtms.net に変更してみました。 サブドメイン名をリポジトリ名と同じにしてみましたが、別にその必要はありません。

まず mysql-params.tmtms.net レコードを CNAME tmtm.github.io で登録します。

tmtms.net ゾーンはニフクラDNSで管理しているので、ニフクラのコンパネから tmtms.net のゾーン管理で mysql-params レコードを作ります。簡単。

f:id:tmtms:20191123211349p:plain

次に GitHub のリポジトリで、「Settings」の下の方に「GitHub Pages」があるので、そこにドメイン名を設定するだけです。お手軽。

f:id:tmtms:20191123204255p:plain

今までの https://tmtm.github.io/mysql-params にアクセスすると、ちゃんと https://mysql-params.tmtms.net にリダイレクトされます。 相対パスやクエリパラメータも保ったままリダイレクトされるので便利。

HTTPS もチェックボックスをチェックするだけ。簡単。チェックできるようになるまで時間が掛かりますが。

いろいろ追加

今まで mysqld のパラメーターだけにしか対応していなかったのですが、mysql コマンドにも対応しました。

mysqld https://mysql-params.tmtms.net/mysqld/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

mysql https://mysql-params.tmtms.net/mysql/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

あと、Status, Charset, Collation, Privilege にも対応してみました。 これらは項目の有無がわかるだけですが。

Status https://mysql-params.tmtms.net/status/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

Charset https://mysql-params.tmtms.net/charset/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

Collation https://mysql-params.tmtms.net/collation/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

Privilege https://mysql-params.tmtms.net/privilege/?vers=5.0.96,5.1.72,5.5.62,5.6.46,5.7.28,8.0.18

https://mysql-params.tmtms.net/ にアクセスするとメニューを表示するようになってますが、

f:id:tmtms:20191124113607p:plain

過去のURLとの互換のため、https://tmtm.github.io/mysql-params?vers=8.0.11,8.0.18&diff=true のようにクエリパラメータがあったら mysqld のページにリダイレクトするようにしてあります。

mysqld と mysql のパラメータは、/usr/local/mysql にインストールした後に --no-defaults --help -v オプションで実行した出力から得ています。

Status, Charset, Collation は SHOW GLOBAL STATUS, SHOW CHARSET, SHOW COLLATION の結果です。

Privilege は DESC mysql.user のカラム名に _priv がついてるものを取り出して、変換しています(Create_tmp_table -> CREATE TEMPORARY TABLE など)。 PROXY 権限は mysql.user テーブルには載ってないので、mysql.proxies_priv テーブルの有無を見てるような感じです。

権限は結構増えてるような印象があったのですが、5.0 の頃と比べても意外とそんなに増えてなかったですね。

f:id:tmtms:20191130150808p:plain

見た目にはこだわりないのでCSSは最低限でデザインがアレですが、よろしければご利用ください。

[追記] 2020-01-28

MySQL 8.0 で動的パラメータがかなり増えてました。それも反映したので今見たらもっと多いです。 動的パラメータは GRANT ALL でユーザー作って、それを SHOW GRANTS した結果から取り出してます。

MySQLと「令和」その2

MySQLでは異なる字が等しいと見なされることがあるということを書きました。

tmtms.hatenablog.com

この動きはMySQLが独自に変なことをしているわけではなく、Unicodeの規則に従っています。

MySQL 8.0 のデフォルトのCollationは Unicode 9.0.0Unicode Collation Algorithm(UCA) に従っています。

文字にはそれぞれ Weight という値が設定されていてソートに使用されています。この値が同じなら等しい文字とみなされます。

Collation

utf8mb4 のデフォルトの Collation は utf8mb4_0900_ai_ci という名前ですが、これは次のような意味です。

名前の要素 意味
utf8mb4 charset名
0900 Unicodeバージョン9.0.0
ai アクセントの違いを無視 (Accent Insensitive)
ci 大文字小文字の違いを無視 (Case Insensitive)

ai は「a」と「á」が等しくなります。日本語の場合は「」「」「」が等しくなります。 ai ではなく as であれば異なる文字として扱われます。

ci は「A」と「a」が等しくなります。日本語の場合は「」「」が等しくなります。 ci ではなく cs であれば異なる文字として扱われます。

utf8mb4 の Collation は次のようなものがあります。

  • utf8mb4_0900_ai_ci

    • アクセント/大文字小文字の違いを無視。
  • utf8mb4_0900_as_ci

    • アクセントが異なれば異なる文字。大文字小文字の違いは無視。
  • utf8mb4_0900_as_cs

    • アクセント/大文字小文字が異なれば異なる文字。
  • utf8mb4_bin

    • UnicodeのCollationを無視。すべて異なる文字。

以上を踏まえて、この前の記事であげた例を見てみます。

「令」と「令」

令和」(U+4EE4 U+548C)と「令和」(U+F9A8 U+548C)が等しいと見なされるやつです。

Collationによって次のように評価されます。

Collation =
utf8mb4_0900_ai_ci
utf8mb4_0900_as_ci
utf8mb4_0900_as_cs
utf8mb4_bin

」(U+F9A8)のWeightは DUCET(https://www.unicode.org/Public/UCA/9.0.0/allkeys.txt) に次のように定義されています。

F9A8  ; [.FB40.0020.0002][.CEE4.0000.0000] # CJK COMPATIBILITY IDEOGRAPH-F9A8

」(U+4EE4)は定義されていませんが、http://www.unicode.org/reports/tr10/tr10-34.html#Implicit_Weights に計算方法が載っています。 これに従って計算すると、次のようになります。

[.FB40.0020.0002][.CEE4.0000.0000]

」(U+4EE4)と「」(U+F9A8)のWeightは同じ値になるので、utf8mb4_bin 以外は等しいと評価されます。

合字

」と「平成」が等しいと見なされるやつです。 別に元号だけに限らず、「」と「サンチーム」も同じです。

Collationによって次のように評価されます。

Collation =
utf8mb4_0900_ai_ci
utf8mb4_0900_as_ci
utf8mb4_0900_as_cs
utf8mb4_bin

」のWeightは DUCET(https://www.unicode.org/Public/UCA/9.0.0/allkeys.txt) に次のように定義されています。

337B  ; [.FB40.0020.001C][.DE73.0000.0000][.FB40.0020.001C][.E210.0000.0000] # SQUARE ERA NAME HEISEI

」と「」は定義されてませんが「」と同じく計算すると次のようになります。「」と似てますがちょっと異なります。

平成 [.FB40.0020.0002][.DE73.0000.0000][.FB40.0020.0002][.E210.0000.0000]

Unicode では大文字小文字を区別しない場合(ci)は [ ] の中の3番目の数字を無視して評価することになってます。

そうすると次のようにまったく同じ値となります。

㍻   [.FB40.0020][.DE73.0000][.FB40.0020][.E210.0000]
平成 [.FB40.0020][.DE73.0000][.FB40.0020][.E210.0000]

ということで、ci の Collation では「㍻=平成」となるのでした。

異体字セレクタ

「令和」(U+4EE4 U+548C)と「令󠄂和」(U+4EE4 U+E0102 U+548C) が等しいと見なされるやつです。

Collationによって次のように評価されます。

Collation =
utf8mb4_0900_ai_ci
utf8mb4_0900_as_ci
utf8mb4_0900_as_cs
utf8mb4_bin

U+E0102 は DUCET に次のように定義されています。

E0102 ; [.0000.0000.0000] # VARIATION SELECTOR-19

UCA ではすべてが 0 の値は無視することになってるので、異体字セレクタの U+E0102 は無視され、utf8mb4_bin 以外は等しいと評価されます。

LIKE

上のように = での評価は UCA に従ってることで説明できるのですが、LIKE での評価はよくわかりませんでした。

LIKE ではすべての Collation で偽になります。

Collation LIKE
utf8mb4_0900_ai_ci
utf8mb4_0900_as_ci
utf8mb4_0900_as_cs
utf8mb4_bin

MySQL のマニュアルには次のように書いてあります。

Per the SQL standard, LIKE performs matching on a per-character basis, thus it can produce results different from the = comparison operator:

MySQL 5.6 の日本語マニュアルでは:

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

文字数(コードポイント数)が異なる合字や異体字セレクタでは =LIKE が異なるのはまあわかるんですが、 「」(U+4EE4)と「」(U+F9A8)は同じ1文字なのに LIKE では一致しなくなるのが謎…。

(って書いておけば誰かがソース読んで解説してくれるんじゃないかと期待)

MySQLと「令和」

新元号が「令和」に決まったことなので、MySQLでの扱いについての話を。

普通の文字

「令」も「和」もJIS第一水準に含まれている基本的な文字なので普通に日本語が使用できるcharsetで使用できます。

mysql> create table t (
  utf8mb4 varchar(255) charset utf8mb4,
  utf8mb3 varchar(255) charset utf8mb3,
  utf16 varchar(255) charset utf16,
  utf32 varchar(255) charset utf32,
  cp932 varchar(255) charset cp932,
  eucjpms varchar(255) charset eucjpms,
  sjis varchar(255) charset sjis,
  ujis varchar(255) charset ujis
);

mysql> insert into t values ('令和', '令和', '令和', '令和', '令和', '令和', '令和', '令和');

mysql> select * from t\G
*************************** 1. row ***************************
utf8mb4: 令和
utf8mb3: 令和
  utf16: 令和
  utf32: 令和
  cp932: 令和
eucjpms: 令和
   sjis: 令和
   ujis: 令和

mysql> select hex(utf8mb4), hex(utf8mb3), hex(utf16), hex(utf32), hex(cp932), hex(eucjpms), hex(sjis), hex(ujis) from t\G
*************************** 1. row ***************************
hex(utf8mb4): E4BBA4E5928C
hex(utf8mb3): E4BBA4E5928C
  hex(utf16): 4EE4548C
  hex(utf32): 00004EE40000548C
  hex(cp932): 97DF9861
hex(eucjpms): CEE1CFC2
   hex(sjis): 97DF9861
   hex(ujis): CEE1CFC2

なんの問題もないですね。

「令」と「令」

ところで、ユニコードには「令」と見た目が同じ字が他にもあると話題になりました。 普通の「令」は U+4EE4(UTF-8 で E4BBA4)、もうひとつの「令」は U+F9A8(UTF-8 で EFA6A8)です。

mysql> set @4ee4=_utf8mb4 0xE4BBA4E5928C, @f9a8=_utf8mb4 0xEFA6A8E5928C;

mysql> select @4ee4, @f9a8;
+--------+--------+
| @4ee4  | @f9a8  |
+--------+--------+
| 令和   | 令和   |
+--------+--------+

見た目が同じなのにコードが違うから何かトラブルが起きるんじゃないかと言われたりしてますが、実際にはこのような文字は他にもあるので、今まで問題になったことがないんならそんなに問題にならないんじゃないですかね。

ところで MySQL では、この二文字は = で比較すると一致として扱われます。LIKE では不一致です。

mysql> select @4ee4, @f9a8, @4ee4=@f9a8, @4ee4 LIKE @f9a8;
+--------+--------+-------------+------------------+
| @4ee4  | @f9a8  | @4ee4=@f9a8 | @4ee4 LIKE @f9a8 |
+--------+--------+-------------+------------------+
| 令和   | 令和   |           1 |                0 |
+--------+--------+-------------+------------------+

理由は調べてませんが、たぶん = は正規化して比較するけど LIKE は正規化しないで比較するみたいな感じなんじゃないかと思います(字面的には =LIKE が逆のような印象で面白い)。

異体字セレクタ

もうひとつ「令」と言えば異体字ですよね。ユニコードでは異体字セレクタというコードを後ろにつけることで字体を選択することができます。

環境によりますが「U+4EE4 U+E0102」の文字「令󠄂」は下が「マ」のように見えるコードです。

異体字セレクタを使用した場合も、= で一致し LIKE では不一致となります。

mysql> set @4ee4_e0102=_utf8mb4 0xE4BBA4F3A08482E5928C;

mysql> select @4ee4, @4ee4_e0102, @4ee4=@4ee4_e0102, @4ee4 LIKE @4ee4_e0102;
+--------+-------------+-------------------+------------------------+
| @4ee4  | @4ee4_e0102 | @4ee4=@4ee4_e0102 | @4ee4 LIKE @4ee4_e0102 |
+--------+-------------+-------------------+------------------------+
| 令和   | 令󠄂和        |                 1 |                      0 |
+--------+-------------+-------------------+------------------------+

合字

そして元号と言えば合字ですよね。

「明治」「大正」「昭和」「平成」には、それぞれ1文字で表せる「㍾」「㍽」「㍼」「㍻」という文字があります。 「令和」にも「㋿」(U+32FF)という文字が割り当てられています(見えないかもしれないけどOSやフォントが対応すればちゃんと見えるようになるはず)。

合字も正規化して比較されるので「平成」と「㍻」は一致するのですが、「令和」とU+32FFは一致しません。

mysql> select '明治'='㍾', '大正'='㍽', '昭和'='㍼', '平成'='㍻', '令和'='㋿'\G
*************************** 1. row ***************************
'明治'='㍾': 1
'大正'='㍽': 1
'昭和'='㍼': 1
'平成'='㍻': 1
'令和'='㋿': 0

U+32FF が「令和」の合字となるのはUnicodeのバージョン12.1からです。Unicode 12.1はどうやら日本の新元号に対応した合字を含めるためだけに作られるバージョンらしいです。迷惑な話ですね。2019/5/7 にリリースされるようです。

Unicode 12.1までは U+32FF は何の文字でもないコードです。

MySQL 8.0 のUnicodeのバージョンは9.0なので対応してないのですね。MySQL がいつUnicode 12.1に対応するのかはわかりませんが。


という感じですが、まあ、実際にはそんなに問題になることはないんじゃないですかね。

[追記] 続きを書きました https://tmtms.hatenablog.com/entry/201904/mysql-reiwa2