シンプルなユースケースでは Redis を分散ロックとして使用する

最近、システムの問題に遭遇しました。Ruby Rake タスクとして記述されたバッチ ジョブの一部が重複呼び出しになり、データベースのレコードが重複するか、Slack でアラートが重複するという問題です。これは間違いなくインフラの問題ですが、インフラ側で修正されるまでは、コーディングの観点から対処する方法を見つける必要があります。すべてを可能な限りべき等に書き直すことは選択肢ではなく、アプリケーション レベルの検証やデータベースの一意性制約を強化することも選択肢ではありません。すべてのジョブがべき等になるわけではなく、すべてのジョブがデータベースに書き込むわけでもないからです。さらに、問題は 2 つのスレッドではなく 2 つのプロセスの競合状態によって引き起こされます。調査と実験を行った後、シンプルな分散ロック ソリューションとして Redis を使用することにしました。これは既にアプリケーションで使用可能で、追加の設定は不要です。また、単一インスタンスであるため、レプリケーション同期の懸念もありません。Redis はシングル スレッド アーキテクチャであるため、同時にいくつのクライアントが書き込もうとしても、シーケンシャルかつアトミックであることが保証されます。私はこの性質を利用して、直面している問題を解決できると考えました。

タスクの例

namespace :main do
  task :hello do
    sleep 1 # simulate long-running task
    puts '***hello, world***'
  end
end

ロックなしで実行

$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38358
[2] 38359
***hello, world***
***hello, world***

[2]  + done       bundle exec rake main:hello
[1]  + done       bundle exec rake main:hello

2 つのプロセスが同時に実行され、2 回実行されます。

Redis ディストリビューション ロックを追加

  • ロックを定義する
namespace :lock do
  redis = Redis.new

  task :before do
    task_name = Rake.application.top_level_tasks[0]
    lock_obtained = redis.set task_name, '1', ex: 60, nx: true
    abort "Another process is already running #{task_name}" unless lock_obtained
  end

  task :after do
    task_name = Rake.application.top_level_tasks[0]
    redis.del task_name
  end
end
  • ロックでタスクを強化する
Rake::Task['main:hello'].enhance(['lock:before']) do
  Rake::Task['lock:after'].execute
end

ロックして実行

$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38560
[2] 38561

Another process is already running main:hello
[1]  - exit 1     bundle exec rake main:hello

***hello, world***
[2]  + done       bundle exec rake main:hello

2 つのプロセスが同時に実行しますが、実行されるのは 1 回だけです。

まとめ

ポイントは、set コマンドの nx: true オプションを使用することです。これは、キーがまだ存在しない場合にのみキーを設定する ことを意味します。Redis のシングル スレッド設計では、1 つのプロセスだけがキーを正常に設定し、1 つのプロセスだけがロックを取得することが保証されます。

参考までに、Distributed Locks with Redisには、より複雑なケース向けに Redlock などのより信頼性が高く安全なアルゴリズム ベースのロックを実装する方法が詳しく記載されています。

redis