こんにちは!ピクシブでバックエンドエンジニアとして働いているこのぴーです。
今回はpixivコミックストアの作品検索機能をMySQLの全文検索からElasticsearchに移行したときの手順と移行後の効果についてお話していこうと思います。
背景
pixivコミックストアのバックエンドはRuby on Railsで作成していて、今回Elasticsearchへ移行する作品検索機能はMySQLの全文検索を使用して実装されていました。
作品数が少なかった実装当時は問題なく動いていたのですが、取り扱う作品数が数十万作品となった現在、スロークエリの大部分を検索クエリが占めるほど性能が劣化してしまう事態となっていました。
さらに、インクリメンタルサーチによりクエリが大量に発行されたことが引き金となる障害が発生したため今回Elasticsearchへと移行することとなりました。
Elasticsearchについて
Elasticsearchとは、Apache Luceneを基盤とした分散型の検索エンジンです。
ドキュメントの検索に特化しており大量のドキュメントからでも目的の単語を含んだものを高速で探し出すことが可能です。
技術選定
ElasticsearchのRubyクライアントとしてelasticsearch gemを選択しました。
RailsにはActiveRecordと相性の良いelasticsearch-railsというgemが存在していますが、執筆現在対応しているElasticsearchのバージョンが7.Xまでと、まもなくEOLとなるバージョンまでしかサポートされていません。
そのため、このgemの利用は避け、elasticsearch gemを利用することを選択しました。
また、Elasticsearchのsearchクエリはhashで書くと長くなりがちなためelasticsearch-dsl gemを利用することとしました。
移行過程
移行に当たり、まずマッピングの定義を行いました。
ElasticsearchはRBDと違い、ドキュメントを登録した際に内部で動的にフィールドが推測されるためマッピング定義を行わずに利用することができます。
しかし、あくまでも推測なのと、不要なフィールドを含めたりtypeの指定を誤ったりするとindexingのスピードが遅くなったりストレージの使用量が多くなったりするおそれがあります。
今回はカスタムアナライザを使用したり、fieldの型やオプションをこちら側で指定したりする必要があったたため、明示的なマッピング定義を行いました。
マッピングを行う上で重要だったポイントはアナライザの設定と、どのフィールドをどのタイプで保持するかです。
カスタムアナライザの作成
カスタムアナライザはchar filter、tokenizer、filterの3つから構成されています。
char filterは文字列に最初にかけられるものです。
これを設定することで大文字を小文字にしたり、全角を半角に変換したりできます。
今回は全角半角の差などを吸収するためにicu_normalizer char filterを使用します。
tokenizerは文字を単語ごとに分かれたトークンに分割するものです。
日本語では、指定した文字数ごとに分割するN-gramやkuromojiプラグインを利用した形態素解析などがよく使用されています。
N-gramの特徴としてはN文字ごとに分割したものを見出し語として登録するため、完全一致で検索することが可能ですが検索ノイズが多くなります。
形態素解析は日本語として意味のある最小単位に分割するため検索ノイズが少なくなりますが、分割するために使用する辞書の性能に依存します。
今回は作品名や作者、出版社などの文章と比べると短いキーワードだけで検索し、本のタイトルや作者名など特殊な単語が多いという特性上辞書を用意するのが大変だと判断したためN-gramのみを採用しました。
filterはtokenizerによって分割されたトークンごとにかけられるフィルターですが今回は特に使用していません。
最終的に出来上がったアナライザは以下となります
char filterにicu_normalizer、tokenizerにbi-gramに設定したngram_tokenizerを指定しました。 このアナライザをtextフィールドのanalyzerに指定することでインデックス時と検索時に使用されます。
マッピング定義
次にマッピング定義です。
最初は、Elasticsearchには検索に最低限必要なカラムだけを入れ、検索後にデータをDBから取ってくるというようなロジックで行くこととし、作品のタイトルや作者名など、検索の対象となるカラムだけを入れるつもりでした。
しかし、「作品に販売期間中の単行本が存在しているか」などの作品名だけでは判断できない絞り込み条件が必要だったため、DB上で親子関係になっている作品と単行本の2つをElasticsearch上に登録する必要が出てきました。
実際に検討した方法は以下の2つです
- nestedを使用して作品の中に単行本の配列をElasticsearchに格納する
- DBの作品と単行本テーブルをinner joinして非正規化したものをElasticsearchに格納する
これらの2つの方法を実装して計測した結果、1のnestedを使用する方がパフォーマンス的に良かったためnested fieldを採用しました。
また、検索時に対象となるフィールドはなるべく少なくなる方が検索にかかる時間が短くなるためcopy_toを使用して1つのフィールドに集約しています。
完成したマッピング定義のイメージは以下のとおりです
ドキュメントの登録
マッピングの定義後に現在RDBに登録されているすべての作品をElasticsearchに登録するためのスクリプトを作成しました。
作品に単行本をeager_loadした後にfind_in_batchesで回してActiveRecord::RelationからElasticsearch用のハッシュを作成するといった処理にします。
また、1件毎にElasticsearchへリクエストを送っていると無駄が多いためbulk apiを使用して同時に1000件ずつ登録するようにしました。
以下が実際に使用したコードを抜粋してまとめたものです
検索クエリの作成
次に作品検索用のクエリを作成しました。
作品の検索条件としては、(「販売中の単行本が1冊以上存在する」OR「予約期間中の単行本が1冊以上存在する」)AND「キーワードが作品名に引っかかる」となります。
この内の「作品名がキーワードに引っかかる」の部分について試行錯誤を行いました。
作品名の完全一致はもちろん、略称やtypoでもなるべく作品が引っかかるようにする必要があります。
これを実現するためにmatchクエリとmatch phraseクエリを併用し、略称にもある程度対応できるようmatch phraseクエリにslop を指定しました。
クエリはelasticsearch-dsl gemをつかって書きました
上半分のshouldの部分でmatch phraseクエリを、下半分のmustの部分でmatchクエリを構築しています。
match phrase側にはboostを掛けてあり、一致する作品があった場合に順位が部分一致の作品より上に来るように調整しています。
DBとElasticsearchの同期
ここまでの実装で、作品の検索が可能になりました。
しかし、DB側に更新があってもElasticsearch側には更新が走らないためいつまでも古いデータしか提供できません。
pixivコミックストアでは定期的に、電子書籍のタイトルや発売日などのデータを提供する外部のAPIから作品の更新情報を取得してDBに反映させるバッチ処理を動かしています。
今回はこのバッチ処理にElasticsearchのデータも更新する処理を入れました。
この作品の更新には新しい作品が販売開始されたときのinsertと作品の情報が更新されたときのupdateが混在しています。
そのため、upsertできるindex actionを使いbulk apiでドキュメントの一括更新・追加を行うようにしました。
移行結果
以下の表がpixivコミックで人気なジャンルである「悪役令嬢」を検索した際のレスポンスタイムです。
ave | p90 | |
---|---|---|
MySQL全文検索 | 634 ms | 685 ms |
ElasticSearch | 393 ms | 435 ms |
Elasticsearchに移行後は240ms程度改善されていることがわかりました。
また、現在検索クエリが大量に発行された際にDBへの負荷が高くなりすぎないようDBのサーバを増やす対応をしていますが、これが必要なくなりサーバ代の節約にも成功しました。
まとめと今後の課題
MySQLからElasticsearchへの移行で作品の検索を行った際のレスポンスを速くすることができました。
また、今まで全文検索やElasticsearchなどに触れてこなかったため、実装を通して理解を深めることができました。
しかし、現状では略称やtypo、完全一致以外でひらがなカタカナが混在した状態で検索した場合に検索結果の精度がよくないと感じているためこれから少しずつ改善していきたいと考えています。