導入
マイクロサービスやクラスタ構成において、複数ノード間でリソースへのアクセスを制御する「分散ロック」は必須の仕組みです。Redis はその高速性と原子性から、分散ロックの実装プラットフォームとして広く採用されています。
本稿では、Redis を利用して信頼性の高い分散ロックを構築するための要件と、実装時に陥りやすい誤りを検証します。
分散ロックが満たすべき要件
可用性の高いシステムを設計するためには、以下の性質を保証する必要があります。
- 排他性: 同一時刻に単一のクライアントのみがロックを保持できる。
- デッドロックの防止: ロック保持中のクライアントが故障しても、ロックが自動的に解放されるメカニズムが必要。
- フォールトトレランス: Redis ノードの一部で障害が発生しても、ロック機能は維持されることが望ましい。
- 所有者特定: ロックの取得者と解放者が同一であること(自分の鍵でしか開けられない)。
ロック獲得の実装
信頼性を確保するために、複数のコマンドではなく、一度のコマンド実行で値を設定かつ有効期限を設定するアプローチを採用します。
public class RedisLockCoordinator {
private static final int LOCK_SUCCESS_CODE = 1;
/**
* 分散ロックの取得を試みる
* @param resourceKey リソース固有のキー
* @param ownerToken 所有権を示す一意なトークン
* @param leaseTimeMillis ロックの有効期限(ミリ秒)
* @return 取得成功時 true
*/
public boolean tryAcquireLock(DefaultSerializer serializer, String resourceKey, String ownerToken, long leaseTimeMillis) {
// SET キー バリュー NX PX 時間
// NX: 存在しない場合のみセット
// PX: ミリ秒指定で过期時間設定
String result = serializer.executeCommand(
"SET",
resourceKey,
ownerToken,
"NX",
"PX",
String.valueOf(leaseTimeMillis)
);
if (result != null && "OK".equals(result)) {
return true;
}
return false;
}
}
上記のアプローチでは、`SET` コマンドに条件フラグを組み込むことで、競合状態を防ぎつつ原子性を担保しています。
- 互斥性の担保: `NX` 引数により、キーが存在する場合の設定を阻止。
- 自動解放: `PX` と時間パラメータにより、サーバー側で自動的に TTL が管理されるため、クライアント停止時のデッドロック回避が可能。
- 所有者識別: Value にユニークな ID(例:UUID)を格納することで、後続の解放処理で正当性を確認できます。
一般的なロック実装の欠陥
問題点1:別コマンドでの設定
`SETNX` で値を設定し、その後 `EXPIRE` で期限を付けるパターンは非推奨です。
// 危険な実装例
public void unsafeLock(String key, String token, long timeoutSec) {
Long exists = redisClient.setnx(key, token);
if (exists == 1) {
// ここでプログラムがクラッシュすると、期限が設定されない
redisClient.expire(key, timeoutSec);
}
}
2 つのコマンド間における中断リスクがあり、期限切れのない永続的なロック状態が引き起こされます。
問題点2:手動タイムスタンプ管理
Value に过期時間を文字列として保存し、`GETSET` を組み合わせて競合を処理しようとする試みは複雑になりすぎます。
// 複雑かつ不正確な実装例
public boolean attemptLockWithTime(String lockKey, long ttlMillis) {
String timestamp = String.valueOf(System.currentTimeMillis() + ttlMillis);
if (redisClient.setnx(lockKey, timestamp) == 1) {
return true;
}
String currentVal = redisClient.get(lockKey);
if (Long.parseLong(currentVal) < System.currentTimeMillis()) {
String oldVal = redisClient.getSet(lockKey, timestamp);
// タイムライン不一致や競合による誤判定の可能性あり
if (currentVal.equals(oldVal)) {
return true;
}
}
return false;
}
この方式は全クライアント間の時刻同期を前提としており、ネットワーク遅延による不正なロック取得を招く恐れがあります。
ロック解放の実装
ロック解除時には、保有者の認証を行う必要があります。これをスレッドセーフに行うためには、Redis の Lua スクリプトを使用するのが定石です。
public class RedisLockCoordinator {
private static final Long DELETED_VALUE = 1L;
// Lua スクリプトの定義
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
/**
* ロックの解放
* @param key ロックキー
* @param token 保持者のトークン
*/
public boolean releaseLock(Object redisClient, String key, String token) {
Object evalResult = ((DefaultSerializer)redisClient).evalScript(
UNLOCK_SCRIPT,
Collections.singletonList(key),
Collections.singletonList(token)
);
return DELETED_VALUE.equals(evalResult);
}
}
Lua スクリプトを使用することで、「取得(GET)」から「削除(DEL)」までの一連の操作を原子命令として扱います。これが保証されない場合、以下のトラブルが発生し得ます。
一般的な解放実装の欠陥
問題点1:正当性チェックなし
単に `DEL` コマンドを実行してしまうと、自分が保持していない他のプロセスのロックを解除してしまう可能性があります。
// 単純すぎる削除処理
public void unsafeRelease(String key) {
redisClient.del(key);
}
問題点2:非原子的操作
チェックと削除を別のコマンドで行う場合、間にタイミングシフトが生じます。
// チェックと削除の隙
public void unsafeReleaseStepByStep(String key, String token) {
if (token.equals(redisClient.get(key))) {
// この瞬間にロックがタイムアウトして別人のものに変わっている可能性がある
redisClient.del(key);
}
}
チェック完了直後にロックが自然消滅し、新しいプロセスがロックを取得した後、元のクライアントが削除を実行すると、正しく動作している相手のロックが破壊されてしまいます。
高可用性環境への推奨
上述の実装は主に単一 Redis インスタンスを対象としています。レプリケーション構成やシャーディング環境では、より高度なアルゴリズムが必要な場合があります。そのようなケースでは、公式ライブラリである Redisson を利用することで、RedLock アルゴリズムを含む堅牢な分散ロック機能を容易に利用することが可能です。