はじめに
Redisは以下のユースケースで特に強みを発揮します:
- カウンター(いいね数、閲覧数など)
- セッションストア
- 分散ロック
- レート制限
- リーダーボード
本ドキュメントでは、特にカウンターと分散ロックに焦点を当て、なぜRedisがMySQLより優れているのか、その内部動作の仕組みまで詳しく解説します。
RedisとMySQLの違い
基本的な特性比較
| 特性 | Redis | MySQL |
|---|---|---|
| データ保存 | メモリ(RAM) | ディスク(SSD/HDD) |
| レイテンシ | <1ms | 数ms〜数十ms |
| 設計思想 | Key-Valueストア | リレーショナルDB |
| トランザクション | 軽量 | ACID準拠で重い |
| 同時実行 | シングルスレッド(競合なし) | マルチスレッド(ロック競合) |
| データ永続化 | オプション(RDB/AOF) | デフォルト |
カウンター操作の比較
Redis:
INCR post:123:likes
# 1コマンドで完結、アトミック保証
MySQL:
BEGIN;
SELECT count FROM counters WHERE id = 1 FOR UPDATE;
UPDATE counters SET count = count + 1 WHERE id = 1;
COMMIT;
MySQLの課題:
- ディスクI/Oが発生(たとえインデックスがあっても)
- 複数ステップ必要
- ロック競合が起きやすい
- 秒間数万回のインクリメントには不向き
パフォーマンス比較
秒間100万回のカウント操作:
- ✅ Redis: 問題なく処理可能
- ❌ MySQL: ボトルネックになる(ロック待ち、I/Oボトルネック)
Redisのシングルスレッドアーキテクチャ
基本構造
Redisの最大の特徴はシングルスレッドモデルです:
クライアント1: INCR counter → |
クライアント2: INCR counter → | → キュー → Redis(単一スレッド) → 順次実行
クライアント3: INCR counter → |
重要ポイント:
- Redisはコマンドを1つずつ順番に実行
- 複数のクライアントからリクエストが来ても、内部では直列化されて処理される
- そのため、ロック機構が不要
なぜシングルスレッドなのか?
-
アトミック性の保証が簡単
- 競合条件(Race Condition)が発生しない
- 複雑なロック機構が不要
-
メモリ操作の最適化
- コンテキストスイッチのオーバーヘッドがない
- CPUキャッシュの効率が良い
-
シンプルな実装
- デッドロックの心配がない
- デバッグとメンテナンスが容易
よくある誤解
❌ 誤解: 「メモリに書き込む(I/Oボトルネックがない)ので、安全性が保証されている」
✅ 正解:
- メモリ操作だから速い ← パフォーマンス面
- シングルスレッドで順次実行されるから安全 ← 一貫性面
メモリ操作とアトミック性は別の概念です。
カウンターの仕組み
アトミック操作の保証
INCR post:123:likes
このコマンドは内部で以下が途中で割り込まれることなく実行されます:
-
post:123:likesの値を読む - +1する
- 書き込む
マルチスレッドとの比較
もしRedisがマルチスレッドだったら:
Thread1: 読む(100) → +1 → 書く(101)
Thread2: 読む(100) → +1 → 書く(101) ← 本当は102になるべき
# Lost Update問題発生!
Redisのシングルスレッド:
Client1: INCR (100 → 101) ← 完全に実行
Client2: INCR (101 → 102) ← 次に実行
# 正確に102になる
同時に1000人がいいねを押しても安全
# 同時に1000人が「いいね」を押しても
Client1: INCR post:123:likes
Client2: INCR post:123:likes
Client3: INCR post:123:likes
...
Client1000: INCR post:123:likes
# 結果は正確に +1000 される
# なぜなら各INCRが順番に実行されるから
その他のカウンター操作
# インクリメント
INCR counter # +1
INCRBY counter 5 # +5
# デクリメント
DECR counter # -1
DECRBY counter 3 # -3
# 現在の値を取得
GET counter
# 複数のカウンターを同時に取得
MGET counter1 counter2 counter3
分散ロックの仕組み
基本的な実装
# SET NX (Not eXists) = キーが存在しない場合のみセット
SET lock:resource1 token123 NX EX 30
# 内部処理(アトミックに実行される)
# 1. lock:resource1 が存在するかチェック
# 2. 存在しなければセット、存在すれば失敗
# 3. TTLを30秒にセット
シングルスレッドによる保証:
Client1: SET lock:resource1 xxx NX → 成功 (ロック取得)
Client2: SET lock:resource1 yyy NX → 失敗 (既に存在)
Client3: SET lock:resource1 zzz NX → 失敗 (既に存在)
これらが順次実行されるため、必ず1つだけが成功します。
カウンターとの違い
| 用途 | カウンター | 分散ロック |
|---|---|---|
| 目的 | 値を変更する | 排他制御する |
| コマンド | INCR |
SET NX |
| 結果 | 全員の操作が反映される | 1人だけが成功する |
| 原理 | 同じ(シングルスレッド) | 同じ(シングルスレッド) |
分散ロックが必要な理由
なぜカウンターだけじゃダメ?
# ❌ ロックなし(Race Condition)
1. サーバーA: stock = redis.get('stock:item123') # 読み取り: 1
2. サーバーB: stock = redis.get('stock:item123') # 読み取り: 1
3. サーバーA: if stock > 0 → redis.decr('stock:item123')
4. サーバーB: if stock > 0 → redis.decr('stock:item123')
# 両方とも減らしてしまう(在庫が-1になる)
# ✅ ロックあり
1. サーバーA: redis.set('lock:stock', tokenA, nx=True) → 成功
2. サーバーB: redis.set('lock:stock', tokenB, nx=True) → 失敗(待機)
3. サーバーA: 在庫確認 → 減らす → 複雑なビジネスロジック → ロック解放
4. サーバーB: ロック取得 → 在庫確認 → 減らす
自動期限切れの重要性
SET lock:resource1 token NX EX 30
# 30秒後に自動削除
# → サーバーがクラッシュしてもロックが永久に残らない
MySQLのロックとの違い:
- MySQL: 接続が切れるまでロック保持(デッドロックのリスク)
- Redis: TTLで自動解放(デッドロック防止)
MySQLロックとの比較
根本的な違い: スコープ
MySQLのロック
[アプリサーバー1] ──┐
[アプリサーバー2] ──┼─→ [MySQL] ← ここだけでロック
[アプリサーバー3] ──┘
- MySQL内のデータを保護
- 同じDBセッション/トランザクション内でのみ有効
- 目的: データベース層の整合性保護
Redisの分散ロック
[アプリサーバー1] ──┐
[アプリサーバー2] ──┼─→ [Redis] ← 複数サーバー間を調整
[アプリサーバー3] ──┘
↓
各サーバーがそれぞれ
[MySQL]、[API呼び出し]、[ファイル処理] などを実行
- アプリケーションレベルの処理を保護
- 複数サーバー間で排他制御
- 目的: アプリケーション層の排他制御
具体例: 注文処理
❌ MySQLロックだけでは不十分
-- サーバーA、B、Cが同時にこの処理を実行
BEGIN;
SELECT stock FROM items WHERE id=123 FOR UPDATE;
-- ← ここでMySQLの行はロックされている
-- 在庫確認
IF stock > 0 THEN
-- 外部API呼び出し(時間がかかる)
-- payment_api.charge() → 5秒かかる
-- 在庫を減らす
UPDATE items SET stock=stock-1 WHERE id=123;
END IF;
COMMIT;
-- ← ここでやっとロック解放
問題点:
- トランザクション中に外部API呼び出しをすると、MySQLのロックを長時間保持
- 他のリクエストが待たされる
- デッドロックのリスク
- MySQLはデータベース操作のみを保護する設計
✅ Redis分散ロック + MySQLトランザクション
1. redis.set('lock:order:123', token, nx=True, ex=30) → 成功
# Redis分散ロックで全体を保護
2. SELECT stock FROM items WHERE id=123
# MySQLの読み取り(ロックなし)
3. IF stock > 0:
3-1. payment_api.charge()
# 外部API(Redisのロック下で実行)
3-2. BEGIN
UPDATE items SET stock=stock-1 WHERE id=123
COMMIT
# MySQLは短いトランザクションだけ
4. redis.delete('lock:order:123')
# ロック解放
メリット:
- MySQLのトランザクションは短く保てる
- 外部APIやファイル操作も含めて保護できる
- 複数サーバー間で調整可能
MySQLのロックの種類
1. 行ロック (Row Lock)
BEGIN;
SELECT * FROM orders WHERE id=123 FOR UPDATE;
-- この行だけロック
UPDATE orders SET status='processing' WHERE id=123;
COMMIT;
- スコープ: そのトランザクション内だけ
- 限界: トランザクション外の処理は保護できない
2. テーブルロック
LOCK TABLES orders WRITE;
-- テーブル全体をロック
UPDATE orders SET status='processing' WHERE id=123;
UNLOCK TABLES;
- 問題: 重すぎる、スケールしない
3. アドバイザリロック
SELECT GET_LOCK('my_lock', 10);
-- アプリケーションレベルのロック
SELECT RELEASE_LOCK('my_lock');
- 問題: 接続に紐付く、複数DBインスタンスでは使えない
使い分けの判断基準
| ケース | MySQLロック | Redis分散ロック |
|---|---|---|
| DB内の整合性保護 | ✅ 適切 | ❌ 不要 |
| 複数サーバー間の調整 | ❌ 不可能 | ✅ 必須 |
| 外部API呼び出しを含む処理 | ❌ 不適切 | ✅ 適切 |
| ファイル処理の排他制御 | ❌ 不可能 | ✅ 適切 |
| クーポン重複使用防止 | △ 可能だが遅い | ✅ 最適 |
| マイクロサービス間の調整 | ❌ 不可能 | ✅ 必須 |
アーキテクチャパターン
[複数のアプリサーバー]
↓
[Redis分散ロック] ← アプリケーション層の排他制御
↓
クリティカルセクション:
- 外部API呼び出し
- ファイル操作
- 複雑なビジネスロジック
- ↓
[MySQLトランザクション] ← データ層の整合性保証
分散ロックの"分散"の意味
"分散"とは何を指すか
答え: 複数のアプリケーションサーバー/プロセス間での調整を意味する
[アプリサーバー1] ──┐
[アプリサーバー2] ──┼─→ [Redis] ← 中央の調整役
[アプリサーバー3] ──┘
↑
"分散"している
"分散ロック"と呼ぶ理由:
- アプリケーションが物理的に分散している(複数サーバー、複数プロセス)
- それらの間でロックを共有する必要がある
- Redisが中央の調整役として機能
ローカルロックとの違い
ローカルロック(分散じゃない)
単一プロセス内のロック
- threading.Lock() (Python)
- synchronized (Java)
- Mutex (Go)
このプロセス内のスレッド間でのみ有効
問題点:
[サーバー1]
└─ ローカルロック ← このサーバーだけ
[サーバー2]
└─ ローカルロック ← 別のロック!
# サーバー1とサーバー2が同時に同じ注文を処理してしまう
分散ロック
すべてのサーバーが同じRedisを参照
redis.set('lock:order:123', token, nx=True, ex=10)
サーバー1、2、3のどれか1つだけが成功する
実際のユースケース
ケース1: ロードバランサー配下の複数サーバー
[ロードバランサー]
↓
┌─────────┼─────────┐
↓ ↓ ↓
[サーバー1] [サーバー2] [サーバー3]
└─────────┼─────────┘
↓
[Redis] ← 分散ロック
シナリオ: クーポン1回限り使用
ユーザーが2つのブラウザタブで同時に「使用」ボタンをクリック
リクエストがサーバー1とサーバー2に振り分けられる
サーバー1: redis.set('lock:coupon:ABC', token1, nx=True) → 成功
サーバー2: redis.set('lock:coupon:ABC', token2, nx=True) → 失敗
サーバー1だけがクーポン使用処理を実行
サーバー2は「クーポンは使用中です」を返す
ケース2: バッチ処理(複数のWorker)
[Workerプロセス1] ──┐
[Workerプロセス2] ──┼─→ [Redis分散ロック]
[Workerプロセス3] ──┘
↓
同じタスクを重複実行しないように
シナリオ: 日次レポート生成(1日1回だけ実行したい)
Worker1: redis.set('lock:report:2025-01-15', id1, nx=True) → 成功
Worker2: redis.set('lock:report:2025-01-15', id2, nx=True) → 失敗
Worker3: redis.set('lock:report:2025-01-15', id3, nx=True) → 失敗
Worker1だけがレポート生成を実行
Worker2、3は「別のWorkerが処理中」を認識
ケース3: マイクロサービス環境
[注文サービス@サーバーA] ──┐
[在庫サービス@サーバーB] ──┼─→ [Redis分散ロック]
[決済サービス@サーバーC] ──┘
↓
異なるサービス間でも調整が必要
各サービスが独自のDBを持つと、MySQLのロックでは調整不可能
"分散"の定義まとめ
| 用語 | 意味 | スコープ |
|---|---|---|
| ローカルロック | 1つのプロセス/サーバー内だけ | 単一プロセス |
| 分散ロック | 複数のプロセス/サーバー間で共有 | 複数プロセス/サーバー |
| 分散システム | 複数のマシンで構成されるシステム | 複数マシン |
まとめ
核心の理解
- MySQLロック = DB内のデータ整合性を保護
- Redis分散ロック = アプリケーション処理の排他制御(複数サーバー間)
- Redisはシングルスレッド により操作を保証 → カウントアップ、排他制御が得意
Redisが得意な理由
| 特徴 | 理由 | 得意な用途 |
|---|---|---|
| シングルスレッド | 順次実行 → 競合なし | カウンター、分散ロック |
| メモリベース | ディスクI/Oなし | 高速アクセス |
| TTL自動設定 | 自動期限切れ | デッドロック防止 |
| アトミックコマンド |
INCR, SET NX
|
安全な更新 |
適切な使い分け
# ✅ 良いパターン: Redis分散ロック + MySQLトランザクション
1. redis.set('lock:order:123', token, nx=True, ex=30)
2. 外部API呼び出し(この間MySQLはロックされていない)
3. BEGIN; UPDATE orders; COMMIT;(短いトランザクション)
4. redis.delete('lock:order:123')
# ❌ 悪いパターン: MySQLロックで全てを保護
1. BEGIN; SELECT FOR UPDATE;(MySQLロック開始)
2. 外部API呼び出し(この間ずっとMySQLをロック)
3. UPDATE; COMMIT;(ここでやっとロック解放)
パフォーマンス比較
| 操作 | Redis | MySQL |
|---|---|---|
| カウント(秒間) | 100万回+ | 数千〜数万回 |
| ロック取得 | <1ms | 数ms〜 |
| 複数サーバー対応 | ✅ | ❌ |
| 自動期限切れ | ✅ | △(複雑) |
ベストプラクティス
- カウンター: 常にRedisを使用
-
分散ロック:
- 複数サーバー環境 → Redis必須
- 単一サーバー → ローカルロックで十分
- データ整合性: MySQLトランザクション
- 複合処理: Redis分散ロック + 短いMySQLトランザクション