Redisの基本と負荷テストやってみた
こんな人に読んで欲しい!
- Redisって聞いたことあるけど、使ったことがない
- RedisをDBのキャッシュとして使うと実際どのくらい早くなるの?
3行でこの記事をまとめると
- Redisの基本的な使い方を学びます
- RedisでDBキャッシュをする方法を学びます
- 負荷テストをして、どのくらい高速化するかを試します
↓ こちらでコードを公開しています(docker compose up
とするだけで試すことができます!)
自己紹介
zacker(ざっかー)といいます。
アプリ開発が大好きな大学院生です。
「komichi」という2時間くらいの暇つぶしプランを作ってくれるサービスを作っています。
ぜひ、一度触ってみてください!
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処理では基本的に以下のフローを踏みます。
- Redisからキャッシュを取得
- キャッシュが無かったらDBから取得
- 取得した結果を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を介してキャッシュする方法で比較します。
シナリオ
- キャッシュヒットあり:ユーザの検索操作のみ
- キャッシュヒットなし:ユーザ情報の更新処理のみ
- キャッシュヒットあり & なし:ユーザ作成・更新・取得
前提
- 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