カテゴリー: テクノロジー

[Solr]ドキュメントルーティングのcompositeIdのハッシュを実際に計算してみる

前回の記事に書いたように、compositeIdによるドキュメントルーティングを採用した場合、compositeIdのハッシュ値のどの範囲をどのシャードが担当するのかはSolrの管理画面で確認することができます。ただし、実際のハッシュ値を知ることは実はできません。

簡単なテストプログラムを書いて、compositeIdのハッシュ値がどのように計算されるのかを確認してみました。

まずは単独のIDでハッシュを計算してみます。

$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '1234'
id = 1234
hash = 721c5dc3
$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '中央区'
id = 中央区
hash = a812f493
$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '公園'
id = 公園
hash = eb2b06c4

IDを組み合わせてみます。

$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '中央区!1234'
id = 中央区!1234
hash = a8125dc3
$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '中央区!公園!1234'
id = 中央区!公園!1234
hash = a82b5dc3

8ビットで16進数2桁、16ビットで16進数4桁です。
「中央区!1234」の場合は16ビット:16ビットになるので
「中央区(a812f493)」の上位4桁
「1234(721c5dc3)」の下位4桁
でa8125dc3になっていることが分かります。
「中央区!公園!1234」の場合は8ビット:8ビット:16ビットなので
「中央区(a812f493)」の上位2桁
「公園(eb2b06c4)」の3桁目と4桁目
「1234(721c5dc3)」の下位4桁
でa82b5dc3となります。

さらにビット数を指定してみます。

$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '中央区/4!公園/4!1234'
id = 中央区/4!公園/4!1234
hash = ab1c5dc3
$ java -cp .:solr-solrj-8.1.0.jar CompositeIdTest '中央区/4!公園!1234'
id = 中央区/4!公園!1234
hash = ab2c5dc3

「中央区/4!公園/4!1234」の場合は4ビット:4ビット:24ビットなので
「中央区(a812f493)」の上位1桁
「公園(eb2b06c4)」の2桁目
「1234(721c5dc3)」の3桁目以降
でab1c5dc3となります。
「中央区/4!公園!1234」の場合に無指定の「公園」の扱いがどうなるか興味があったのですが、2階層の場合はデフォルト8ビットなので「中央区/4!公園/8!1234」と同じになるようです。
「中央区(a812f493)」の上位1桁
「公園(eb2b06c4)」の2,3桁目
「1234(721c5dc3)」の4桁目以降
でab2c5dc3となります。

簡単なプログラムではありますが、特定のレコードがどのシャードに配置されているのかを確認したい場合に便利に使えそうです。


Solrのドキュメントルーティングで複数階層のシャードキーを指定するとどうなるのか

はじめに

SolrのcompositeIdを使ったドキュメントルーティングではシャードキーを複数指定することができます。シャードキーを複数指定した場合の動作をちゃんと考えたことが無かったので調べてみました。

compositeIdを使ったドキュメントルーティング

compositeIdを使ったドキュメントルーティングではドキュメントIDとシャードキーから計算されたハッシュ値に基づいて、インデッ クス先のシャードが決まります。シャードキーが指定されなければドキュメントIDのハッシュだけが使われます。

1レベルのシャードキー

シャードキーを指定する場合、ドキュメントIDのフィールドを以下のように記述します。
“シャードキー!ドキュメントID”
以前サンプルに利用した大阪市の施設情報のデータの場合、区名に基づいてシャードを分けるには
“中央区!1234”
のようにドキュメントIDのフィールドを指定します。
こうすることで、同じ区の施設データは必ず同じシャードに配置されるようにインデックスされます。

2レベルのシャードキー

2段階目のシャード分けの条件として施設タイプを使うとすると、ドキュメントIDの指定の仕方は以下のようになります。
“中央区!官公庁!1234”
こう書けるところまではドキュメントを読めば書いてあります。ではこう書いた場合に具体的にはどのような配置になるのでしょうか。

ドキュメントIDのハッシュとシャードの対応付け

ハッシュは32ビットです。00000000からffffffffまでの値をシャード数に応じて均等に区分けします。たとえばシャード数が8の場合、以下のように分けられます。

8等分なので上位3ビットで分類するわけです。

シャード最初の3ビット(2進数)最初の1桁(16進数)
shard10000〜1
shard20012〜3
shard30104〜5
shard40116〜7
shard51008〜9
shard6101a〜b
shard7110c〜d
shard8111e〜f

シャードキーが指定されたときにハッシュがどう計算されるのか

こちらの記事によると、
シャードキーが1つの場合
ハッシュの上位16ビット:シャードキーのハッシュから
ハッシュの下位16ビット:ドキュメントIDのハッシュから

シャードキーが2つの場合
ハッシュの上位8ビット:シャードキー1のハッシュから
ハッシュの次の8ビット:シャードキー2のハッシュから
ハッシュの下位16ビット:ドキュメントIDのハッシュから
となるようです。

シャードキーを2つ指定したとしても、最上位の8ビットをシャードキー1が占めているため、シャード数が256より小さい限りはシャードキー2は影響を与えることができません。

実際に8シャードのコレクションに2パターンのデータを投入して、シャード毎のレコード数をカウントしてみました。シャードキーが1つの場合も2つの場合も同じになっていることが分かります。

$ head -3 data1.json
[
{"id":"住之江区!158","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62"}
{"id":"住之江区!157","type":"官公庁","area":"住之江区","name":"大阪陸運支局なにわ自動車検査登録事務所","address":"住之江区南港東3-1-14"},
$ curl 'http://localhost:8983/solr/compositeid/update?commit=true&indent=true' --data-binary @data1.json -H 'Content-Type: application/json'
$ for i in {1..8}; do curl -s "http://localhost:8983/solr/compositeid/select?q=*:*&rows=0&shards=shard${i}"|jq '.response.numFound'; done
580
509
1471
1203
2092
761
545
2077
$ head -3 data2.json
[
{"id":"住之江区!官公庁!158","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62"},
{"id":"住之江区!官公庁!157","type":"官公庁","area":"住之江区","name":"大阪陸運支局なにわ自動車検査登録事務所","address":"住之江区南港東3-1-14"},
$ curl 'http://localhost:8983/solr/compositeid/update?commit=true&indent=true' --data-binary @data1.json -H 'Content-Type: application/json'
$ for i in {1..8}; do curl -s "http://localhost:8983/solr/compositeid/select?q=*:*&rows=0&shards=shard${i}"|jq '.response.numFound'; done
580
509
1471
1203
2092
761
545
2077

ビット数の制限付きでシャードキーを指定する

シャードキーと共にビット数を指定することもできます。たとえば
“中央区/2!官公庁/14!1234”
とすると、
ハッシュの上位2ビット:区名のハッシュから
ハッシュの次の14ビット:施設タイプのハッシュから
ハッシュの下位16ビット:ドキュメントIDのハッシュから
となります。
2ビットということは、区名により4つに分類されるということです。
たとえば8シャード用意されている場合、特定の区は特定の2シャードのどちらかに配置されることになり、そのどちらになるのかは施設タイプにより決定される、という訳です。

このパターンで先程同様にデータを投入すると、シャードごとのレコード数が大きく変化したことが分かります。

$ head -3 data3.json
[
{"id":"住之江区/2!官公庁/4!158","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62"},
{"id":"住之江区/2!官公庁/4!157","type":"官公庁","area":"住之江区","name":"大阪陸運支局なにわ自動車検査登録事務所","address":"住之江区南港東3-1-14"},
$ curl 'http://localhost:8983/solr/compositeid/update?commit=true&indent=true' --data-binary @data4.json -H 'Content-Type: application/json'
$ for i in {1..8}; do curl -s "http://localhost:8983/solr/compositeid/select?q=*:*&rows=0&shards=shard${i}"|jq '.response.numFound'; done
578
511
1414
1260
1572
1281
1390
1232

それほど大規模なシステムではない場合、このように少ないビット数で制限を掛けるやり方が、現実的なシャードキーの利用方法になるでしょう。


Server-Sent Events に光を

採用機会がなかなかやってこない Server-Sent Events (以下SSE) を使用してみたときのメモです。
想定している環境は nginx + php-fpm です。

SSE を利用すると、サーバーからウェブページにメッセージをプッシュ送信できます。

ここに下手な説明を書くよりも翻訳済みの MDN を見ていただいた方が何万倍も分かりやすいはず。

Server-sent events – Web API | MDN

https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events

SSE API を利用すると、SSE を簡単に扱うことができます。

今回は、オンラインゲームのログテキストっぽいものをサーバーから受信し、ページに表示するページを作成してみます。

ログテキストの内容は架空のものです。

ログっぽいものを表示するページ

SSE 受信の様子

上記で作成した HTML と PHP をブラウザで表示するとこうなります。

やっていること

index.html でやっていることは、「読込」ボタンを押すとサーバーからのメッセージを待機する状態になります。

サーバーからメッセージを受信する度に onmessage イベントが発火し、ログが追加されていきます。

メッセージを待つ間はサーバーとの接続が維持され、10件受信すると接続を解除します。

イベントは “event:%s\n” の形式で色々作れます。

ここでは control、invite、system を イベントとして addEventListener に登録し、ログの色を変えたり接続を切ったりさせています。

はまったこと

地味にはまったのが、nginx で出力がバッファリングされてしまいリアルタイムとは程遠いタイミングでしかメッセージを受信できなかったこと。

その場合は X-Accel-Buffering: no を指定することでバッファリングさせないようにできます。

header(‘X-Accel-Buffering: no’);

MS Edge 非対応がつらい

一方通行ではありますが、非常に簡単に実装できる SSE 。

Web アプリケーション として実装するには MS Edge の非対応がやっぱつらいわ…です。

MS的には WebSocket でいいじゃんってことなんでしょうか。

SSE だけで完結することはなかなかないと思いますが、便利なものは積極的に使いたい。

SSE に光を。

星に電力を (暑いので)


[Solr] Lucene8.1に同梱されるようになったLukeを使う

はじめに

LukeはLucene用のインデックスブラウザです。SolrはLuceneのインデックスを利用しているので、SolrのインデックスをLukeでブラウズすることができます。
Lukeは従来Luceneから見るとサードパーティのソフトウェアであったので、その時々のLuceneのバージョンに合わせてコンパイルが必要で、さらに近年ではLukeのメンテナンスが追いついていない部分もあり、実際に使うにはいろいろ手間が必要な状態になっていました。

そのLukeがLucene 8.1(Solr 8.1)でLuceneのモジュールとして取り込まれました。 この長い長いチケットを見れば分かるように、様々な方の尽力の賜物です。

Luke の特徴

  • インデックスされている文書の閲覧
  • インデックスされているタームを、頻度順で表示
  • 検索の実行と結果の分析
  • 特定の文書の削除
  • ドキュメントのフィールド構成の変更と再インデックス
  • インデックスの最適化

起動

Luke の起動に必要な lucene-luke.jar は Solr の配布物には含まれていないので Lucene をダウンロードします。 ダウンロードしたら展開して luke/luke.sh を実行します。これだけで良くなったので、以前に比べると断然使いやすくなりました。

tar zxf lucene-8.1.0.tgz
cd lucene-8.1.0
luke/luke.sh

起動直後に開くダイアログで、Solrのインデックスが格納されているディレクトリ指定します。

インデックスブラウザ

Overviewのタブでは、インデックスのフィールド毎に頻度の高い順にタームを表示することができます。

wikipedia-ja を Kuromoji の normal モードで形態素解析した場合
wikipedia-ja を Kuromoji の extended モードで形態素解析した場合

たとえば同じ Wikipedia-ja をインデックスした場合でも、Kuromoji の normal モードを使うか extended モードを使うかでインデックスの内容が大きく異なることが分かります。

extended モードでは未知語が uni-gram に分割されるという特性を反映して、上位の多くを “e”, “t”, “r” などのアルファベット1文字のタームが占めています。normal モードでは “年”, “月”, “日” や数字など1文字のタームに加えて”category”,”リンク”,”外部”,”脚注”などWikipediaで頻出の用語も上位に来ています。

おわりに

上に挙げたような、インデックスの内容を直接参照するような使い方の他にも

  • 特定の文章が期待通りのタームに分割されているか調べる
  • 期待通りの検索結果を得るためのクエリを試行錯誤する
  • Analyzerを切り替えたときの形態素解析結果の違いを調べる

など、開発に役立つ機能が満載です。

使いやすい形で配布されるようになった Luke を活用していきたいと思います。


巨大なJSONをSolrに投入する

今回は小ネタです。

先日、1GB近くある巨大なJSONファイルをSolrに投入する機会がありました。とあるシステムからダンプしたデータで、以下のような形になっています。

[{"id":"10001","name":"名前1","description":"説明文1","timestamp":"2018-01-01 12:00:00"},{"id":"10002","name":"名前2","description":"説明文2","timestamp":"2018-01-02 12:00:00"},{"id":"10003","name":"名前3","description":"説明文3","timestamp":"2018-01-03 12:00:00"},{"id":"10004","name":"名前4","description":"説明文4","timestamp":"2018-01-04 12:00:00"},{"id":"10005","name":"名前5","description":"説明文5","timestamp":"2018-01-05 12:00:00"},...]

要するに、改行のない巨大な1行のテキストファイルです。
SolrにJSONファイルをPOSTしてインデックスを作成させることはできますが、1GBはちょっと大きすぎるので、分割することを考えました。

1行1レコードになっていれば話は簡単で、適当な行数で分割してからJSONの配列になるように加工すればいいだけのことですが、全部が1行になっているのでそういう訳にはいきません。

スクリプト言語でJSONを読み込んで分割することも考えましたが、JSON全体を一括で読み込んで処理するタイプのJSONパーサーでは1GBを扱うのは辛いものがあります。SAXタイプのJSONパーサーを探さないといけないかなあと考えているうちに、jq コマンドを使うのがいいんじゃないかと思い当たりました。

$ jq '.[]' sample.json
{
  "id": "10001",
  "name": "名前1",
  "description": "説明文1",
  "timestamp": "2018-01-01 12:00:00"
}
{
  "id": "10002",
  "name": "名前2",
  "description": "説明文2",
  "timestamp": "2018-01-02 12:00:00"
}
{
  "id": "10003",
  "name": "名前3",
  "description": "説明文3",
  "timestamp": "2018-01-03 12:00:00"
}
{
  "id": "10004",
  "name": "名前4",
  "description": "説明文4",
  "timestamp": "2018-01-04 12:00:00"
}
{
  "id": "10005",
  "name": "名前5",
  "description": "説明文5",
  "timestamp": "2018-01-05 12:00:00"
}

一番外の配列を外して各レコードを取り出すことはできました。1レコード1行になっていると加工しやすいので-cオプションを指定します。

$ jq -c '.[]' sample.json
{"id":"10001","name":"名前1","description":"説明文1","timestamp":"2018-01-01 12:00:00"}
{"id":"10002","name":"名前2","description":"説明文2","timestamp":"2018-01-02 12:00:00"}
{"id":"10003","name":"名前3","description":"説明文3","timestamp":"2018-01-03 12:00:00"}
{"id":"10004","name":"名前4","description":"説明文4","timestamp":"2018-01-04 12:00:00"}
{"id":"10005","name":"名前5","description":"説明文5","timestamp":"2018-01-05 12:00:00"}

ここまでくれば後は簡単で、1000行程度ずつ読み込んでまとめてPOSTするスクリプトを作成して無事に投入することができました。