---
id: 1c72ae6efee059c2c727ea53b56bf6c5
author: snowmerak
title: クライアントサイドキャッシュ(Redis)によるリアクティビティの向上
description: Redis Server Assisted Client Side Cacheを用いたAPI応答性の向上
language: ja
date: 2024-11-29T00:51:27.525566863Z
path: /blog/posts/improving-responsiveness-with-redis-client-side-caching-zb711e502
category: database
---

## Redisとは何か

Redisをご存じない方はあまりいないと思います。しかし、いくつかの特性について簡単に触れてから先へ進むとすれば、次のように整理できるでしょう。

- **シングルスレッド**で演算が実行されるため、すべての演算が**原子性**を持ちます。
- **In-Memory**でデータが保存され演算されるため、すべての演算が**高速**です。
- Redisはオプションに応じて**WAL**を保存することができ、最新の状態を迅速に**バックアップ**し、**復旧**することができます。
- Set、Hash、Bit、Listなどの**様々な型**をサポートしており、高い**生産性**を持ちます。
- **大規模なコミュニティ**を持っているため、多様な経験、課題、解決方法を**共有**してもらうことができます。
- **長期間にわたり開発および運用**されてきたため、信頼できる**安定性**があります。

## では本題へ

### 想像してみてください

もし皆さんのサービスのキャッシュが次の二つの条件に当てはまるとしたらどうでしょうか。

1. 頻繁に照会されるデータを最新の状態でユーザーに提供しなければならないが、更新が不規則であるため、キャッシュの更新を頻繁に行う必要がある場合
2. 更新は行われないが、同一のキャッシュデータに頻繁にアクセスして照会しなければならない場合

最初のケースとしては、ショッピングモールのリアルタイム人気ランキングが考えられます。ショッピングモールのリアルタイム人気ランキングをsorted setで保存した場合、ユーザーがメインページにアクセスするたびにRedisから読み取るのは非効率です。  
二番目のケースは為替データに関するものです。おおよそ10分周期で為替データが告示されるにもかかわらず、実際の照会は非常に頻繁に発生します。特にウォン-ドル、ウォン-円、ウォン-元については、非常に頻繁にキャッシュを照会することになります。  
このようなケースでは、APIサーバーがローカルに別途キャッシュを保持しておき、データが変更された場合にRedisを再度照会して更新する方が効率的な動作となるでしょう。

では、データベース - Redis - APIサーバーの構造において、このような動作をどのように実装できるのでしょうか??

### Redis PubSubでは実現できないか？

> キャッシュを使用する際、更新の有無を受け取れるチャネルを購読しよう！

- そのためには、更新時にメッセージを送信するロジックを作成しなければなりません。
- PubSubによる追加の動作が加わるため、性能に影響を与えます。

![pubsub-write](/assets/images/redis-client-side-cache/01-pubsub-write.png)

![pubsub-read](/assets/images/redis-client-side-cache/01-pubsub-read.png)

### では、Redisが変更を検知するとしたら？

> Keyspace Notificationを使用して、該当キーに対するコマンドの通知を受け取るとしたら？

- 更新に使用されるキーとコマンドを事前に保存し、共有しなければならないという煩わしさがあります。
- 例えば、あるキーについては単純なSetが更新コマンドであり、あるキーではLPush、あるいはRPushやSAddおよびSRemが更新コマンドになるなど、複雑になります。
- これは開発過程でのコミュニケーションミスやコーディング上のヒューマンエラーを発生させる可能性を大幅に高めます。

> Keyevent Notificationを使用して、コマンド単位で通知を受け取るとしたら？

- 更新に使用されるすべてのコマンドに対する購読が必要です。そこから入ってくるキーについて適切なフィルタリングが必要になります。
- 例えば、Delで入ってくるすべてのキーのうち一部については、そのクライアントがローカルキャッシュを保持していない可能性が高いです。
- これは不要なリソースの浪費につながる可能性があります。

### そこで必要になるのがInvalidation Messageです！

#### Invalidation Messageとは何か？

Invalidation MessagesはRedis 6.0から追加されたServer Assisted Client-Side Cacheの一環として提供される概念です。Invalidation Messageは次の流れで伝達されます。

1. ClientBがすでにkeyを一度読み取ったと仮定します。
2. ClientAが該当keyを新たに設定します。
3. Redisは変更を検知し、ClientBにInvalidation Messageを発行して、ClientBにキャッシュを削除するよう通知します。
4. ClientBは該当メッセージを受け取り、適切な措置を講じます。

![invalidation-message](/assets/images/redis-client-side-cache/02-invalidation-message.png)

### どのように使うのか

#### 基本動作の構造

Redisに接続されたクライアントが`CLIENT TRACKING ON REDIRECT <client-id>`を実行することで、invalidation messageを受け取るようにします。そして、メッセージを受け取る必要のあるクライアントは`SUBSCRIBE __redis__:invalidate`を実行して、invalidation messageを購読します。

#### default tracking

```shell
# client 1
> SET a 100
```

```shell
# client 3
> CLIENT ID
12
> SUBSCRIBE __redis__:invalidate
1) "subscribe"
2) "__redis__:invalidate"
3) (integer) 1
```

```shell
# client 2
> CLIENT TRACKING ON REDIRECT 12
> GET a # tracking
```

```shell
# client 1
> SET a 200
```

```shell
# client 3
1) "message"
2) "__redis__:invalidate"
3) 1) "a"
```

#### broadcasting tracking

```shell
# client 3
> CLIENT ID
12
> SUBSCRIBE __redis__:invalidate
1) "subscribe"
2) "__redis__:invalidate"
3) (integer) 1
```

```shell
# client 2
CLIENT TRACKING ON BCAST PREFIX cache: REDIRECT 12
```

```shell
# client 1
> SET cache:name "Alice"
> SET cache:age 26
```

```shell
# client 3
1) "message"
2) "__redis__:invalidate"
3) 1) "cache:name"
1) "message"
2) "__redis__:invalidate"
3) 1) "cache:age"
```

## 実装！実装！実装！

### Redigo + Ristretto

このように説明するだけでは、実際にコード上でどのように使えばよいか曖昧です。そこで、簡単に`redigo`と`ristretto`で先に構成してみましょう。

まず二つの依存関係をインストールします。

- `github.com/gomodule/redigo`
- `github.com/dgraph-io/ristretto`

```go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/dgraph-io/ristretto"
	"github.com/gomodule/redigo/redis"
)

type RedisClient struct {
	conn  redis.Conn
	cache *ristretto.Cache[string, any]
	addr  string
}

func NewRedisClient(addr string) (*RedisClient, error) {
	cache, err := ristretto.NewCache(&ristretto.Config[string, any]{
		NumCounters: 1e7,     // キーの頻度を追跡する数 (10M)。
		MaxCost:     1 << 30, // キャッシュの最大コスト (1GB)。
		BufferItems: 64,      // Getバッファごとのキー数。
	})
	if err != nil {
		return nil, fmt.Errorf("failed to generate cache: %w", err)
	}

	conn, err := redis.Dial("tcp", addr)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to redis: %w", err)
	}

	return &RedisClient{
		conn:  conn,
		cache: cache,
		addr:  addr,
	}, nil
}

func (r *RedisClient) Close() error {
	err := r.conn.Close()
	if err != nil {
		return fmt.Errorf("failed to close redis connection: %w", err)
	}

	return nil
}
```

まず簡単に、ristrettoとredigoを含む`RedisClient`を作成します。

```go
func (r *RedisClient) Tracking(ctx context.Context) error {
	psc, err := redis.Dial("tcp", r.addr)
	if err != nil {
		return fmt.Errorf("failed to connect to redis: %w", err)
	}

	clientId, err := redis.Int64(psc.Do("CLIENT", "ID"))
	if err != nil {
		return fmt.Errorf("failed to get client id: %w", err)
	}
	slog.Info("client id", "id", clientId)

	subscriptionResult, err := redis.String(r.conn.Do("CLIENT", "TRACKING", "ON", "REDIRECT", clientId))
	if err != nil {
		return fmt.Errorf("failed to enable tracking: %w", err)
	}
	slog.Info("subscription result", "result", subscriptionResult)

	if err := psc.Send("SUBSCRIBE", "__redis__:invalidate"); err != nil {
		return fmt.Errorf("failed to subscribe: %w", err)
	}
	psc.Flush()

	for {
		msg, err := psc.Receive()
		if err != nil {
			return fmt.Errorf("failed to receive message: %w", err)
		}

		switch msg := msg.(type) {
		case redis.Message:
			slog.Info("received message", "channel", msg.Channel, "data", msg.Data)
			key := string(msg.Data)
			r.cache.Del(key)
		case redis.Subscription:
			slog.Info("subscription", "kind", msg.Kind, "channel", msg.Channel, "count", msg.Count)
		case error:
			return fmt.Errorf("error: %w", msg)
		case []interface{}:
			if len(msg) != 3 || string(msg[0].([]byte)) != "message" || string(msg[1].([]byte)) != "__redis__:invalidate" {
				slog.Warn("unexpected message", "message", msg)
				continue
			}

			contents := msg[2].([]interface{})
			keys := make([]string, len(contents))
			for i, key := range contents {
				keys[i] = string(key.([]byte))
				r.cache.Del(keys[i])
			}
			slog.Info("received invalidation message", "keys", keys)
		default:
			slog.Warn("unexpected message", "type", fmt.Sprintf("%T", msg))
		}
	}
}
```

コードが少し複雑です。

- Trackingを行うためにコネクションをもう一つ確立します。これはPubSubが他の動作の妨げになることを考慮した措置です。
- 追加したコネクションのIDを照会し、データを照会するコネクションでTrackingをそのコネクションへリダイレクトするようにします。
- そして、invalidation messageを購読します。
- 購読を処理するコードが少し複雑です。redigoがinvalidation messageのパースに対応していないため、パース前の応答を受け取って処理しなければなりません。

```go
func (r *RedisClient) Get(key string) (any, error) {
	val, found := r.cache.Get(key)
	if found {
		switch v := val.(type) {
		case int64:
			slog.Info("cache hit", "key", key)
			return v, nil
		default:
			slog.Warn("unexpected type", "type", fmt.Sprintf("%T", v))
		}
	}
	slog.Info("cache miss", "key", key)

	val, err := redis.Int64(r.conn.Do("GET", key))
	if err != nil {
		return nil, fmt.Errorf("failed to get key: %w", err)
	}

	r.cache.SetWithTTL(key, val, 1, 10*time.Second)
	return val, nil
}
```

`Get`メソッドは次のように、まずristrettoを照会し、なければRedisから取得するようにします。

```go
package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"time"
)

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
	defer cancel()

	client, err := NewRedisClient("localhost:6379")
	if err != nil {
		panic(err)
	}
	defer client.Close()

	go func() {
		if err := client.Tracking(ctx); err != nil {
			slog.Error("failed to track invalidation message", "error", err)
		}
	}()

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	done := ctx.Done()

	for {
		select {
		case <-done:
			slog.Info("shutting down")
			return
		case <-ticker.C:
			v, err := client.Get("key")
			if err != nil {
				slog.Error("failed to get key", "error", err)
				return
			}
			slog.Info("got key", "value", v)
		}
	}
}
```

テストのためのコードは上記のとおりです。一度テストしてみると、Redisでデータが更新されるたびに新しい値へ更新される様子を確認できるでしょう。

しかし、これは非常に複雑です。何よりも、クラスタに対して拡張するためには、必然的にすべてのマスターまたはレプリカに対してTrackingを有効化する必要があります。

### Rueidis

Go言語を使う以上、私たちには最も現代的で発展した`rueidis`があります。rueidisを用いたRedisクラスタ環境でのserver assisted client side cacheを使用するコードを書いてみましょう。

まず、依存関係をインストールします。

- `github.com/redis/rueidis`

次に、Redisからデータを照会するコードを書きます。

```go
package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"time"

	"github.com/redis/rueidis"
)

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
	defer cancel()

	client, err := rueidis.NewClient(rueidis.ClientOption{
		InitAddress: []string{"localhost:6379"},
	})
	if err != nil {
		panic(err)
	}

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	done := ctx.Done()

	for {
		select {
		case <-done:
			slog.Info("shutting down")
			return
		case <-ticker.C:
			const key = "key"
			resp := client.DoCache(ctx, client.B().Get().Key(key).Cache(), 10*time.Second)
			if resp.Error() != nil {
				slog.Error("failed to get key", "error", resp.Error())
				continue
			}
			i, err := resp.AsInt64()
			if err != nil {
				slog.Error("failed to convert response to int64", "error", err)
				continue
			}
			switch resp.IsCacheHit() {
			case true:
				slog.Info("cache hit", "key", key)
			case false:
				slog.Info("missed key", "key", key)
			}
			slog.Info("got key", "value", i)
		}
	}
}
```

rueidisでclient side cacheを使用するには、単に`DoCache`を呼び出すだけで十分です。すると、ローカルキャッシュにどれだけ保持するかといった設定とともにローカルキャッシュへ追加され、同じく`DoCache`を呼び出せば、ローカルキャッシュ内からデータが取得されます。当然ながら、無効化メッセージも正しく処理されます。

### なぜredis-goではないのか？

`redis-go`は残念ながら、公式APIとしてserver assisted client side cacheをサポートしていません。さらに、PubSubを生成する際に新しいコネクションを作成しながら、そのコネクションに直接アクセスするAPIがないため、client idを知ることすらできません。そのため、`redis-go`では構成自体が不可能と判断し、見送りました。

## セクシーになりましたね

### client side cache構造を通じて

- 事前に準備できるデータであれば、この構造によってRedisへのクエリおよびトラフィックを最小化し、常に最新のデータを提供できるでしょう。
- これにより、一種のCQRS構造を作ることができ、読み取り性能を飛躍的に向上させられます。

![cqrs](/assets/images/redis-client-side-cache/003-cqrs.jpg)

### どれほどセクシーになったのか？

実際に現場では、ちょうどこのような構造で運用しているため、二つのAPIについて簡単なレイテンシを調べてみました。非常に抽象的な形でしか示せないことをご容赦ください。

1. 一つ目のAPI
   1. 初回照会時: 平均14.63ms
   2. 以降の照会時: 平均2.82ms
   3. 平均差: 10.98ms
2. 二つ目のAPI
   1. 初回照会時: 平均14.05ms
   2. 以降の照会時: 平均1.60ms
   3. 平均差: 11.57ms

最大で約82%の追加のレイテンシ改善がありました！

おそらく次のような改善点があったものと期待しています。

- クライアントとRedis間のネットワーク通信過程の省略およびトラフィックの節約
- Redis自体が実行すべき読み取りコマンド数の減少
  - これは書き込み性能の向上にも効果があります。
- Redisプロトコルのパース最小化
  - Redisプロトコルをパースすることにもコストはゼロではありません。これを減らせるのは大きな機会です。

しかし、すべてはトレードオフです。このために、私たちは少なくとも以下の二つを犠牲にしました。

- クライアントサイドキャッシュ管理要素の実装、運用および保守が必要であること
- これによるクライアントのCPUおよびメモリ使用量の増加

## 結論

個人的には満足のいくアーキテクチャの構成要素であり、レイテンシおよびAPIサーバーへのストレスも非常に少なかったです。今後も可能であれば、このような構造でアーキテクチャを構成できればと考えています。
