Redisの効率的な使い方|キー走査、データ構造、トランザクションの実践ガイド

Redisの効率的な使い方|キー走査、データ構造、トランザクションの実践ガイド

はじめに

Redisは高速なインメモリデータストアとして広く使われていますが、適切に使わないとパフォーマンス問題やデータ不整合を引き起こすことがあります。本記事では、Redisを効率的に使うための4つのポイントを、具体例を交えながら解説していきます。

対象者

この記事は下記のような人を対象にしています。

  • Redisを使い始めたばかりで、基本的な使い方を学びたい方
  • Redisのパフォーマンスを改善したいと考えている方
  • Redisのトランザクションやデータ構造について理解を深めたい方
  • 本番環境でRedisを安全に運用したい方

1. キー走査の問題と対策

KEYS vs SCAN:ブロッキングの違い

Redisでキーを検索する際、KEYSコマンドとSCANコマンドの2つの選択肢があります。しかし、この2つは動作が大きく異なりますね。

KEYSコマンドの問題点

# 本番環境では絶対に使ってはいけません
KEYS user:*

KEYSはすべてのキーを一度に走査し、パターンにマッチするものを返します。この処理中、Redisはブロックされ、他のクライアントからのリクエストを処理できません。数万キーが存在する環境でKEYSを実行すると、数秒間Redisが応答不能になる可能性があります。

SCANコマンドによる解決

# カーソルベースの走査(0から開始)
SCAN 0 MATCH user:* COUNT 100
# 返り値: ["6", ["user:1001", "user:1002", ...]]
# 次のカーソルが6なので続けて実行
SCAN 6 MATCH user:* COUNT 100

SCANはカーソルベースで動作し、少しずつキーを返します。各呼び出しは短時間で完了するため、他のクライアントへの影響を最小限に抑えられます。カーソルが0に戻ったら走査完了となりますね。

SCANの落とし穴:全キー走査は避けられない

SCANを使えば安全、と思いがちですが、実は重要な落とし穴があります。

SCAN 0 MATCH session:active:* COUNT 100

このMATCHパターンは、走査範囲を絞るわけではありません。SCANは内部的にすべてのキーを走査し、パターンに一致するものだけをフィルタして返します。つまり、100万キーの中から10個のsession:active:*キーを探す場合でも、100万キー全体を走査することになります。

対策:適切なデータ構造で管理する

全キー走査を避けるには、キーの管理方法を見直す必要があります。

改善前(非効率)

# 個別のキーとして保存
SET session:active:abc123 "user_data_1"
SET session:active:def456 "user_data_2"
SET session:active:ghi789 "user_data_3"

# 取得には全キー走査が必要
SCAN 0 MATCH session:active:*

改善後(効率的)

# Setで管理
SADD session:active abc123 def456 ghi789

# O(1)で存在確認
SISMEMBER session:active abc123

# O(N)で全メンバー取得(Nはセッション数のみ)
SMEMBERS session:active

Setを使えば、キー走査なしで目的のデータにアクセスできます。これは計算量がO(N)(全キー数)からO(M)(Set内の要素数)に改善されることを意味しますね。

2. データ構造の使い分け

Redisは単なるキーバリューストアではなく、複数のデータ構造を提供しています。適切な構造を選ぶことで、コードの簡潔性とパフォーマンスが大きく向上します。

String:単純なキーバリュー

最もシンプルな構造です。1つのキーに1つの値を保存します。

# ユーザーのログイン状態を保存
SET user:1001:logged_in "true"
GET user:1001:logged_in

# カウンターとして使用
INCR page:views:home
# => 1
INCR page:views:home
# => 2

計算量:GET/SETO(1)です。単純な値の保存に適しています。

Hash:オブジェクト的なデータ

複数のフィールドを持つオブジェクトを表現します。

# ユーザー情報を保存
HSET user:1001 name "太郎" email "taro@example.com" age "25"

# 全フィールドを取得
HGETALL user:1001
# => ["name", "太郎", "email", "taro@example.com", "age", "25"]

# 特定フィールドのみ取得
HGET user:1001 name
# => "太郎"

計算量:HGET/HSETO(1)HGETALLO(N)(Nはフィールド数)です。

Hashを使うメリットは、複数のStringキーを1つのHashにまとめることで、キーの総数を削減できる点にあります。例えば、ユーザー1000人の情報をuser:1001:nameuser:1001:emailのように保存すると2000キーになりますが、Hashなら1000キーで済みますね。

Set:重複なし集合

順序なしの一意な要素の集合です。

# タグの管理
SADD article:101:tags "Redis" "NoSQL" "Database"
SADD article:102:tags "Redis" "Performance"

# 共通タグの取得(積集合)
SINTER article:101:tags article:102:tags
# => ["Redis"]

# 全タグの取得(和集合)
SUNION article:101:tags article:102:tags
# => ["Redis", "NoSQL", "Database", "Performance"]

# 存在確認
SISMEMBER article:101:tags "Redis"
# => 1 (true)

計算量:SADD/SISMEMBERO(1)SMEMBERSO(N)(N=要素数)です。

Sorted Set:スコア付き集合

各要素にスコア(数値)が付いた集合です。スコア順にソートされており、範囲取得や削除が効率的に行えます。

# ランキングの管理(スコアは得点)
ZADD game:ranking 1500 "player_A" 2000 "player_B" 1800 "player_C"

# スコア順で取得(上位3人)
ZREVRANGE game:ranking 0 2 WITHSCORES
# => ["player_B", "2000", "player_C", "1800", "player_A", "1500"]

# スコア範囲で削除(1000点未満を削除)
ZREMRANGEBYSCORE game:ranking -inf 1000

# 特定のスコア範囲を取得
ZRANGEBYSCORE game:ranking 1500 2000
# => ["player_A", "player_C", "player_B"]

計算量:ZADDO(log N)ZRANGEO(log N + M)(M=取得要素数)です。

Sorted Setは、ランキング、タイムライン、優先度付きキューなど、順序が重要な場面で威力を発揮しますね。

3. トランザクションと原子性

Redisのトランザクションは、一般的なRDBMSとは異なる特性を持っています。この違いを理解しないと、データ不整合を引き起こす可能性がありますので注意が必要です。

MULTI/EXEC:パイプライン + 割り込み防止

MULTI
INCR counter
INCR counter
INCR counter
EXEC
# => [1, 2, 3]

MULTIEXECは、複数のコマンドをまとめて実行します。この間、他のクライアントからのコマンドは割り込みません。しかし、重要な点が2つあります。

1. ロールバックはありません

MULTI
SET key1 "value1"
INCR key1  # エラー:文字列をインクリメントできません
SET key2 "value2"
EXEC
# => [OK, (error), OK]

途中でエラーが発生しても、他のコマンドは実行されます。key1key2は両方とも設定されます。つまり、失敗したら全部巻き戻す、という動作はしません。

2. 条件分岐はできません

MULTI
GET counter
# => counterの値を見て次の処理を変えたい?できません
SET another_key "value"
EXEC

MULTI内のコマンドは、実行前にすべてキューイングされます。GETの結果を見て次の処理を変える、といった条件分岐はできません。

WATCH:楽観的ロック

# counter の現在値を監視
WATCH counter
current_value=\$(GET counter)

# 他のクライアントがcounterを変更したらトランザクション失敗
MULTI
SET counter \$((current_value + 1))
EXEC
# => counterが変更されていなければ成功、されていればnil

WATCHは指定したキーを監視し、EXECまでに他のクライアントがそのキーを変更したら、トランザクション全体を中断します。これは楽観的ロックと呼ばれる仕組みで、競合が少ない場合に効率的です。

ただし、競合が頻繁に発生する場合は、リトライロジックが必要になり、コードが複雑になりますね。

Luaスクリプト:真の原子性

-- Luaスクリプト(increment_with_limit.lua)
local current = redis.call('GET', KEYS[1])
if current == false then
    current = 0
else
    current = tonumber(current)
end

if current >= tonumber(ARGV[1]) then
    return {err = "limit exceeded"}
end

return redis.call('INCR', KEYS[1])
# スクリプトを実行(上限10)
EVAL "\$(cat increment_with_limit.lua)" 1 counter 10

Luaスクリプトは、Redis内部で原子的に実行されます。条件分岐、ループ、複雑なロジックをすべて1つのアトミックな操作として実行できます。

Luaの利点:

  • 完全なアトミック性(スクリプト全体が不可分)
  • ネットワークラウンドトリップの削減(複数コマンドを1回で実行)
  • 条件分岐やループが可能

欠点:

  • スクリプトのデバッグが難しい
  • 長時間実行すると他のクライアントをブロックしてしまう

DBトランザクション(ACID)との違い

RedisのトランザクションとRDBMSのACIDトランザクションは、名前は同じでも別物です。

特性RDBMS (ACID)Redis (MULTI/EXEC)Redis (Lua)
Atomicity○ ロールバック可能△ ロールバック不可○ 完全
Consistency○ 制約チェック× アプリ側で保証△ スクリプト内で実装
Isolation○ 分離レベル選択可○ 完全分離○ 完全分離
Durability○ コミット時永続化△ 設定次第△ 設定次第

Redisは速度を優先するため、厳密なACIDは保証しません。データ整合性が重要な場合は、Luaスクリプトか、RDBMSの併用を検討することをおすすめします。

4. TTLと有効期限管理

Redisはキャッシュとして使われることが多く、データに有効期限を設定する機能が充実しています。

基本的なTTL設定

# 値を設定して60秒後に自動削除
SETEX session:abc123 60 "user_data"

# 既存のキーに30秒のTTLを設定
EXPIRE session:abc123 30

# TTL確認
TTL session:abc123
# => 28 (残り28秒)

# TTLを削除(永続化)
PERSIST session:abc123

計算量:すべてO(1)です。

GETEX:読み取り時にTTL更新

Redis 6.2以降では、GETEXコマンドで値を取得しながらTTLを更新できます。

# セッションデータを取得し、TTLを60秒に更新
GETEX session:abc123 EX 60

これは、アクセスされたセッションの有効期限を延長するLRU(Least Recently Used)キャッシュのような動作を実現できますね。

従来はGET + EXPIREの2コマンドが必要でしたが、GETEXなら1コマンドで済み、アトミック性も保証されます。

Sorted Setのスコアでの期限管理パターン

TTLは便利ですが、「期限切れのキーを一括で削除したい」場合には不向きです。なぜなら、TTL付きキーを列挙する効率的な方法がないからです(SCANで全キー走査が必要になります)。

この問題は、Sorted Setで解決できます。

# Unixタイムスタンプをスコアとして保存
ZADD session:expiry 1706745600 "session:abc123"
ZADD session:expiry 1706749200 "session:def456"

# 現在時刻より古いセッションを削除
# 例:2026-01-21 12:00:00 (1706756400) 時点で実行
ZREMRANGEBYSCORE session:expiry -inf 1706756400

# 削除対象を先に確認することも可能
ZRANGEBYSCORE session:expiry -inf 1706756400
# => ["session:abc123", "session:def456"]

この方法のメリット:

  • 期限切れのキーをO(log N + M)で効率的に取得・削除可能
  • 削除前に確認できる(ログ記録やクリーンアップ処理)
  • 一括削除が容易

注意点:

  • Sorted Set自体のメンテナンスが必要
  • 実際のデータ(session:abc123の内容)は別途削除が必要

実装例:

# 1. セッションを作成
SET session:abc123 "user_data" EX 3600
ZADD session:expiry 1706749200 "session:abc123"

# 2. 定期的にクリーンアップ(cronなど)
ZRANGEBYSCORE session:expiry -inf \$(date +%s)
# => 期限切れセッションのリスト
# 各セッションを削除
ZREMRANGEBYSCORE session:expiry -inf \$(date +%s)

複雑に見えますが、大量のキーを効率的に管理する場合、この方法が最も実用的です。

おわりに

本記事では、Redisを効率的に使うためのポイントについてまとめました。

キー走査

  • KEYSは本番環境で使わないでください
  • SCANでもパターンマッチは全キー走査になります
  • Set/Sorted Setでキーを管理し、走査を回避しましょう

データ構造

  • String:単純な値、カウンター
  • Hash:オブジェクト、キー数削減
  • Set:一意な集合、集合演算
  • Sorted Set:順序が重要なデータ、範囲操作

トランザクション

  • MULTI/EXEC:ロールバックなし、条件分岐不可
  • WATCH:楽観的ロック、競合時はリトライ
  • Luaスクリプト:完全な原子性、複雑なロジック
  • RDBMSのACIDとは別物です

TTL管理

  • SETEX/EXPIRE:基本的なTTL設定
  • GETEX:読み取り時にTTL更新
  • Sorted Set:期限切れキーの一括管理

これらを理解すれば、Redisのパフォーマンスを最大限に引き出し、安全に運用できます。データ構造の選択とトランザクションの理解が、特に重要ですね。

この記事がどなたかの参考になれば幸いです。