Carpe Diem

備忘録

go-redisのRingの挙動を検証してみる

概要

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で分散アクセスします。

f:id:quoll00:20211029042048p:plain:w400

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は使えないものと考える
  • ノード名の変更をするとハッシュテーブルのマッピングが変わるため、これまで保存したデータの一部にはアクセスできなくなる
  • サーバをスケールアウト・スケールダウンするとハッシュテーブルのマッピングが変わるため、これまで保存したデータの一部にはアクセスできなくなる
  • サーバが死ぬと一時的にエラーが発生するが、少しすると対象から外されエラーは収まる。

Redis ClusterかRedis Ringか

Redis ClusterもConsistent Hashingを使っており、分散ロジック自体は大きくは変わりません。
ざっくり比較するとこんな感じです。

Redis Cluster Redis Ring
水平スケール
データマイグレーション
構築・運用コスト

もしパブリッククラウドでRedis Clusterのマネージドサービスがあればそれを使うのが良いですし、それが無くて運用コストを下げたいのであればRedis Ringを選択肢に入れると良さそうです。

参考