1. Redisベースの分散ロックを実現
Redis 分散ロックの原理は上図の通りで、複数の Set コマンドが Redis に送信されると、Redis はそれらをシリアルに処理し、最終的に 1 つの Set コマンドのみが正常に実行されるため、ロックに成功するスレッドは 1 つだけになります。
2: SetNx コマンドロック
Redis のsetNxコマンドを使用して、Redis データベースに <Key, Value> レコードを作成します。このコマンドは、Redis にキーが存在しない場合にのみ正常に実行でき、キーがすでに存在する場合は失敗を返します。
上記のsetNxコマンドを使用すると、簡単にロック機能を実装できます。複数のスレッドがロック コマンドを実行すると、1 つのスレッドだけが正常に実行され、その後ビジネス ロジックが実行されます。他のスレッドはロックに失敗し、リターンまたは再試行されます。
3: デッドロック問題
上記setNxコマンドは基本的なロック機能を実現していますが、業務コード実行中にプログラムがクラッシュすると、その後のロック解除命令が実行できなくなり、デッドロック問題が発生するという致命的な問題があります。
デッドロックの問題を解決するには、ここで有効期限の概念を導入する必要があります。有効期限とは、現在のキーに特定の生存時間を設定することです。生存時間が経過すると、Redis は期限切れのキーを自動的に削除します。ロックは、期限が切れたときに自動的に解除することもできます。
上図のように、 Redisのexpireコマンドを使用してロックの有効期限を設定し、期限切れ時に自動でロックを解除する機能を実現しますが、ここでも問題は、ロックとロックの有効期限の設定はアトミックではありません
次の状況を考えてみましょう。
ロック完了後、有効期限が設定される前にプログラムがクラッシュすると、ロックが自動的に解除されず、デッドロック現象が発生します。
4: アトミックコマンドを使用する
ロックと有効期限の設定がアトミック コマンドではないという上記の問題に対して、Redis は次のようなアトミック コマンドを提供します。
ロックと有効期限の設定を組み合わせたアトミック コマンドであるSetNx(key, value, timeOut)を通じて、 Redis に基づく分散ロックのロック ステップを完全に実現できます。
5: ロック解除原理
ロック解除の原則は、Redis del delete key コマンドに基づいています。
6: ロック問題の削除エラー
ロックを解除するキーを直接削除する上記の方法では問題が発生します。次の状況を考慮してください。
(1)スレッド 1 が長時間ビジネスを実行し、追加したロックの有効期限が切れる
(2)この時点で、スレッド 2 が入ってきて、正常にロックされます。
(3)その後、スレッド 1 のビジネス ロジックの実行が完了し、del キー コマンドが実行されます。
(4)このとき、スレッド2が追加したロックを削除するとエラーが発生します。
(5)スレッド 2 のロックを誤って削除した後、スレッド 3 は再度正常にロックできるため、2 つのスレッドがビジネス コードを実行することになります。
7: ロックロゴを追加
他のスレッドのロックを誤って削除するというこの問題を解決するには、ここで lock コマンドを変更し、現在のスレッドの ID を value フィールドに追加する必要があります。これは、ここで uuid を使用することで実現できます。スレッドがロックを削除するとき、スレッドは自身の uuid と Redis のロックの uuid を比較し、それが独自のロックであれば削除され、そうでない場合は削除されません。
上図のように、ロック時に現在のスレッドのIDを値フィールドに格納し、ロック解除時に現在のロックが自分のものかどうかを比較することでロックが成功したかどうかを判断することで、他人のロックが削除される問題を解決します。誤ってロックしてしまいましたが、ここにはアトミック コマンドの問題もあります。比較と削除の操作はアトミック コマンドではありません。次の状況を考えてください。
(1)スレッド 1 が uuid を取得し、自身のロックであると判断する
(2)ロック解除の準備中に、GC などの理由でプログラムがフリーズし、Del コマンドがすぐに実行できなくなり、スレッド 1 のロックが切れてしまう
(3)この時点でスレッド 2 は正常にロックされます。
(4)スレッド 1 がフリーズしてロック解除コマンドを実行し続けると、スレッド 2 のロックが誤って削除されてしまいます。
この問題の根本原因は、これら 2 つの操作の比較と削除がアトミック コマンドではないことです。2 つのコマンドが中断される限り、同時実行の問題が発生する可能性があります。2 つのコマンドがアトミック コマンドに変換されると、この問題が発生する可能性があります。解決しました。
8: アトミックな削除操作を実現するluaスクリプトの導入
Lua スクリプトは非常に軽量なスクリプト言語です。Redis の基礎となる層は本質的に lua スクリプトの実行をサポートしています。lua スクリプトには複数の Redis コマンドを含めることができます。Redis は lua スクリプト全体をアトミックな操作として実行し、複数のコマンドの集約を実現します。 Redis 命令のアトミック操作の原理を次の図に示します。
ここでは、ロックを解除するときに、lua スクリプトを使用して比較および削除操作をアトミック操作に変換します。
//lua脚本如下
luaScript = " if redis.call('get',key) == value then
return redis.call('del',key)
else
return 0
end;"
上記の lua スクリプトに示されているように、Redis は lua スクリプト全体を 1 つのコマンドとして実行することで、複数のコマンドのアトミック操作を実現し、マルチスレッド競合の問題を回避し、最終的に lua スクリプトと組み合わせた完全な分散処理を実現します。ロックとロック解除のプロセス、疑似コードは次のとおりです。
uuid = getUUID();
//加锁
lockResut = redisClient.setNx(key,uuid,timeOut);
if(!lockResult){
return;
}
try{
//执行业务逻辑
}finally{
//解锁
redisClient.eval(delLuaScript,keys,values)
}
//解锁的lua脚本
delLuaScript = " if redis.call('get',key) == value then
return redis.call('del',key)
else
return 0
end;"
ここまでで、ようやく完全なロック・ロック解除機能を備えた Redis 分散ロックを実装することができましたが、もちろんロックとしては、ロックの失敗やリエントラントの考慮など、さらに改善すべき機能がいくつかあります。
9:自動更新機能
業務コードを実行すると、業務の実行時間が長いため、業務実行中に自身のロックがタイムアウトになり、自動的にロックが解除される場合がありますが、この場合、2番目のスレッドは正常にロックされます。その結果、次の図に示すように、データの不整合が発生します。
上記の場合は、設定した有効期限が短すぎるか、業務実行時間が長すぎてロックが切れてしまうことが考えられますが、デッドロックの問題を回避するには有効期限を設定する必要があります。自動更新機能の導入:次の図に示すように、ロックが成功すると、スケジュールされたタスクを開始して、Redis ロック キーのタイムアウト期間を自動的に更新し、アピールの発生を回避します。
uuid = getUUID();
//加锁
lockResut = redisClient.setNx(key,uuid,timeOut);
if(!lockResult){
return;
}
//开启一个定时任务
new Scheduler(key,time,uuid,scheduleTime)
try{
//执行业务逻辑
}finally{
//删除锁
redisClient.eval(delLuaScript,keys,values)
//取消定时任务
cancelScheduler(uuid);
}
アピール コードに示されているように、ロックが正常にロックされた後、スケジュールされたタスクを開始してロックを自動的に更新できます。スケジュールされたタスクの実行ロジックは次のとおりです。
(1) Redis のロックが自分のものかどうかを確認する
(2)存在する場合は、expired コマンドを使用して有効期限をリセットします。
ここでは 2 つの Redis コマンドが必要であるため、lua スクリプトを使用してアトミック操作を実装することも必要です。コードは次のとおりです。
luaScript = "if redis.call('get',key) == value) then
return redis.call('expire',key,timeOut);
else
return 0;
end;"
10: リエントラントロック
フル機能のロックの場合、リエントラント機能は必須の機能です。いわゆるロックのリエントラントは同じスレッドです。最初のロックが正常にロックされた後は、2 番目のロックをキューに入れる必要はありません。待っているだけです。自分のロックかどうかを判断する必要がある場合は、次の図に示すように、ロックを直接取得してビジネス ロジックを実行することができます。
リエントラント機構を実現する原理は、ロック時にロック数を記録し、ロックを解放する際にロック数を減らすことであり、次の図のようにロック数の記録をRedisに保存することができます。
上図に示すように、リエントラント機能を追加した後のロックの手順は次のようになります。
(1)ロックが存在するかどうかを確認します。
(2)ロックが自分のものであるかどうかを判断する
(3)ロック数を増やす
回数を増やす、減らすは複数の操作となるため、再度luaスクリプトを使用する必要があり、同時にロック数をRedisに保存する必要があるため、Mapのデータ構造がMap(key, uuid , lockCount)、ロック lua スクリプトは次のとおりです。
//锁不存在
if (redis.call('exists', key) == 0) then
redis.call('hset', key, uuid, 1);
redis.call('expire', key, time);
return 1;
end;
//锁存在,判断是否是自己的锁
if (redis.call('hexists', key, uuid) == 1) then
redis.call('hincrby', key, uuid, 1);
redis.call('expire', key, uuid);
return 1;
end;
//锁不是自己的,返回加锁失败
return 0;
リエントラント関数を追加した後のロック解除ロジックは次のようになります。
(1)ロックが自分のものかどうかを確認する
(2)自分の場合はロックの数を減らし、そうでない場合はロック解除失敗に戻す
//判断锁是否是自己的,不是自己的直接返回错误
if (redis.call('hexists', key,uuid) == 0) then
return 0;
end;
//锁是自己的,则对加锁次数-1
local counter = redis.call('hincrby', key, uuid, -1);
if (counter > 0) then
//剩余加锁次数大于0,则不能释放锁,重新设置过期时间
redis.call('expire', key, uuid);
return 1;
else
//等于0,代表可以释放锁了
redis.call('del', key);
return 1;
end;
これまでに、基本的なロックおよびロック解除ロジックを実現するために、リエントラント機能と自動更新機能を追加しました。それ以来、Redis 分散ロックの完全なプロトタイプが実現されました。疑似コードは次のとおりです。
uuid = getUUID();
//加锁
lockResut = redisClient.eval(addLockLuaScript,keys,values);
if(!lockResult){
return;
}
//开启一个定时任务
new Scheduler(key,time,uuid,scheduleTime)
try{
//执行业务逻辑
}finally{
//删除锁
redisClient.eval(delLuaScript,keys,values)
//取消定时任务
cancelScheduler(uuid);
}
11: Zookeeper は分散ロックを実装します
Zookeeper は分散調整サービスです. 分散調整は主に分散システム内の複数のアプリケーション間のデータの整合性を解決することです. Zookeeper の内部データ保存方法はファイル ディレクトリ形式の保存構造に似ています. そのメモリ結果は次のとおりです以下に示されています:
12: 飼育員のロック原理
Zookeeper で指定したパス下にノードを作成し、クライアントは現在のパス下のノードの状態に応じてロックが成功したかどうかを判断します。たとえば、次の図の場合、スレッド 1 がノードの作成に成功した後、スレッド2 はノードを再度作成します。作成に失敗しました。
13: Zookeeper ノードのタイプ
永続ノード: Zookeeper で作成された後、クライアントがアクティブに削除するまで永続的に保存されます。
一時ノード:クライアント セッション セッションのディメンションにノードを作成します。クライアント セッションが切断されると、ノードは自動的に削除されます。
一時的/永続的シーケンシャル ノード:同じパスの下に作成されたノードには、作成順に各ノードに番号が付けられます。
zookeeper.exists("/watchpath",new Watcher() {
@Override
public void process(WatchedEvent event) {
System.out.println("进入监听器");
System.out.println("监听路径Path:"+event.getPath());
System.out.println("监听事件类型EventType:"+event.getType());
}
});
14: 一時的な順次ノードと監視メカニズムを使用して分散ロックを実装する
分散ロックを実装するにはさまざまな方法がありますが、一時ノードと順次ノードを使用して分散ロックを実装できます。
1: 一時ノードを使用すると、クライアント プログラムがクラッシュしたときにロックが自動的に解放され、デッドロックの問題が回避されます。
2: 順次ノードを使用する利点は、ロック解放のイベント監視メカニズムを使用してブロック監視分散ロックを実装できることです。
以下では、これら 2 つの特性に基づいて分散ロックを実装します。
15: ロック原理
1: まず、Zookeeper 上に一時的な連続ノード Node01、Node02 などを作成します。
2: 2 番目のステップでは、クライアントはロックされたパスの下に作成されたすべてのノードを取得します。
3: シリアル番号が最小かどうかを確認します。最小であれば、ロックが成功したことを意味します。最小でない場合は、前のノードのリスナーを作成します。
4: 前のノードが削除された場合、リスナーはクライアントにロックを再取得する準備をするように通知します。
ロックの原理とコードを次の図に示します。
//加锁路径
String lockPath;
//用来阻塞线程
CountDownLatch cc = new CountDownLatch(1);
//创建锁节点的路径
Sting LOCK_ROOT_PATH = "/locks"
//先创建锁
public void createLock(){
//lockPath = /locks/lock_01
lockPath = zkClient.create(LOCK_ROOT_PATH+"/lock_", CreateMode.EPHEMERAL_SEQUENTIAL);
}
//获取锁
public boolean acquireLock(){
//获取当前加锁路径下所有的节点
allLocks = zkClient.getChildren("/locks");
//按节点顺序大小排序
Collections.sort(allLocks);
//判断自己是否是第一个节点
int index = allLocks.indexOf(lockPath.substring(LOCK_ROOT_PATH.length() + 1));
//如果是第一个节点,则加锁成功
if (index == 0) {
System.out.println(Thread.currentThread().getName() + "获得锁成功, lockPath: " + lockPath);
return true;
} else {
//不是序号最小的节点,则监听前一个节点
String preLock = allLocks.get(index - 1);
//创建监听器
Stat status = zkClient.exists(LOCK_ROOT_PATH + "/" + preLockPath, watcher);
// 前一个节点不存在了,则重新获取锁
if (status == null) {
return acquireLock();
} else {
//阻塞当前进程,直到前一个节点释放锁
System.out.println(" 等待前一个节点锁释放,prelocakPath:"+preLockPath);
//唤醒当前线程,继续尝试获取锁
cc.await();
return acquireLock();
}
}
}
private Watcher watcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
//监听到前一个节点释放锁,唤醒当前线程
cc.countDown();
}
}
16: リエントラントロックの実装
リエントラント分散ロックを実装する Zookeeper のメカニズムは、Map レコードをローカルに維持することです。これは、データが Zookeeper ノード上に維持されている場合、Zookeeper の書き込み操作が非常に遅く、クラスターはデータを同期するために投票する必要があるためです。記録はローカルに保持される 現在のロック数とロック状態を記録し、ロックを解放する際にロック数を減らすための原理を以下の図に示します。
//利用Map记录线程持有的锁
ConcurrentMap<Thread, LockData> lockMap = Maps.newConcurrentMap();
public Boolean lock(){
Thread currentThread = Thread.currentThread();
LockData lockData = lockMap.get(currentThread);
//LockData不为空则说明已经有锁
if (lockData != null)
{
//加锁次数加一
lockData.lockCount.increment();
return true;
}
//没有锁则尝试获取锁
Boolean lockResult = acquireLock();
//获取到锁
if (lockResult)
{
LockData newLockData = new LockData(currentThread,1);
lockMap.put(currentThread, newLockData);
return true;
}
//获取锁失败
return false;
}
17: ロック解除の原理
ロックを解除する手順は次のとおりです。
(1) ロックが自分のものかどうかを確認する
(2) 「はい」の場合、ロックの数を減らします。
(3) ロック数が 0 の場合、ロックを解放し、作成された一時ノードを削除します。このノードをリッスンしている次のクライアントはノード削除イベントを認識し、再度ロックを取得します。
public Boolean releaseLock(){
LockData lockData = lockMap.get(currentThread);
//没有锁
if(lockData == null){
return false;
}
//有锁则加锁次数减一
lockCount = lockData.lockCount.decrement();
if(lockCount > 0){
return true;
}
//加锁次数为0
try{
//删除节点
zkClient.delete(lockPath);
//断开连接
zkClient.close();
finally{
//删除加锁记录
lockMap.remove(currentThread);
}
return true;
}
18: Redis と Zookeeper のロックの比較
|
レディス |
動物園の飼育員 |
読み取りパフォーマンス |
メモリベースの |
メモリベースの |
ロック性能 |
直接書き込みメモリロック |
マスター ノードが作成されると、他のフォロワー ノードと同期され、そのうちの半分が成功した場合にのみ書き込み成功が返されます。 |
データの一貫性 |
AP アーキテクチャの Redis クラスター間のデータ同期には一定の遅延があり、マスター ノードがダウンしたときにデータがスレーブ ノードに同期されていないと、分散ロックが失敗し、データの不整合が発生します。 |
CP アーキテクチャ リーダー ノードがダウンすると、クラスターが再選出され、その時点で一部のノードのみがデータを受信した場合、クラスター データの整合性を確保するためにクラスター内でデータが同期されます。 |
19: まとめ
分散ロックを実装するために Redis と Zookeeper のどちらを使用するかは、最終的にはビジネスによって決まります。次の 2 つの状況を参照できます。
(1) ビジネスの同時実行性が大きい場合、Redis 分散ロックの効率的な読み取りおよび書き込みパフォーマンスにより、高い同時実行性をより適切にサポートできます。
(2) ビジネスでロックの強力な一貫性が必要な場合は、Zookeeper を使用する方が良い選択肢になる可能性があります。
著者: JD Logistics Zhong Lei
出典: JD Cloud Developer Community