Redisを使って排他制御を行う(簡易版)


高トラフィックなWebアプリケーションだとロードバランサの後ろにサーバーを複数台おいてトラフィックを分散させて処理することになると思います。
そんなシステムで、ある一つのリソースを更新しようとしたときにどうしても排他制御が必要になりました。さてどうしますか?

複数台のサーバーが処理しているためJavaのsynchronizedでは違うサーバー同士では排他制御できません。そこで利用するのがここで紹介するRedisを使った排他制御になります。

1.原理

RedisにはSETNXという指定のキーがなければ値をセットするけど既にキーが存在した場合はなにもしないというコマンドがあります
これを利用して値がセット出来たらロック取得成功。セット出来なかったらロック失敗という判定を行います
ロックを取得できたプロセスは処理が終わったらDELコマンドでキーを削除することでロックを開放します

2.実装

ロック部分のキモは以下の部分(MutexRedisServiceのwaitForSingleObject)です

  @Override
  public Mutex waitForSingleObject(String objectKey) {
    final long startTime = System.currentTimeMillis();
    final String lockId = UUID.randomUUID().toString();
    while(true) {
      try (Jedis r = this.jedisPool.getResource()) {
        String result = r.set(
                objectKey,
                lockId,
                SetParams.setParams().nx().ex(this.lockTimeSec)); // 1.SETNX(SETのNXフラグ)
        if ("OK".equalsIgnoreCase(result)) {
          break; // 2.SETNXでキーの登録に成功した場合はロック獲得とみなしループを抜ける
        }
      }
      if (System.currentTimeMillis() - startTime > this.timeWaitMillis) {
        throw new MutexTimeoutException("MUTEX wait timeout : mutex=" + objectKey);
      }
      try {
        Thread.sleep(50); // 3.SETNXが失敗した場合は少し待ってもう一度トライ(スピンロック)
      } catch (InterruptedException e) {
      }
    }
    System.out.printf("create key : lock_id=%s\n", lockId);
    return new Mutex(objectKey, lockId, this::releaseMutex);
  }

1.SETNXで指定されたキーを設定しにいきます。プログラムではSETNXコマンドではなくSETコマンドにNXフラグと期限を設定するEXフラグを指定して実行しています(意味は同じ)
EXフラグで期限を設定するのは、もしロック獲得中のプロセスがなんらかの理由で落ちてしまった場合にもある程度時間がたったらロックを自動的に開放するためです。

2.SETNXが成功してキーが登録できた場合はループを抜けてロック獲得とします

3.SETNXが失敗した場合は少し待って(ここでは50ms)からもう一度SETNXを実行します。SETNXが成功するか設定された最大待ち時間に到達するまで何度もループします

3.実行結果

実行すると最初にロックを使わないで10タスク並列実行、その次にロックを利用して10タスク並列実行(ロックを利用しているので結果的に直列実行になる)が行われます

以下に結果の一例(タスクの実行順はその時によるため)を示します

start(parallel)
process start 2
process start 9
process start 10
process start 8
process start 7
process start 6
process start 5
process start 4
process start 3
process start 1
process end 10
process end 9
process end 8
process end 1
process end 3
process end 7
process end 6
process end 4
process end 5
process end 2
finish(parallel)



start(serial)
wait 1
wait 9
wait 8
wait 7
wait 6
wait 5
wait 2
wait 4
wait 3
wait 10
create key : lock_id=f3f213c5-6e10-4cb1-8401-fd1baf83a173
Lock by 8
process start 8
process end 8
Unlock by 8
delete key : lock_id expect=f3f213c5-6e10-4cb1-8401-fd1baf83a173 actual=f3f213c5-6e10-4cb1-8401-fd1baf83a173
create key : lock_id=3f7beea3-44db-49ce-b97c-32b148964b69
Lock by 1
process start 1
process end 1
Unlock by 1
delete key : lock_id expect=3f7beea3-44db-49ce-b97c-32b148964b69 actual=3f7beea3-44db-49ce-b97c-32b148964b69
create key : lock_id=b0e75835-9e40-4560-ad2b-fe2191c77835
Lock by 5
process start 5
process end 5
Unlock by 5
delete key : lock_id expect=b0e75835-9e40-4560-ad2b-fe2191c77835 actual=b0e75835-9e40-4560-ad2b-fe2191c77835
create key : lock_id=0f606672-75f7-4bd0-abba-6d341447c76e
Lock by 3
process start 3
process end 3
Unlock by 3
delete key : lock_id expect=0f606672-75f7-4bd0-abba-6d341447c76e actual=0f606672-75f7-4bd0-abba-6d341447c76e
create key : lock_id=9134f4c8-c023-4b45-9e2f-1088b0fa9842
Lock by 7
process start 7
process end 7
Unlock by 7
delete key : lock_id expect=9134f4c8-c023-4b45-9e2f-1088b0fa9842 actual=9134f4c8-c023-4b45-9e2f-1088b0fa9842
create key : lock_id=37fb5359-b430-4979-bef0-62c70d7cf0b3
Lock by 4
process start 4
process end 4
Unlock by 4
delete key : lock_id expect=37fb5359-b430-4979-bef0-62c70d7cf0b3 actual=37fb5359-b430-4979-bef0-62c70d7cf0b3
create key : lock_id=2276944c-03c3-417f-a270-07e9f3efdbd1
Lock by 9
process start 9
process end 9
Unlock by 9
delete key : lock_id expect=2276944c-03c3-417f-a270-07e9f3efdbd1 actual=2276944c-03c3-417f-a270-07e9f3efdbd1
create key : lock_id=4852fa63-83da-4a18-8f70-2a834777b0a2
Lock by 6
process start 6
process end 6
Unlock by 6
delete key : lock_id expect=4852fa63-83da-4a18-8f70-2a834777b0a2 actual=4852fa63-83da-4a18-8f70-2a834777b0a2
create key : lock_id=6186f94d-de84-43f5-888b-889fab452c3e
Lock by 2
process start 2
process end 2
Unlock by 2
delete key : lock_id expect=6186f94d-de84-43f5-888b-889fab452c3e actual=6186f94d-de84-43f5-888b-889fab452c3e
create key : lock_id=bbc37fb9-af13-48bf-895c-28d624f7e7f1
Lock by 10
process start 10
process end 10
Unlock by 10
delete key : lock_id expect=bbc37fb9-af13-48bf-895c-28d624f7e7f1 actual=bbc37fb9-af13-48bf-895c-28d624f7e7f1
finish(serial)

最初に実行されるparallelのほうはほぼ同時に実行開始され、終わるのもほぼ同時になっていると思います

次に実行されるserialのほうは最初に全タスクがwaitになりロックを獲得した順に8>1>5>3>7>...と実行されているのがわかると思います
うまく排他制御が動作していそうです

しかし実はこの実装には問題点があります

4.問題点

前記コードの1でSETNX時にEXフラグで有効期限を付けました。これはプロセス落ち等でロック解除ができなかった場合にそれ以降誰もロックが取得できなくなってしまうことを避けるためなのですが、もしロック中の処理がこの有効期限を過ぎるような長い処理だった場合どうでしょうか?
以下の図のようになってしまいます

処理に長時間かかってしまったTask1によってTask2が取得したロックが解除されてしまっています。
有効期限を設定する方法はどうしても避けて通れないのですが、たまたま処理が長くなってしまったTask1のせいで通常の長さの処理であるTask2が影響を受けてしまっています。

これを解消する手段は自分が設定したキーかどうかを判断して自分の出なかったらキーの削除を行わないということをする必要があります。
実際の答えは公式 https://redis.io/topics/distlock に載っているのですが、Javaの実装については後日別記事に乗せることにします。

(2021/7/31追記)上記問題を考慮した版の記事書きました。