Redisを用いた分散ロックの実装方法とベストプラクティス

はじめに

分散システムにおいてリソースへの同時アクセスを制御するための分散ロックは、現代の分散アプリケーション開発において不可欠な要素です。本記事では、Redisを利用した分散ロックの実装方法について詳しく解説します。インターネット上には多くのRedis分散ロックの実装例がありますが、その多くは重要な考慮事項を見落としている場合があります。本記事では、堅牢な分散ロックを実現するための正しいアプローチを紹介します。

分散ロックの信頼性要件

効果的な分散ロックを実現するためには、以下の4つの要件を満たす必要があります:

  1. 相互排他性:任意の時点で1つのクライアントのみがロックを保持できること。
  2. デッドロックの防止:クライアントがロックを保持中にクラッシュした場合でも、他のクライアントがロックを取得できること。
  3. フォールトトレランス:Redisノードの大部分が正常に動作していれば、クライアントはロックの取得と解放ができること。
  4. 所有権の確認:ロックの取得と解放は同じクライアントによって行われること。クライアントは自身が取得したロックのみを解放できること。

実装方法

依存関係の設定

まず、Mavenを使用してJedisライブラリをプロジェクトに追加します。pom.xmlファイルに以下の依存関係を追加してください:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

ロックの取得

正しい実装方法

まずコードを見て、その後でなぜこの実装が正しいのかを説明します:

public class DistributedLockManager {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    
    /**
     * 分散ロックの取得を試みる
     * @param redisClient Redisクライアント
     * @param lockKey ロックのキー
     * @param clientId クライアントID
     * @param expirationTime 有効期間(ミリ秒)
     * @return ロックの取得に成功したかどうか
     */
    public static boolean tryAcquireLock(RedisClient redisClient, String lockKey, String clientId, long expirationTime) {
        String result = redisClient.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expirationTime);
        return LOCK_SUCCESS.equals(result);
    }
}

この実装では、RedisのSETコマンドを1行で使用しています。このメソッドのパラメータは以下の通りです:

  • key:ロックの識別子として使用する一意のキー
  • value:クライアントを識別するためのID(UUIDなどを使用)
  • nxxx:"NX"を指定し、キーが存在しない場合のみ設定することを保証
  • expx:"PX"を指定し、キーに有効期間を設定することを指定
  • time:キーの有効期間(ミリ秒単位)

この実装は信頼性要件の3つを満たしています:

  • NXパラメータにより、キーが既に存在する場合はロックの取得に失敗し、相互排他性が保証されます。
  • 有効期間の設定により、クライアントがクラッシュしてもロックは自動で解放され、デッドロックを防ぎます。
  • valueにクライアントIDを設定することで、解放時に所有権を確認できます。

誤った実装例1:非原子的な操作

よくある間違いは、setnx()とexpire()を組み合わせてロックを実装する方法です:

public static void incorrectLockImplementation1(RedisClient redisClient, String lockKey, String clientId, int expirationTime) {
    Long result = redisClient.setnx(lockKey, clientId);
    if (result == 1) {
        // ここでプログラムがクラッシュすると、有効期間が設定されずデッドロックが発生
        redisClient.expire(lockKey, expirationTime);
    }
}

この方法は、setnx()とexpire()が別々のコマンドであるため、原子性が保証されません。setnx()の実行後にプログラムがクラッシュすると、ロックに有効期間が設定されず、デッドロックが発生します。

誤った実装例2:タイムベースのロック実装

もう一つの誤った実装例として、タイムスタンプを利用した方法があります:

public static boolean incorrectLockImplementation2(RedisClient redisClient, String lockKey, int expirationTime) {
    long expires = System.currentTimeMillis() + expirationTime;
    String expiresStr = String.valueOf(expires);
    
    if (redisClient.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
    
    String currentValueStr = redisClient.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        String oldValueStr = redisClient.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            return true;
        }
    }
    
    return false;
}

この実装には以下の問題があります:

  • クライアント側で有効期間を生成するため、分散システム内で時間同期が必要になります。
  • > 複数のクライアントが同時に有効期間を更新しようとすると、最終的に1つのクライアントのみがロックを取得できますが、その有効期間が他のクライアントによって上書きされる可能性があります。
  • ロックの所有者を識別できないため、任意のクライアントがロックを解放できます。

ロックの解放

正しい実装方法

ロックの解放にはLuaスクリプトを使用して原子性を保証します:

public class DistributedLockManager {
    private static final Long RELEASE_SUCCESS = 1L;
    
    /**
     * 分散ロックを解放する
     * @param redisClient Redisクライアント
     * @param lockKey ロックのキー
     * @param clientId クライアントID
     * @return ロックの解放に成功したかどうか
     */
    public static boolean releaseLock(RedisClient redisClient, String lockKey, String clientId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = redisClient.eval(script, Collections.singletonList(lockKey), Collections.singletonList(clientId));
        return RELEASE_SUCCESS.equals(result);
    }
}

このLuaスクリプトは、以下の操作を原子的に実行します:

  1. 指定されたキーの値を取得
  2. その値がクライアントIDと一致するか確認
  3. 一致すればキーを削除(ロックを解放)

なぜLuaスクリプトを使用する必要があるのかというと、RedisのevalコマンドはLuaスクリプトを単一の原子操作として実行するためです。これにより、ロックの解放操作が途中で他のクライアントに割り込まれることがなくなります。

誤った実装例1:直接削除

最も単純だが誤った実装は、直接キーを削除する方法です:

public static void incorrectReleaseImplementation1(RedisClient redisClient, String lockKey) {
    redisClient.del(lockKey);
}

この方法では、ロックの所有者を確認せずに直接キーを削除するため、任意のクライアントが他人のロックを解放できてしまいます。

誤った実装例2:非原子的な確認と削除

もう一つの誤った実装は、確認と削除を別々のコマンドで実行する方法です:

public static void incorrectReleaseImplementation2(RedisClient redisClient, String lockKey, String clientId) {
    if (clientId.equals(redisClient.get(lockKey))) {
        redisClient.del(lockKey);
    }
}

この方法では、get()とdel()の間にタイムウィンドウが存在し、その間にロックが期限切れになると、別のクライアントがロックを取得した後にdel()が実行され、他人のロックが解放されてしまいます。

まとめ

Redisを用いた分散ロックを実装する際は、信頼性要件を満たすことが重要です。特に、相互排他性、デッドロックの防止、フォールトトレランス、所有権の確認の4点を考慮する必要があります。

ロックの取得と解放の両方で原子性を保証するために、RedisのSETコマンドのNXオプションとPXオプション、およびLuaスクリプトの使用が推奨されます。

Redisが複数のノードで構成されている環境では、Redissonのような専用ライブラリの使用を検討すると良いでしょう。RedissonはRedis公式が提供するJavaライブラリで、より高度な分散ロックの機能を提供します。

タグ: redis 分散ロック Luaスクリプト デッドロック データ一貫性

5月28日 06:41 投稿