🚅

Redisの基本と負荷テストやってみた

に公開

こんな人に読んで欲しい!

  • Redisって聞いたことあるけど、使ったことがない
  • RedisをDBのキャッシュとして使うと実際どのくらい早くなるの?

3行でこの記事をまとめると

  • Redisの基本的な使い方を学びます
  • RedisでDBキャッシュをする方法を学びます
  • 負荷テストをして、どのくらい高速化するかを試します

↓ こちらでコードを公開しています(docker compose upとするだけで試すことができます!)
https://github.com/zackerms/playground-redis

自己紹介

zacker(ざっかー)といいます。
アプリ開発が大好きな大学院生です。

profile

「komichi」という2時間くらいの暇つぶしプランを作ってくれるサービスを作っています。
ぜひ、一度触ってみてください!
https://komichi.app/

Redisのキホン

Redisとは?

  • 高速なインメモリデータベース
  • キーバリュー型のNoSQLデータベースとして広く利用されている
  • データをメモリ上に保持することで高速な読み書きが可能

CRUD

複雑な操作を見る前に、基礎的なCRUDの方法を見てみます。

# Create
SET key value

# Read
GET key

# Update
SET key newvalue

# Delete
DEL key

有効期限の設定

Redisでは「キャッシュをどれくらいの時間保持するか」という有効期限を設定できます。

# 保存時に有効期限を設定:SET key value EX seconds    
SET user:1 "John" EX 3600           # 1時間の有効期限でユーザー保存
SET session:xyz "data" EX 1800      # 30分の有効期限でセッション保存

# 既存のキーに有効期限を設定:EXPIRE key seconds   
SET user:1 "John"                  # ユーザーを保存
EXPIRE user:1 3600                 # 後から1時間の有効期限を設定     

# 有効期限を削除(永続化):PERSIST key   
SET user:1 "John" EX 3600          # 有効期限付きでユーザー保存
PERSIST user:1                     # 有効期限を削除して永続化   

# 残り有効期限を確認(秒):TTL key          
SET user:1 "John" EX 3600          # 1時間の有効期限でユーザー保存
TTL user:1                         # 残り時間を秒で取得         

条件付き操作

# キーが存在しない場合のみ保存:SETNX key value
SETNX user:1 "John"                # ユーザーが存在しない場合のみ保存
SETNX lock:task1 "locked"          # ロック処理に使用

# キーが既に存在する場合のみ保存:SET key value XX
SET user:1 "John" XX               # ユーザーが既に存在する場合のみ更新
SET cache:item "data" XX           # キャッシュ更新に使用

# 数値を1増加:INCR key
SET counter 10                     # カウンターを初期化
INCR counter                       # 11になる

# 数値を1減少:DECR key
SET counter 10                     # カウンターを初期化
DECR counter                       # 9になる

# 複数のキーの値を一度に取得:MGET key1 key2
MGET user:1 user:2                 # 複数ユーザーを一度に取得

# パターンにマッチするキーを検索:KEYS pattern
KEYS user:*                        # userで始まる全てのキー
KEYS session:*                     # sessionで始まる全てのキー

データ型

基本的なデータ型について紹介します。

  • String:文字列(テキストだけでなく、数値、シリアライズされたJSONなども保存可能)

    SET key "Hello"                    # キーに文字列"Hello"を保存
    INCR counter                       # counterキーの値を1増加(数値のみ可能)
    
  • List:順序付きリスト

    LPUSH mylist "world"              # リストの先頭(左)に"world"を追加
    RPUSH mylist "hello"              # リストの末尾(右)に"hello"を追加
    LRANGE mylist 0 -1                # リストの全要素を取得(0から最後まで)
    
  • Hashes:フィールドと値のペアを格納

    HSET user:1 name "John" email "john@example.com"  # 複数のフィールドと値を設定
    HGETALL user:1                    # user:1の全フィールドと値を取得
    

DBのキャッシュサーバとしてRedisを使う

以下のようにMySQLに保存したデータをキャッシュするようなRedisサーバを建てます。

データ構造

key value
all_users 全件取得したときの結果
user:<mail> メールアドレスに対応するユーザ情報

Redisクライアントの初期化

redis_client = redis.Redis(
    host='localhost',
    port=6379,
    db=0,
    decode_responses=True
)

CREATE:ユーザ作成

@app.post("/users/")
async def create_user(user: User):
		# dbにユーザを保存
    conn = mysql.connector.connect(**db_config)
    cursor = conn.cursor()
    cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)", (user.name, user.email))
    conn.commit()
    
    # キャッシュを削除(ユーザー一覧の更新)
    redis_client.delete("all_users")
    return {"message": "User created successfully"}

READ:ユーザ取得

READ処理では基本的に以下のフローを踏みます。

  1. Redisからキャッシュを取得
  2. キャッシュが無かったらDBから取得
  3. 取得した結果をRedisに保存

全件取得

@app.get("/users")
async def get_all_users() -> List[dict]:
    # Redisからキャッシュを確認
    cached_users = redis_client.get("all_users")
    if cached_users:
        return json.loads(cached_users)

    # DBから全ユーザーを取得
    conn = mysql.connector.connect(**db_config)
    cursor = conn.cursor(dictionary=True)
    cursor.execute("SELECT name, email FROM users")
    users = cursor.fetchall()
    
    # キャッシュに保存
    redis_client.setex(
        "all_users",
        CACHE_EXPIRATION,
        json.dumps(users)
    )
    return users

1件取得

@app.get("/users/{email}")
async def get_user(email: str):
    # Redisからキャッシュを確認
    cache_key = f"user:{email}"
    cached_user = redis_client.get(cache_key)
    if cached_user:
        return json.loads(cached_user)

    # DBから検索
    conn = mysql.connector.connect(**db_config)
    cursor = conn.cursor(dictionary=True)
    cursor.execute("SELECT name, email FROM users WHERE email = %s", (email,))
    user = cursor.fetchone()
    
     # キャッシュに保存
    redis_client.setex(
        cache_key,
        CACHE_EXPIRATION,
        json.dumps(user)
    )
    
    return user

UPDATE: ユーザ名更新

指定されたユーザの名前を更新します。

そのユーザのキャッシュだけではなく、全体検索のキャッシュも削除します。

@app.put("/users/{email}")
async def update_user_name(email: str, name: str):
    conn = mysql.connector.connect(**db_config)
    cursor = conn.cursor()
    cursor.execute("UPDATE users SET name = %s WHERE email = %s", (name, email))
    conn.commit()
    
    # 関連するキャッシュを削除
    redis_client.delete(f"user:{email}")
    redis_client.delete("all_users")
    return {"message": "User updated successfully"}

実際、どれくらい早くなるの?

Redisの基本的な使い方を学んだところで、Redisがどれだけすごいかを体感してみようと思います。

ここでは、DBを直接読み書きする方法と、Redisを介してキャッシュする方法で比較します。

シナリオ

  1. キャッシュヒットあり:ユーザの検索操作のみ
  2. キャッシュヒットなし:ユーザ情報の更新処理のみ
  3. キャッシュヒットあり & なし:ユーザ作成・更新・取得

前提

  • Redis:DB操作 + Redis操作
  • DB:DB操作のみ
  • 負荷テスト行う前に毎回、DB, Redisのデータをリセットします
  • DBに保存されるデータ数はせいぜい10件程度です(データ量増加による影響は考慮しません)

キャッシュヒットありの場合

Redisのほうが100倍高速になっています 🚀

DBのデータ数が少ないため、DBのデータ取得は時間がかからない方だと思うのですが、すごいですね。

同じユーザーの連続検索(キャッシュヒット) Redis DB
レスポンスタイム(平均) 3.46ms 259.33ms
レスポンスタイム(最小) 0.398ms 36.68ms
レスポンスタイム(最大) 87.21ms 974.07ms
レスポンスタイム(90パーセンタイル) 5.64ms 442.62ms
スループット 72.29回/秒 21.01回/秒

キャッシュヒットなしの場合

キャッシュを利用しない場合、DBのほうがややレスポンスタイムが短くなりました。

これは、Redisの場合、DB操作に加えてRedisのキャッシュ操作の時間が加わるためだと考えられます。

キャッシュがない状態でのユーザー検索 Redis DB
成功率 100% 100%
レスポンスタイム(平均) 322.31ms 318.63ms
レスポンスタイム(最小) 40.94ms 42.88ms
レスポンスタイム(最大) 716.60ms 752.59ms
レスポンスタイム(90パーセンタイル) 416.87ms 417.82ms
スループット 23.24回/秒 23.55回/秒

キャッシュヒットあり & なし

以下の操作を決められた確率で発生するようにテストします

操作 発生確率
新規作成 30%
更新 20%
検索 50%

キャッシュヒットあり・なしをミックスすると、Redisのほうがやや高速という結果になりました。

今回はCREATE, UPDATE処理が全体の50%とかなりデータ更新の頻度を高く設定しているので、READが多いアプリケーションではRedisによる効果がもっと大きく出ると考えられます。

書き込みと読み取りの混合テスト Redis DB
成功率 100% 100%
レスポンスタイム(平均) 65.68ms 71.61ms
レスポンスタイム(最小) 37.74ms 37.70ms
レスポンスタイム(最大) 526.16ms 674.75ms
レスポンスタイム(90パーセンタイル) 90.95ms 87.70ms
スループット 8.31回/秒 8.23回/秒

結論

  • READが多いほどRedisの効果が大きくなる
  • CREATE, UPDATEが多いとRedisのキャッシュ書き換えの時間がボトルネックになる(DBのデータ数が少ない場合)

Appendix

データ型

  • String:文字列(テキストだけでなく、数値、シリアライズされたJSONなども保存可能)

    SET key "Hello"                    # キーに文字列"Hello"を保存
    INCR counter                       # counterキーの値を1増加(数値のみ可能)
    
  • List:順序付きリスト

    LPUSH mylist "world"              # リストの先頭(左)に"world"を追加
    RPUSH mylist "hello"              # リストの末尾(右)に"hello"を追加
    LRANGE mylist 0 -1                # リストの全要素を取得(0から最後まで)
    
  • Set: 重複を許さない集合

    SADD myset "member1"              # セットにメンバーを追加
    SMEMBERS myset                    # セットの全メンバーを取得
    SINTER set1 set2                  # set1とset2の積集合を取得
    
  • Sorted Sets: スコア付きの順序付きセット

    ZADD leaderboard 100 "player1"    # スコア100でplayer1を追加
    ZRANGE leaderboard 0 -1 WITHSCORES # 全メンバーをスコア付きで取得
    
  • Hashes:フィールドと値のペアを格納

    HSET user:1 name "John" email "john@example.com"  # 複数のフィールドと値を設定
    HGETALL user:1                    # user:1の全フィールドと値を取得
    
  • Bitmap:: ビット操作によるフラグ管理

    SETBIT online:users 123 1         # ID 123のユーザーをオンライン状態に設定
    BITCOUNT online:users             # オンライン状態のユーザー数をカウント
    
  • HyperLogLog:ユニーク要素のカウント

    PFADD visitors "user1"            # visitorsにユーザーを追加
    PFCOUNT visitors                  # ユニークな訪問者数を概算で取得
    
  • Stream:時系列データの追加と取得

    XADD mystream * sensor-id 1234 temperature 19.8  # センサーデータをストリームに追加
    XRANGE mystream - +               # ストリームの全エントリを取得
    
  • Geospatial:位置情報

    GEOADD locations 139.7673068 35.6809591 "Tokyo"  # 東京の位置情報を追加
    GEODIST locations "Tokyo" "Osaka" km              # 東京-大阪間の距離をkm単位で計算
    

有効期限の設定

Redisでは「キャッシュをどれくらいの時間保持するか」という有効期限を設定できます。

# 保存時に有効期限を設定
# SET key value EX seconds    
SET user:1 "John" EX 3600           # 1時間の有効期限でユーザー保存
SET session:xyz "data" EX 1800      # 30分の有効期限でセッション保存

# 保存時に有効期限を設定
# SETEX key seconds value
SETEX user:1 3600 "John"           # 1時間の有効期限でユーザー保存
SETEX session:xyz 1800 "data"      # 30分の有効期限でセッション保存

# 既存のキーに有効期限を設定
# EXPIRE key seconds   
SET user:1 "John"                  # ユーザーを保存
EXPIRE user:1 3600                 # 後から1時間の有効期限を設定     

# ミリ秒単位で有効期限を設定
# PEXPIRE key milliseconds  
SET user:1 "John"                 # ユーザーを保存
PEXPIRE user:1 3600000            # 1時間(3600000ミリ秒)の有効期限を設定

# 有効期限を削除(永続化)
# PERSIST key   
SET user:1 "John" EX 3600          # 有効期限付きでユーザー保存
PERSIST user:1                     # 有効期限を削除して永続化   

# 残り有効期限を確認(秒)       
# TTL key          
SET user:1 "John" EX 3600          # 1時間の有効期限でユーザー保存
TTL user:1                         # 残り時間を秒で取得

# 残り有効期限を確認(ミリ秒)        
# PTTL key      
SET user:1 "John" EX 3600          # 1時間の有効期限でユーザー保存
PTTL user:1                        # 残り時間をミリ秒で取得           

条件付き更新

# キーが存在しない場合のみ保存
# SETNX key value
SETNX user:1 "John"                # ユーザーが存在しない場合のみ保存
SETNX lock:task1 "locked"          # ロック処理に使用

# キーが存在しない場合のみ保存(SET NX版)
# SET key value NX
SET user:1 "John" NX               # ユーザーが存在しない場合のみ保存
SET lock:task1 "locked" NX         # ロック処理に使用

# キーが既に存在する場合のみ保存
# SET key value XX
SET user:1 "John" XX               # ユーザーが既に存在する場合のみ更新
SET cache:item "data" XX           # キャッシュ更新に使用

# 数値を1増加
# INCR key
SET counter 10                     # カウンターを初期化
INCR counter                       # 11になる

# 数値を1減少
# DECR key
SET counter 10                     # カウンターを初期化
DECR counter                       # 9になる

# 指定した数値を加算
# INCRBY key amount
SET counter 10                     # カウンターを初期化
INCRBY counter 5                   # 15になる

# 指定した数値を減算
# DECRBY key amount
SET counter 10                     # カウンターを初期化
DECRBY counter 3                   # 7になる

# 小数点数の加算
# INCRBYFLOAT key amount
SET price 10.5                     # 価格を設定
INCRBYFLOAT price 1.5             # 12.0になる

# 複数のキーと値を一度に保存
# MSET key1 value1 key2 value2
MSET user:1 "John" user:2 "Jane"   # 複数ユーザーを一度に保存

# 全てのキーが存在しない場合のみ保存
# MSETNX key1 value1 key2 value2
MSETNX user:1 "John" user:2 "Jane" # 両方のキーが存在しない場合のみ保存

条件付き検索

# 複数のキーの値を一度に取得
# MGET key1 key2
MGET user:1 user:2                 # 複数ユーザーを一度に取得

# パターンにマッチするキーを検索
# KEYS pattern
KEYS user:*                        # userで始まる全てのキー
KEYS session:*                     # sessionで始まる全てのキー

# 大規模データでの安全な検索
# SCAN cursor
SCAN 0 MATCH user:* COUNT 10       # userで始まるキーを10件ずつ検索

Discussion