Redisクラスターで読み取りクエリをプライマリとレプリカで負荷分散したい

# はじめに
この記事は朝日新聞社 Advent Calendar 2023の12日目の記事です。🎉

Redis一般の話をしますが、ここではAWS EalastiCache for Redisを例にお話しします。

# 一般的なElastiCache for Redisの構成
ElastiCacheをクラスターモードで運用する場合、writerインスタンスに対し5台までのreadインスタンスを配置することが一般的と思います。

https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/red-ug/Replication.Redis-RedisCluster.html

```mermaid


flowchart

%%グループとサービス
subgraph GC[AWS]
  subgraph GV[vpc]
    subgraph OU1[App]
    end
    subgraph GS1[shard-1]
        NW1{{"Redis<br>primary"}}
        NW2{{"Redis<br>replica"}}
        NW3{{"Redis<br>replica"}}
    end
    subgraph GS2[shard-2]
        NW4{{"Redis<br>primary"}}
        NW5{{"Redis<br>replica"}}
        NW6{{"Redis<br>replica"}}
        NW9{{"Redis<br>replica"}}
    end
    subgraph GS3[shard-n]
        NW7{{"Redis<br>primary"}}
        NW8{{"Redis<br>replica"}}
    end
  end
end

%%サービス同士の関係
OU1 --> NW1
OU1 --> NW4
OU1 --> NW7



%%グループのスタイル
classDef SGC fill:none,color:#a51,stroke:#a51
class GC SGC

classDef SGV fill:none,color:#0a0,stroke:#0a0
class GV SGV

classDef SGPrS fill:#def,color:#07b,stroke:none
class GS2 SGPrS

classDef SGPuS fill:#efe,color:#092,stroke:none
class GS1 SGPuS

classDef SGPuS fill:#efe,color:#092,stroke:none
class GS3 SGPuS

classDef SGPrS fill:#def,color:#07b,stroke:none
class AZ3 SGPrS

classDef SGPuS fill:#efe,color:#092,stroke:none
class AZ2 SGPuS

classDef SGPuS fill:#efe,color:#092,stroke:none
class AZ1 SGPuS

%%サービスのスタイル
classDef SOU fill:#aaa,color:#fff,stroke:#fff
class OU1 SOU

classDef SNW fill:#84d,color:#fff,stroke:none
class NW1 SNW

classDef SNW fill:#84d,color:#fff,stroke:none
class NW4 SNW

classDef SNW fill:#84d,color:#fff,stroke:none
class NW7 SNW

classDef SCP fill:#e83,color:#fff,stroke:none
class NW2 SCP

classDef SCP fill:#e83,color:#fff,stroke:none
class NW3 SCP

classDef SCP fill:#e83,color:#fff,stroke:none
class NW5 SCP

classDef SCP fill:#e83,color:#fff,stroke:none
class NW6 SCP

classDef SCP fill:#e83,color:#fff,stroke:none
class NW8 SCP

classDef SCP fill:#e83,color:#fff,stroke:none
class NW9 SCP

classDef SOU fill:#aaa,color:#fff,stroke:#fff
class OU1 SOU

```



# Redis構成の課題
この構成の際、レプリカインスタンス間、プライマリとレプリカインスタンス間でそれぞれ負荷分散が行われることを想定すると思います。
しかし、実際には単純に`get`コマンドを発行するだけだと、クラスターエンドポイントをアプリケーションで読み込んでも負荷分散は行われません。
readインスタンスへ送られるクエリは、全てwriteインスタンスにリダイレクトされます。

使用しているElastiCacheをAmazon Cloudwatch上でメトリクスを確認すると、`GetTypeCmds`がprimaryの数しか存在していないことがわかります。

![スクリーンショット 2023-11-29 22.45.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269233/88a46e4c-f695-645f-33b5-0db21ddf620c.png)

メトリクス上でも、読み取りのクエリは全てプライマリインスタンスに向けて発行されていそうです。

※GetTypeCmdsメトリクスは、クラスター内で発行された読み取り系コマンドの合計数を出すメトリクスです。

https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/red-ug/CacheMetrics.Redis.html

### 原因
Redisをクラスターモードで動作させる場合、クエリは全て各種シャードのプライマリに対して送られます。
そのため、レプリカに対してクエリが発行された場合はリダイレクトによってプライマリインスタンスに対して送られます。

https://redis.io/docs/reference/cluster-spec/#scaling-reads-using-replica-nodes


### 解決策
Redisリファレンスのクラスターモードの解説にある通りですが、この挙動は`READONLY`コマンドを発行することで無効化することができます。

例えば、goアプリケーションでgo-redisでの実装の場合、`cluster.go`の`ClusterOptions`構造体に`Readonly`のプロパティがあるのでこちらを有効にしましょう。


```go
client := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs:         []string{addr},
	ReadOnly:      true, // READONLYコマンドの発行
})
```


https://github.com/redis/go-redis/blob/30a6f7107e9e633cef70b3b104aa1c6eb2db751b/cluster.go#L44


なお、余談ですが、go-redisには`RouteByLatency`と`RouteRandomly`のオプションが用意されていて、レプリカの優先度をレイテンシ優先かランダムアクセスかで制御できます。


```go
client := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs:             []string{addr},
	ReadOnly:          true, // READONLYコマンドの発行
    // RouteByLatency: true, // READONLYコマンド発行時、レイテンシ低いレプリカにアクセス
	// RouteRandomly:  true, // READONLYコマンド発行時、ランダムにレプリカにアクセス
})

```


# まとめ
Redisのドキュメントを読めば書いてあることではありますが、携わるプロダクトでRedisの負荷分散ができていないものがあったので、気になって調べました。


この案内はAWSのre:Postにも投稿されていましたが、私は調査して初めて知りました。
https://repost.aws/ja/knowledge-center/elasticache-redis-client-readonly

クラスターモードを有効にする際、レプリカで負荷分散したい場合は`READONLY`を有効にしてみてください。

# 参考

https://redis.io/docs/reference/cluster-spec

https://repost.aws/ja/knowledge-center/elasticache-redis-client-readonly

# おまけ
本項ではレプリカにも読み取りクエリを発行することでプライマリとの負荷分散を意図するものですが、逆にプライマリにしか意図的にクエリを発行させないコマンド（既存のREADONELYを無効化するもの）として、`READWRITE`コマンドも存在します。

https://redis.io/commands/readwrite/