Aurora MySQLのtextをEC2 MariaDBレプリカでMroonga全文検索

# 前説

クリアコード 堀本さんの「[Amazon RDS + Amazon EC2 + ロジカルレプリケーションを使った低コスト高速全文検索](https://slide.rabbit-shocker.org/authors/komainu8/postgresql-conference-japan-2019/)」を
2020-01-25 の[第28回 中国地方DB勉強会 in 岡山](https://dbstudychugoku.connpass.com/event/158127/)で聞いて、
Aurora MySQLとMroongaでもできるかな、とドキュメント調査したのが、「[Amazon Aurora に Mroonga全文検索は可能か](https://qiita.com/masudakz/items/3ed65d828ca15465d8d9)」 です。
同じくクリアコード 須藤さんの「[MariaDBとMroongaで作る全言語対応超高速全文検索システム](https://www.slideshare.net/kou/jpmugdbstudy1)」を見つけて

   >MariaDBには超高速な全文検索を実現するMroongaストレージエンジンがバンドルされていることを知っていますか？

バンドルされてるぐらいなら情報もたくさんあるだろうと、レプリケーション先をMariaDBにして、実際にinstance建ててやってみました。

# Aurora MySQL

レプリケーションマスターとするAuroraは開発環境の既存のものを使いました。

- MySQLバージョン5.6.10a
  - ちょっと古い
- t3.medium 2vCPU 4GB
- 全体では100GB超えるが、レプリケーション対象のDBはmysqldump結果で2GB程度

# EC2 MariaDB/Mroonga

AWS使うなら、Amazon Linux2 が一番手厚くサポートされると期待できます。EC2関連での新しいサービスやinstance typeが真っ先にサポートされるなど。
Amazon Linux2, MariaDB, Free という都合のいいAMIはなさそうです。2016くらいの古いのしか見つからないので、素のAmazonLinux2に自力でMariaDBを入れることにします。

- AMI Amazon Linux 2 latest
- t3.small
- vpc, subnet, securitygroup は既存Auroraに合わせます

```
$ sudo yum update
...
$ yum info mariadb
...
Version     : 5.5.64
```

すんなり入るMariaDBでは古すぎまずね。
MroongaのDocumentページにある最新版install手順を採用します。
[2.5.18. CentOS 7（MariaDB 10.4のパッケージを利用）](https://mroonga.org/ja/docs/install/centos.html#centos-7-with-mariadb-10-4-package)

まずは EPEL を有効に

https://aws.amazon.com/jp/premiumsupport/knowledge-center/ec2-enable-epel/

```
sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
```

```
$ sudo vi /etc/yum.repos.d/mariadb.repo
$ cat /etc/yum.repos.d/mariadb.repo
[mariadb]
name = MariaDB
baseurl = http://yum.mariadb.org/10.4/centos7-amd64
gpgkey=https://yum.mariadb.org/RPM-GPG-KEY-MariaDB
gpgcheck=1
$ sudo yum install -y https://packages.groonga.org/centos/groonga-release-latest.noarch.rpm
$ sudo yum install -y MariaDB-server MariaDB-client
$ sudo systemctl start mariadb
$ sudo yum install -y --enablerepo=epel mariadb-10.4-mroonga
$ sudo yum install -y --enablerepo=epel groonga-tokenizer-mecab
$ mysql -u root < /usr/share/mroonga/install.sql
```

## インストール確認

```sql

$ mysql -u root
Your MariaDB connection id is 15
Server version: 10.4.12-MariaDB MariaDB Server
...
MariaDB [(none)]> show engines;
...
| Mroonga            | YES     | CJK-ready fulltext search, column store     | NO           | NO   | NO         |
...
MariaDB [(none)]> SHOW VARIABLES LIKE 'mroonga_version';
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| mroonga_version | 9.12  |
+-----------------+-------+
```

# 接続準備

## MariaDB初期設定

[MariaDBの最新版をインストールする CentOS](https://www.petitmonte.com/linux/mariadb_install.html)

```
sudo vi /etc/my.cnf.d/server.cnf
// 以下のコードを追加する
[mariadb]
character-set-server=utf8mb4

[client-mariadb]
default-character-set=utf8mb4
```

installしただけではrootのパスワードもついていないし、そのままでは remote接続させるわけにもいきません。secure設定をいい感じにしてくれる同梱スクリプトを使います。

```
sudo systemctl enable mariadb
sudo mysql_secure_installation
// デフォールトに従う
```

## レプリカターゲット

受け入れデータベースとクライアント接続用ユーザを作っておきます。

```sql
mysql> CREATE DATABASE db_search CHARACTER SET utf8mb4;
mysql> GRANT ALL PRIVILEGES ON db_search.* TO searcher@localhost IDENTIFIED BY 'xxxxxx';
mysql> FLUSH PRIVILEGES;
```

## タイムゾーンテーブルのロード

@hmatsu47 さんの[オンプレMySQL～RDS for MySQL／Aurora間のレプリケーションにおけるタイムゾーン設定](https://qiita.com/hmatsu47/items/c8b573ec4839ca1e7394#%E5%95%8F%E9%A1%8C%E3%81%AE%E5%9B%9E%E9%81%BF) より

> RDS for MySQL／Aurora側をマスターにする場合、バイナリログには「Asia/Tokyo」形式でタイムゾーンが記録されることもありますので、トラブルを避けるために、オンプレミス側でタイムゾーンテーブルをロードしておいたほうが良いでしょう。

[mysql_tzinfo_to_sql — タイムゾーンテーブルのロード](https://dev.mysql.com/doc/refman/5.6/ja/mysql-tzinfo-to-sql.html)（MySQL 5.6 リファレンスマニュアル）

アクセス頻度の少ない開発環境とはいえ、稼働から半年もたって突然、同期が止まって面食らいました。

```
Slave: Unknown or incorrect time zone: 'Asia/Tokyo' Error_code: 1298
```

## 疎通テスト

既存Auroraに接続している既存instanceから新規MariaDBへ

```sql
$ mysql -h 10.xx.xx.xx -u searcher -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 21
Server version: 5.5.5-10.4.12-MariaDB MariaDB ServerCopyright (c) 2000, 2014, Oracle and/or its affiliates. All rights reserved.Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| db_search          |
| information_schema |
+--------------------+
2 rows in set (0.01 sec)
```

# Aurora to MariaDBレプリケーション

[Aurora と MySQL との間のレプリケーション](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Replication.MySQL.html)

上記AWSの文書はAurora, RDS, 非マネージドMySQL（文書では「外部 MySQL 」）の全組み合わせに対応しているので、Aurora->非マネージドMySQL目的だと読み飛ばすところが多いです。
詳細は上記ドキュメントにあるのでここは読み飛ばしガイドと思ってください。

## レプリケーションマスターのバイナリログ記録を有効にする

1. クラスターのパラメータグループで `binlog_format=MIXED`
    - クラスタのパラメータグループが他のクラスタと共用だったら、コピーして専用にするなどの事前調整もする
1. 変更を有効にするために Aurora DB クラスターを再起動
    - 運用環境だとこの「再起動」は計画メンテ組むなど要調整

## 不要になるまでレプリケーションマスターのバイナリログを保持する

Aurora の default は可及的速やかに binlog を削除している。マスターのsnapshotやdumpをとって、MariaDBにロードしてレプリケーション開始するまでbinlogは消えてはならない。ここでは24hを指定した。

```sql
$ mysql -h xxxxx.cluster-xxxxx.ap-northeast-1.rds.amazonaws.com -u xxxxx -p
mysql> CALL mysql.rds_set_configuration('binlog retention hours', 24);
Query OK, 0 rows affected (0.00 sec)
mysql> call mysql.rds_show_configuration;
+------------------------+-------+------------------------------------------------------------------------------------------------------+
| name                   | value | description                                                                                          |
+------------------------+-------+------------------------------------------------------------------------------------------------------+
| binlog retention hours | 24    | binlog retention hours specifies the duration in hours before binary logs are automatically deleted. |
+------------------------+-------+------------------------------------------------------------------------------------------------------+
```

## レプリケーションマスターのスナップショットを作成する

レプリケーションマスターがAuroraだと、binlog の filename, position は snapshot を復元したときのイベントにしか出てこない。`--master-data=2` を指定した mysqldump はrootになれないAuroraではできない。
そのため、二度手間っぽくなるが、下記手順となる

1. Aurora の snapshot取得(dailyのでも可)
1. snapshotから新規のAurora clusterを復元
  - DBクラスタのパラメータグループは default のままではなく、binlog_format 変更済みのものを選択する
  - DBパラメータグループも元と同じものを選択する
1. 復元Aurora cluster の Event から binlog の filename, position を得る
    - 例 `Thu, 13 Feb 2020 03:53:28 GMT	Binlog position from crash recovery is mysql-bin-changelog.000003 21684`
1. 復元Aurora cluster から mysqldump
    - backup.sql が大きくなるリスクがあるので作成場所には十分なスペースが必要

```
$ sudo mysqldump -h xxxxx-binlog.cluster-xxxxx.ap-northeast-1.rds.amazonaws.com
 -u xxxxx -p --databases db_search
 --single-transaction --order-by-primary -r /work/backup.sql
```

## レプリカターゲットにスナップショットをロードする

1. MariaDBに上記dump結果をロード
1. 復元Aurora cluster 削除
1. mysqldump結果を削除

```sql
$ mysql -h 10.xx.xx.xx -u searcher -p
mysql> source /work/backup.sql
mysql> \r db_search
Connection id:    14
Current database: db_search

mysql> show tables;
+------------------------+
| Tables_in_db_search |
+------------------------+
...
| t_tagproperty          |
+------------------------+
```

## レプリカターゲットでレプリケーションを有効にする

レプリカマスターにレプリカユーザを登録

```sql
> CREATE USER 'replicator' IDENTIFIED BY 'xxxxxx';
> GRANT REPLICATION CLIENT, REPLICATION SLAVE ON *.* TO 'replicator';
```

## レプリカターゲットでレプリカ設定

`MASTER_HOST`は60文字以下にする必要がある。RDSのエンドポイントは長くなりがちで、東京リージョンではcluster名に使えるのはわずか5文字。Route53を使って短い別名を登録しておくのが本来だろう。疎通確認を急ぐため今回は60文字以下になる名前に付けなおした。

```sql
mysql> CHANGE MASTER TO
MASTER_HOST = 'xxxxx.cluster-xxxxxxx.ap-northeast-1.rds.amazonaws.com',
MASTER_PORT = 3306,
MASTER_USER = 'replicator',
MASTER_PASSWORD = 'xxxxxx',
MASTER_LOG_FILE = 'mysql-bin-changelog.000003',
MASTER_LOG_POS = 21684;
mysql> START SLAVE;
```

`/etc/my.cnf.d/server.cnf` に追記

```
replicate-wild-do-table=db_search.%
```

```
$ sudo systemctl restart mariadb
```

## レプリカをモニタリングする

MariaDB側で

```sql
> show slave status\G
*************************** 1. row ***************************
                Slave_IO_State: Waiting for master to send event
       Replicate_Wild_Do_Table: db_search.%
                 Last_IO_Errno: 0
       Slave_SQL_Running_State: Slave has read all relay log; waiting for the slave I/O thread to update it
```

Last_IO_Error になにか異常が出ていれば、内容に従って対応する。

## レプリカ反映確認

マスターAuroraへのINSERT/UPDATEがターゲットに伝播していることを確認

```sql
mysql> UPDATE db_search.t_jobquery SET bi_processtime = UNIX_TIMESTAMP(now())*1000, i_status=0 WHERE bi_recordid =107288;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> select i_status, bi_processtime from t_jobqueue WHERE bi_recordid =107288;
+----------+----------------+
| i_status | bi_processtime |
+----------+----------------+
|        0 |  1581574477000 |
+----------+----------------+
```

```sql
MariaDB [db_search]> select i_status, bi_processtime from t_jobqueue WHERE bi_recordid =107288;
+----------+----------------+
| i_status | bi_processtime |
+----------+----------------+
|        0 |  1581574477000 |
+----------+----------------+
```

# レプリカターゲットのストレージエンジン変更

```sql
MariaDB [db_search]> ALTER TABLE t_tagproperty ENGINE=mroonga COMMENT='engine "InnoDB"';
Query OK, 9956068 rows affected (3 min 42.965 sec)
```

1000万行級だったので、それなりに時間かかりましたがそれも４分弱。
ストレージ変更後も、 `show slave status\G` にエラーがないこと、UPDATEなどがAuroraから伝播することを確認しました。

# FULLTEXT INDEXの追加

形態素解析はMeCabで。

```sql
MariaDB [db_search]> CREATE FULLTEXT INDEX ftx_stringvalue 
ON t_tagproperty(t_stringvalue) COMMENT 'tokenizer "TokenMecab"';
Query OK, 0 rows affected (1 min 25.290 sec)
Records: 0  Duplicates: 0  Warnings: 0
MariaDB [db_search]> SHOW INDEX FROM t_tagproperty WHERE Column_name='t_stringvalue'\G
*************************** 1. row ***************************
        Table: t_tagproperty
   Non_unique: 1
     Key_name: ftx_stringvalue
 Seq_in_index: 1
  Column_name: t_stringvalue
    Collation: NULL
  Cardinality: NULL
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: FULLTEXT
      Comment:
Index_comment: tokenizer "TokenMecab"
```

検索速い。

```sql
MariaDB [db_search]> SELECT t_stringvalue,MATCH(t_stringvalue) AGAINST("作業場所") AS score 
FROM db_mmjsearch.t_tagproperty 
WHERE MATCH(t_stringvalue) AGAINST("作業場所") 
ORDER BY score DESC LIMIT 5;
+-----------------------------------------------------------+-------+
| t_stringvalue                                             | score |
+-----------------------------------------------------------+-------+
| iOS標準ディレクトリ：ファイルの保存場所                   |  6513 |
| 本体工(場所打ち）                                         |  6513 |
| 作業場所の整地                                            |  6513 |
| 作業場所の地耐力を事前に確認すること。                    |  6513 |
| 作業場所の整地                                            |  6513 |
+-----------------------------------------------------------+-------+
5 rows in set (0.002 sec)
```

Bi-gramだとhitしそうなところで、Mecabだとhitしない例になってるかな？　「東京都港区」に「京都」の検索ではhitしてほしくないのが、形態素解析Tokenizerを使う主な理由になります。

```sql
MariaDB [db_search]> SELECT t_stringvalue,MATCH(t_stringvalue) AGAINST("業場") AS score FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("業場") ORDER BY score DESC LIMIT 5;
+--------------------------------+---------+
| t_stringvalue                  | score   |
+--------------------------------+---------+
| 担当はその場に居る人           | 1048577 |
+--------------------------------+---------+
1 row in set (0.002 sec)
```

# Mroongaの表記正規化

まずは`NormalizerAuto`を試してみます。
> 大文字・小文字や、全角・半角の区別なく検索する事が出来ます。
> 特殊文字への対応が厚く、例えば「㌖」は「キロメートル」に正規化されます。

```sql
MariaDB [db_search]> CREATE FULLTEXT INDEX ftx_stringvalue ON t_tagproperty(t_stringvalue) COMMENT 'tokenizer "TokenMecab", normalizer "NormalizerAuto"';
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("マスト") LIMIT 5\G
*************************** 1. row ***************************
t_stringvalue: フォークリフトの運転席から身を乗り出して荷ずれを修正しようとして、誤って操作レバーに触れ、頭部をマストとヘッドガードの間にはさまれた。
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("ﾏｽﾄ") LIMIT 5\G
*************************** 1. row ***************************
t_stringvalue: フォークリフトの運転席から身を乗り出して荷ずれを修正しようとして、誤って操作レバーに触れ、頭部をマストとヘッドガードの間にはさまれた。
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("cdcd") LIMIT 1\G
*************************** 1. row ***************************
t_stringvalue: 3B833F3A-4D58-4725-A2B2-43257CDCD251
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("ＣＤＣＤ") LIMIT 1\G
*************************** 1. row ***************************
t_stringvalue: 3B833F3A-4D58-4725-A2B2-43257CDCD251
```

Mroongaにはもっと高度にカスタマイズできるNormalizerがあります。
IT考古学では、カナ長音をマイナス記号にしている古文書が大量発掘されます。一部機種のカナフォントの長音の見た目が悪かったので流行してしまったんですよね。
OCR結果テキストも増えているので新規発生もとまりません。

- https://groonga.org/ja/docs/reference/normalizers/normalizer_nfkc100.html#advanced-usage
- NormalizerNFKC100 にオプション指定することでカスタマイズ可能
- ハイフンとか、長音とか、ヴ、中黒

# 読みがな検索

日本語全文検索では異体字も頭の痛い問題です。

- 渡辺さん、斉藤さん、広田さん…

送りがな問題も

- 焼肉、焼き肉

MroongaのTokenMecabには、読みがなオプションを付与することができます。

```sql
MariaDB [db_search]> CREATE FULLTEXT INDEX ftx_yomi_stringvalue ON t_tagproperty(t_stringvalue) COMMENT 'tokenizer "TokenMecab(\'use_reading\',true)", normalizer "NormalizerAuto"';
Query OK, 0 rows affected (1 min 47.840 sec)
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("トウブ") LIMIT 5\G
Empty set (0.001 sec)
```

あたらない?!

```sql
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty FORCE INDEX(ftx_yomi_stringvalue) WHERE MATCH(t_stringvalue) AGAINST("トウブ") LIMIT 5\G
Empty set (0.002 sec)
MariaDB [db_search]> explain SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty FORCE INDEX(ftx_yomi_stringvalue) WHERE MATCH(t_stringvalue) AGAINST("トウブ") LIMIT 5\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_tagproperty
         type: fulltext
possible_keys: ftx_stringvalue
          key: ftx_stringvalue
      key_len: 0
          ref:
         rows: 1
        Extra: Using where
```

先につくった読みがな無しのindexを使っています。`FORCE INDEX`つけても効いてません。1カラムにFULLTEXT INDEXは1つなんでしょうか。

```sql
MariaDB [db_search]> DROP INDEX ftx_stringvalue ON t_tagproperty;
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty FORCE INDEX(ftx_yomi_stringvalue) WHERE MATCH(t_stringvalue) AGAINST("トウブ") LIMIT 5\G
*************************** 1. row ***************************
t_stringvalue: フォークリフトの運転席から身を乗り出して荷ずれを修正しようとして、誤って操作レバーに触れ、頭部をマストとヘッドガードの間にはさまれた。
```

先につくった`FULLTEXT INDEX`を削除すると、読みがなであたりました。

```sql
MariaDB [db_search]> SELECT t_stringvalue FROM db_mmjsearch.t_tagproperty WHERE MATCH(t_stringvalue) AGAINST("頭部") LIMIT 5\G
*************************** 1. row ***************************
t_stringvalue: フォークリフトの運転席から身を乗り出して荷ずれを修正しようとして、誤って操作レバーに触れ、頭部をマストとヘッドガードの間にはさまれた。
```

読みがなオプションは、読みがな「も」indexに入れるオプションのようです。漢字でもちゃんと入っています。なるほど`FULLTEXT INDEX`を２つ持つ必要はないのですね。

# 読みがな適用範囲

MeCabが読めない単語・熟語ではこのご利益は効きません。
このへんの調査になると、10万行程度の開発用DBでは既存の語彙がたりないので、mroonga関数を使って、TokenMecabが読めるかどうか、即値で与えて確認できます。

```sql
MariaDB [db_search]> SELECT mroonga_command('tokenize \'TokenMecab("use_reading",true)\' "斎藤 齋藤 齊藤 齊籐 齋籐"') as mecab\G
*************************** 1. row ***************************
mecab: [{"value":"サイトウ","position":0,"force_prefix":false,"force_prefix_search":false},
{"value":"サイトウ","position":1,"force_prefix":false,"force_prefix_search":false},
{"value":"サイトウ","position":2,"force_prefix":false,"force_prefix_search":false},
{"value":"ヒトシ","position":3,"force_prefix":false,"force_prefix_search":false},
{"value":"トウ","position":4,"force_prefix":false,"force_prefix_search":false},
{"value":"齋籐","position":5,"force_prefix":false,"force_prefix_search":false}]
```

実用するときも、ユーザが入力した検索語を、`mroonga_command(```tokenize...)`　して、出てきた読みがなを追加する形になるでしょう。

# 結果

Aurora MySQL を EC2 MariaDB/Mroonga にレプリケートして、MeCabの形態素解析とMroongaの正規化ありの日本語検索が可能なことを、開発環境の疎通レベルで確認しました。

## Todo

- NormalizerNFKC100のカスタマイズ
- 精度検証
- 性能検証
- 運用設計と運用検証