SlideShare a Scribd company logo
Tritonn から
Elasticsearch
への移行話
2019/06/07
MySQL Casual Talks vol.11
do_aki
@do_aki
@do_aki
http://do-aki.net/
脱Tritonn
Tritonn
• Tritonn := MySQL + Senna
• MyISAM の全文検索をフックするパッチで
実現 (ストレージエンジンではない)
• MyISAM + Senna インデックス
• 検索は MATCH…AGAINST 構文を利用
CREATE TABLE items (
item_id INT,
name TEXT NOT NULL,
description TEXT NOT NULL,
FULLTEXT KEY USING NGRAM,
NORMALIZE, SECTIONALIZE (name,description)
) ENGINE=MyISAM
SELECT item_id, name, description
FROM items
WHERE MATCH (name, description)
AGAINST ('*E-1*D+*W1:10,2:3 検索語 –除外' IN BOOLEAN MODE)
DDL
SQL
Tritonn の今
• 開発停止
• Groonga ストレージエンジン (現在の
Moronga ストレージエンジン)に移行
• Trironn 最終リリースは 2009/11
(MySQL 5.0.87)
Tritonn の今
• 開発停止
• Groonga ストレージエンジン (現在の
Moronga ストレージエンジン)に移行
• Trironn 最終リリースは 2009/11
(MySQL 5.0.87)
現役で稼働中!!
10年前のMySQL
• ハードウェア特性の相違
• Clientライブラリとの互換性
• OS (glibc) との互換性 / 脆弱性
さすがに MySQL の
バージョンアップを
しないとね!
(脱 Tritonn)
移行にあたって重要視したこと
• 従来の検索エクスペリエンスを大きくは変えない
=> 漏れより網羅性重視 / 正規化 / OR,AND,NOT
• 検索速度が従来より極端に遅くならないようにする
=> 厳密な基準は設けなかったが、現状同等以下
• 運用コストはなるべく抑える
=> Tritonn 自体を運用するコストはほぼ0だった
移行案
LIKE に置き換え
転置インデックステーブル自作
InnoDB 全文検索
Mroonga
MySQLで解決
HyperEstraier
Groonga
Solr
Elasticsearch
他 Middle ware 導入
優先して検討
LIKE に置き換え
• 複数語検索が必要な場合は LIKE を OR で結合していく感じ
• 試しに200万件程度のデータに対して MATCH..AGAINST を 単純な
LIKE に置き換えてみたところ ミリ秒単位で返ってきていたクエリ
が数十秒単位に
• データ量が少なく、今後も急には増えないところに対しての妥協案
としてあり -> 他の条件で十分に絞り込め、単純検索で問題ない
ケースについての置き換え策として採用
InnoDB Full Text Search
• MySQL5.6 以降で利用可能な全文検索インデックス(ngram parser
は 5.7 以降)
• Senna 同様 MATCH..AGAINT を使うので、改修箇所が少なくて済
むかもという期待
↓ ↓ ↓
• ngram parser (bigram)だと十分な速度が出なかった (LIKE同様
のデータで数秒程度)
• 現状の挙動と合わせるためには前処理が必要
• ngram における stopword の扱いが微妙……
Mroonga
• Tritonn の後継プロダクト
• ラッパーモードを利用すればほぼ現状と変わらないはず
• とはいえ、せっかく 脱Tritonn するのになーという思いも
• 検証時点では 8.0系には未対応(2019年6月現在も)
-> 今後のバージョンアップの枷になりそう
(ソースコードに手を入れて 8.0 対応の手助けできないか試したのだけど、
力及ばず。。。)
MySQL のみでの解決を断念
-> 他の Middle Ware導入を検討
• Groonga
– 速度は申し分ない
– HAのための冗長構成をすべて自前で組む必要がある
• Solr
– Lucene のフロントエンド
– Solr Cloud
• Elasticsearch
– Lucene をつかった全文検索サーバ
– 組み込みのクラスタ
採用
ngram 対応
形態素 vs ngram
• 転置インデックスに格納される語の単位
• 「東京都の水」
– 形態素:「東京」「都」「の」「水」
辞書依存, 一般的に新語や固有名詞に弱い
辞書のメンテが必要
– bigram:「東京」「京都」「都の」「の水」
インデックスサイズが肥大, 網羅的に検索できる
辞書不要
{"type": "kuromoji_tokenizer"}
{"type": "ngram", "min_gram": 2, "max_gram": 2}
USING NGRAM
• 弊社の Tritonn では ngram インデックスが利用されてた
– もともと 高速な LIKE がほしい的な理由からだった気がする
– 様々なジャンルの商品 -> 未知語が多い
– 適合性より網羅性重視
• Elasticsearch で ngram を使う事例は少ない?
– 日本語検索の多くは kuromoji 利用
• Elasticsearch 採用決定までに試行錯誤してる
「世界に一つだけの花」 に
「世界一」 がマッチする問題
趣旨とは関
係ないけど、
S は小文字
が正しいの
でこれは誤
り
ngram と match
• 「世界に一つだけの花」「世界一」
– index: 「世界」「界に」「に一」「一つ」「つだ」
「だけ」「けの」「の花」
– search: 「世界」「界一」
• match query は デフォルトでは OR
– 「界一」にはヒットしないが、「世界」 にヒットし
たことでマッチ
– ならば AND にしてやれば……
{"match": {"name": "世界一"}}
↓
{"match": {"name": {"query":"世界一", "operator":"and"}}
そんな甘くはない
• @johtani さんからの指摘
– https://twitter.com/johtani/status/10
58195319228268545
match_phrase
• 「世界に一つだけの花」にはマッチしなくなったが
「MySQL界一の地雷職人、世界のyokuさん」にはマッチ
• 解決するために match_phrase を利用
– match や multi_match を使わないという選択
{"match": {"name": "世界一"}}
↓
{"match_phrase": {"name": "世界一"}}
複数フィールドへの対応
• 例:「世界」 と 「花」 を含む name検索
• description フィールドも対象に
• match が使えないので bool query を駆使
↓ ↓ ↓
{"match" : {"name": "世界 花"}}
{"multi_match" :
{"query": "世界 花", "fields": ["name", "description"]}
}
bool query
{
"bool": {
"must": [
{
"bool": {
"should": [
{"match_phrase": {"name": {"世界"}}},
{"match_phrase": {"description": {"世界"}}},
]
}
},
{
"bool": {
"should": [
{"match_phrase": {"name": {"花"}}},
{"match_phrase": {"description": {"花"}}},
]
}
},
]
}
}
検索ワードの解析
• "(世界一 OR 日本一) 地雷職人 –yoku"
– Trironn では、そのまま渡せば意図通り
– 簡易パーサを作って対応
{"bool": {"must": [
{"bool": {"must": [
{"bool": {"should": [
{"match_phrase": {"name": {"query": "世界一"}}},
{"match_phrase": {"description": {"query": "世界一"}}
]}},
{"bool": {"should": [
{"match_phrase": {"name": {"query": "日本一"}}},
{"match_phrase": {"description": {"query": "日本一"}}}
]}}
]}},
{"bool": {"should": [
{"match_phrase": {"name": {"query": "地雷"}}},
{"match_phrase": {"description": {"query": "地雷"}}}
]}},
{"bool": {"must_not": [
{"bool": {"should": [
{"match_phrase": {"name": {"query": "yoku"}}},
{"match_phrase": {"description": {"query": "yoku"}}}
]}}
]}}
]}}
一文字にもマッチするように
• Tritonn では基本 bigram のはずなのだけどなぜか 1文字
でも検索可能 (ex:「壺」)
– 単体で検索することは稀かもしれないけど、複合語ではマッチ
させたい (ex: 「漬物」+「壺」)
• bigram だけでなく unigram も作成することで対応
– 単純に作成するだけだとインデックスサイズが肥大化
– stopword で一文字の 英字 数字 記号 平仮名 片仮名 を除外
スペースや # が除外されない
{"type": "stop", "stopwords_path": "stopwords.txt"}
{
"type": "stop",
"stopwords": [
"t", " ", "!", """, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", ";", "<", "=", ">", "?", "@",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"[", "", "]", "^", "_", "`",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"{", "|", "}", "~", " ",
"ぁ", "あ", "ぃ", "い", "ぅ", "う", "ぇ", "え", "ぉ", "お", "か", "が", "き", "ぎ", "く", "ぐ", "け", "げ", "こ", "ご",
"さ", "ざ", "し", "じ", "す", "ず", "せ", "ぜ", "そ", "ぞ", "た", "だ", "ち", "ぢ", "っ", "つ", "づ", "て", "で", "と", "ど",
"な", "に", "ぬ", "ね", "の", "は", "ば", "ぱ", "ひ", "び", "ぴ", "ふ", "ぶ", "ぷ", "へ", "べ", "ぺ", "ほ", "ぼ", "ぽ",
"ま", "み", "む", "め", "も", "ゃ", "や", "ゅ", "ゆ", "ょ", "よ", "ら", "り", "る", "れ", "ろ", "ゎ", "わ", "を", "ん",
"ゔ", "ゕ", "ゖ", " ゙", " ゚", "゛", "゜",
"ァ", "ア", "ィ", "イ", "ゥ", "ウ", "ェ", "エ", "ォ", "オ", "カ", "ガ", "キ", "ギ", "ク", "グ", "ケ", "ゲ", "コ", "ゴ",
"サ", "ザ", "シ", "ジ", "ス", "ズ", "セ", "ゼ", "ソ", "ゾ", "タ", "ダ", "チ", "ヂ", "ッ", "ツ", "ヅ", "テ", "デ", "ト", "ド",
"ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "バ", "パ", "ヒ", "ビ", "ピ", "フ", "ブ", "プ", "ヘ", "ベ", "ペ", "ホ", "ボ", "ポ",
"マ", "ミ", "ム", "メ", "モ", "ャ", "ヤ", "ュ", "ユ", "ョ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ヮ", "ワ", "ヲ", "ン", "ヴ",
"ヵ", "ヶ", "ー", "、", "。", "「", "」", "『", "』", "【", "】", "〔", "〕", "〖", "〗", "〘", "〙", "〚", "〛", "〜", "!", "?"
]
}
行単位で除外ワードを記述したファイルで stopword を指定
ファイルを読み込む処理で、# はコメント扱いの上、
スペース除去をしているため、 無視されていた
直接列挙することで対応
非正規化
category_id name code
1 cat1 1010
2 cat2 1011
3 cat3 2010
field化 (非正規化)
item_id name category_id ...
10 商品A 1 ...
20 商品B 2 ...
items table
categories table
{
"item_id": 10,
"name": "商品A",
"category_id": 1,
"categories::name": "cat1",
"categories::code": "1010",
},
{
"item_id": 20,
"name": "商品B",
"category_id": 2,
"categories::name": "cat2",
"categories::code": "1011",
}
items index
0…n
1
item_id plan provide
1 A 2019/06/01
1 B 2019/07/01
2 A 2019/06/07
nested field
item_id ...
10 ...
20 ...
items table
item_provisions table
{
"item_id": 10,
"item_provisions" : [
{
"plan": "A",
"provide": "2019-06-01"
},
{
"plan": "B",
"provide": "2019-07-01"
}
]
},
{
"item_id": 20,
"item_provisions" : [
{
"plan": "A",
"provide": "2019-06-07"
}
]
}
items index
1
0…n
user_id item_id user_price
1 10 1500
1 20 2500
2 10 3000
ユーザー設定価格
item_id default_price
10 1000
20 2000
items table
user_settings table
item_id price
10 1500
20 2500
item_id price
10 3000
20 2000
user_id:1 の場合
user_id:2 の場合
1
0…n
user_settings があれば
user_price を採用。なけれ
ば default_price を採用
user_id:2 が扱う 2000円 以上の
商品を抽出するSQL
SELECT item_id,
IFNULL(user_price, default_price) AS price
FROM items
LEFT OUTER JOIN user_settings USING(item_id)
WHERE user_id = 2
AND IFNULL(user_price, default_price) <= 2000
user_id item_id user_price
1 10 1500
1 20 2500
2 10 3000
非正規化+展開
item_id default_price
10 1000
20 2000
items table
user_settings table
1
0…n
{
"item_id": 10,
"user_settings" : [
{
"user_id": 1,
"price": "1500"
},
{
"user_id": 2,
"price": "3000"
},
]
},
{
"item_id": 20,
"user_settings" : [
{
"user_id": 1,
"price": "2500"
},
{
"user_id": 2,
"price": "2000"
},
]
}
items index
`number of documents in the
index cannot exceed
2147483519`
• 200万以上の商品と70万以上のユーザー
– 1兆4000億以上の nested object が必要
– user_settings は 2500万件程度
• 1シャードあたりの document 上限は 2147483519
– nested object は 1document として格納
– シャードを増やせば格納することは可能
• nested object が多すぎる document の操作でメモリ不足
– java.lang.OutOfMemoryError: Java heap space
user_id item_id user_price
1 10 1500
1 20 2500
2 10 3000
DB のデータを
そのまま非正規化
item_id default_price
10 1000
20 2000
items table
user_settings table
1
0…n
{
"item_id": 10,
"default_price": 1000,
"user_settings" : [
{
"user_id": 1,
"price": "1500"
},
{
"user_id": 2,
"price": "3000"
}
]
},
{
"item_id": 20,
"default_price": 2000,
"user_settings" : [
{
"user_id": 1,
"price": "2500"
}
/* user_id:2 は含めない */
]
}
items index
price による絞り込みをどう実現するか
• DB と同じデータをつかって実現
– SQL を参考に
• ES で柔軟に値を調整
– 検索スコア
– スコア調整用のクエリ
– painless (スクリプト)が利用可能
user_id:2 が扱う 2000円 以上の
商品を抽出するSQL (再掲)
SELECT item_id,
IFNULL(user_price, default_price) AS price
FROM items
LEFT OUTER JOIN user_settings USING(item_id)
WHERE user_id = 2
AND IFNULL(user_price, default_price) <= 2000
{
“function_score”: {
“query”: {
“function_score”: {
“query”: {
“bool”: {
"filter": [
{"match_all": {}}
],
“should”: [
{
“nested”: {
“path”: “user_settings",
"query": {
"function_score": {
"query": {
"term": {
"user_settings.user_id":{"value": 2, "boost": 0}
}
},
"field_value_factor" : {
"field":"user_settings.user_price", "missing": 0
},
"boost_mode": "replace"
}
}
}
}
]
}
},
"script_score": {
"script": {
"source": "0 < _score ? _score : doc['default_price'].value"
}
},
"boost_mode": "replace",
"min_score": 2000
}},
"boost_mode": "replace",
"weight": 1
}
}
1.全体を対象にして
2. user_id:2 の
user_settings があれば
3.user_price を _score に置き換える
(なければ0)
4. _score が 0 の場合は default_price を
_score に置き換え (_score が price になる)
5. _score (price) が 2000 以上のみに絞り込む
6. 重みづけを 1に戻すことで、金額がスコアに影響するのを抑制
現在
• Elasticsearch クラスタが稼働中
• 一部の処理については Elasticsearch
利用の検索に置き換え済み
• MySQL バージョンアップ
(5.0系 -> 8系) は隣の人が頑張ってる
まとめ
• Tritronn から Elasticsearch に移行
しています (現在進行形)
• データ同期の手法とか移行によるパ
フォーマンス改善とか話しきれなかった
ことも多い
• 俺たちの戦いはこれからだ
(_blank)

More Related Content

KEY
はじめてのCouch db
PPTX
BPStudy32 CouchDB 再入門
PDF
Jpug study-jsonb-datatype-20141011
PDF
jQuery超入門編
PDF
Django boodoo
PDF
20140920 tokyo r43
ZIP
負荷テストことはじめ
PDF
Monadicプログラミング マニアックス
はじめてのCouch db
BPStudy32 CouchDB 再入門
Jpug study-jsonb-datatype-20141011
jQuery超入門編
Django boodoo
20140920 tokyo r43
負荷テストことはじめ
Monadicプログラミング マニアックス

What's hot (20)

PDF
Yahoo!ボックスAPI Hackathon向け資料
PDF
Yahoo!ボックスAPI Hackday資料
PDF
いまさら始めてみたRxJS
PPTX
Ember.js Tokyo event 2014/09/22 (Japanese)
PDF
【GCPUG滋賀】BigQueryでさるでもわかるSQL(20190323)
PDF
Pythonで始めるDropboxAPI
KEY
Actor&stm
PDF
20170923 excelユーザーのためのr入門
PDF
Scala with DDD
PDF
jQuery Mobile 1.2 最新情報 & Tips
PDF
jQuery Mobile 1.3 最新情報
PDF
EucalyptusのHadoopクラスタとJaqlでBasket解析をしてHiveとの違いを味わってみました
PDF
Html5 Web Applications
PDF
10分で分かるr言語入門ver2.9 14 0920
PDF
LINQソースでGO!
PDF
Listを串刺し
PDF
BigDataの集め方
PDF
ビギナーだから使いたいO/Rマッパー ~Tengを使った開発~
PDF
脱コピペ!デザイナーにもわかるPHPとWP_Query
PPTX
PHP Object Injection入門
Yahoo!ボックスAPI Hackathon向け資料
Yahoo!ボックスAPI Hackday資料
いまさら始めてみたRxJS
Ember.js Tokyo event 2014/09/22 (Japanese)
【GCPUG滋賀】BigQueryでさるでもわかるSQL(20190323)
Pythonで始めるDropboxAPI
Actor&stm
20170923 excelユーザーのためのr入門
Scala with DDD
jQuery Mobile 1.2 最新情報 & Tips
jQuery Mobile 1.3 最新情報
EucalyptusのHadoopクラスタとJaqlでBasket解析をしてHiveとの違いを味わってみました
Html5 Web Applications
10分で分かるr言語入門ver2.9 14 0920
LINQソースでGO!
Listを串刺し
BigDataの集め方
ビギナーだから使いたいO/Rマッパー ~Tengを使った開発~
脱コピペ!デザイナーにもわかるPHPとWP_Query
PHP Object Injection入門
Ad

Similar to Tritonn から Elasticsearch への移行話 (20)

PDF
Mysql+Mroongaで全文検索
PDF
MySQLの全文検索に関するあれやこれや
PDF
Javaでmongo db
PDF
全文検索In着うた配信サービス
PDF
Elasticsearch入門 pyfes 201207
PDF
Mroongaを使ったときの MySQLの制限との戦い
ODP
mysqlftppc 紹介
PDF
ニコニコニュースと全文検索
PDF
オープンソースソフトウェア検索サーバ Solr入門
PDF
オープンソースソフトウェア検索サーバ Solr入門
PDF
Random partionerのデータモデリング
 
PDF
MySQL Casual Talks Vol.4 「MySQL-5.6で始める全文検索 〜InnoDB FTS編〜」
PDF
blogサービスの全文検索の話 - #groonga を囲む夕べ
PDF
textsearch_jaで全文検索
PDF
DSIRNLP#3 LT: 辞書挟み込み型転置インデクスFIg4.5
PDF
20160929_InnoDBの全文検索を使ってみた by 株式会社インサイトテクノロジー 中村範夫
PDF
PHP と MySQL でカジュアルに MapReduce する (Short Version)
PDF
Solr勉強会第10回
PDF
PHP と MySQL でカジュアルに MapReduce する
PPTX
SQLマッピングフレームワーク「Kobati」のはなし
Mysql+Mroongaで全文検索
MySQLの全文検索に関するあれやこれや
Javaでmongo db
全文検索In着うた配信サービス
Elasticsearch入門 pyfes 201207
Mroongaを使ったときの MySQLの制限との戦い
mysqlftppc 紹介
ニコニコニュースと全文検索
オープンソースソフトウェア検索サーバ Solr入門
オープンソースソフトウェア検索サーバ Solr入門
Random partionerのデータモデリング
 
MySQL Casual Talks Vol.4 「MySQL-5.6で始める全文検索 〜InnoDB FTS編〜」
blogサービスの全文検索の話 - #groonga を囲む夕べ
textsearch_jaで全文検索
DSIRNLP#3 LT: 辞書挟み込み型転置インデクスFIg4.5
20160929_InnoDBの全文検索を使ってみた by 株式会社インサイトテクノロジー 中村範夫
PHP と MySQL でカジュアルに MapReduce する (Short Version)
Solr勉強会第10回
PHP と MySQL でカジュアルに MapReduce する
SQLマッピングフレームワーク「Kobati」のはなし
Ad

More from do_aki (20)

PPTX
php-src の歩き方
PPTX
PHP と SAPI と ZendEngine3 と
PPTX
PHPとシグナル、その裏側
PPTX
再考:列挙型
PPTX
signal の話 或いは Zend Signals とは何か
PPTX
PHP AST 徹底解説(補遺)
PPTX
PHP AST 徹底解説
PPTX
Writing php extensions in golang
PPTX
php7's ast
PPTX
N対1 レプリケーション + Optimizer Hint
PPTX
20150212 プレゼンテーションzen
PPTX
MySQL Casual Talks 7 「N:1 レプリケーション ~進捗どうですか?~」
PPTX
20141017 introduce razor
PPTX
20141011 mastering mysqlnd
PPTX
php in ruby
PPTX
PHP から Groonga を使うにはこんなコードになるよ!
PPTX
N:1 Replication meets MHA
PDF
Php radomize
PPTX
php and sapi and zendengine2 and...
PPTX
セキュアそうでセキュアじゃない少しセキュアな気分になれるmysql_config_editor
php-src の歩き方
PHP と SAPI と ZendEngine3 と
PHPとシグナル、その裏側
再考:列挙型
signal の話 或いは Zend Signals とは何か
PHP AST 徹底解説(補遺)
PHP AST 徹底解説
Writing php extensions in golang
php7's ast
N対1 レプリケーション + Optimizer Hint
20150212 プレゼンテーションzen
MySQL Casual Talks 7 「N:1 レプリケーション ~進捗どうですか?~」
20141017 introduce razor
20141011 mastering mysqlnd
php in ruby
PHP から Groonga を使うにはこんなコードになるよ!
N:1 Replication meets MHA
Php radomize
php and sapi and zendengine2 and...
セキュアそうでセキュアじゃない少しセキュアな気分になれるmysql_config_editor

Tritonn から Elasticsearch への移行話

  • 4. Tritonn • Tritonn := MySQL + Senna • MyISAM の全文検索をフックするパッチで 実現 (ストレージエンジンではない) • MyISAM + Senna インデックス • 検索は MATCH…AGAINST 構文を利用
  • 5. CREATE TABLE items ( item_id INT, name TEXT NOT NULL, description TEXT NOT NULL, FULLTEXT KEY USING NGRAM, NORMALIZE, SECTIONALIZE (name,description) ) ENGINE=MyISAM SELECT item_id, name, description FROM items WHERE MATCH (name, description) AGAINST ('*E-1*D+*W1:10,2:3 検索語 –除外' IN BOOLEAN MODE) DDL SQL
  • 6. Tritonn の今 • 開発停止 • Groonga ストレージエンジン (現在の Moronga ストレージエンジン)に移行 • Trironn 最終リリースは 2009/11 (MySQL 5.0.87)
  • 7. Tritonn の今 • 開発停止 • Groonga ストレージエンジン (現在の Moronga ストレージエンジン)に移行 • Trironn 最終リリースは 2009/11 (MySQL 5.0.87) 現役で稼働中!!
  • 8. 10年前のMySQL • ハードウェア特性の相違 • Clientライブラリとの互換性 • OS (glibc) との互換性 / 脆弱性 さすがに MySQL の バージョンアップを しないとね! (脱 Tritonn)
  • 9. 移行にあたって重要視したこと • 従来の検索エクスペリエンスを大きくは変えない => 漏れより網羅性重視 / 正規化 / OR,AND,NOT • 検索速度が従来より極端に遅くならないようにする => 厳密な基準は設けなかったが、現状同等以下 • 運用コストはなるべく抑える => Tritonn 自体を運用するコストはほぼ0だった
  • 11. LIKE に置き換え • 複数語検索が必要な場合は LIKE を OR で結合していく感じ • 試しに200万件程度のデータに対して MATCH..AGAINST を 単純な LIKE に置き換えてみたところ ミリ秒単位で返ってきていたクエリ が数十秒単位に • データ量が少なく、今後も急には増えないところに対しての妥協案 としてあり -> 他の条件で十分に絞り込め、単純検索で問題ない ケースについての置き換え策として採用
  • 12. InnoDB Full Text Search • MySQL5.6 以降で利用可能な全文検索インデックス(ngram parser は 5.7 以降) • Senna 同様 MATCH..AGAINT を使うので、改修箇所が少なくて済 むかもという期待 ↓ ↓ ↓ • ngram parser (bigram)だと十分な速度が出なかった (LIKE同様 のデータで数秒程度) • 現状の挙動と合わせるためには前処理が必要 • ngram における stopword の扱いが微妙……
  • 13. Mroonga • Tritonn の後継プロダクト • ラッパーモードを利用すればほぼ現状と変わらないはず • とはいえ、せっかく 脱Tritonn するのになーという思いも • 検証時点では 8.0系には未対応(2019年6月現在も) -> 今後のバージョンアップの枷になりそう (ソースコードに手を入れて 8.0 対応の手助けできないか試したのだけど、 力及ばず。。。)
  • 14. MySQL のみでの解決を断念 -> 他の Middle Ware導入を検討 • Groonga – 速度は申し分ない – HAのための冗長構成をすべて自前で組む必要がある • Solr – Lucene のフロントエンド – Solr Cloud • Elasticsearch – Lucene をつかった全文検索サーバ – 組み込みのクラスタ 採用
  • 16. 形態素 vs ngram • 転置インデックスに格納される語の単位 • 「東京都の水」 – 形態素:「東京」「都」「の」「水」 辞書依存, 一般的に新語や固有名詞に弱い 辞書のメンテが必要 – bigram:「東京」「京都」「都の」「の水」 インデックスサイズが肥大, 網羅的に検索できる 辞書不要 {"type": "kuromoji_tokenizer"} {"type": "ngram", "min_gram": 2, "max_gram": 2}
  • 17. USING NGRAM • 弊社の Tritonn では ngram インデックスが利用されてた – もともと 高速な LIKE がほしい的な理由からだった気がする – 様々なジャンルの商品 -> 未知語が多い – 適合性より網羅性重視 • Elasticsearch で ngram を使う事例は少ない? – 日本語検索の多くは kuromoji 利用 • Elasticsearch 採用決定までに試行錯誤してる
  • 19. ngram と match • 「世界に一つだけの花」「世界一」 – index: 「世界」「界に」「に一」「一つ」「つだ」 「だけ」「けの」「の花」 – search: 「世界」「界一」 • match query は デフォルトでは OR – 「界一」にはヒットしないが、「世界」 にヒットし たことでマッチ – ならば AND にしてやれば…… {"match": {"name": "世界一"}} ↓ {"match": {"name": {"query":"世界一", "operator":"and"}}
  • 20. そんな甘くはない • @johtani さんからの指摘 – https://twitter.com/johtani/status/10 58195319228268545
  • 21. match_phrase • 「世界に一つだけの花」にはマッチしなくなったが 「MySQL界一の地雷職人、世界のyokuさん」にはマッチ • 解決するために match_phrase を利用 – match や multi_match を使わないという選択 {"match": {"name": "世界一"}} ↓ {"match_phrase": {"name": "世界一"}}
  • 22. 複数フィールドへの対応 • 例:「世界」 と 「花」 を含む name検索 • description フィールドも対象に • match が使えないので bool query を駆使 ↓ ↓ ↓ {"match" : {"name": "世界 花"}} {"multi_match" : {"query": "世界 花", "fields": ["name", "description"]} }
  • 23. bool query { "bool": { "must": [ { "bool": { "should": [ {"match_phrase": {"name": {"世界"}}}, {"match_phrase": {"description": {"世界"}}}, ] } }, { "bool": { "should": [ {"match_phrase": {"name": {"花"}}}, {"match_phrase": {"description": {"花"}}}, ] } }, ] } }
  • 24. 検索ワードの解析 • "(世界一 OR 日本一) 地雷職人 –yoku" – Trironn では、そのまま渡せば意図通り – 簡易パーサを作って対応 {"bool": {"must": [ {"bool": {"must": [ {"bool": {"should": [ {"match_phrase": {"name": {"query": "世界一"}}}, {"match_phrase": {"description": {"query": "世界一"}} ]}}, {"bool": {"should": [ {"match_phrase": {"name": {"query": "日本一"}}}, {"match_phrase": {"description": {"query": "日本一"}}} ]}} ]}}, {"bool": {"should": [ {"match_phrase": {"name": {"query": "地雷"}}}, {"match_phrase": {"description": {"query": "地雷"}}} ]}}, {"bool": {"must_not": [ {"bool": {"should": [ {"match_phrase": {"name": {"query": "yoku"}}}, {"match_phrase": {"description": {"query": "yoku"}}} ]}} ]}} ]}}
  • 25. 一文字にもマッチするように • Tritonn では基本 bigram のはずなのだけどなぜか 1文字 でも検索可能 (ex:「壺」) – 単体で検索することは稀かもしれないけど、複合語ではマッチ させたい (ex: 「漬物」+「壺」) • bigram だけでなく unigram も作成することで対応 – 単純に作成するだけだとインデックスサイズが肥大化 – stopword で一文字の 英字 数字 記号 平仮名 片仮名 を除外
  • 26. スペースや # が除外されない {"type": "stop", "stopwords_path": "stopwords.txt"} { "type": "stop", "stopwords": [ "t", " ", "!", """, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", ";", "<", "=", ">", "?", "@", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "[", "", "]", "^", "_", "`", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "{", "|", "}", "~", " ", "ぁ", "あ", "ぃ", "い", "ぅ", "う", "ぇ", "え", "ぉ", "お", "か", "が", "き", "ぎ", "く", "ぐ", "け", "げ", "こ", "ご", "さ", "ざ", "し", "じ", "す", "ず", "せ", "ぜ", "そ", "ぞ", "た", "だ", "ち", "ぢ", "っ", "つ", "づ", "て", "で", "と", "ど", "な", "に", "ぬ", "ね", "の", "は", "ば", "ぱ", "ひ", "び", "ぴ", "ふ", "ぶ", "ぷ", "へ", "べ", "ぺ", "ほ", "ぼ", "ぽ", "ま", "み", "む", "め", "も", "ゃ", "や", "ゅ", "ゆ", "ょ", "よ", "ら", "り", "る", "れ", "ろ", "ゎ", "わ", "を", "ん", "ゔ", "ゕ", "ゖ", " ゙", " ゚", "゛", "゜", "ァ", "ア", "ィ", "イ", "ゥ", "ウ", "ェ", "エ", "ォ", "オ", "カ", "ガ", "キ", "ギ", "ク", "グ", "ケ", "ゲ", "コ", "ゴ", "サ", "ザ", "シ", "ジ", "ス", "ズ", "セ", "ゼ", "ソ", "ゾ", "タ", "ダ", "チ", "ヂ", "ッ", "ツ", "ヅ", "テ", "デ", "ト", "ド", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "バ", "パ", "ヒ", "ビ", "ピ", "フ", "ブ", "プ", "ヘ", "ベ", "ペ", "ホ", "ボ", "ポ", "マ", "ミ", "ム", "メ", "モ", "ャ", "ヤ", "ュ", "ユ", "ョ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ヮ", "ワ", "ヲ", "ン", "ヴ", "ヵ", "ヶ", "ー", "、", "。", "「", "」", "『", "』", "【", "】", "〔", "〕", "〖", "〗", "〘", "〙", "〚", "〛", "〜", "!", "?" ] } 行単位で除外ワードを記述したファイルで stopword を指定 ファイルを読み込む処理で、# はコメント扱いの上、 スペース除去をしているため、 無視されていた 直接列挙することで対応
  • 28. category_id name code 1 cat1 1010 2 cat2 1011 3 cat3 2010 field化 (非正規化) item_id name category_id ... 10 商品A 1 ... 20 商品B 2 ... items table categories table { "item_id": 10, "name": "商品A", "category_id": 1, "categories::name": "cat1", "categories::code": "1010", }, { "item_id": 20, "name": "商品B", "category_id": 2, "categories::name": "cat2", "categories::code": "1011", } items index 0…n 1
  • 29. item_id plan provide 1 A 2019/06/01 1 B 2019/07/01 2 A 2019/06/07 nested field item_id ... 10 ... 20 ... items table item_provisions table { "item_id": 10, "item_provisions" : [ { "plan": "A", "provide": "2019-06-01" }, { "plan": "B", "provide": "2019-07-01" } ] }, { "item_id": 20, "item_provisions" : [ { "plan": "A", "provide": "2019-06-07" } ] } items index 1 0…n
  • 30. user_id item_id user_price 1 10 1500 1 20 2500 2 10 3000 ユーザー設定価格 item_id default_price 10 1000 20 2000 items table user_settings table item_id price 10 1500 20 2500 item_id price 10 3000 20 2000 user_id:1 の場合 user_id:2 の場合 1 0…n user_settings があれば user_price を採用。なけれ ば default_price を採用
  • 31. user_id:2 が扱う 2000円 以上の 商品を抽出するSQL SELECT item_id, IFNULL(user_price, default_price) AS price FROM items LEFT OUTER JOIN user_settings USING(item_id) WHERE user_id = 2 AND IFNULL(user_price, default_price) <= 2000
  • 32. user_id item_id user_price 1 10 1500 1 20 2500 2 10 3000 非正規化+展開 item_id default_price 10 1000 20 2000 items table user_settings table 1 0…n { "item_id": 10, "user_settings" : [ { "user_id": 1, "price": "1500" }, { "user_id": 2, "price": "3000" }, ] }, { "item_id": 20, "user_settings" : [ { "user_id": 1, "price": "2500" }, { "user_id": 2, "price": "2000" }, ] } items index
  • 33. `number of documents in the index cannot exceed 2147483519` • 200万以上の商品と70万以上のユーザー – 1兆4000億以上の nested object が必要 – user_settings は 2500万件程度 • 1シャードあたりの document 上限は 2147483519 – nested object は 1document として格納 – シャードを増やせば格納することは可能 • nested object が多すぎる document の操作でメモリ不足 – java.lang.OutOfMemoryError: Java heap space
  • 34. user_id item_id user_price 1 10 1500 1 20 2500 2 10 3000 DB のデータを そのまま非正規化 item_id default_price 10 1000 20 2000 items table user_settings table 1 0…n { "item_id": 10, "default_price": 1000, "user_settings" : [ { "user_id": 1, "price": "1500" }, { "user_id": 2, "price": "3000" } ] }, { "item_id": 20, "default_price": 2000, "user_settings" : [ { "user_id": 1, "price": "2500" } /* user_id:2 は含めない */ ] } items index
  • 35. price による絞り込みをどう実現するか • DB と同じデータをつかって実現 – SQL を参考に • ES で柔軟に値を調整 – 検索スコア – スコア調整用のクエリ – painless (スクリプト)が利用可能
  • 36. user_id:2 が扱う 2000円 以上の 商品を抽出するSQL (再掲) SELECT item_id, IFNULL(user_price, default_price) AS price FROM items LEFT OUTER JOIN user_settings USING(item_id) WHERE user_id = 2 AND IFNULL(user_price, default_price) <= 2000
  • 37. { “function_score”: { “query”: { “function_score”: { “query”: { “bool”: { "filter": [ {"match_all": {}} ], “should”: [ { “nested”: { “path”: “user_settings", "query": { "function_score": { "query": { "term": { "user_settings.user_id":{"value": 2, "boost": 0} } }, "field_value_factor" : { "field":"user_settings.user_price", "missing": 0 }, "boost_mode": "replace" } } } } ] } }, "script_score": { "script": { "source": "0 < _score ? _score : doc['default_price'].value" } }, "boost_mode": "replace", "min_score": 2000 }}, "boost_mode": "replace", "weight": 1 } } 1.全体を対象にして 2. user_id:2 の user_settings があれば 3.user_price を _score に置き換える (なければ0) 4. _score が 0 の場合は default_price を _score に置き換え (_score が price になる) 5. _score (price) が 2000 以上のみに絞り込む 6. 重みづけを 1に戻すことで、金額がスコアに影響するのを抑制
  • 39. • Elasticsearch クラスタが稼働中 • 一部の処理については Elasticsearch 利用の検索に置き換え済み • MySQL バージョンアップ (5.0系 -> 8系) は隣の人が頑張ってる
  • 40. まとめ • Tritronn から Elasticsearch に移行 しています (現在進行形) • データ同期の手法とか移行によるパ フォーマンス改善とか話しきれなかった ことも多い • 俺たちの戦いはこれからだ

Editor's Notes

  • #13: ngram分解された単語の一部に stopword が含まれているとインデックスされない (ngram parser の場合)
  • #17: NEologd