.NET CoreでRedisを使った分散ロックの実装方法を徹底解説
はじめに
本記事では、.NET Core環境においてRedisを活用して分散ロックを実装する方法について詳しく解説します。
分散システムを構築する際、複数のプロセスが同じ共有リソースへ同時にアクセスする場面に直面することがあります。共有リソースは同時に1つのプロセスしか利用できないため、適切な制御を行わないと予期しない問題が発生してしまいます。
このような課題を解決するために有効なのが「分散ロック」です。
なぜ分散ロックが必要なのか?
通常、この種の問題に対してはロックを使用して対処します。以下はC#における一般的なlock構文の使用例です。
public void SomeMethod()
{
// 何らかの処理...
lock (obj)
{
// 排他制御したい処理...
}
// 何らかの処理...
}
しかし、このようなlock構文では問題を十分に解決できません。これはプロセス内ロックであり、単一プロセス内でのスレッド間の排他制御にしか対応できないためです。
まさにこれこそが、分散ロックが必要となる主な理由です。
ここでは、Redisを使ってシンプルな分散ロックを作成していきます。なぜRedisを選ぶのかというと、Redisはシングルスレッドで動作しており、アトミック(原子的)な操作が可能だからです。この特性により、複数のクライアントからの同時アクセスでも安全にロック制御を実現できます。
ロックの取得方法
実際に.NET Coreのコンソールアプリケーションを作成して確認してみましょう。作業を始める前に、必ずRedisサーバーを起動しておいてください。
.NET環境で最も人気のあるRedisクライアントはStackExchange.Redisです。今回はこれを使用して実装を進めます。
まず、Redisへの接続を作成します。
/// <summary>
/// 遅延初期化される接続
/// </summary>
private static Lazy<ConnectionMultiplexer> lazyConnection = new Lazy<ConnectionMultiplexer>(() =>
{
ConfigurationOptions configuration = new ConfigurationOptions
{
AbortOnConnectFail = false,
ConnectTimeout = 5000,
};
configuration.EndPoints.Add("localhost", 6379);
return ConnectionMultiplexer.Connect(configuration.ToString());
});
/// <summary>
/// 接続を取得する
/// </summary>
public static ConnectionMultiplexer Connection => lazyConnection.Value;
共有リソースに対してロックを要求するには、次のコマンドを使用します。
SET resource_name unique_value NX PX duration
各パラメーターの意味は以下の通りです。
- resource_name: アプリケーションの全インスタンス間で共有されるキー名
- unique_value: 各インスタンスごとに一意である必要がある値。ロック解除(アンロック)時に自分自身が取得したロックかどうかを判定するために使われます
- duration: 有効期限(ミリ秒)。期限を過ぎるとRedisが自動的にロックを削除します
これをC#で実装すると以下のようになります。
/// <summary>
/// ロックを取得する
/// </summary>
/// <returns><c>true</c>: 取得成功、<c>false</c>: 失敗</returns>
static bool AcquireLock(string key, string value, TimeSpan expiration)
{
bool flag = false;
try
{
flag = Connection.GetDatabase().StringSet(key, value, expiration, When.NotExists);
}
catch (Exception ex)
{
Console.WriteLine($"Acquire lock fail...{ex.Message}");
flag = true;
}
return flag;
}
続いて、ロック取得をテストするコードを見てみましょう。
static void Main(string[] args)
{
string lockKey = "lock:eat";
TimeSpan expiration = TimeSpan.FromSeconds(5);
// 5人が同時に食事を試みる
Parallel.For(0, 5, x =>
{
string person = $"person:{x}";
bool isLocked = AcquireLock(lockKey, person, expiration);
if (isLocked)
{
Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
}
else
{
Console.WriteLine($"{person} cannot eat food due to not getting the lock.");
}
});
Console.WriteLine("end");
Console.Read();
}
このコードを実行すると、次のような結果が得られます。

このように、ロックを取得できるのは1人だけで、他の人は待機状態になります。
ただし、Redisが自動的にロックを削除するとはいえ、これでは共有リソースを有効活用できません。あるプロセスが処理を完了したら、すぐに他のプロセスへリソースを譲るべきであり、無限に待たせるのは好ましくありません。
そこで、次はロックの解放も実装する必要があります。
ロックの解放方法
ロックを解放するには、Redisから該当のキーを削除するだけです。
ただし、ロック取得時に設定した一意の値(unique_value)と照合する必要があります。これにより、自分が取得した正しいロックだけを安全に解放できるようになります。
値が一致した場合はロックを削除してアンロック成功、一致しなければアンロック失敗となります。
ここで重要なポイントがあります。「値の照合(GET)」と「削除(DEL)」は同時に実行されなければなりません。2つのコマンドを分けて実行すると、その間に別のクライアントがロックを操作する可能性があり、競合が発生する恐れがあるためです。そこでLuaスクリプトを使って、この処理をアトミックに実行します。
/// <summary>
/// ロックを解放する
/// </summary>
/// <returns><c>true</c>: 解放成功、<c>false</c>: 失敗</returns>
static bool ReleaseLock(string key, string value)
{
string lua_script = @"
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
redis.call('DEL', KEYS[1])
return true
else
return false
end
";
try
{
var res = Connection.GetDatabase().ScriptEvaluate(lua_script,
new RedisKey[] { key },
new RedisValue[] { value });
return (bool)res;
}
catch (Exception ex)
{
Console.WriteLine($"ReleaseLock lock fail...{ex.Message}");
return false;
}
}
このメソッドは、プロセスが処理を完了したタイミングで呼び出すべきものです。
一方で、あるプロセスがロックを取得したまま何らかの理由で解放しなかった場合、他のプロセスは永遠に待ち続けることになります。そのような状況では、一定時間待ってもロックが取れないなら、他のプロセスは先へ進むべきです。
このシナリオに対応したサンプルコードが以下になります。
Parallel.For(0, 5, x =>
{
string person = $"person:{x}";
var val = 0;
bool isLocked = AcquireLock(lockKey, person, expiration);
while (!isLocked && val <= 5000)
{
val += 250;
System.Threading.Thread.Sleep(250);
isLocked = AcquireLock(lockKey, person, expiration);
}
if (isLocked)
{
Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
if (new Random().NextDouble() < 0.6)
{
Console.WriteLine($"{person} release lock {ReleaseLock(lockKey, person)} {DateTimeOffset.Now.ToUnixTimeMilliseconds()}");
}
else
{
Console.WriteLine($"{person} do not release lock ....");
}
}
else
{
Console.WriteLine($"{person} begin eat food (without lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
}
});
このサンプルを実行すると、次のような結果が得られます。
ご覧の通り、ロックを取得できなかったperson 3とperson 4は、一定時間の再試行の後、諦めて先へ進んでいることがわかります。
今回使用したソースコードはGitHubページから入手できます。
- RedisLockDemo
まとめ
本記事では、.NET Core環境でRedisを利用した分散ロックの作成方法を紹介しました。ここで示したのは基本版の実装ですが、実際のビジネス要件に応じて、再試行ポリシー、ウォッチドッグによる有効期限の延長、Redlockアルゴリズムなどへ発展させることができます。
この記事が皆さんの開発の一助になれば幸いです。
-
LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル
本記事では、Upstash、Next.js、LangChain、Fly.ioを組み合わせて、オープンソースのカスタムコンテンツAIチャットボットを実際に構築した手順を詳しく解説します。Upstashを活用することで、モデル学習のスケジューリング、寛容なレートリミット設定、OpenAI APIレスポンスのキャッシングを実現できました。 使用する技術スタック Next.js: フロントエンドとバックエンドの両方を担当 LangChain: 言語モデルを活用したアプリケーション開発用フレームワーク Upstash: QStashによるモデル学習のスケジューリング、レートリミット、OpenAIレス
-
Azure Cache for Redis Enterpriseで「アクティブ・アクティブ地理分散」が一般提供開始
Azure Cache for RedisのEnterpriseおよびEnterprise Flash階層において、Redisによる「アクティブ・アクティブ地理分散(Active-Active Geo-Distribution)」の一般提供が開始されたことをお知らせします。これにより、Azure Cache for Redisのお客様は、異なるAzureリージョンに最大5つのEnterprise階層キャッシュデータベースインスタンスを配置し、コンフリクトフリーレプリケーションデータ型(CRDT)を活用したアクティブなgeoレプリケーションキャッシュを構築できるようになりました。Enterpri