【ElastiCache】Redisサーバーのレプリケーションラグについて

## はじめに
初めまして。
オークファンの新卒3年目エンジニアの@isodaです。
今年もアドベントカレンダーの季節がやってきましたね、毎年頭を悩ませていますが。
今回はRedisサーバーのレプリケーション時のタイムラグについて書いていこうと思います。

## 導入
### なぜ今回のテーマにしたのか
私が開発を行った案件で、Redisを用いたAPIの実装を行った際に、レプリケーション時のタイムラグを考慮していなかったせいで、不具合が発生したので、自分の勉強も兼ねてこちらのテーマにしました。

### RedisとAWS ElastiCacheの概要
Redisは、高性能なキー・バリュー型のインメモリデータベースシステムであり、データの読み書きが非常に速いことで知られています。一方、AWS ElastiCacheは、クラウド環境におけるキャッシングソリューションを提供するサービスで、RedisやMemcachedなどの人気あるオープンソースインメモリデータベースをサポートしています。

### レプリケーションの重要性
ElastiCacheのRedisクラスターでは、レプリケーションが重要な役割を果たします。レプリケーションにより、データは複数のノード間で複製され、これによってデータの耐久性と可用性が向上します。レプリケーションを用いることで、単一ノードの障害が発生しても、他のノードがデータのバックアップとして機能し、システムの継続的な運用を支えます。

* プライマリ (Primary): 書き込み操作が行われるメインのノードを指します。このノードに対するデータ変更（追加、更新、削除）は、レプリカノードに自動的に複製されます。
* レプリカ (Replica): プライマリノードからデータを受け取り、それを複製します。レプリカノードは読み取り専用であり、データの耐久性と可用性を向上させるために用いられます。

### タイムラグの問題と影響
レプリケーションプロセスでは「タイムラグ」が発生する可能性があります。これは、プライマリノードでのデータ変更がレプリカノードに反映されるまでの時間差を指します。このタイムラグは、特にリアルタイム性が要求されるアプリケーションにおいては、データ整合性やユーザーエクスペリエンスに影響を与える可能性があります。

## 計測方法
プライマリへの書き込みを行った後に、レプリカでのキーの存在確認を複数回行い、レプリケーションラグを測定する

### 今回使用するRedisクラスター
* エンジンバージョン
    * 7.1.0
* ノードの数
    * 2
* ノードのタイプ
    * cache.t3.micro
* クラスターモード
    * 無効

### タイムラグ計測用のプログラム
```go
package main

import (
	"context"
	"fmt"
	"github.com/go-redis/redis/v8"
	"strconv"
	"strings"
	"time"
)

var ctx = context.Background()

const primaryEndpoint = "XXXXX.0001.apne1.cache.amazonaws.com:6379"
const replicaEndpoint = "XXXXX.0001.apne1.cache.amazonaws.com:6379"

func main() {
	key := "test_key"
	warmUpKey := "warm_up_key"

	performWarmUpRequest(warmUpKey)

	const numTries = 10
	var totalLatency time.Duration
	value := generateFixedString(1)

	for i := 0; i < numTries; i++ {
		redisPrimary := redis.NewClient(&redis.Options{
			Addr: primaryEndpoint,
			ReadTimeout:  100 * time.Second,
			WriteTimeout: 100 * time.Second,
		})
		redisReplicas := redis.NewClient(&redis.Options{
			Addr: replicaEndpoint,
			ReadTimeout:  100 * time.Second,
			WriteTimeout: 100 * time.Second,
		})

		err := redisPrimary.Set(ctx, key+strconv.Itoa(i), value, 0).Err()
		if err != nil {
			panic(err)
		}

		startTime := time.Now()
		for {
			exists, err := redisReplicas.Exists(ctx, key+strconv.Itoa(i)).Result()
			if err != nil {
				panic(err)
			}
			if exists > 0 {
				break
			}
		}
		latency := time.Since(startTime)
		totalLatency += latency
		fmt.Printf("%d回目: %v\n", i+1, latency)

		redisPrimary.Close()
		redisReplicas.Close()
	}

	avgLatency := totalLatency / numTries
	fmt.Printf("平均タイムラグ: %v\n", avgLatency)
}

// ウォームアップリクエストを行う関数
func performWarmUpRequest(warmUpKey string) {
	redisPrimary := redis.NewClient(&redis.Options{
		Addr: primaryEndpoint,
	})
	redisReplicas := redis.NewClient(&redis.Options{
		Addr: replicaEndpoint,
	})

	err := redisPrimary.Set(ctx, warmUpKey, "warm_up_data", 0).Err()
	if err != nil {
		panic(err)
	}
	_, err = redisReplicas.Get(ctx, warmUpKey).Result()
	if err != nil {
		panic(err)
	}
	err = redisPrimary.Del(ctx, warmUpKey).Err()
	if err != nil {
		panic(err)
	}
	redisPrimary.Close()
	redisReplicas.Close()
}

func generateFixedString(sizeInKB int) string {
	return strings.Repeat("a", sizeInKB*1024)
}


```

## 計測結果
### 通常負荷時

```
./a
1回目: 21.758227ms
2回目: 21.165325ms
3回目: 20.15599ms
4回目: 21.816078ms
5回目: 21.688203ms
6回目: 21.763929ms
7回目: 21.680009ms
8回目: 23.025055ms
9回目: 21.304444ms
10回目: 21.436278ms
平均タイムラグ: 21.579353ms
```
平均タイムラグは約21.58msでした。この状況下では、システムは安定しており、比較的一貫したレスポンスタイムが得られました。

### CPU使用率を[cache.t3.microのベースライン性能](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html)程度まで負荷をかけて計測

```
./a
1回目: 22.820318ms
2回目: 24.673663ms
3回目: 40.57443ms
4回目: 23.845245ms
5回目: 23.803492ms
6回目: 24.182028ms
7回目: 21.680567ms
8回目: 22.631332ms
9回目: 22.981919ms
10回目: 26.122212ms
平均タイムラグ: 25.33152ms
```

平均タイムラグが約25.33msに増加しました。これは、CPU負荷が増加することでレスポンスタイムが若干ですが長くなる可能性があります。

### メモリ使用率を100%近くにしてみて計測
```
# Memory
used_memory_human:370.94M
maxmemory_human:384.00M

./a
1回目: 22.515965ms
2回目: 20.040775ms
3回目: 20.690362ms
4回目: 22.220336ms
5回目: 21.000059ms
6回目: 21.034027ms
7回目: 21.519962ms
8回目: 22.504239ms
9回目: 20.817889ms
10回目: 21.483554ms
平均タイムラグ: 21.382716ms
```

平均タイムラグは約21.38msとなり、通常の負荷下と比較して大きな変動は見られませんでした。これは、メモリ使用率が高くても、Redisが効率的に動作を続けられることを示唆しています。

### 10MBのデータセットを使用
```
./a
1回目: 29.172222ms
2回目: 22.437096ms
3回目: 21.212145ms
4回目: 29.249352ms
5回目: 21.98116ms
6回目: 23.7891ms
7回目: 30.167353ms
8回目: 20.628346ms
9回目: 43.714641ms
10回目: 25.150683ms
平均タイムラグ: 26.750209ms
```

平均タイムラグが約26.75msに上昇しました。これは、大きなデータセットがレプリケーションラグに影響を与える可能性があることを示しています。

### 50MBのデータセットを使用
```
./a
1回目: 74.862734ms
2回目: 115.989861ms
3回目: 99.498602ms
4回目: 97.553066ms
5回目: 105.155581ms
6回目: 118.972364ms
7回目: 132.483097ms
8回目: 93.049629ms
9回目: 96.836444ms
10回目: 96.222103ms
平均タイムラグ: 103.062348ms
```

平均タイムラグが約103.06msに大幅に上昇しました。これは、非常に大きなデータサイズがレプリケーションラグを顕著に増加させる可能性を示しています。

## まとめ
今回の計測ではEXISTSコマンドを用いており、キーの存在を迅速に確認するためのものであり、実際のデータ同期の完了を厳密には表しておらず、データの中身まで確認しようと思うと逆にデータ取得のオーバーヘッドが大きくなる為正確なレプリケーションラグは計測できておりません、CloudWatchでのレプリケーションラグの項目も存在しますが正確な値は取れず。
今回の計測でわかった点はRedisのレプリケーションラグは複数の要因によって影響を受けることがわかり。特に、大きなデータセットを扱う場合、レプリケーションラグは顕著に増加する可能性があります。特に大きなデータセットを扱う際には、システムの設計と構成を慎重に考慮する必要があります。
また今回はネットワークに関連する要因については計測が行われていませんが。プライマリとレプリカ間のネットワーク遅延は、レプリケーションラグに直接的な影響を与えるので注意が必要です。