Ruby から Redis Cluster を使うための gem を作ってみた

## 概要

もう半年くらい前のことになりますが、ElastiCache で Redis の 3.2 系がサポートされ、Redis Cluster が使えるようになりました (https://aws.amazon.com/jp/blogs/news/amazon-elasticache-for-redis-update-sharded-clusters-engine-improvements-and-more/ )。これで書き込みを大量にするケースでもスケールアウトできるし、最大で 3.5TiB のメモリ上にデータをのせることができるようになりました。

早速 Ruby で使ってみようと思ったら Ruby 用のまともな Redis Cluster の gem が見つからなかったので作ってみました。この記事では簡単に使い方を紹介してみようかと思います。

## Renoir

`redis-cluster` という gem 名がすでに取られていたので、Memcached のそこそこ有名な gem である `dalli` を微妙に意識しつつ `renoir` という名前にしました。

Gem: https://rubygems.org/gems/renoir
GitHub: https://github.com/saidie/renoir

### Renoir がやってくれること

例えば、`gem install renoir` した後、こんな感じで使えます。

```ruby
require 'renoir'

rc = Renoir::Client.new(cluster_nodes: ['127.0.0.1:30001'])

p rc.set('hoge', 123, nx: true)
p rc.zrange('fuga', 0, -1, with_scores: true)
```

127.0.0.1:30001 がクラスタの一つのノードのアドレスです。クラスタは例えば http://qiita.com/uryyyyyyy/items/fc767f7f41144e5f10a1 に載っている create-cluster スクリプトを使うと簡単に立ち上げられます。

上記コードでは `hoge` というキーに値をセットし、`fuga` というキーの sorted-sets から全要素とスコアを取得しています。シングルインスタンスの Redis にこれらのコマンドを投げると、普通にそのインスタンスがコマンドを処理してレスポンスを返してくるわけですが、Redis Cluster の場合はキー毎にそれを担当する Redis のインスタンス (ノード) が異なっているので、コマンドを投げるべきノードを同定する必要があります。間違ったノードにコマンドを投げると、正しいノードへのリダイレクト指示 (`MOVED` レスポンス) が返ってきます。また、ノード追加に伴う resharding などでキーをノード間で移行したりすることがあるのですが、この最中はあるキーを見つけるために移行先と移行元の両方のノードに問い合わせる必要があったりします。

で、Renoir はこれら

- キー (正確にはキースロット) とノードの割当情報の管理
- リダイレクトのハンドリング
- 移行中のキーのハンドリング

をよしなにやってくれるというわけです。

### `redis` gem との互換性

個人的に Ruby から Redis を使う場合は `redis` gem をよく使っており、同じように使えるようにしたいなと思ったので、`Renoir::Client` は `::Redis` と「できるだけ」互換性のあるインターフェースを提供するという設計方針で実装しました。また、内部的なコネクションとしても `::Redis` インスタンスを使っています。というわけで、`redis` と同様にドライバとして `hiredis` を使ったりもできます。`redis` gem は割とメジャーだと思うので、学習コストほぼなく使えるのではないかと思います。

ちなみに、このあたりはアダプタ (`Renoir::ConnectionAdapters::` 系) で吸収しているので、例えば `redic` 向けのアダプタを提供することもできます (そのうち実装するかも？)

注意点としては、`::Redis` の全てのメソッドが同じように使えるわけではない、ということです。すなわち、コマンドのキーからノードを特定できない場合は、コマンドの送り先が不定なので `Renoir::Client` はエラーを返します。例えば、

- `MSET`, `MGET` などで複数キーを指定して、それらのキースロットが異なる場合
    - 全部同一キースロットなら OK なので、[キーハッシュタグ](https://redis.io/topics/cluster-spec#keys-hash-tags) を使って下さい
- `KEYS`, `INFO` などそもそもキーがないコマンド
    - ただし、`KEYS` に関してはパターンにキーハッシュタグが明示的に含まれてたら良しとするかもしれないです

などです。後者のケースは、`Renoir::Client#each_node` が全てのノードの `::Redis` インスタンスを返すので、それを使って置き換えることはできます。

## 実際に使う時

gem をテストしている時に遭遇したいくつかの例外を考慮して、以下のような感じでコードを書くのが良さそうです。

```ruby
rc = Renoir::Client.new(cluster_nodes: ['127.0.0.1:30001'])
begin
  rc.set('hoge', 123, nx: true)
rescue Redis::CannotConnectError
  # そもそもノードに繋がらないケース。そのうち直るかもしれないのでリトライしてみるといいかも
rescue Redis::ConnectionError, Redis::TimeoutError
  # 繋がったんだけど途中で切れたケース。コマンドが既に届いてる可能性があるからリトライしない方がいいかも
rescue Redis::CommandError => e
  if /CLUSTERDOWN/.match(e.message)
    # failover 中などクラスタの状態が不完全な時に返ってくる。そのうち直るはずなのでリトライすると良さげ
  end
  raise
rescue Renoir::RedirectionError
  # ノード間のクラスタ情報の差異が大きいと起こる可能性があるけど低確率だと思う
  # クラスタに存在しなかったキーのスロットが移行中で起きる(たぶん)ケースが多いはず
  # 数回リトライしてもいいかも
end
```

## まとめ

`redis` gem と同じような手触りで使うことのできる Redis Cluster 用の gem の紹介でした。より詳しくはドキュメント http://www.rubydoc.info/gems/renoir とかを見ていただければと思います。

ちなみに、このコードは Redis の作者である antirez 氏のリファレンス実装 https://github.com/antirez/redis-rb-cluster をベースにしていろいろ整理して gem にしたものになっています。