分散キーバリューストアokuyamaの性能をまともなマシンで測ってみた(続)

この前okuyamaの性能測定の結果を書きましたが、
そこで"トランザクションログ+データファイルでの永続化モード"での性能で
5万QPS出ていたのですが、これって本当にファイルReadしてるのかって
怪しかったので、再度検証してみました。


[測定時の構成]

そもそもこの測定時の構成では全てのDataNodeが1台のマシン上で稼動しています。
その構成でkeyとvalueを全てメモリで保持している"トランザクションログ永続化モード"よりも
"トランザクションログ+データファイルでの永続化モード"のほうが良いQPSが出るのはおかしいので少し考察を




考察前に少しokuyamaの仕組みを書きます。
okuyamaのDataNodeのコアであるデータ保持部分は
■org.imdst.util.KeyManagerValueMap
になります。
※okuyamaをダウンロードしていただいていればsrc/org/imdst/util/KeyManagerValueMap.javaになります。
このクラスはjava.util.concurrent.ConcurrentHashMapを継承したクラスとなっています。
データは全てこのクラスから出し入れしています。
実際には"トランザクションログ永続化モード"と"データファイルでの永続化モード"ではvalueの格納先が違うのですが、
内部で処理を切り替えることで、データを取り出す外部クラスからは、モードを意識せずにアクセスできます。


で、実際にこのクラスを使用しているのは、
■org.imdst.util.KeyMapManager
になります。
※okuyamaをダウンロードしていただいていればsrc/org/imdst/util/KeyMapManager.javaになります。


この2つのクラスを使用しokuyamaはデータの永続化を実現しています。


"トランザクションログ永続化モード"と"データファイルでの永続化モード"それぞれの詳細は

■"トランザクションログ永続化モード"

データ登録(set)、削除時(remove)の操作を全ての1つのファイルに追記で記録し続けます。
ファイル名は設定ファイルで任意に決めれます。ここではtransaction.logとします。
"transaction.log"ファイルの内容

"+,ZGF0YXNhdmVrZXlfMA==,c2F2ZWRhdGF2YWx1ZXN0cl8w,1275668116734,;"  <-set
"+,ZGF0YXNhdmVrZXlfMQ==,c2F2ZWRhdGF2YWx1ZXN0cl8x,1275668116750,;"  <-set
"+,ZGF0YXNhdmVrZXlfMg==,c2F2ZWRhdGF2YWx1ZXN0cl8y,1275668116765,;"  <-set
"-,ZGF0YXNhdmVrZXlfMA==, ,1275668125000,;"   <-remove
"+,a2V5MQ==,dmFsdWUx,1275668137109,;"   <-set
"+,a2V5Mg==,dmFsdWUy,1275668141984,;"   <-set
上記の内容をDataNode起動時に1行づつ実行していくと、データが復元できる仕組みです。


■"データファイルでの永続化モード"
"transaction.log"はデータの復元に使用します。復元後のデータと、以降の登録データをどこに持たせるかを
決定するモードが、"データファイルでの永続化モード"です。
このモードにしない場合は、メモリモードとなりorg.imdst.util.KeyManagerValueMapの継承している、ConcurrentHashMapにkeyとvalue
保持されます。すなわち起動後は全てインメモリで保持します。
この場合、もてるデータのサイズがメモリに依存するので、大きなサイズのデータを保持するのには向きません。
後、メモリ容量の小さい一昔前のサーバも同様です。
そこで、"データファイルでの永続化モード"の登場です。
このモードでは、valueは全て1つのファイルに保存されます。
KeyManagerValueMapはkey値とvalueの格納されているファイル上での行位置を
インメモリで持ちます。
データファイルはこんな感じです。

c2F2ZWRhdGF2YWx1ZXN0cl8w&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
c2F2ZWRhdGF2YWx1ZXN0cl8x&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
c2F2ZWRhdGF2YWx1ZXN0cl8y&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
dmFsdWUx&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
dmFsdWUy&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
固定長でファイルに記録されています。
固定長なので、データの行位置さえ持っていれば始点と終点を計算しread出来るという仕組みです。


では実際のKeyManagerValueMapのソースはどうなっているかというと
まずは登録処理である
KeyManagerValueMapのput(key, value)メソッド

1 public Object put(Object key, Object value) {
2 Object ret = null;
3 if (memoryMode) {
4
5 ret = super.put(key, value);
6 } else {
7
8 StringBuffer writeStr = new StringBuffer();
9 int valueSize = (value.toString()).length();
10 try {
11
12 if (readObjectFlg == true) {
13
14 writeStr.append((String)value);
15 // 渡されたデータが固定の長さ分ない場合は足りない部分を補う
16 // 足りない文字列は固定の"&"で補う(38)
17 byte[] appendDatas = new byte[oneDataLength - valueSize];
18
19 for (int i = 0; i < appendDatas.length; i++) {
20 appendDatas[i] = 38;
21 }
22
23 writeStr.append(new String(appendDatas));
24 writeStr.append("\n");
25 String write = writeStr.toString();
26
27 // 書き込む行を決定
28 synchronized (sync) {
29
30 if (vacuumExecFlg) {
31 // Vacuum中の差分データを登録
32 Object[] diffObj = {"1", key, value};
33 vacuumDiffDataList.add(diffObj);
34 }
35
36 this.bw.write(write);
37 this.bw.flush();
38 this.lineCount++;
39 super.put(key, new Integer(lineCount));
40 this.nowKeySize = super.size();
41 }
42 } else {
43 super.put(key, value);
44 }
45 } catch (Exception e) {
46 StatusUtil.setStatusAndMessage(1, "KeyManagerValueMap - put - Error [" + e.getMessage() + "]");
47 }
48 }
49 return ret;
50 }
まずはメモリモードかファイル永続化モードかを確認[3行目]し、メモリモードなら自身のConcurrentHashMapにputし終了です[5行目]。
ファイル永続化時は、固定長になるようにデータを補ってやり[14行目-21行目]、ファイルに書き出しています[36行目-37行目]。
その後、key値と行位置をConcurrentHashMapにput[38行目-39行目]し終了です。
※ファイルに書き出される値は全てbase64エンコードされた値です。効率悪いですね。。。


では取得処理である
KeyManagerValueMap.get(key)メソッドは

1 public Object get(Object key) {
2 Object ret = null;
3 if (memoryMode) {
4
5 ret = super.get(key);
6 } else {
7 try {
8
9 // Vacuum中はsyncを呼び出す
10 if (vacuumExecFlg) {
11
12 ret = syncGet(key);
13 } else {
14
15 int i = 0;
16 int line = 0;
17 Integer lineInteger = null;
18 byte[] buf = new byte[oneDataLength];
19 long seekPoint = 0L;
20
21 lineInteger = (Integer)super.get(key);
22
23 if (lineInteger != null) {
24 line = lineInteger.intValue();
25 } else {
26 return null;
27 }
28
29 // seek計算
30 seekPoint = new Long(seekOneDataLength).longValue() * new Long((line - 1)).longValue();
31
32 synchronized (sync) {
33 if (raf != null) {
34 raf.seek(seekPoint);
35 raf.read(buf,0,oneDataLength);
36 } else {
37 return null;
38 }
39 }
40
41 for (; i < buf.length; i++) {
42 if (buf[i] == 38) break;
43 }
44
45 ret = new String(buf, 0, i, ImdstDefine.keyWorkFileEncoding);
46 }
47 } catch (Exception e) {
48 StatusUtil.setStatusAndMessage(1, "KeyManagerValueMap - get - Error [" + e.getMessage() + "]");
49 }
50 }
51 return ret;
52 }
まずはメモリモードかファイル永続化モードかを確認[3行目]し、メモリモードなら自身のConcurrentHashMapからgetし終了です[5行目]。
ファイル永続化時は、ConcurrentHashMapからkey値でデータのファイル中での行位置情報を取り出し[21行目]、行位置からファイル中での
データの始点位置を計算し[30行目]、その位置までseekします[34行目]。seek後その位置から固定長分データを読み込み[35行目]、読み込んだ値を返します。
※ファイルのreadアクセスにはRandomAccessFileクラスを使用しています。


removeに関しては、ファイル永続モードに関係なく、ConcurrentHashMapからkey値でremoveしているだけです。
一部Vacuum処理のコードも入っていますが(Vacuumに関してはまた別のエントリー書こうと思います)おおむねこういう感じです。
基本的にはデータファイルも追記型で、登録、更新関係なく最新の行位置を更新していく構成になっています。




この仕組みから分かるように、永続化時はファイルをReadするのでインメモリ時よりの性能が出るのは考えられないのですが、
ひとつ可能性があるとすればOSのページキャッシュです。
プログラムではディスクを読んでいるつもりが、実はメモリ上のキャッシュページを読んでいる可能性です。
そこで以下の方法でOS上のページキャッシュから、okuyamaのデータファイルを追い出してやります。

cat 巨大なファイル > /dev/null
実行後、okuyamaのgetを実行すると、予想通り大幅に処理性能が落ちました。
前回は5万QPS出ていたのが、今回は2000QPSです。
その差25倍!!
しかも、DataNodeのiowaitが70%〜100%という高い値で推移しています。
topコマンドでDataNodeの状況を見ると

top - 19:57:27 up 5 days, 9:42, 2 users, load average: 2.44, 0.60, 0.20
Tasks: 131 total, 1 running, 130 sleeping, 0 stopped, 0 zombie
Cpu0 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu1 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu2 : 1.0%us, 0.0%sy, 0.0%ni, 14.9%id, 79.2%wa, 2.0%hi, 3.0%si, 0.0%st
Cpu3 : 1.0%us, 2.0%sy, 0.0%ni, 0.0%id, 90.0%wa, 0.0%hi, 7.0%si, 0.0%st
Mem: 4033988k total, 4003552k used, 30436k free, 4208k buffers
Swap: 8388600k total, 35956k used, 8352644k free, 1587284k cached
CPUは4コアで、DatNodeも8インスタンス稼動しているのに、実際にはiowaitのせいで、
2つのCPUしかまともに動けてません。
これはHDDが2つ且つ、Raid0の影響だと思います。
まさにディスクI/Oがボトルネックになっています。


今度は以下のコマンドで、okuyamaのデータをOSにキャッシュします。

cat okuyamaのデータファイル > /dev/null
そして同じようにレスポンスを測定。測定中の状態は

top - 20:04:32 up 5 days, 9:49, 2 users, load average: 0.66, 0.53, 0.35
Tasks: 131 total, 1 running, 130 sleeping, 0 stopped, 0 zombie
Cpu0 : 25.7%us, 10.9%sy, 0.0%ni, 58.4%id, 0.0%wa, 0.0%hi, 5.0%si, 0.0%st
Cpu1 : 18.8%us, 20.8%sy, 0.0%ni, 54.5%id, 0.0%wa, 0.0%hi, 5.9%si, 0.0%st
Cpu2 : 19.2%us, 19.2%sy, 0.0%ni, 57.6%id, 0.0%wa, 0.0%hi, 4.0%si, 0.0%st
Cpu3 : 24.8%us, 23.8%sy, 0.0%ni, 26.7%id, 0.0%wa, 1.0%hi, 23.8%si, 0.0%st
Mem: 4033988k total, 4003084k used, 30904k free, 4624k buffers
Swap: 8388600k total, 35956k used, 8352644k free, 1584616k cached
見事にCPUが全て動いていて、iowaitもなくなりました。
スループットは前回をさらに越えて6万6千QPSほど出ました。


この結果からOSのキャッシュが原因でインメモリモードよりもファイル永続化モードのほうが良い結果が
出たことがわかりました。
このことから、OSキャッシュから落ちてしまった場合を想定した取り組みが必要なことがわかりました。
その対応方法の検討はまた後日。
本日はここまで。