こんにちは!株式会社GROWTH VERSEに所属しております川田剣士です。実務でRedisはキャッシュサーバーとして使用してはいるもののの、Redisはシステムデザインにおいては一般的に非常に多用途で使用される技術であるため、改めてしっかり整理したいなと思いまして、知見を整理し技術記事で体系的にまとめようと思いました。記事に対する質問・コメント大歓迎です!
Redisとは何か
Redisはオープンソースのインメモリデータストアです。多くのエンジニアはRedisを「キャッシュサーバー」として認識していますが、それだけではRedisの本質を十分に表現できません。Redisは単なるキャッシュではなく、豊富なデータ構造を扱える高速なデータストアとして設計されています。公式ドキュメントでも Redis は "Data Structure Server" と表現されており、Key-Value Storeの枠を超えた用途で利用されています。例えば以下のような用途があります。
- キャッシュ
- セッション管理
- レートリミット
- 分散ロック
- ランキングシステム
- Pub/Sub
- イベントストリーム処理
現代のWebサービスにおいて、Redisは単なる補助的なミドルウェアではなく、システム全体の性能やスケーラビリティを支える重要なコンポーネントとなっています。
Redisはキャッシュサーバーと捉えるのは勿体無い
Redisを学び始めた頃、多くの人は次のような構成を目にします。
Application
↓
Redis
↓
Database
データベースへのアクセスを削減するためにRedisへキャッシュする構成です。実際、この用途はRedisの代表的な利用方法です。しかしRedisの価値はキャッシュだけではありません。ここからはRedisが採用される代表的なユースケースを紹介します。
Redisが採用される代表的なユースケース
キャッシュ
最も一般的な用途です。頻繁に参照されるデータをRedisへ保存することで、データベース負荷を大幅に削減できます。例えば以下のようなデータが対象になります。
- 商品情報
- ユーザー情報
- 設定情報
- 集計結果
セッション管理
Redisは複数台のアプリケーションサーバーが存在する環境でも、共有ストレージとして利用することでセッション情報を一元管理できます。昨今のモダンな開発では、Cognitoが発行するBearer Token(JWT)を自社バックエンドでローカル検証(署名と有効期限のチェック)し、リクエストを認可する手法が一般的です。しかし、この手法単体では「管理者サイドから特定のユーザーを即時ログアウトさせる」といったリアルタイムな制御が難しいという課題があります。そのため、ログアウトされたトークンのID(jti)や停止されたユーザーIDを「ブラックリスト」としてRedisで管理する手法が有効です。これにより、JWTのローカル検証に成功した場合であっても、ブラックリストに該当するリクエストを確実に弾くことができます。こうした「JWT(ローカル検証) + Redis(ブラックリスト)」によるセッション管理の併用は、実務でも広く採用されている設計パターンです。さらに、Redisサーバーのダウン時にはブラックリストチェックを一時的にスキップ(フォールバック)させる実装を施すことで、Redisがシステム全体の単一障害点(SPOF)になるリスクを防止し、高い可用性を維持できます。
レートリミット
APIの利用回数制限を実装する際にもRedisは非常に相性が良いです。例えば以下のような要件です。
- 1分間に100回まで
- 1時間に1000回まで
RedisのINCRコマンドとTTLを組み合わせることで効率的に実装できます。
ランキングシステム
RedisのSorted Setはランキング用途に非常に適しています。
例えば、
- ゲームランキング
- 売上ランキング
- 人気記事ランキング
などを高速に実現できます。
Pub/Sub
RedisのPub/Sub機能を利用することで、システム間の結合度を下げる(疎結合にする)メッセージング基盤を容易に構築できます。パブリッシャー(送信側)がチャネルへイベントを発行(Publish)すると、そのチャネルを購読(Subscribe)している複数のサブライバー(受信側)へ、ミリ秒単位で一斉にデータをリアルタイム配信(プッシュ配信)できます。複雑な設定なしでイベント駆動型のアーキテクチャを実現できるため、軽量な通知システムや即時同期処理に非常に有効です。
Stream処理
Redis Streamsを活用することで、大規模なApache Kafkaと同じような、信頼性の高いリアルタイムデータ処理(ストリーム処理)を構築できます。複数のサーバーで作業を安全に分担する「負荷分散」や、エラー時の「自動再試行」、データの「二重処理防止」といった重要な機能を標準で装備。専用の巨大なシステムを導入せず、すでに実績のあるRedisだけで実現できるため、小〜中規模のシステム開発において非常にスマートな選択肢となります。
Redisはなぜ高速なのか
Redisは「高速なキャッシュサーバー」として広く知られています。実際にRedisを利用すると、データベースへのアクセス回数を大幅に削減でき、アプリケーションのレスポンス改善を体感できます。しかし、「Redisはなぜ速いのか?」と聞かれたときに説明できるでしょうか。Redisの性能を理解するためには、まずRedisのアーキテクチャを理解する必要があります。
Redisはシングルスレッドで動作する
Redisの特徴としてよく挙げられるのが、
Redisはシングルスレッドで動作する
という点です。現代のサーバーアプリケーションの多くはマルチスレッドで実装されています。例えばWebサーバーやアプリケーションサーバーでは、複数のリクエストを同時に処理するために複数スレッドや複数プロセスが利用されます。一方Redisでは、クライアントから送信されたコマンドを基本的に1つずつ順番に処理します。
GET user:1
↓
SET user:2
↓
INCR counter
↓
ZADD ranking
この説明だけを聞くと、「CPUを1コアしか使えないので遅いのでは?」と思うかもしれません。しかし実際にはRedisは非常に高い性能を発揮します。ではなぜRedisはシングルスレッドという設計を採用し、それでも高速に動作できるのでしょうか。
Redisがシングルスレッドを選択した理由
Redisが設計された当時、開発者たちはCPU性能よりも別の問題に注目していました。それは並行処理による複雑さです。マルチスレッドで共有データを扱う場合、次のような問題が発生します。
- ロック管理
- デッドロック
- コンテキストスイッチ
- スレッド間競合
例えば2つのスレッドが同じデータを書き換えるケースを考えてみます。
Thread A
SET counter = 10
Thread B
SET counter = 20
どちらの値が最終結果になるのかを保証するためにはロックが必要になります。ロックは整合性を保つ上で重要ですが、その分オーバーヘッドも発生します。Redisはこの複雑さを排除するために、「コマンドを順番に処理する」というシンプルな設計を採用しました。その結果、
- ロック不要
- 実装が単純
- レイテンシが予測しやすい
というメリットを得ています。
データがメモリ上に存在する
Redis最大の特徴は、データをメモリ上に保持することです。一般的なRDBMSでは最終的にディスクへのアクセスが発生します。
Application
↓
Database
↓
SSD
一方Redisは基本的にRAM上でデータを管理します。
Application
↓
Redis
↓
RAM
メモリアクセスはSSDアクセスと比較して圧倒的に高速です。Redisが高速である最大の理由はここにあります。
シンプルな処理モデル
Redisは複雑なクエリ解析を行いません。例えばRDBMSでは以下のような処理が発生します。
- SQL解析
- 実行計画作成
- インデックス選択
- JOIN処理
一方Redisのコマンドは非常にシンプルです。
GET user:1
SET user:1 value
INCR counter
そのため処理コストが小さく、高速に実行できます。
Redis 6からは一部マルチスレッド化された
なお、Redisは現在完全なシングルスレッドではありません。Redis 6以降ではネットワークI/O処理の一部がマルチスレッド化されています。ただし重要なのは、
コマンド実行そのものは現在もシングルスレッド
という点です。Redisのシンプルさと性能を両立するため、この設計は現在も維持されています。
Redisのデータ構造
前章では、Redisが高速な理由について説明しました。しかしRedisの価値は単に高速なだけではありません。Redisが多くのシステムで採用される理由の一つが、用途に応じた豊富なデータ構造を提供していることです。例えば、
- ユーザー情報をキャッシュしたい
- キューを実装したい
- ランキングを管理したい
- 重複を排除したい
- 非同期イベントを処理したい
といった要件に対して、それぞれに適したデータ構造が用意されています。Redisが「Data Structure Server」と呼ばれる理由もここにあります。
String
StringはRedisで最も基本的なデータ構造です。名前からは単なる文字列を保存する機能に見えますが、実際にはJSONなどのシリアライズ済みデータを格納する用途で広く利用されています。例えば、アプリケーションで扱う構造体をJSONへ変換してRedisへ保存するケースは非常に一般的です。
SET user:1 '{"id":1,"name":"Alice","age":30}'
また、カウンターやレートリミットの実装にも利用されます。(Redisには独立したInteger型は存在しない)
INCR page_view
主な用途
- オブジェクトキャッシュ
- カウンター
- レートリミット
Hash
Hashはフィールド単位でデータを管理できる構造です。
HSET user:1 name Alice
HSET user:1 age 30
StringでJSON全体を保持する方法と異なり、一部フィールドだけを更新できます。
HSET user:1 name Bob
ユーザー情報や設定情報など、フィールド単位で更新が発生するデータに適しています。
主な用途
- ユーザープロフィール
- 商品情報
- 設定情報
List
Listは順序を持つデータ構造です。
LPUSH queue job1
LPUSH queue job2
追加されたデータを順番に取り出せるため、シンプルなキューとして利用できます。
RPOP queue
ただし、メッセージの再処理や複数コンシューマーでの処理が必要な場合は、後述するStreamsの方が適しています。
主な用途
- タスクキュー
- FIFO処理
- シンプルなジョブキュー
Set
Setは重複を許さない集合です。
SADD online_users user1
SADD online_users user2
同じ値を追加しても重複しません。
SADD online_users user1
また、集合演算もサポートしています。
SINTER group_a group_b
例えば、
- 両方のグループに所属するユーザー
- 共通のタグを持つ記事
などを効率的に取得できます。
主な用途
- オンラインユーザー管理
- タグ管理
- 重複排除
- 集合演算
Sorted Set
Sorted SetはRedisを代表するデータ構造の一つです。値に対してスコアを持たせることができます。
ZADD ranking 1000 Alice
ZADD ranking 800 Bob
ZADD ranking 1200 Carol
スコア順に自動でソートされるため、ランキング機能を効率的に実装できます。例えば上位10件を取得する場合は次のようになります。
ZREVRANGE ranking 0 9
特定ユーザーの順位も取得できます。
ZREVRANK ranking Alice
内部的にはHash TableとSkip Listを組み合わせて実装されており、順位取得や範囲検索を高速に実行できます。
主な用途
- ゲームランキング
- 人気記事ランキング
- 売上ランキング
- スコア管理
Streams
Streamsはイベントやメッセージを扱うためのデータ構造です。RedisにはListも存在するため、
キューならListで十分では?
と思うかもしれません。実際、単純なジョブキューであればListでも実装できます。
LPUSH jobs job1
LPUSH jobs job2
Workerはジョブを取得して処理します。
RPOP jobs
しかし、実際のシステムでは次のような問題が発生します。
ジョブ取得
↓
DB更新中
↓
Worker障害
この場合、ジョブは既にListから取り出されています。Redisはそのジョブが
- 正常に処理されたのか
- 処理途中で失敗したのか
を把握できません。その結果、ジョブが失われる可能性があります。Streamsはこの問題を解決するために設計されています。Workerがメッセージを取得しただけでは、Redisはそのメッセージを完了扱いしません。
Producer
↓
Streams
↓
Worker
メッセージは「処理中」として管理されます。処理が成功した場合、WorkerはRedisへACKを送信します。
Worker
↓
処理成功
↓
ACK
Redisは初めて
このメッセージは処理完了
と認識します。もしWorkerが処理途中で停止した場合はどうでしょうか。
Worker
↓
メッセージ取得
↓
処理中
↓
障害発生
この場合、RedisはACKを受け取っていないため、
未処理メッセージ
として管理し続けます。そのため別のWorkerが後から再処理できます。またStreamsはConsumer Groupをサポートしています。
Order Stream
↓
Order Consumer Group
↓
┌──────┬──────┬──────┐
↓ ↓ ↓
Pod1 Pod2 Pod3
複数のWorkerへジョブを分散できるため、大量のイベントも効率的に処理できます。このようにStreamsの本質は、単にメッセージを保存することではありません。
「誰が処理したか」「処理が完了したか」をRedisが管理できること にあります。
そのためStreamsは、
- 非同期ジョブ処理
- イベント処理
- メッセージキュー
- イベント駆動アーキテクチャ
など、信頼性が求められるシステムで利用されています。
主な用途
- 非同期ジョブ処理
- イベント処理
- メッセージキュー
- イベント駆動アーキテクチャ
Redisの永続化
これまでRedisを
- 高速なデータストア
- インメモリデータベース
として紹介してきました。ここで一つ疑問が生まれます。
Redisはメモリ上にデータを保持しているのに、サーバーが停止したらデータは消えてしまうのではないか?
その通りです。Redisはデータをメモリ上で管理しているため、何もしなければプロセス終了時にデータを失います。しかし実際のRedisは永続化機能を備えており、再起動後もデータを復元できます。Redisには主に2つの永続化方式があります。
- RDB(Redis Database)
- AOF(Append Only File)
それぞれ特徴とトレードオフが異なります。
RDB
RDBはスナップショット方式の永続化です。
一定間隔ごとにメモリ上のデータをファイルへ保存します。
Redis Memory
↓
Snapshot
↓
dump.rdb
例えば、「5分ごと」や「1000回更新されたら」といった条件でスナップショットを取得できます。
RDBのメリット
RDBは保存されたデータの状態をそのまま保持してコマンドまでは管理しないため、ファイルサイズが比較的小さくなります。また、Redis起動時の復元も高速です。そのため、
- バックアップ
- 障害復旧
- レプリカ作成
などで広く利用されています。
RDBのデメリット
スナップショット取得後に障害が発生すると、その間のデータは失われます。例えば、
12:00 Snapshot
12:04 Redis障害
の場合、
12:00〜12:04
の更新内容は失われる可能性があります。つまりRDBは性能と引き換えに、一定量のデータ損失を許容する方式です。
AOF
AOFは操作ログ方式の永続化です。Redisへ実行されたコマンドをログとして記録します。例えば、
SET user:1 Alice
INCR page_view
ZADD ranking 100 Alice
がそのままファイルへ保存されます。
Redis Command
↓
appendonly.aof
Redis再起動時は、このログを順番に再実行することでデータを復元します。
AOFのメリット
RDBよりもデータ損失を抑えられます。設定によっては、毎秒ログをディスクへ同期できます。そのため障害発生時でも、失われるデータを最小限にできます。
AOFのデメリット
コマンドを記録し続けるため、ファイルサイズが大きくなりやすいという欠点があります。例えば、
INCR counter
INCR counter
INCR counter
のような更新もすべて記録されます。そのためRedisは定期的にAOF Rewriteを実行し、不要な履歴を整理します。
AOF Rewrite
AOFはコマンドを記録し続けるため、長期間運用するとファイルサイズが肥大化します。例えば次のような操作を考えてみます。
SET counter 0
INCR counter
INCR counter
INCR counter
INCR counter
INCR counter
最終状態は
counter = 5
です。しかしAOFには全ての操作が記録されるため、更新回数が増えるほどファイルサイズも大きくなります。そこでRedisはAOF Rewriteという仕組みを提供しています。AOF Rewriteでは過去の操作履歴を保持するのではなく、現在の状態を再現するために必要な最小限のコマンドへ変換します。例えば先ほどの例であれば、
SET counter 5
へ置き換えることができます。これによってAOFファイルサイズを大幅に削減できます。
Rewrite中の更新はどうなるのか
ここで疑問になるのが、
Rewrite中に新しい書き込みが発生したらどうなるのか
という点です。RedisはRewrite専用の子プロセスを生成し、その時点のメモリ内容から新しいAOFファイルを作成します。
Redis
├─ Main Process
└─ Rewrite Process
一方で、Rewrite中もRedisは通常通りリクエストを受け付けます。その間に発生した更新は、
旧AOF
+
AOF Rewrite Buffer
へ記録されます。Rewrite完了後にその差分を新しいAOFへ追記し、最後にファイルを切り替えます。
新AOF作成
↓
差分追記
↓
アトミックに切り替え
そのためサービス停止なしでAOF Rewriteを実行できます。
RDBとAOFの違い
両者を比較すると次のようになります。
| 項目 | RDB | AOF |
|---|---|---|
| 保存方式 | スナップショット | コマンドログ |
| 復元速度 | 高速 | やや遅い |
| ファイルサイズ | 小さい | 大きくなりやすい |
| データ損失 | 多い | 少ない |
| バックアップ用途 | ◎ | ○ |
本番環境ではどちらを使うか
実際の本番環境では、
RDBかAOFか
ではなく、
RDBとAOFを組み合わせる
ケースが一般的です。
RDB
↓
スナップショット保存
↓
バックアップや高速な復旧に利用
AOF
↓
更新履歴を保存
↓
データ損失を最小限に抑える
RDBは現在の状態をスナップショットとして保存するため、バックアップや復旧時間の短縮に適しています。一方、AOFは更新履歴を記録するため、障害発生時のデータ損失を最小限に抑えることができます。RedisではAOFとRDBの両方が有効になっている場合、起動時はAOFを優先して復旧を行います。これは、AOFの方がより新しい状態を保持している可能性が高いためです。また、RDBはバックアップファイルとしても利用できるため、万が一AOFファイルが破損した場合や復旧できない場合には、運用者がRDBを利用して過去のスナップショット時点まで復旧することも可能です。そのため、RDBとAOFはどちらか一方を選択する関係ではなく、
RDB:バックアップと高速な復旧
AOF:データ損失の最小化
という補完関係にあると考えると理解しやすいでしょう。
キャッシュ用途なら永続化は不要な場合もある
ここで重要なのは、
すべてのRedisに永続化が必要なわけではない
ということです。例えばキャッシュ専用のRedisを考えてみます。
Application
↓
Redis
↓ miss
Database
Redisが消えても、データベースから再生成できます。この場合、
Redis再起動
↓
キャッシュ消失
↓
再構築
で問題ありません。そのためキャッシュ用途では永続化を無効化するケースもあります。一方、
- セッション情報
- ランキング
- Streams
- キュー
などRedis自体がデータソースとなっている場合は、永続化を検討する必要があります。
Redisは永続化が目的のデータベースではない
ここまで見ると、
Redisは永続化もできるならRDBMSの代わりになるのでは?
と思うかもしれません。しかしRedisの永続化は、高速なインメモリ処理を補完するための機能です。Redisの主目的は依然として高速なデータアクセスにあります。そのため、
- 長期保存
- 強い整合性
- 大容量データ
が必要な場合は、RDBMSやNoSQLデータベースとの併用が一般的です。
Pub/Sub・Streamsの使い分け
前章ではRedis Streamsについて紹介しました。StreamsはConsumer GroupやACKをサポートしており、メッセージキューやイベント処理基盤として利用できます。しかしここで疑問が生まれます。RedisにはPub/Subも存在します。では、
- Pub/Sub
- Streams
は何が違うのでしょうか。ここではそれぞれの特徴と使い分けについて見ていきます。
Pub/Sub
Pub/SubはPublish/Subscribeモデルを実現する仕組みです。Publisherがメッセージを送信し、Subscriberが受信します。
Publisher
↓
Redis Pub/Sub
↓
Subscriber
Pub/Subの最大の特徴はリアルタイム性です。Subscriberが接続していれば、メッセージは即座に配信されます。一方で、メッセージは保持されません。例えば、
Subscriber停止
↓
Message送信
した場合、そのメッセージは失われます。後から取得することはできません。
Pub/Subが向いている用途
- チャット
- 通知
- リアルタイム配信
- WebSocket連携
リアルタイム性を重視し、メッセージ損失を許容できるケースに向いています。
Streams
Streamsはイベントやメッセージを保持するためのデータ構造です。
Producer
↓
Redis Streams
↓
Consumer
Pub/Subとの大きな違いは、メッセージを保持できることです。StreamsではConsumerがメッセージを取得しても、Redisはすぐに完了扱いしません。
Message取得
↓
処理
↓
ACK
が行われて初めて処理完了になります。そのため、
Message取得
↓
Consumer障害
が発生しても、未ACKメッセージとして再処理できます。
Streamsが向いている用途
- 非同期ジョブ処理
- イベント処理
- メッセージキュー
- イベント駆動アーキテクチャ
メッセージを失いたくない場合に適しています。
Pub/SubとStreamsの違い
両者の違いを整理すると次のようになります。
| 項目 | Pub/Sub | Streams |
|---|---|---|
| メッセージ保持 | × | ○ |
| 再処理 | × | ○ |
| Consumer Group | × | ○ |
| ACK | × | ○ |
| リアルタイム性 | ◎ | ○ |
Pub/Subは通知、Streamsはジョブ処理やイベント処理と考えると分かりやすいでしょう。
Redisと他の技術の使い分け
ここまでRedisの特徴や内部構造について説明してきました。ここからはRedisと他の代表的な技術を比較しながら、それぞれの使い分けについて整理します。
Redis vs RDBMS
RedisとRDBMSは比較されることがありますが、役割は大きく異なります。
| 項目 | Redis | RDBMS |
|---|---|---|
| 保存場所 | メモリ | ディスク |
| 速度 | 非常に高速 | 高速 |
| 永続化 | 補助的 | 主目的 |
| トランザクション | 限定的 | 強力 |
| 複雑な検索 | 苦手 | 得意 |
例えばユーザー情報を考えてみます。
User Table
のような永続データはRDBMSが適しています。一方、
user:1
のような頻繁に参照されるデータはRedisへキャッシュすることで性能を向上できます。RedisはRDBMSの代替ではなく、補完する存在として利用されることが一般的です。
Redis vs Memcached
Redisとよく比較されるキャッシュサーバーがMemcachedです。
| 項目 | Redis | Memcached |
|---|---|---|
| データ構造 | 豊富 | Key-Valueのみ |
| 永続化 | ○ | × |
| レプリケーション | ○ | × |
| メモリ効率 | ○ | ◎ |
| 機能 | 多い | 少ない |
純粋なキャッシュ用途であればMemcachedでも十分な場合があります。
しかし現在では、
- Sorted Set
- Streams
- Pub/Sub
- 永続化
などの機能を利用できるRedisが選ばれるケースが増えています。
Redis vs Kafka
Redis StreamsとKafkaは比較されることがあります。
| 項目 | Redis Streams | Kafka |
|---|---|---|
| 導入難易度 | 低い | 高い |
| 運用コスト | 低い | 高い |
| スケーラビリティ | 中 | 高 |
| 保持期間 | 短い | 長い |
| 主な用途 | ジョブ処理 | イベント基盤 |
例えば、
会員登録
↓
メール送信
程度の非同期処理であればRedis Streamsで十分です。
一方、
全サービスのイベント収集
↓
分析基盤
↓
機械学習基盤
のような大規模なイベント処理ではKafkaが適しています。
Redis vs RabbitMQ
RabbitMQもメッセージブローカーとして広く利用されています。
| 項目 | Redis Streams | RabbitMQ |
|---|---|---|
| 導入難易度 | 低い | 中 |
| ルーティング機能 | 限定的 | 強力 |
| メッセージング機能 | ○ | ◎ |
| データ構造 | 豊富 | なし |
Redis Streamsはメッセージの保持や再処理に優れています。一方RabbitMQはExchangeによる柔軟なルーティング機能を備えており、「どのサービスへイベントを配送するか」を細かく制御できます。特に複雑なイベント配信や多様な購読パターンが求められるシステムではRabbitMQが選択されることがあります。
発展:Redisによる分散ロックの導入
複数のサーバーやプロセスが同時に同じリソース(特定のエンティティIDなど)を操作するのを防ぐため、Redisを用いた分散ロック(Distributed Lock)を導入して排他制御を行うことも可能です。
Redis側で実行される生のコマンド
Redisを使った分散ロックでは、「すでに別のプロセスが処理中のため、ロックの取得に失敗した際」に、自動で順番待ちをさせるようなキュー機能はデフォルトでは働きません。
そのため、アプリケーションから以下のようなコマンドを発行し、その成否(戻り値)を自前でハンドリングして処理を制御する必要があります。
ロックの取得(SETコマンド)
# キーが存在しない場合のみ(NX)、30秒間(PX 30000)の期限付きで、識別用UUIDを保存
SET lock:user:12345 "64f3b18d-4f05-4e78-9e6b-67a32b2e811c" NX PX 30000
-
成功時:
OKが返り、処理を続行できます。 -
失敗時(すでに他がロック中):
(nil)が返ります。アプリ側でこれを検知し、待機(リトライ)するかエラーを返します。
ロックの安全な解除(Luaスクリプト)
他プロセスのロックを誤って消さないよう、**「自分がセットしたUUIDと一致する場合のみ削除する」**という処理を、Luaスクリプトを用いてアトミックに実行します。
EVAL "if redis.call('get', KEYS) == ARGV then return redis.call('del', KEYS) else return 0 end" 1 lock:user:12345 "64f3b18d-4f05-4e78-9e6b-67a32b2e811c"
キーに lock: を付与する理由
Redisのキー名を単に user:12345 とするのではなく、lock:user:12345 のように lock: という接頭辞(プレフィックス)をつけるのには、運用の継続性において非常に重要な意味があります。
-
データの「実体」と「ロック用フラグ」の混同防止
Redisをキャッシュ(ユーザー情報の保管など)としても使用している場合、lock:をつけないと「キャッシュデータ」を「ロック用のトークン」で上書きして消し去ってしまう致命的なバグの原因になります。 -
運用の可視化
障害発生時などにredis-cliでキー一覧を確認する際、lock:がついているだけで「今まさに排他制御中で、時間が経てば自動消滅(TTLで削除)するキーだな」と即座に判別できます。 -
一括管理の容易さ
Redisの慣習通りコロン(:)で階層化しておくことで、万が一デッドロックなどのトラブルが起きた際も、他のキャッシュデータに影響を与えずlock:*という条件だけで安全に一括走査・削除が可能になります。
## 値に「識別用UUID」をセットする理由
ロックを取得する際、値として 1 などの固定値ではなく、毎回生成した uuid.New().String() をセットします。これは、「そのロックをかけた『実行プロセス(スレッド)』自身にしかロックを解除させないため」です。
固定値(例: "1")にしてしまった場合のバグ
- プロセスAがロック(値: "1")を取得し、重い処理を開始する。
- 処理が長引き、プロセスAのロックの有効期限(TTL)が切れて自動消滅する。
- プロセスBがやってきて、新しくロック(値: "1")を取得して処理を開始する。
- 遅れてプロセスAの処理が完了し、ロック解除(
DEL)を実行する。 - 値が同じ
"1"なので、プロセスAはプロセスBが今まさに使っているロックを誤って消し去ってしまう。 - 結果、守りが崩れてプロセスCまで同時に侵入できるようになり、ロックが機能しなくなる。
UUID(プロセス固有の値)にした場合の挙動
値に プロセスA専用のUUID を入れておけば、4の手順でプロセスAが解除しようとした際、Luaスクリプト内部で「今RedisにあるのはプロセスBのUUIDだな」と判定できます。これにより、他人のロックを誤って消す事故を確実に防ぐことができます。
Go言語による実装例
Goの定番Redisクライアントである go-redis/v9 を使用し、他プロセスが処理中でロックが取得できなかった場合に、一定時間リトライ(スピンロック)を行う実装例です。
package main
import (
"context"
"errors"
"fmt"
"time"
"://github.com"
"://github.com"
)
var ctx = context.Background()
// ロック取得を試みる(スピンロック実装)
func acquireLockWithRetry(rdb *redis.Client, lockKey string, token string, ttl time.Duration, maxRetries int, retryDelay time.Duration) (bool, error) {
for i := 0; i < maxRetries; i++ {
// 生のコマンド: SET key token NX PX ttl
success, err := rdb.SetNX(ctx, lockKey, token, ttl).Result()
if err != nil {
return false, err
}
if success {
return true, nil // ロック取得成功
}
// 他のプロセスが処理中で取得失敗した場合は、一定時間待機してリトライ
time.Sleep(retryDelay)
}
return false, nil // リトライ上限に達し、ロック取得失敗
}
// ロックを安全に解除する(Luaスクリプト実行)
func releaseLock(rdb *redis.Client, lockKey string, token string) error {
// 自分が取得したロック(トークンが一致)のみ削除するLuaスクリプト
var luaReleaseScript = redis.NewScript(`
if redis.call("get", KEYS) == ARGV then
return redis.call("del", KEYS)
else
return 0
end
`)
// 生のコマンド: EVAL script 1 key token
res, err := luaReleaseScript.Run(ctx, rdb, []string{lockKey}, token).Result()
if err != nil {
return err
}
if res.(int64) == 0 {
return errors.New("lock could not be released (token mismatch or expired)")
}
return nil
}
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
lockKey := "lock:user:12345"
token := uuid.New().String() // クライアント(プロセス)固有の一意のID
ttl := 30 * time.Second
// 1. ロックの取得(最大5回リトライ、100ms間隔)
locked, err := acquireLockWithRetry(rdb, lockKey, token, ttl, 5, 100*time.Millisecond)
if err != nil {
panic(err)
}
if !locked {
fmt.Println("二重実行防止:ロックの取得に失敗しました。")
return
}
// 確実にロックを解除する
defer func() {
if err := releaseLock(rdb, lockKey, token); err != nil {
fmt.Printf("ロック解除失敗: %v\n", err)
} else {
fmt.Println("ロックを安全に解除しました。")
}
}()
// 2. メインのビジネスロジックをここに記述
fmt.Println("ロック取得成功。排他処理を実行中...")
time.Sleep(2 * time.Second)
}
導入時のポイント(競合と有効期限の管理)
-
競合時の挙動(デフォルトは非ブロッキング)
前述の通り、RedisのSetNXは非ブロッキングなコマンドです。誰かが処理中のときは即座に失敗を返すため、順番に処理を待たせたい場合は上記のGoコードのようにtime.Sleepを挟んだリトライ制御(スピンロック)の自作か、既存の検証済みライブラリ(Redsyncなど)の導入が必要です。 -
適切なTTL(有効期限)の設定
プロセスが途中で強制終了した際のデッドロックを防ぐためにTTLは必須ですが、実際の処理時間がTTL(上記例では30秒)を超えると、処理の途中でロックが自動解放されてしまいます。処理時間に対して十分余裕を持った値を設定してください。
難所:処理が長引いてRedisのTTLが切れた場合の対策
「DBのSQL実行や外部APIのレスポンス待ちで処理が長引き、RedisのTTL(有効期限)が切れたが、プロセス自体はまだ動いている」という状況が発生すると、別のプロセスがロックを取得できてしまい、排他制御が崩壊します。
この「Redisのロック状態」と「実際の処理状態」のズレを防ぐには、主に3つの解決アプローチがあります。
① ロックの自動延長(ウォッチドッグ機構)の導入
処理が続いている間、バックグラウンドでタイマーを動かし、**RedisのTTLを定期的に後ろへ延ばし続ける(生存報告をする)**仕組みです。
- 仕組み: 例えばTTLが30秒なら、10秒ごとに「まだ処理中だからTTLをあと30秒にリセットして」というコマンドをRedisに送り続けます。処理が終わったら通常通りロックを削除します。
-
Goでの実現方法: 自前でゴルーチン(
go func())とtime.Tickerを使って実装することも可能ですが、バグを生みやすいため、この機構が最初から組み込まれている検証済みライブラリを導入するのが業界の定石です。
② DB負荷を抑えつつ、Redis障害やTTL切れに備える「悲観的ロック」との組み合わせ
Redisで分散ロックを行う最大の目的は、大量のリクエストを前段の軽量なRedisで弾き、DB側での悲観的ロック(FOR UPDATEなど)の多発によるコネクション占有やデッドロック(=DBパフォーマンスの大幅な悪化)を防ぐことです。
基本的にはRedis側でリクエストが1つに絞られるため、DB側で重ねて悲観的ロックをかけるのは無駄に見えますが、「RedisのTTL切れ」や「Redis自体の障害」に対する最強の最終防衛線(防御的プログラミング)として非常に有用です。
-
仕組み: アプリ層ではRedisロックで99.9%の重複リクエストを即座に弾きます。Redisを通過した「たった1つの本職プロセス」だけが、DB側で短時間の
SELECT ... FOR UPDATE(悲観的ロック)を実行します。 -
相乗効果(メリット):
- パフォーマンスの維持: 同時多発的な重いDBロックは発生しないため、データベースのパフォーマンスは高く保たれます。
-
Redisの「TTL切れ」の救済: 万が一、ネットワーク遅延や処理遅延でRedisのTTLが切れて「2つ目のプロセス」が侵入してきても、DB側の悲観的ロックによって書き込み手前(
SELECT時点)で確実にブロック(待機)されます。 - Redisの「予期せぬ障害」への耐性: Redisのノードが突然ダウンしたり、フェイルオーバー(主従切り替え)時の同期ズレによって一時的にロックの整合性が崩れたりすることがあります。そうしたRedis側のインフラ障害が起きた瞬間でも、DB側の悲観的ロックが最後の砦として機能するため、データの不整合(レースコンディション)が起きるリスクを絶対ゼロに抑え込めます。
③ 諦めて「超長いTTL」を設定する
対象の処理が「数秒〜数十秒で確実に終わるもの」であり、万が一システムがクラッシュしてデッドロックになっても数分後に自動解除されれば問題ないレベルであれば、実際の最大処理時間の3〜5倍のTTL(例: 5分など)を最初から設定しておくという割り切ったアプローチです。
単純ですが、ウォッチドッグなどの複雑なコードを管理しなくて済むというメリットがあります。
まとめ
Redisというとキャッシュサーバーというイメージを持つ方も多いかもしれません。実際、Redisは高速なインメモリデータベースとして広く利用されており、データベース負荷の軽減やレスポンス速度の向上に大きく貢献します。しかし本記事で見てきたように、Redisの価値は単なるキャッシュに留まりません。Redisは豊富なデータ構造を備えており、
- キャッシュ
- ランキング
- レートリミット
- 分散ロック
- 非同期ジョブ処理
- イベント処理
など、様々な用途で活用できます。また、
- RDBとAOFによる永続化
- Streamsによるメッセージング
といった機能によって、単なるKey-Valueストア以上の役割を担うことができます。一方でRedisは万能な技術ではありません。長期的なデータ保存や複雑な検索処理はRDBMSが得意ですし、大規模なイベントストリーミング基盤ではKafkaやRabbitMQといった選択肢が適している場合もあります。Redisの内部構造や特徴を理解しておくことで、
「なぜRedisを採用するのか」
「なぜRedisではなく別の技術を選ぶのか」
といった技術選定の判断もできるようになります。
本記事が、Redisを単なるキャッシュサーバーとしてではなく、システム設計を支える重要なコンポーネントとして理解するきっかけになれば幸いです。