CreateField Blog

オープンソースを使って個人でWebサービスを開発・運営していたブログ

Mroongaのラッパーモードからストレージモードに変えた理由

前回は、全文検索Webサービスを作ったときにはまったことの第1回という記事を書きました。

今回は、Mroongaを使って全文検索Webサービスを作ったときにはまったことの第2回として、ラッパーモードからストレージモードに変えた理由について書きたいと思います。

なお、かなり長く、MySQL、Groongaについて前提知識がないと理解できない部分が多々含まれている可能性があります。

ラッパーモードとは

全文検索Mroongaストレージエンジンでは、全文検索するためにラッパーモードとストレージモードの2つのモードが用意されています。

(引用) ラッパーモードでは全文検索機能のみGroongaの機能を利用し、データストアはInnoDBなど既存のストレージエンジンを利用します。ラッパーモードを利用することにより、ストレージエンジンとして多くの利用実績のあるInnoDBに全文検索エンジンとして実績のあるMroongaを組み合わせて、高速な全文検索機能付きの信頼性のあるデータベースとして利用できるという特長があります。

ラッパーモードを使うことにより、たとえば、トランザクション対応のInnoDBを使うことができます。全文検索も高速にできてトランザクションも使えるとなるといいことづくめのように見えます。

ラッパーモードとストレージモードの違い

ドキュメントを比較すると、ラッパーモードは、ストレージモードに比べて、位置情報検索と、レコードIDの取得と、Groongaコマンドの実行と、カラムの刈り込みと、行カウント高速化がないぐらいです。

また、私が使いはじめたときは、割とよくmysqldがクラッシュしてデータベースが破損しやすかったこともあり、ストレージモードではデータファイルがたくさんできて取り扱いにくかったのでMyISAMのラッパーモードでデータベースを作りはじめました。

しかしながら、ストレージモードでは、以下の点が優れていることに気づきラッパーモードからストレージモードに変更しました。

ストレージモードの利点その1 所定の条件の場合に全文インデックス以外の複数インデックスを使用した高速な絞込みができる

ストレージモードでは、所定の条件の場合、ORDER BY LIMITで全文インデックス以外のインデックスを複数使用して高速に絞込み操作を行うことができます。

なお、この所定の条件は、条件の数だけソースを追加しなければいけないというデメリットがあります。 以前、ユーザが要望したこの条件わずか一日で追加されて、検索速度が10倍改善されたというエピソードがあります。 Groonga開発チームのこの対応速度は素晴らしいですね。Groonga開発チームのこの対応の良さがMroongaを使う最も大きな利点かもしれません。

ちなみにこの所定の条件に関わらず複数インデックスを使うちょっとした裏技みたいな方法があります。 ただし、これは今のところソースを変更する必要があるというのと、AGAINST句の中でGroongaのクエリ構文を使う必要があります。

ここに試した結果と方法を記載しています。インデックスが使われない場合は、数秒かかっていた検索クエリが0.数秒になっています。

この方法もGroonga開発チームにメーリングリストで教えてもらいました。

ストレージモードの利点その2 インデックスが使用されないときのレコード操作が高速

インデックスが使われないときのカウントやレコード操作が他のストレージエンジンに比べてストレージモードは段違いに速いです。

具体的にInnoDBのラッパーモードとストレージモードで比較してみましょう。

データベースのデータサイズは20GiB強、レコード数は数十万ぐらいです。

まず、以下のように全文インデックスのみでの絞込みが7万8千件ぐらいの場合を比較します*1

mysql> SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像" in boolean mode);
+----------+
| COUNT(*) |
+----------+
|    78467 |
+----------+
1 row in set (0.04 sec)
  • InnoDBラッパーモード (一時結果が7万8千の場合)
mysql>  SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像" in boolean mode) AND date LIKE '2010%';
+----------+
| COUNT(*) |
+----------+
|    69644 |
+----------+
1 row in set, 1 warning (19.55 sec)
  • ストレージモード (一時結果が7万8千の場合)
mysql> SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像" in boolean mode) AND date LIKE '2010%';
+----------+
| COUNT(*) |
+----------+
|    69644 |
+----------+
1 row in set, 1 warning (2.24 sec)

InnoDBラッパーモードに比べると、ストレージモードの方がだいぶ速いですね。 もう少し全文インデックスでの絞り込み件数を少なくし、2万件ぐらいの場合を比較してみます。

mysql> SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像処理" in boolean mode);
+----------+
| COUNT(*) |
+----------+
|    20742 |
+----------+
1 row in set (0.19 sec)
  • InnoDBラッパーモード (一時結果が2万の場合)
mysql>  SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像処理" in boolean mode) AND date LIKE '2010%';
+----------+
| COUNT(*) |
+----------+
|    19448 |
+----------+
1 row in set, 1 warning (4.09 sec)
  • ストレージモード (一時結果が2万の場合)
mysql> SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+画像処理" in boolean mode) AND date LIKE '2010%';
+----------+
| COUNT(*) |
+----------+
|    19448 |
+----------+
1 row in set, 1 warning (0.68 sec)

このように、ストレージモードはインデックスが使われなくともInnoDBラッパーモードに比べてかなり速いレコード操作を行うことができます。 全文インデックスを使った一時絞込み結果が数万件ぐらいでは、十分実用レベル*2であることがわかります。

さらに、InnoDBのラッパーモードでは、オフラインでのインデックス構築に非常に時間がかかります。 たとえば、数十GiBのテーブルでALTER TABLE ftext ENABLE KEYS;すると、ストレージモードの場合は26分に対し、InnoDBのラッパーモードだと3時間21分かかりました

4/17 追記
少しだけソースを読みました。ストレージモードの場合、ha_mroonga::storage_create_indexgrn_obj_set_infogrn_ii_buildなのに対し、ラッパーモードの場合、ha_mroonga::wrapper_fill_indexesgrn_column_index_updategrn_ii_column_updategrn_ii_update_oneとなっていました。ラッパーモードの場合このアップデートがたくさん走るからストレージモードに比べて遅いような気がしました。

ストレージモードの利点その3 Groongaコマンドが使える

(引用) ストレージモードでは、全文検索機能だけではなくデータストアも含めてGroongaの機能を利用します。ストレージエンジンのすべての機能をGroongaで実現するため、Groongaが得意としている集計操作が高速です。また、Groongaコマンドで直接データベースを操作できるという特長もあります。

私にとっては、ストレージモードにするとGroongaコマンドで直接操作できるという点が非常に大きかったです。Groongaでは、ドリルダウンという高速な集計操作を行うことができます。

Groongaでは、以下のようにドリルダウン検索することで、全文検索結果に含まれる他のカラムの件数を簡単、且つ、割と*3高速に取得することができます。

mysql> SELECT mroonga_command('select ftext --match_columns title||abstract||description --query データベース --output_columns id --limit 0 --drilldown applicants --drilldown_sortby -_nsubrecs --drilldown_limit 10') as result;
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| result                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[[17701],[["id","ShortText"]]],[[4330],[["_key","ShortText"],["_nsubrecs","Int32"]],  
["F株式会社",495],  
["N株式会社",478],  
["株式会社H",459],  
["M株式会社",330],  
["株式会社T",323],  
["NT株式会社",319],  
["S株式会社",314],  
["C株式会社",308],  
["P株式会社",303],  
["Q株式会社",274]]] |  
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.45 sec)

SQLで同じことをしようとするとかなり大変なクエリになると思います。 なお、同じことをするSQLをぱっと思いつかなかったので比較していません。

ストレージモードの利点その4 ベクターカラムが使える

Groongaでは、ベクターカラムという1カラムに複数の値を格納できる仕組みがあります。このベクターカラムは、Mroongaでも作ることができます。すなわち、1対nの関係を別テーブルにせずに1テーブルで表現することができます。これによりJOINやGROUP BYが不要になります。

たとえば、MySQLでは、以下のようなテーブル定義があったとします。 1つのIDに対して、複数の値がぶら下がる形です。

  • ベクターカラムなしの場合のテーブル定義
CREATE TABLE `ftext` (
  `id` varchar(20) NOT NULL,
  `date` date NOT NULL,
  `title` text NOT NULL,
  `abstract` text NOT NULL,
  `description` longtext NOT NULL,
  PRIMARY KEY (`id`),
  KEY `date` (`date`),
  FULLTEXT INDEX `ftext` (`title`,`abstract`,`description`)
) ENGINE=mroonga DEFAULT CHARSET=utf8;

CREATE TABLE applicants (
  `id` varchar(20) NOT NULL,
  `name` varchar(80),
  PRIMARY KEY (`id`,`name`)
) ENGINE=mroonga DEFAULT CHARSET=utf8;

f:id:naoa_y:20140413160705p:plain

上記のテーブル定義をベクターカラムを使うと以下のように表現することができます。

  • ベクターカラムありの場合のテーブル定義
CREATE TABLE applicants (
  `name` varchar(80) PRIMARY KEY
) ENGINE=mroonga DEFAULT CHARSET=utf8
  COLLATE=utf8_bin
  COMMENT='default_tokenizer "TokenDelimit"';

CREATE TABLE `ftext` (
  `id` varchar(20) NOT NULL,
  `date` date NOT NULL,
  `title` text NOT NULL,
  `abstract` text NOT NULL,
  `description` longtext NOT NULL,
  `applicants` TEXT NOT NULL COMMENT 'flags "COLUMN_VECTOR", type "applicants"',
  PRIMARY KEY (`id`),
  KEY `date` (`date`),
  FULLTEXT INDEX `ftext` (`title`,`abstract`,`description`),
  FULLTEXT INDEX applicants (applicants) COMMENT 'table "applicants"'
) ENGINE=mroonga DEFAULT CHARSET=utf8;

ベクターカラムの場合、半角スペースで区切られた値を渡すことでカラムを更新することができます*4

  • 更新例
mysql > INSERT INTO ftext VALUES("1","2001-01-01","title","abstract","description","name1 name2 name3");

title,abstract,descriptionカラムのいずれかに"装置"が含まれ、且つ、applicantsに"S株式会社"が含まれる件数を集計してみましょう。 上記同様、データベースのデータサイズは20GiB強、レコード数は数十万ぐらいです。

"装置"だけの一時検索結果数は23万7435件です。

mysql> SELECT COUNT(*) FROM ftext WHERE MATCH(title,abstract,description) AGAINST("+装置" in boolean mode) ;
+----------+
| COUNT(*) |
+----------+
|   237435 |
+----------+
1 row in set (0.10 sec)
  • ベクターカラムなしの場合のSQLでのSELECT
mysql> SELECT COUNT(*) FROM ftext
    -> INNER JOIN applicants ON applicants.id = ftext.id
    -> WHERE MATCH(title,abstract,description) AGAINST("+装置" in boolean mode)
    -> AND applicants.name = 'S株式会社';
+----------+
| COUNT(*) |
+----------+
|     3259 |
+----------+
1 row in set (24.81 sec)

ベクターカラムなしのSQLの場合、JOINをする必要があり検索速度が遅いですね。 また、applicants.nameが1IDで複数ヒットする条件ならば、GROUP BYをする必要もありそうです。

  • ベクターカラムありの場合のSQLでのSELECT
mysql> SELECT COUNT(*) FROM ftext
    -> WHERE MATCH(title,abstract,description) AGAINST("+装置" in boolean mode)
    -> AND MATCH(applicants) AGAINST("+S株式会社" in boolean mode);
+----------+
| COUNT(*) |
+----------+
|     3259 |
+----------+
1 row in set (29.49 sec)

ベクターカラムに対しては、MATCH ... AGAINSTで検索しないといけません。これでは、後ろ側のインデックスが使われず遅いですね。

  • ベクターカラムありの場合のSQLでのSELECT(AGAINST句でGroongaのクエリ構文使用) 上記の複数インデックスが使えるちょっとした裏技を使ってみます。
mysql> SELECT COUNT(*) FROM ftext
    -> WHERE MATCH(title,abstract,description) AGAINST("+装置 +applicants:@S株式会社" in boolean mode);
+----------+
| COUNT(*) |
+----------+
|     3259 |
+----------+
1 row in set (0.08 sec)

この方法ならSQLでも複数インデックスが使えて速いですね。 ただし、ソースをいじる必要があり、Groongaのクエリ構文が含まれるというイレギュラーな形になってしまいます。

  • ベクターカラムありの場合のGroongaのselectコマンド
mysql> SELECT mroonga_command("select ftext --match_columns title||abstract||description --query '装置 +applicants:@S株式会社' --output_columns id --limit 0") as result;
+---------------------------------+
| result                          |
+---------------------------------+
| [[[3259],[["id","ShortText"]]]] |
+---------------------------------+
1 row in set (0.29 sec)

Groongaのselectコマンドでは、結果がJSONで返ってきます。複数インデックスが使われていて高速に結果が取得されていますね。

MySQLのクエリチューニングは非常に奥が(闇が)深いのでSQLの方は、もっと速くする方法があるのかもしれません。 Groongaのselectコマンドでは、さほどチューニングが不要ということも利点かもしれません。

このように、ストレージモードは速度的なメリットとGroongaコマンドが使えるというメリットがあります。

ここで、MroongaストレージモードのSQLのSELECT構文とGroongaのselectコマンドを比較するとさらに以下の利点があります。

Groongaの利点その1 自由に複数インデックスを使って高速な絞込みができる

Groongaのselectコマンドでは、上記の所定の条件に縛られずに複数インデックスを使うことができます。 したがって、多数の絞込み条件を自由に追加して、簡単に高速に全文検索することができます。

Groongaの利点その2 カウントが検索クエリの結果と同時に取得できる

Groongaでは、上記のように結果セットがJSONとなるので、検索結果とカウントを同時に取得することができます。なお、これは、既存のアプリをそのまま適用できないというデメリットともとらえることができます。

Groongaの利点その3 オフセットがストレージモードよりもかなり速い

MySQLを使っている方だとわかると思うのですが、MySQLのオフセットは数が大きくなると検索速度が顕著に劣化します。 このため、MySQLではBETWEENで絞り込んだりすると思います。

Groongaのselectコマンドでは、オフセット処理がMroongaストレージモードのSELECT構文よりもかなり速いです。 約1000万件のオフセット速度を比較してみます。

mysql> SELECT COUNT(*) FROM ftext;
+----------+
| COUNT(*) |
+----------+
| 11549665 |
+----------+
1 row in set (0.00 sec)
  • MroongaストレージモードのSELECT構文*5
mysql> SELECT app_id FROM ftext LIMIT 5 OFFSET 11549660;
+---------------+
| app_id        |
+---------------+
| JP20130513403 |
| JP20130513406 |
| JP20130513423 |
| JP20130513425 |
| JP20130513426 |
+---------------+
5 rows in set (8.09 sec)
  • Groongaのselectコマンド
mysql> SELECT mroonga_command("select ftext --output_columns app_id --limit 5 --offset 11549660") as result;
+-----------------------------------------------------------------------------------------------------------------------------------+
| result                                                                                                                            |
+-----------------------------------------------------------------------------------------------------------------------------------+
| [[[11549665],[["app_id","ShortText"]],  
["JP20130513403"],  
["JP20130513406"],  
["JP20130513423"],  
["JP20130513425"],  
["JP20130513426"]]] |  
+-----------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.25 sec)

Groongaのselectコマンドでは、約1000万件の末尾でも0.数秒で取得できますね。これなら、わざわざBETWEENで絞り込む必要はありません。

Groongaの利点その4 カウントがストレージモードよりもわずかに速い

これは、かなり多数のレコードにならないと差がでてきませんが、検索結果が数百万レコードぐらいになると1秒ぐらいの差がでてきます。 ただ、数百万レコードで1秒ぐらいなのであまり影響はない範囲だと思います。

Qiitaに試した結果があります。

おわりに

かなり長くなってしまいました。あまりまとまっておらず、判りづらかったと思います。

種類 利点
ストレージモード 所定の条件の場合に全文インデックス以外の複数インデックスを使用した高速な絞込みができる (ソースをいじってAGAINST句にGroongaのクエリ構文を使うと自由に複数インデックスが使える)
ストレージモード インデックスが使用されないときのレコード操作が他のストレージエンジンに比べて高速 全文インデックスでの一時絞込み結果が数万件ぐらいであれば実用レベル
ストレージモード Groongaコマンドが使える ドリルダウンが使える
ストレージモード ベクターカラムが使える ベクターカラムを使うとJOINやGROUP BYが不要
Groonga 自由に複数インデックスを使って高速な絞込みができる
Groonga 結果がJSONなのでカウントが検索クエリの結果と同時に取得できる
Groonga オフセットがストレージモードよりもかなり速い 1000万件の末尾でも0.数秒
Groonga カウントがストレージモードよりもわずかに速い 数百万件で1秒

以上のような利点から私はMroongaをストレージモードにし、更新やメンテナンスでの簡単な検索はMroongaのSQLで楽にデータ操作しつつ、全文検索はGroongaのselectコマンドというスタイルが最もフィットしました。

最初は、Groongaは敷居が高く入りづらかったですが、Mroongaをストレージモードで使っているうちにGroongaについても慣れてきました。Groongaは、いろいろいじれて面白いです。

私は個人で開発していたのでGroongaのコマンドを使うのは特に問題がなかったですが、Groongaコマンドまでいってしまうと完全にSQLを逸脱してしまうので、 保守性や属人性を考えると、一般的なシステム構築現場では、採用できない・提案できないことがあるかもしれません。 また、Groongaコマンドは、結果セットがJSONになるので既存のアプリをそのまま使うこともできません。

それでも私は、単純なカラムを全文検索するという場合を除き*6、がっつり全文検索したいならば、ストレージモードをおすすめします。 堅牢性と高速性を両方狙ってもあまりいいことはありません。この2つはベクトルの向きが違います。Mroongaは、正直割と壊れるときは壊れます*7。 全文検索用のデータベースは、壊れても泣かないぐらいの心づもりでいると良いかもしれません。 私は、全文検索用のデータベースの他にデータ保持用のデータベースを作るか他のストレージエンジンとレプリケーションをしたほうがいいと思っています。

次回は、ストレージモードにしたことによってはまったことや改良点について書く予定です。

4/15 記事を追加しました。
ストレージモードにしてはまったことについて説明しています。
数百GiBの全文検索用データベースをMroongaのストレージモードにしてはまったこと - CreateField Blog

4/16 追記
MroongaのInnoDBラッパーモードの採否について以下の記事も参考になると思います。転置インデックスにはトランザクションが効きません。
日々の覚書: MroongaのラッパーモードでInnoDBを使う落とし穴

6/26 記事を追加しました。
Groongaがあまり得意でない類似文書検索に連想検索エンジンGETAssocを使った話

2014-11-29(土)13:30 - 17:30
年に1度のGroongaに関するイベントがあります。Groongaを使っている人、興味がある人は参加してみてはいかがでしょうか。

全文検索エンジンGroongaを囲む夕べ5 - Groonga | Doorkeeper

*1:全てのクエリにおいて、クエリキャッシュは効いておらず、毎クエリごとにmysqldを再起動しています。

*2:検索速度はサーバスペック、ディスク速度に応じて変わると思います。

*3:ドリルダウン検索は割と高速ですが、全文検索の速度と比べると検索結果がかなり多い(数百万件クラス)と件数の積み上げに結構時間がかかります。

*4:半角スペース以外にしたい場合は、default_tokenizerを変えればよいです。

*5:InnoDBのオフセットはストレージモードのオフセットよりもさらに遅いと思います。

*6:全文インデックスのみを使った全文検索ならラップしているストレージエンジンは関係なく高速に全文検索できると思います。

*7:とはいっても、動きはじめて安定すれば、更新もせずに勝手に壊れるというのはないです。