概要
Redis Clusterが生まれるまではRedisの水平スケール手段としては前回紹介した
Consistent Hashing (コンシステントハッシュ法) - Carpe Diem
を用いた手法が使われていました。
これはRedis Ringと呼ばれる形でいくつかのライブラリでサポートされており、Goでもgo-redisでサポートされているので検証してみました。
環境
- Redis v6.2.6
- Go v1.7.2
- go-redis v8.11.4
検証
構成
以下のような独立したRedisサーバ3台に対してConsistent Hashingで分散アクセスします。
docker-compose.yml
上記構成をdocker-composeで用意します。
version: "3" services: redis1: image: redis:6.2.6 ports: - "6379:6379" redis2: image: redis:6.2.6 ports: - "6380:6379" redis3: image: redis:6.2.6 ports: - "6381:6379"
コード
以下のように簡単な検証コードを用意します。
package main import ( "context" "errors" "fmt" "log" "time" "github.com/go-redis/redis/v8" ) func initialize() *redis.Ring { r := redis.NewRing(&redis.RingOptions{ Addrs: map[string]string{ "shard1": "localhost:6379", "shard2": "localhost:6380", "shard3": "localhost:6381", }, }) return r } func main() { r := initialize() defer r.Close() err := write(r) if err != nil { log.Fatal(err) } get(r) } func write(r *redis.Ring) error { for i := 0; i < 10000; i++ { err := r.Set(context.Background(), fmt.Sprintf("%d", i), i, 10*time.Hour).Err() if err != nil { return err } } return nil } func get(r *redis.Ring) { var noHitCount int var errCount int for i := 0; i < 10000; i++ { err := r.Get(context.Background(), fmt.Sprintf("%d", i)).Err() if err != nil { if errors.Is(err, redis.Nil) { noHitCount++ } else { errCount++ } } } fmt.Printf("no hit: %d\n", noHitCount) fmt.Printf("err count: %d\n", errCount) }
動作確認
データは分散されるか
10,000件のデータを投入したところ
ring $ redis-cli -p 6379 info keyspace # Keyspace db0:keys=3305,expires=3305,avg_ttl=35805353 ring $ redis-cli -p 6380 info keyspace # Keyspace db0:keys=3338,expires=3338,avg_ttl=35888734 ring $ redis-cli -p 6381 info keyspace # Keyspace db0:keys=3357,expires=3357,avg_ttl=35952814
このようにほぼ均等に分散されました。
MGETは成功するか
Redis Clusterの場合、複数のノードにまたがるキーをMGETで取得しようとするとコケてしまいます。
そのため{foo}hoge
, {foo}fuga
といったように{}
を使うことで同じノードに配置されるワークアラウンドがあります。
あらかじめ各ノードに分散されたキー(今回だとshard1: 2144
, shard2: 2357
, shard3: 3021
)を見つけておき、以下のようなコードを用意すると
// GETで取得 k1, err := r.Get(context.Background(), "2144").Result() if err != nil { log.Fatal(err) } fmt.Println("key1: ", k1) k2, err := r.Get(context.Background(), "2357").Result() if err != nil { log.Fatal(err) } fmt.Println("key2: ", k2) k3, err := r.Get(context.Background(), "3021").Result() if err != nil { log.Fatal(err) } fmt.Println("key3: ", k3) // MGETで取得 res, err := r.MGet(context.Background(), "2144", "2357", "3021").Result() if err != nil { log.Fatal(err) } for i, v := range res { fmt.Printf("mget key%d: %s\n", i+1, v) }
結果はこうなります。
ring $ go run main.go key1: 2144 key2: 2357 key3: 3021 mget key1: 2144 mget key2: %!s(<nil>) mget key3: %!s(<nil>)
つまりエラーにはなりませんが、1つのノードのデータしか取得できないようです。
しかしRedis Clusterと同様に、ハッシュタグ{}
を用いれば同じノードに配置されるので、MGETでも取得できます。
登録するノード名を変更すると既存のデータは取得できるのか
以下のように変更してみます。
import ( func initialize() *redis.Ring { r := redis.NewRing(&redis.RingOptions{ Addrs: map[string]string{ - "shard1": "localhost:6379", + "shard4": "localhost:6379", "shard2": "localhost:6380", "shard3": "localhost:6381",
ring $ go run main.go
no hit: 3344
この結果から
- 取得できないデータが1/3ほどでた
- shard1だけでなくshard2, shard3にあるデータも一部アクセスできなくなった
ということが分かります。
ノードを追加すると既存のデータは取得できるのか
docker-composeでコンテナを増やし、
services: image: redis:6.2.6 ports: - "6381:6379" + redis4: + image: redis:6.2.6 + ports: + - "6382:6379"
RingのAddrsも追加してみます。
func initialize() *redis.Ring {
"shard1": "localhost:6379",
"shard2": "localhost:6380",
"shard3": "localhost:6381",
+ "shard4": "localhost:6382",
},
})
return r
そして取得をしてみます。
ring $ go run main.go
no hit: 2579
このようにConsistent Hashingで見るノードが変化したため、先程保存したデータにアクセスできなくなりました。
疎通できないサーバが発生するとどうなるか
途中でサーバが落ちたらどうなるでしょうか
ring $ docker stop ring_redis2_1
ring_redis2_1
するとエラーが何件が発生しますが、すぐにConsistent Hashingのノード対象から外されてエラーが収まりました(=Not Foundになった)。
ring $ go run main.go redis: 2021/10/29 03:22:46 ring.go:325: ring shard state changed: Redis<localhost:6380 db:0> is down no hit: 3324 err count: 14
これはハートビートがConfigで用意されており、デフォルトだと500ミリ秒の設定になっているためです。
if opt.HeartbeatFrequency == 0 { opt.HeartbeatFrequency = 500 * time.Millisecond }
https://github.com/go-redis/redis/blob/v8.11.4/ring.go#L103-L105
まとめ
検証から以下のことが分かりました。
- データは均等に分散されるため水平スケールが可能
- ただしMGETは使えないものと考える
- ハッシュタグ
{}
を使えばMGETも可能
- ハッシュタグ
- ただしMGETは使えないものと考える
- ノード名の変更をするとハッシュテーブルのマッピングが変わるため、これまで保存したデータの一部にはアクセスできなくなる
- サーバをスケールアウト・スケールダウンするとハッシュテーブルのマッピングが変わるため、これまで保存したデータの一部にはアクセスできなくなる
- サーバが死ぬと一時的にエラーが発生するが、少しすると対象から外されエラーは収まる。
Redis ClusterかRedis Ringか
Redis ClusterもConsistent Hashingを使っており、分散ロジック自体は大きくは変わりません。
ざっくり比較するとこんな感じです。
Redis Cluster | Redis Ring | |
---|---|---|
水平スケール | ◯ | ◯ |
データマイグレーション | ◯ | ✗ |
構築・運用コスト | ✗ | ◯ |
もしパブリッククラウドでRedis Clusterのマネージドサービスがあればそれを使うのが良いですし、それが無くて運用コストを下げたいのであればRedis Ringを選択肢に入れると良さそうです。