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)

Tritonn から Elasticsearch への移行話

  • 1.
  • 2.
  • 3.
  • 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だった
  • 10.
  • 11.
    LIKE に置き換え • 複数語検索が必要な場合はLIKE を OR で結合していく感じ • 試しに200万件程度のデータに対して MATCH..AGAINST を 単純な LIKE に置き換えてみたところ ミリ秒単位で返ってきていたクエリ が数十秒単位に • データ量が少なく、今後も急には増えないところに対しての妥協案 としてあり -> 他の条件で十分に絞り込め、単純検索で問題ない ケースについての置き換え策として採用
  • 12.
    InnoDB Full TextSearch • 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 をつかった全文検索サーバ – 組み込みのクラスタ 採用
  • 15.
  • 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 採用決定までに試行錯誤してる
  • 18.
  • 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 を指定 ファイルを読み込む処理で、# はコメント扱いの上、 スペース除去をしているため、 無視されていた 直接列挙することで対応
  • 27.
  • 28.
    category_id name code 1cat1 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 1A 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 110 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 110 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 documentsin 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 110 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に戻すことで、金額がスコアに影響するのを抑制
  • 38.
  • 39.
    • Elasticsearch クラスタが稼働中 •一部の処理については Elasticsearch 利用の検索に置き換え済み • MySQL バージョンアップ (5.0系 -> 8系) は隣の人が頑張ってる
  • 40.
    まとめ • Tritronn からElasticsearch に移行 しています (現在進行形) • データ同期の手法とか移行によるパ フォーマンス改善とか話しきれなかった ことも多い • 俺たちの戦いはこれからだ
  • 41.

Editor's Notes

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