KVS に RDB を slave としてつなげてみる

TokyoTyrant/TokyoCabinet (TT) が動いているマシンが今世界に何台あるのか興味深いところですが、ツイキャスでは現役の KVS として日々多数のクエリーをさばいています。

あらゆるツールがそうであるように TT も得意不得意があり、我々の場合何と言っても今動いている、という現状があります。更新が絶えて久しいので、今から新たに使うデータベースにはなりえないでしょうが、ディスクに書き込むタイプの KVS として速度、運用のしやすさで利点があることは間違いありません。

ここでは TT の不得意な面が露見した障害と、それをカバーするために RDB を使った方法を紹介します。



### 問題発覚

2015年の秋頃、TT を使ったデータベースの1つが応答しなくなる現象が発生しました。

```
ERROR   ttacceptsock failed
SYSTEM  listening restarted
```

ログから推測するに file descriptor が枯渇して止まっていたようなのですが、ulimit の値は十分に大きな値を取っていて、その値に到達してしまうほど異常にファイルのオープン数が増えてしまう原因がわからない状態がしばらく続きました。

いかんせん TT の開発は止まっており、同じ現象に見舞われている報告も見当たらなかったので、`close()` の抜けている箇所があったりしないか少し見てはみましたが、TT のコードに手を入れてどうこうするよりも、この現象が発生しないようアプリケーション側で対策する道が現実的でした。



### 対策

master でしか発生していなかったので書き込み回数を減らすべくデータの一部分を別データベースに分離したり、MySQL で言うところの slow log 的なものをアプリケーション側で実装したりして原因を特定していき、結論としては、TT はどうも secondary index を使ったソートを伴うクエリーが重い、ということを学びました。TT は Redis の hash と同じように primary key に対して複数の key-value のペアを保存でき、そのうちの任意のキーにインデックスが張れます。通常問題はないのですがクエリーの結果が肥大化すると時間がかかるようになり、その状態で多数のクエリーを投げると応答しなくなることがあるようです。

ローカルでは1回目のクエリーに時間がかかるところまでは再現が取れましたが、2回目以降は TT 自体の機能なのかキャッシュが効いているようで、固まる状態は作れませんでした。運用環境相当の書き込みを含んだ高負荷状態での実験はできなかったのではっきりした原因は何とも言えません。

根本的な解決にはいたりませんでしたが、クエリーが殺到しない工夫をすることで障害にいたる事態は回避できるようになりました。



### 残った問題

一部の機能はデータの構造上、どうしても重いクエリーを投げざるを得ず、一定秒数以上かかるクエリーが慢性的にログに登場していました。TT をまるっと MySQL か何かに置き換えてインデックスを張れれば解決するかもしれませんが、なかなかそうもいきません。

RDB に `create table users ( id varchar(128) primary key, data blob )` のようなテーブルがあって、`data` に JSON を insert していたとして、さて JSON のプロパティーで例えば `created_at` が一定の範囲の全 id を高速で取得したくなった、というのと状況は似ていると思います。



### RDB as Slave

アプリケーション側に大きな変更を加えずに今後どんな TT のクエリー、テーブルでも対応できる方法を考えてみました。前提として、当該の重いクエリーは少々遅延があって最新のデータを返さなくても大丈夫です。いろいろとやり方はありそうですが、複合インデックスを使ったクエリーを安定した速度で返してくれそうなのは RDB だったので、TT のデータを RDB にマッピングできないか試してみました。

TT には master/slave の機能があり、問題のデータベースもたくさん slave を持っています。slave は master に投げられた更新系のクエリーをバイナリファイルのログ (TT は `ulog` (update log) と呼んでいます) として生成していきます。sharding されていると複雑になりますがこのデータベースの場合、分散なしの1つです。新たに1つ slave を用意することで、安全に実験できる環境が作れます。

続いて、この ulog ファイルを監視して更新があれば読み込み、パースするプログラムを書きました。ulog の中身は TT のコードを少し読めばわかるシンプルな構造です。

```go
func parseCommandPut(c string, f *os.File, numElements uint32) (Row, error) {
	var row Row
	row.Key = readString(f)
	row.Value = make(map[string]string)
	row.CommandName = c

	for i := uint32(0); i < (numElements-1)/2; i++ {
		key := readString(f)
		value := readString(f)
		row.Value[key] = value
	}
	return row, nil
}
```

ここまでできればあとは RDB の世界にはめ込められるよう TT の primary key + hash を `create table` で作ったテーブルのカラム名や型にマッピングしてやるだけです。SQL で insert (実際には `replace into ...`) できるようになります。想定していたスキーマでは収まらないデータがあって何度か `truncate table` しましたが ulog を1から読んでも insert していくスピードの方が新しい ulog のデータが入ってくるより十分速かったので1日も待てば追いつく感じでした。

先の `created_at` の例で言うと `create index users (created_at)` すれば `select * from users where created_at between '2016-01-01' and '2016-02-01' order by created_at desc limit 100` で安全に結果を取得できます。TT では重くて投げられなかった複数のカラムにまたがった条件文もインデックス次第で投げられるようになりました。

なお、今回選んだのは PostgreSQL でしたが必要になるストレージのサイズなどを確認するため MySQL (InnoDB) 等他にも試しました。



### まとめ

KVS の master に RDB を slave としてくっつける事例を紹介しました。failover 等々いろいろ実装して複雑化していくことを考えると TT を丸ごと RDB に置き換えた方が後々幸せになれる可能性は否定しませんが、ユーザーに影響を与えることなくマイグレーションを実施するのは骨が折れるので実際やるとなると難しいところです。

とても汎用的な手法とは言えないので他の方の参考になる自信はありませんが、特定の問題を最小限の労力で解決する、という意味でうまくいった例ではあったと思います。こんなやり方もあるのでは、等々あればぜひ教えてください。