Redis 分散ロック設計:Redlock のリスクと安全な代替・運用ルール
分散システムでは、複数のサーバーが同時に同じリソースにアクセスすることを防ぐために「分散ロック」が必要になります。Redis を使った分散ロックは高速で実装も比較的簡単なため、多くのシステムで採用されていますね。
しかし、Redis の分散ロックアルゴリズム「Redlock」には、実は見過ごせないリスクが潜んでいることをご存知でしょうか。本記事では、Redlock の仕組みと問題点を詳しく解説し、より安全な代替案と運用ルールをご紹介します。分散ロックの設計で悩んでいる方や、既存システムの見直しを検討している方にとって、実践的なガイドラインとなる内容です。
背景
分散システムにおけるロックの必要性
分散システムでは、複数のアプリケーションサーバーが同時に稼働しています。例えば、在庫管理システムで複数のサーバーが同時に「在庫数を減らす」処理を実行すると、データの整合性が崩れてしまうでしょう。
このような問題を防ぐために、一度に 1 つのサーバーだけが特定の処理を実行できるようにする仕組みが「分散ロック」です。分散ロックは、データベースのトランザクションだけでは解決できない、複数ステップにまたがる処理の排他制御に使われますね。
Redis を分散ロックに使う理由
Redis は以下の特徴から、分散ロックの実装に広く使われています。
| # | 特徴 | 説明 |
|---|---|---|
| 1 | 高速性 | インメモリデータベースなので応答速度が非常に速い |
| 2 | アトミック操作 | SET NX EX などのアトミックなコマンドを提供 |
| 3 | TTL 機能 | キーに有効期限を設定でき、デッドロックを防げる |
| 4 | シンプルな実装 | 数行のコードでロック機構を実装できる |
以下の図は、Redis を使った基本的な分散ロックの動作フローを示しています。
mermaidsequenceDiagram
participant App1 as アプリ1
participant App2 as アプリ2
participant Redis as Redis
App1->>Redis: SET lock_key unique_id NX EX 10
Redis-->>App1: OK(ロック取得成功)
App2->>Redis: SET lock_key unique_id NX EX 10
Redis-->>App2: NULL(ロック取得失敗)
Note over App1: 排他処理を実行
App1->>Redis: DEL lock_key(ロック解放)
Redis-->>App1: OK
App2->>Redis: SET lock_key unique_id NX EX 10
Redis-->>App2: OK(ロック取得成功)
この図から、App1 がロックを取得している間は App2 が待機し、ロック解放後に処理を開始できることがわかります。
Redlock アルゴリズムの登場
単一の Redis サーバーでは、そのサーバーがダウンするとロック機能全体が停止してしまいます。この問題を解決するために、Redis の作者である Salvatore Sanfilippo 氏が提案したのが「Redlock」アルゴリズムです。
Redlock は複数の独立した Redis インスタンス(通常は 5 つ)を使い、過半数(3 つ以上)からロックを取得できた場合にのみ、ロックの取得成功とみなす仕組みですね。
課題
Redlock の仕組みと理論
Redlock は以下のステップでロックを取得します。
Redlock のロック取得手順
- 現在時刻を取得
- すべての Redis インスタンスに対して順番にロックを試行
- 各インスタンスに短いタイムアウトを設定してロックを要求
- 過半数のインスタンスからロックを取得できたか確認
- 取得にかかった時間がロックの有効期限より十分短いか検証
以下は Redlock の基本的な動作を示す図です。
mermaidflowchart TB
start["ロック取得開始"] --> time1["現在時刻を記録"]
time1 --> redis1["Redis1へロック要求"]
redis1 --> redis2["Redis2へロック要求"]
redis2 --> redis3["Redis3へロック要求"]
redis3 --> redis4["Redis4へロック要求"]
redis4 --> redis5["Redis5へロック要求"]
redis5 --> check["過半数取得できた?"]
check -->|Yes| valid["有効時間は十分?"]
check -->|No| fail["ロック取得失敗"]
valid -->|Yes| success["ロック取得成功"]
valid -->|No| fail
fail --> release["取得したロックを解放"]
この図から、Redlock が複数段階の検証を経てロックの取得を判断していることがわかります。
Redlock の重大な問題点
一見堅牢に見える Redlock ですが、分散システムの専門家である Martin Kleppmann 氏が 2016 年に重大な欠陥を指摘しました。
タイミング依存性の問題
Redlock は「時間」に依存したアルゴリズムです。しかし、分散システムでは以下の理由で時間の正確性を保証できません。
| # | 問題 | 影響 |
|---|---|---|
| 1 | GC(ガベージコレクション)の停止 | アプリケーションが数秒間停止する可能性 |
| 2 | ネットワーク遅延 | パケット遅延により応答が遅れる |
| 3 | 時刻のずれ | NTP による時刻同期のズレ |
| 4 | プロセスの一時停止 | OS のスケジューリングによる遅延 |
具体的な破綻シナリオ
以下のシナリオで Redlock が破綻します。
mermaidsequenceDiagram
participant Client1 as クライアント1
participant Redis1 as Redis1-3
participant Redis2 as Redis4-5
participant Client2 as クライアント2
Client1->>Redis1: ロック取得(3/5成功)
Note over Client1: ロック取得成功と判断
Note over Client1: GCが発生し10秒停止
Note over Redis1: TTL切れでロック自動解放
Client2->>Redis1: ロック取得(5/5成功)
Client2->>Redis2: ロック取得(5/5成功)
Note over Client2: ロック取得成功と判断
Note over Client1,Client2: 両方がロックを持っている状態
Note over Client1: GC終了、処理を実行
Note over Client2: 処理を実行
Note over Client1,Client2: データ競合発生
この図から、GC による停止がロックの安全性を破壊することが明確にわかりますね。
フェンシングトークンの欠如
真に安全な分散ロックには「フェンシングトークン」が必要です。これは単調増加する番号で、リソースへのアクセス時にトークンを検証することで、古いロック保持者からのアクセスを拒否できます。
しかし、Redlock にはこの仕組みが組み込まれていません。
Martin Kleppmann 氏の批判
Kleppmann 氏は自身のブログ記事で以下のように指摘しています。
「Redlock は、安全性(safety)と活性(liveness)のどちらも保証できない。安全性が必要なら Zookeeper のような合意アルゴリズムを使うべきで、単なる効率性のためなら単一 Redis で十分だ」
この批判は、Redlock が「中途半端」であることを示唆しています。高い安全性が必要なら不十分で、単純な効率性だけなら過剰に複雑だということですね。
Redlock が適さないユースケース
以下の要件がある場合、Redlock は使用すべきではありません。
- 金融取引など、絶対にデータ不整合が許されない処理
- 長時間(数秒以上)かかる処理のロック
- クリティカルセクションで外部 API を呼び出す処理
- 厳密な順序保証が必要な処理
解決策
単一 Redis インスタンスでの分散ロック
多くの場合、複雑な Redlock ではなく、単一の Redis インスタンスで十分です。
シンプルなロック実装
以下は Node.js での基本的な実装例です。
typescript// Redis クライアントのインポート
import { createClient } from 'redis';
// Redis クライアントの初期化
const redis = createClient({
url: 'redis://localhost:6379'
});
typescript// ロック取得関数の実装
async function acquireLock(
lockKey: string,
uniqueId: string,
ttlSeconds: number
): Promise<boolean> {
// SET NX EX コマンドでアトミックにロックを取得
// NX: キーが存在しない場合のみセット
// EX: 有効期限を秒単位で設定
const result = await redis.set(lockKey, uniqueId, {
NX: true,
EX: ttlSeconds
});
return result === 'OK';
}
この実装では、SET コマンドの NX オプションでアトミック性を保証し、EX オプションで自動的にロックを解放します。
typescript// 安全なロック解放関数(Luaスクリプト使用)
async function releaseLock(
lockKey: string,
uniqueId: string
): Promise<boolean> {
// Luaスクリプトで値を確認してから削除
// 自分が取得したロックのみ解放できるようにする
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, {
keys: [lockKey],
arguments: [uniqueId]
});
return result === 1;
}
Lua スクリプトを使うことで、値の確認と削除をアトミックに実行できます。これにより、他のクライアントのロックを誤って解放してしまう事態を防げますね。
使用例
typescript// ロックを使った排他処理の実装
async function processWithLock() {
const lockKey = 'inventory:product:123';
// ユニークIDは UUID などを使用
const uniqueId = crypto.randomUUID();
const ttl = 10; // 10秒
try {
// ロック取得を試行
const acquired = await acquireLock(lockKey, uniqueId, ttl);
if (!acquired) {
console.log('ロックを取得できませんでした');
return;
}
console.log('ロック取得成功');
// クリティカルセクション: 排他制御が必要な処理
await updateInventory();
} finally {
// 必ずロックを解放
await releaseLock(lockKey, uniqueId);
console.log('ロック解放完了');
}
}
この例では、try-finally ブロックを使用してロックの解放を保証しています。
単一 Redis の利点
| # | 利点 | 説明 |
|---|---|---|
| 1 | シンプル | 実装と運用が簡単 |
| 2 | 低レイテンシ | 複数インスタンスへの問い合わせが不要 |
| 3 | デバッグ容易 | 動作の追跡と問題の特定が簡単 |
| 4 | コスト削減 | 1つのインスタンスのみで済む |
Redis Sentinel による高可用性
単一 Redis の弱点は可用性です。これを解決するのが Redis Sentinel による監視とフェイルオーバーです。
mermaidflowchart LR
App["アプリケーション"] -->|接続| Sentinel1["Sentinel 1"]
App -->|接続| Sentinel2["Sentinel 2"]
App -->|接続| Sentinel3["Sentinel 3"]
Sentinel1 -->|監視| Master["Redis Master"]
Sentinel2 -->|監視| Master
Sentinel3 -->|監視| Master
Master -->|レプリケーション| Replica1["Redis Replica 1"]
Master -->|レプリケーション| Replica2["Redis Replica 2"]
style Master fill:#ff9999
style Sentinel1 fill:#99ccff
style Sentinel2 fill:#99ccff
style Sentinel3 fill:#99ccff
Sentinel はマスターの障害を検知し、自動的にレプリカを新しいマスターに昇格させます。
Sentinel 構成での注意点
Sentinel を使用する場合も、レプリケーションの遅延により一時的に複数のクライアントがロックを取得する可能性があります。これは以下の理由で発生します。
- マスターでロックを取得
- レプリカへの同期前にマスターが故障
- レプリカがマスターに昇格(ロック情報なし)
- 別のクライアントがロック取得
完全な安全性が必要な場合は、後述する合意ベースのシステムを検討すべきでしょう。
Redisson ライブラリの活用
Java/JVM 環境では、Redisson というライブラリが高度なロック機能を提供します。
Redisson の特徴
java// Redisson クライアントの設定
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonExample {
public static RedissonClient createClient() {
Config config = new Config();
// 単一サーバー構成
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5);
return Redisson.create(config);
}
}
Redisson は設定も簡潔で、接続プールの管理も自動的に行います。
java// Redisson の分散ロック使用例
import org.redisson.api.RLock;
import java.util.concurrent.TimeUnit;
public class DistributedLockExample {
private final RedissonClient redisson;
public void processWithLock() throws InterruptedException {
// ロックオブジェクトの取得
RLock lock = redisson.getLock("inventory:product:123");
try {
// ロック取得を試行(最大10秒待機、30秒でTTL)
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
System.out.println("ロック取得失敗");
return;
}
// クリティカルセクション
updateInventory();
} finally {
// ロックの解放(自分が保持している場合のみ)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Redisson は内部でウォッチドッグ機構を実装しており、処理が長引いた場合に自動的にロックの TTL を延長します。これにより、処理中にロックが期限切れになる問題を回避できますね。
Redisson の高度な機能
java// フェアロック:先着順でロックを取得
RLock fairLock = redisson.getFairLock("myFairLock");
// 読み書きロック:複数の読み取りを許可
RReadWriteLock rwLock = redisson.getReadWriteLock("myRWLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();
// セマフォ:同時実行数を制限
RSemaphore semaphore = redisson.getSemaphore("mySemaphore");
semaphore.tryAcquire(3, TimeUnit.SECONDS);
これらの機能により、複雑な排他制御も実装できます。
Zookeeper による合意ベースのロック
厳密な安全性が必要な場合は、Apache Zookeeper のような合意アルゴリズム(Paxos/Zab)を使ったシステムが適切です。
Zookeeper の仕組み
mermaidflowchart TB
Client["クライアント"] -->|ロック要求| ZK["Zookeeper クラスタ"]
ZK --> Leader["Leader"]
Leader --> Follower1["Follower 1"]
Leader --> Follower2["Follower 2"]
subgraph ZookeeperCluster["Zookeeper クラスタ(Quorum)"]
Leader
Follower1
Follower2
end
Leader -.->|過半数の合意| Follower1
Leader -.->|過半数の合意| Follower2
Zookeeper は過半数のノードが合意した場合のみデータを確定するため、ネットワーク分断時でも一貫性を保証します。
Zookeeper のロック実装例
javascript// Node.js での Zookeeper クライアント使用例
const zookeeper = require('node-zookeeper-client');
// Zookeeper クライアントの作成
function createZKClient() {
const client = zookeeper.createClient('localhost:2181', {
sessionTimeout: 30000,
retries: 3
});
client.connect();
return client;
}
javascript// Zookeeper でのロック取得関数
async function acquireZKLock(client, lockPath) {
return new Promise((resolve, reject) => {
// エフェメラルシーケンシャルノードを作成
// クライアント切断時に自動削除される
client.create(
lockPath + '/lock-',
Buffer.from(''),
zookeeper.CreateMode.EPHEMERAL_SEQUENTIAL,
(error, path) => {
if (error) {
reject(error);
return;
}
// 作成されたノードのパスを返す
resolve(path);
}
);
});
}
エフェメラルノードは、クライアントのセッションが切れると自動的に削除されるため、デッドロックを防げます。
javascript// 最小シーケンス番号の確認とロック取得
async function checkAndWaitForLock(client, lockPath, myPath) {
const children = await getChildren(client, lockPath);
// シーケンス番号でソート
children.sort();
const mySequence = myPath.split('/').pop();
// 自分が最小のシーケンス番号なら、ロック取得成功
if (children[0] === mySequence) {
return true;
}
// そうでなければ、前のノードを監視して待機
const myIndex = children.indexOf(mySequence);
const watchPath = lockPath + '/' + children[myIndex - 1];
// 前のノードの削除を待つ
await watchNode(client, watchPath);
return checkAndWaitForLock(client, lockPath, myPath);
}
この実装では、各クライアントが順番待ちの列に並び、自分の順番が来るまで待機します。
Zookeeper の利点と欠点
| # | 項目 | 内容 |
|---|---|---|
| 1 | 利点: 強い一貫性 | Paxos/Zab による厳密な合意保証 |
| 2 | 利点: フェンシング | 単調増加する zxid でフェンシングトークンを実現 |
| 3 | 利点: デッドロック回避 | エフェメラルノードによる自動クリーンアップ |
| 4 | 欠点: 複雑性 | 運用とメンテナンスが Redis より複雑 |
| 5 | 欠点: レイテンシ | ディスクへの書き込みが発生し Redis より遅い |
etcd による分散ロック
Kubernetes のバックエンドとして有名な etcd も、優れた分散ロック機能を提供します。
etcd の特徴
typescript// etcd クライアントの初期化
import { Etcd3 } from 'etcd3';
const client = new Etcd3({
hosts: ['localhost:2379'],
dialTimeout: 3000
});
typescript// etcd でのロック取得と使用
async function processWithEtcdLock() {
const lock = client.lock('inventory/product/123');
try {
// ロックを取得(最大10秒待機)
await lock.acquire();
console.log('ロック取得成功');
// クリティカルセクション
await updateInventory();
} finally {
// ロックを解放
await lock.release();
}
}
etcd のロックは内部的にリース機構を使い、クライアントが定期的にハートビートを送ることでロックを保持します。
etcd のリース機構
typescript// 明示的なリース管理の例
async function lockWithLease() {
// 10秒間有効なリースを作成
const lease = client.lease(10);
// リースにキーを関連付け
await client.put('lock/myresource')
.value('locked')
.lease(lease)
.exec();
// リースのキープアライブを開始(自動延長)
lease.on('lost', () => {
console.log('リース失効:ロックが解放されました');
});
// 処理実行
await doWork();
// リースを取り消し(ロック解放)
await lease.revoke();
}
リースのキープアライブにより、プロセスが停止した場合でも自動的にロックが解放されます。
各ソリューションの比較
以下の表で、各分散ロックソリューションを比較します。
| # | ソリューション | 一貫性 | パフォーマンス | 運用複雑度 | 推奨ケース |
|---|---|---|---|---|---|
| 1 | 単一 Redis | 低 | 非常に高 | 低 | 効率性重視、可用性は Sentinel で補完 |
| 2 | Redlock | 中 | 中 | 中 | 推奨しない |
| 3 | Redisson | 低〜中 | 高 | 低 | Java 環境での実用的な選択 |
| 4 | Zookeeper | 高 | 中 | 高 | 厳密な一貫性が必要 |
| 5 | etcd | 高 | 中 | 中 | Kubernetes 環境で統合 |
具体例
ケーススタディ 1:EC サイトの在庫管理
要件
- 複数のアプリケーションサーバーが在庫を更新
- 在庫の過剰販売は絶対に避けたい
- 購入処理は 1 秒以内に完了することが多い
設計判断
このケースでは、データの一貫性が極めて重要です。しかし、処理時間が短く、Redis の高速性を活かせるため、以下の設計を選択します。
mermaidflowchart TB
User["ユーザー"] -->|購入リクエスト| LB["ロードバランサー"]
LB --> App1["アプリ1"]
LB --> App2["アプリ2"]
LB --> App3["アプリ3"]
App1 -->|ロック取得| Redis["Redis Sentinel"]
App2 -->|ロック取得| Redis
App3 -->|ロック取得| Redis
App1 -->|在庫更新| DB[("PostgreSQL")]
App2 -->|在庫更新| DB
App3 -->|在庫更新| DB
Redis -->|フェイルオーバー| Sentinel["Sentinel クラスタ"]
実装コード
typescript// 在庫管理用のロック機能
interface PurchaseRequest {
productId: string;
quantity: number;
userId: string;
}
class InventoryService {
private redis: RedisClientType;
private db: Database; // PostgreSQLクライアント
constructor(redis: RedisClientType, db: Database) {
this.redis = redis;
this.db = db;
}
}
typescript// 在庫減少処理(分散ロック使用)
async purchaseProduct(request: PurchaseRequest): Promise<boolean> {
const lockKey = `inventory:lock:${request.productId}`;
const lockId = crypto.randomUUID();
const lockTTL = 5; // 5秒でタイムアウト
try {
// ロック取得を試行
const acquired = await this.acquireLock(lockKey, lockId, lockTTL);
if (!acquired) {
throw new Error('ロック取得失敗:他の処理が実行中です');
}
// データベーストランザクション開始
await this.db.beginTransaction();
// 在庫確認
const inventory = await this.db.query(
'SELECT quantity FROM inventory WHERE product_id = $1 FOR UPDATE',
[request.productId]
);
if (inventory.quantity < request.quantity) {
throw new Error('在庫不足');
}
// 在庫減少
await this.db.query(
'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2',
[request.quantity, request.productId]
);
// 注文記録作成
await this.db.query(
'INSERT INTO orders (user_id, product_id, quantity) VALUES ($1, $2, $3)',
[request.userId, request.productId, request.quantity]
);
// コミット
await this.db.commit();
return true;
} catch (error) {
// ロールバック
await this.db.rollback();
throw error;
} finally {
// ロック解放
await this.releaseLock(lockKey, lockId);
}
}
この実装では、Redis のロックと PostgreSQL のトランザクションを組み合わせています。FOR UPDATE により、データベースレベルでも行ロックを取得していますね。
typescript// ヘルパー関数:ロック取得
private async acquireLock(
key: string,
id: string,
ttl: number
): Promise<boolean> {
const result = await this.redis.set(key, id, {
NX: true,
EX: ttl
});
return result === 'OK';
}
// ヘルパー関数:ロック解放
private async releaseLock(key: string, id: string): Promise<void> {
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
await this.redis.eval(script, {
keys: [key],
arguments: [id]
});
}
運用ルール
- ロック TTL は処理時間の 2〜3 倍に設定:通常 1 秒の処理なら TTL は 3〜5 秒
- リトライロジックの実装:ロック取得失敗時は指数バックオフでリトライ
- 監視とアラート:ロック取得失敗率を監視し、閾値を超えたらアラート
- データベースとの二重ロック:
FOR UPDATEで DB レベルでも保護
ケーススタディ 2:バッチ処理の排他制御
要件
- 定期実行されるデータ集計バッチ
- 複数サーバーで同時に実行されてはいけない
- 処理時間は 5〜30 分と変動が大きい
設計判断
処理時間が長いため、固定 TTL では対応が難しいです。Redisson のウォッチドッグ機構を活用します。
java// バッチ処理クラス(Redisson使用)
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;
public class DataAggregationBatch {
private final RedissonClient redisson;
private final DataAggregationService service;
public DataAggregationBatch(
RedissonClient redisson,
DataAggregationService service
) {
this.redisson = redisson;
this.service = service;
}
}
java// バッチ実行メソッド
public void execute() {
RLock lock = redisson.getLock("batch:data-aggregation");
try {
// ロック取得を試行(待機なし)
// leaseTime = -1 でウォッチドッグ有効化
boolean acquired = lock.tryLock(0, -1, TimeUnit.SECONDS);
if (!acquired) {
System.out.println("他のサーバーで実行中のため、スキップします");
return;
}
System.out.println("バッチ処理を開始します");
// 長時間の処理を実行
// ウォッチドッグが自動的にTTLを延長
service.aggregateData();
System.out.println("バッチ処理が完了しました");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("バッチ処理が中断されました");
} finally {
// ロックを保持している場合のみ解放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redisson のウォッチドッグは、デフォルトで 30 秒ごとにロックの TTL を延長します。これにより、処理時間が変動しても安全ですね。
障害時の挙動
java// バッチサーバーがクラッシュした場合のシミュレーション
public class BatchFailureScenario {
public static void demonstrateFailover() {
// サーバー1: バッチ実行中にクラッシュ
// → Redisson のセッションが切れる
// → ウォッチドッグが停止
// → TTL 経過後にロックが自動解放(約30秒)
// サーバー2: 次回のスケジュール実行時
// → ロックが解放されているため取得成功
// → バッチ処理を開始
}
}
この仕組みにより、サーバークラッシュ時でも最大 30 秒程度でバッチが再実行可能になります。
ケーススタディ 3:金融システムの送金処理
要件
- 送金処理の重複実行は絶対に許されない
- ネットワーク障害やサーバー障害があっても一貫性を保証
- 処理時間は 1〜3 秒
設計判断
厳密な安全性が必要なため、etcd を採用します。フェンシングトークンも活用しましょう。
typescript// 送金サービス(etcd 使用)
import { Etcd3, Lease } from 'etcd3';
interface TransferRequest {
transferId: string;
fromAccount: string;
toAccount: string;
amount: number;
}
class TransferService {
private etcd: Etcd3;
private db: Database;
constructor(etcd: Etcd3, db: Database) {
this.etcd = etcd;
this.db = db;
}
}
typescript// フェンシングトークンを使った送金処理
async executeTransfer(request: TransferRequest): Promise<void> {
const lockKey = `transfer:lock:${request.transferId}`;
const lease = this.etcd.lease(10); // 10秒リース
try {
// 分散ロックを取得
const lock = this.etcd.lock(lockKey).lease(lease);
await lock.acquire();
// リースIDをフェンシングトークンとして取得
const fencingToken = await lease.grant();
console.log(`ロック取得: フェンシングトークン=${fencingToken}`);
// リースのキープアライブ開始
const keepAlive = lease.keepaliveOnce();
// 送金処理を実行(フェンシングトークン付き)
await this.processTransfer(request, fencingToken);
console.log('送金処理完了');
} finally {
// リースを明示的に取り消し
await lease.revoke();
}
}
typescript// フェンシングトークンを検証する送金処理
private async processTransfer(
request: TransferRequest,
fencingToken: string
): Promise<void> {
await this.db.beginTransaction();
try {
// データベースに記録されている最後のトークンを確認
const lastToken = await this.db.query(
'SELECT last_fencing_token FROM transfer_locks WHERE transfer_id = $1',
[request.transferId]
);
// 古いトークンでのアクセスを拒否
if (lastToken && lastToken >= fencingToken) {
throw new Error('古いフェンシングトークン: 処理を中止');
}
// 送金元の残高確認
const balance = await this.db.query(
'SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE',
[request.fromAccount]
);
if (balance < request.amount) {
throw new Error('残高不足');
}
// 送金元から減額
await this.db.query(
'UPDATE accounts SET balance = balance - $1 WHERE account_id = $2',
[request.amount, request.fromAccount]
);
// 送金先に加算
await this.db.query(
'UPDATE accounts SET balance = balance + $1 WHERE account_id = $2',
[request.amount, request.toAccount]
);
// フェンシングトークンを更新
await this.db.query(
`INSERT INTO transfer_locks (transfer_id, last_fencing_token)
VALUES ($1, $2)
ON CONFLICT (transfer_id)
DO UPDATE SET last_fencing_token = $2`,
[request.transferId, fencingToken]
);
await this.db.commit();
} catch (error) {
await this.db.rollback();
throw error;
}
}
フェンシングトークンにより、たとえネットワーク遅延で古いリクエストが遅れて到着しても、データベースレベルで拒否できます。
障害シナリオと安全性
mermaidsequenceDiagram
participant S1 as サーバー1
participant etcd as etcd
participant DB as データベース
participant S2 as サーバー2
S1->>etcd: ロック取得(トークン=100)
etcd-->>S1: OK
Note over S1: 処理開始
Note over S1: ネットワーク遅延発生
Note over etcd: リースタイムアウト
etcd->>etcd: ロック自動解放
S2->>etcd: ロック取得(トークン=101)
etcd-->>S2: OK
S2->>DB: 送金実行(トークン=101)
DB-->>S2: OK(トークン更新)
Note over S1: ネットワーク回復
S1->>DB: 送金実行(トークン=100)
DB-->>S1: ERROR(古いトークン)
Note over DB: データの一貫性が保たれる
この図から、フェンシングトークンがどのように二重処理を防ぐかが明確にわかります。
運用上のベストプラクティス
監視とアラート
以下の指標を監視しましょう。
| # | 監視項目 | 閾値例 | アラート内容 |
|---|---|---|---|
| 1 | ロック取得失敗率 | 5%以上 | 競合が多発している可能性 |
| 2 | ロック保持時間 | 予想の3倍以上 | 処理が異常に遅延 |
| 3 | ロックタイムアウト発生数 | 1時間に10回以上 | TTL設定の見直しが必要 |
| 4 | Redis/etcd の可用性 | 99.9%未満 | クラスタの健全性確認 |
ログ記録
typescript// 詳細なログ記録の実装
class DistributedLockLogger {
async acquireLockWithLogging(
lockKey: string,
uniqueId: string,
ttl: number
): Promise<boolean> {
const startTime = Date.now();
console.log({
event: 'lock_acquire_attempt',
lockKey,
uniqueId,
ttl,
timestamp: new Date().toISOString()
});
const acquired = await this.acquireLock(lockKey, uniqueId, ttl);
const duration = Date.now() - startTime;
console.log({
event: acquired ? 'lock_acquired' : 'lock_failed',
lockKey,
uniqueId,
duration,
timestamp: new Date().toISOString()
});
return acquired;
}
}
詳細なログにより、問題発生時の原因究明が容易になります。
リトライ戦略
typescript// 指数バックオフによるリトライ
async function acquireLockWithRetry(
lockKey: string,
maxRetries: number = 3
): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
const uniqueId = crypto.randomUUID();
const acquired = await acquireLock(lockKey, uniqueId, 10);
if (acquired) {
return true;
}
// 指数バックオフ: 100ms, 200ms, 400ms...
const delay = Math.pow(2, i) * 100;
await sleep(delay);
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
まとめ
Redis を使った分散ロックは、高速で実装も簡単なため魅力的です。しかし、本記事で解説したように、Redlock アルゴリズムには重大な安全性の問題があります。
重要なポイントを整理すると、以下の通りです。
- Redlock は推奨されない:タイミング依存性により、厳密な安全性を保証できません
- 単一 Redis で十分なケースが多い:効率性が目的なら、単一インスタンスと Sentinel の組み合わせがシンプルで効果的です
- 厳密な安全性が必要なら合意ベースのシステムを:Zookeeper や etcd はフェンシングトークンと強い一貫性を提供します
- Java 環境では Redisson が実用的:ウォッチドッグ機構により、処理時間が変動する場合でも安全に動作します
選択の指針としては、以下を参考にしてください。
- 効率性と低レイテンシが重要で、短時間の処理 → 単一 Redis + Sentinel
- Java/JVM 環境で実用的なバランスを求める → Redisson
- 絶対にデータ不整合が許されない → Zookeeper または etcd
分散ロックは、システムアーキテクチャの重要な要素です。要件を正しく理解し、適切なツールを選択することで、安全で効率的なシステムを構築できるでしょう。既存システムで Redlock を使用している場合は、この記事を参考に代替案の検討をお勧めします。
関連リンク
articleRedis 分散ロック設計:Redlock のリスクと安全な代替・運用ルール
articleRedis キーネーミング規約チートシート:階層・区切り・TTL ルール
articleRedis Docker Compose 構築:永続化・監視・TLS まで 1 ファイルで
articleRedis Pub/Sub vs Redis Streams:配信保証とスケーラビリティ比較
articleRedis 遅延の原因を特定:Latency Monitor と Slowlog の読み方
articleRedis 7 の新機能まとめ:ACL v2/I/O Threads/RESP3 を一気に把握
articleWebSocket Close コード早見表:正常終了・プロトコル違反・ポリシー違反の実務対応
articleStorybook 品質ゲート運用:Lighthouse/A11y/ビジュアル差分を PR で自動承認
articleWebRTC で高精細 1080p/4K 画面共有:contentHint「detail」と DPI 最適化
articleSolidJS フォーム設計の最適解:コントロール vs アンコントロールドの棲み分け
articleWebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
articleShell Script と Ansible/Make/Taskfile の比較:小規模自動化の最適解を検証
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来