go-redisでRedisClusterの負荷分散を実現するためのアプリケーション実装

## はじめに

本記事は「[QualiArts Advent Calender 2024](https://qiita.com/advent-calendar/2024/qualiarts)」の24日目の記事になります。

23新卒のバックエンドエンジニアの苅谷です。
大学・大学院では機械学習を専攻し、入社後、モバイルゲームのバックエンドエンジニアとして働いています。

昨年の「[QualiArts Advent Calender 2023](https://qiita.com/advent-calendar/2023/qualiarts)」では、Google Cloud Platformから昨年GAされたRedis Cluster for Memorystoreを紹介しました。今回はその延長線上で、Redis Clusterの負荷分散をするためのアプリケーション実装について紹介をします。

## Redis Clusterでできる負荷分散

Redis Clusterは、最低3シャードから構築することができます。1シャードでは、1シャードは1つのマスターノードと複数のレプリカノードから構築することができます。

この構成から、Redis Clusterでできる負荷分散として以下の2点が挙げられます。
- キーの自動分散による負荷分散
  - Redis Clusterでは、キーは決定論的なハッシュ関数を用いて、どのシャードに配置するかを決定します。キーの配置は各シャードにほぼ均等に配置されます。これにより、読み込みと書き込みの双方の負荷分散を実現することはできます。
- レプリカノードを用いた負荷分散
    - 1シャードでリードレプリカを複数個のノードを構築することができます。これにより、読み込みの負荷分散を実現することができます。


負荷分散をする場合、シャードを増やすことで、読み込みと書き込みの双方の負荷分散を実現でき、かつ、Redis Cluster for Memorystoreを利用した場合、ダウンタイムなしで、シャード数を増減させることができます。非常にお手軽です。

さらなる負荷軽減のために、アプリケーション上で、書き込み時はマスターノード、読み込み時はレプリカノードにアクセスするように実装します。これにより読み込みと書き込みの負荷分散ができます。

ここでいくつか注意したいことがあります。
- レプリカノードはマスターノードからのレプリケーション遅延があります。アプリケーションでこの遅延を許容できないビジネスロジックの場合は、読み取り時でもマスターノードにアクセスする必要があります。
- Redis Cluster for Memorystore は高価なため、コスト削減のために、アクセスが少ない開発環境では、一般的なRedis Instanceでもアプリケーション側のコードを変更することなく適応できるようにしていく必要があります。

以降では、上記の点に注意を払いながら、レプリカノードを使った負荷分散を実現するためのクライアント実装を紹介します。


## go-redisを用いて実装
満たしたい要件
- 書き込みはマスターノードにアクセスする
- 読み込みはレプリカノードにアクセスする
- 読み込みでも、アプリケーションロジックで、レプリケーション遅延を許容できるか選択できるようにする
- RedisとRedis Clusterの双方に対応する


GoのRedisクライアントは`"github.com/redis/go-redis/v9"`を使用します。


まずはクライアントを作成するコードを見ていきましょう。
```go
type client struct {
	cmd         redis.Cmdable
	readOnlyCmd redis.Cmdable
}

type Client interface {
    Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error
    Get(ctx context.Context, key string, useReadReplica bool) (string, error)
	Close() error
}

func NewRingClient(addrs []string) Client {
	addrMap := make(map[string]string, len(addrs))
	for _, addr := range addrs {
		addrMap[addr] = addr
	}
	c := redis.NewRing(&redis.RingOptions{
		Addrs: addrMap,
	})
	return &client{
		cmd:         c,
		readOnlyCmd: c,
	}
}

func NewClusterClient(addrs []string) Client {
	c := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs:        addrs,
	})
	readOnlyClient := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs:    addrs,
		ReadOnly: true,
	})
	return &client{
		cmd:         c,
		readOnlyCmd: readOnlyClient,
	}
}
```
ここで、Clientインターフェイスをみたす通常のRedis Instanceに接続するクライアントと、Redis Clusterに接続するクライアントを二つ実装します。
`client`構造体では、マスターノードにアクセスするクライアント(`cmd`)と、レプリカノードにアクセスするフライアント(`readOnlyCmd`)を持っており、場合の応じて使い分けることができます。
開発環境などで、Redis Clusterではなく、Redis Instanceを使用する場合でも、アプリケーションコードで変更が生じないように、`cmd`と`readOnlyCmd`に同じクライアントを代入するようにします。

以下で、その例を見ていきましょう。


```go
func (c *client) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
	if err := c.cmd.Set(ctx, key, value, ttl).Err(); err != nil {
		return err
	}
	return nil
}
```

```go
func (c *client) Get(ctx context.Context, key string, useReadReplica bool) (string, error) {
	var cmd *redis.StringCmd
	if useReadReplica {
		cmd = c.readOnlyCmd.Get(ctx, key)
	} else {
		cmd = c.cmd.Get(ctx, key)
	}
	if !errors.Is(cmd.Err(), redis.Nil) {
		return "", cmd.Err()
	}
	return cmd.String(), nil
}
```

`Set()`のような更新系のコマンドを実行する際は、マスターノードにアクセスするクライアントでアクセスを行い、`Get()`のような取得系のコマンドは、引数からレプリカを使用するかどうかのフラグを引数にして、ロジックの切り替えれるようにします。

## まとめ

ここまでにRedis Clusterを使用する場合で、アプリケーション側でできる負荷分散の工夫を紹介しました。参考になれましたら幸いです！