序文
以前にスタンドアロン モードで 1 人 1 注文を実装しましたが、クラスター モードがオンになっている場合、スタンドアロン モードのソリューションは明らかに適用できません。1 つ目はロック ソリューションです。クラスター モードでは、同期されている場合はロックとして使用され、それぞれがスタンドアロンです独自のロックがある場合、ロックが失敗します。この時点では、分散ロックを使用する必要があります。この記事では、redis の setnx 操作を使用して分散ロックを手動で実装します。最後に redisson を使用して分散ロックを実行します。
クラスター環境での同時実行の問題
前回の記事では、スレッドセーフを確保するために synchronized を追加して 1 人 1 注文を実装するコードを記述しましたが、クラスターモードでは動作しません。同期ロックが失敗する場合とロック失敗の原因の分析
複数の Tomcat をデプロイし、各Tomcat が独自の jvm を持っているため、サーバー A の Tomcat 内に 2 つのスレッドがあるとします。これら 2 つのスレッドは同じコードを使用するため、それらのロック、オブジェクトは同じであり、相互排他が可能です。ただし、サーバー B の Tomcat 内に 2 つのスレッドがある場合、それらのロック オブジェクトはサーバー A のものと同じように書き込まれますが、ロック オブジェクトは同じではないため、スレッド 3 とスレッド 4 は相互排他を実現できますが、スレッド 1 とスレッド 2 との相互排他を実現できないため、クラスター環境で syn ロックが失敗するのですが、この場合、分散ロックを使用してこの問題を解決する必要があります。
分散ロック
意味
分散ロック: 分散システムまたはクラスター モードの複数のプロセスに表示され、相互に排他的なロック。
分散ロックの中心的な考え方は、全員が同じロックを使用できるようにすることです。全員が同じロックを使用している限り、スレッドをロックし、スレッドの進行を阻止し、プログラムをシリアルに実行させることができます。これが核心です。分散ロックの思考列
満たすべき条件
可視性: 複数のスレッドが同じ結果を見ることができる、つまり、複数のスレッドが同じ情報を取得する必要がある注:ここで言及される可視性は、同時プログラミングで参照されるメモリの可視性ではなく、複数のプロセス間でのみ変更の意味を感知します。
相互排他: 相互排他は分散ロックの最も基本的な条件であり、プログラムをシリアルに実行します。
高可用性: プログラムはクラッシュしにくく、常に高可用性が保証されます。
高パフォーマンス: ロック自体がパフォーマンスを低下させるため、すべての分散ロックにはより高いロックパフォーマンスとロック解放パフォーマンスが必要です。
セキュリティ: セキュリティもプログラムの不可欠な部分です
一般的な分散ロック
Mysql: mysql 自体にはロック メカニズムがありますが、 mysql の一般的なパフォーマンスのため、分散ロックを使用する場合に mysql を分散ロックとして使用することはまれです。
Redis: 分散ロックとしての Redis は、非常に一般的な使用方法です。現在、エンタープライズ レベルの開発では、基本的に、分散ロックとして Redis または Zookeeper を使用します。setnx メソッドを使用して、キーが正常に挿入された場合、ロックが解除されたことを意味します。挿入が成功した場合、他の人が挿入に失敗した場合は、ロックを取得できないことを意味します。このロジックを使用して分散ロックを実装します。
Zookeeper: Zookeeper は、エンタープライズ レベルの開発で分散ロックを実装するための優れたソリューションでもあります。
Redis は分散ロックを実装します
分散ロックを実装する場合、実装する必要がある基本的な方法が 2 つあります。
-
ロックを取得します。
-
相互排他: 1 つのスレッドだけがロックを取得できるようにします。
-
ノンブロッキング: 1 回試行し、成功した場合は true、失敗した場合は false を返します。
-
-
ロックを解除します。
-
手動リリース
-
タイムアウト解除:デッドロックを防ぐために、ロックを取得するときにタイムアウト期間を追加します。
-
核となるアイデア
redisのsetNxメソッドを使用します複数のスレッドが入った場合はこのメソッドを使用します 最初のスレッドが入ったとき、redis内にこのキーがあり、1を返します 結果が1であれば、キーを取得したことを意味します.lock を実行し、その後、ロックを削除し、ロック ロジックを終了し、ロックを取得していないスレッドが再試行するために一定時間待機します。
コード
ILockインターフェース、ロック動作の仕様を策定
/**
* @author
* @version 1.0
* @description: 锁的基本接口
* @date 2023/9/4 9:16
*/
public interface ILock {
//尝试获取锁
boolean tryLock(Long timeoutSec);
//释放锁
void unlock();
}
Redis 分散ロックのプライマリ バージョン
/**
* @author
* @version 1.0
* @description: redis分布式锁初级版本
* @date 2023/9/4 9:20
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long timeoutSec) {
//获取当前线程id
long id = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, id + "", timeoutSec, TimeUnit.SECONDS);
//防止自动拆箱出现空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
setnx オペレーションを実行すると、注文はユーザー ID と結合され、1 人あたり 1 つの注文が実現されます。
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//判断是否开始,开始时间如果在当前时间之后就是尚未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断是否结束,结束时间如果在当前时间之前就是已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//尝试获取锁
boolean isLock = lock.tryLock(1200L);
//获取锁失败
if(!isLock){
return Result.fail("不允许重复下单");
}
try {
return voucherOrderService.createVoucherOrder(voucherId);
}finally {
//释放锁
lock.unlock();
}
}
誤って削除した場合
論理的な説明
ロックを保持しているスレッドはロック内でブロックされており、そのロックが期限切れになると自動的にロックが解放されます。このとき、他のスレッド (スレッド 2) がロックを取得しようとしてからロックを取得し、スレッド 2 が実行されます。プロセス中、スレッド 1 は反応して実行を続けますが、スレッド 1 の実行中に、ロックを削除するロジックに到達します。このとき、スレッド 2 に属するはずのロックが削除されます。は、他人のロックが誤って削除された状況の説明です。
解決
解決策は、各スレッドがロックを解放するときに、現在のロックが自分自身に属しているかどうかを判断することです。それが自分自身に属していない場合、ロックは削除されません。上記の状況が依然として当てはまると仮定すると、スレッド 1 はスタックし、ロックはは自動的に解放されます。スレッド 2 は、ロックの内部実行ロジックに入ります。このとき、スレッド 1 は反応してロックを削除します。ただし、スレッド 1 は、現在のロックが自分自身に属していないことを認識しているため、ロックのロジックは実行しません。スレッド 2 がロック削除のロジックに到達したとき、ロックの自動解放時点を過ぎていない場合は、現在のロックが自分のものであると判断され、ロックが削除されます。つまり、ロックを解放する前にそのロックが自分のものであるかどうかを判断するには、ロックを取得するときに一意のスレッド ID を追加する必要があります。クラスター モードではスレッド ID が競合する可能性があるため、このプロジェクトでは uuid+スレッド ID を使用します。 uuidをまとめる
コード
核となるアイデア
以前の分散ロックの実装を要件を満たすように変更します。ロックを取得するときにスレッド ID (UUID で表すことができる) を保存し、ロックを解放するときに最初にロック内のスレッド ID を取得し、それが現在のものと一致するかどうかを判断します。スレッドID
-
一貫性がある場合はロックを解除する
-
不一致の場合、ロックは解除されません
コアロジック: ロックを保存する場合は自分のスレッドの ID を入れ、ロックを削除する場合は現在のロック ID が自分で保存されているかどうかを判断し、保存されている場合は削除し、そうでない場合は削除しない。
@Override
public boolean tryLock(Long timeoutSec) {
//获取当前线程id
String id = ID_PREFIX+Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, id, timeoutSec, TimeUnit.SECONDS);
//防止自动拆箱出现空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//从redis中获取锁信息
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//获取当前线程标识
String ThreadId = ID_PREFIX+Thread.currentThread().getId();
//自己的锁才释放
if(id.equals(ThreadId)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
誤って削除されたさらに極端なケース
スレッド 1 がロックを保持した後、ビジネス ロジックを実行する過程で、ロックを削除する準備が整い、条件判断のプロセスに到達します。たとえば、スレッド 1 は実際に自分に属する現在のロックを取得し、その準備をしています。ロックを削除するには、ロックの有効期限が切れます。この時点でスレッド 2 が入りますが、スレッド 1 は後で実行を続けます。スレッド 1 がスタックすると、ロックを削除するコード行を直接実行します。 「判定は機能しませんでした。これは、ロックを削除するときのアトミック性の問題です。この問題の理由は、スレッド 1 のロックの取得、ロックの比較、およびロックの削除が実際にはアトミックではないためです。このような状況が発生した場合、ロックの検証とロックの削除という 2 つの操作が中断できないアトミック操作であることを確認する必要があります。
Lua スクリプトはアトミック性の問題を解決します
Redis は、複数の Redis コマンドを 1 つのスクリプトに記述して、複数のコマンドの実行のアトミック性を確保する Lua スクリプト機能を提供します。Lua はプログラミング言語です。基本的な文法については、次の Web サイトを参照してください: Lua チュートリアル | 初心者チュートリアル. ここでは、Redis が提供する呼び出し関数に焦点を当てます。ロック削除を実現するのはアトミック アクションです。Javaプログラマーとして、単純な要件はありません。熟練する必要はありません。それが何をするのかを知っていれば十分です。
ロックを解除するためのビジネスプロセスは次のとおりです。
1. ロックのネジマークを取得します。
2. 指定されたマーク(現在のスレッドマーク)と一致するかどうかを判断します。
3. 整合性があればロックを解除(削除)します。
4. 矛盾がある場合は何もしない
最終的に、redisを操作してロックとロックを削除するluaスクリプトは次のようになります
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
コードの変更は次のとおりです
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
分散ロックの再ディスセッション
setnx に基づく分散ロックには次の問題があります。
リエントラント問題: リエントラント問題とは、ロックを取得したスレッドが同じロックのコード ブロックに再び入ることができることを意味します. リエントラント ロックの重要性はデッドロックを防ぐことです. たとえば、HashTable などのコードでは、彼の手法は synchronized を使用します修正しました。あるメソッドで別のメソッドを呼び出した場合、その時点でリエントラントでない場合、デッドロックではないでしょうか。したがって、リエントラント ロックの主な重要性は、デッドロックを防ぐことです。同期ロックとロック ロックは両方ともリエントラントです。
Non-retryable : 現在のディストリビューションは 1 回しか試行できないことを意味します。スレッドがロックの取得に失敗した場合、スレッドは再度ロックの取得を試行できるはずであるというのが合理的な状況であると考えられます。
タイムアウト解除:ロックを追加するときに有効期限を長くしてデッドロックを防ぐことができますが、フリーズ時間が長すぎると、他の人のロックを誤って削除しないように lua 式を使用しますが、結局はそうではありません。ロックされているため、セキュリティ上のリスクがあります
マスター/スレーブの一貫性: Redis がマスター/スレーブ クラスターを提供する場合、クラスターにデータを書き込むとき、ホストはデータをスレーブに非同期的に同期する必要があります。同期が完了する前にホストがクラッシュすると、デッドロックが発生します。
リディソンのコンセプト
Redisson は、Redis に基づいて実装された Java インメモリ データ グリッド (In-Memory Data Grid) です。これは、一連の分散共通 Java オブジェクトを提供するだけでなく、さまざまな分散ロックの実装を含む多くの分散サービスも提供します。
Redission は分散ロックのさまざまな機能を提供しており、ロックを手動で実装する必要がありません。同時に、redisson の分散ロックの実装は、上記の setnx の問題も完全に解決します。
クイックスタート
依存関係をインポートします。start を直接インポートすることはお勧めできません。これにより、redisson 用の springboot の元の構成が上書きされます。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
Redisson クライアントを構成します。
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379")
.
// 创建RedissonClient对象
return Redisson.create(config);
}
}
自分で書いたロックを redisson のロックに置き換えるだけです
@Resource
private RedissonClient redissonClient;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
要約する
デッドロックの問題の発生を防ぐために有効期限を追加することでこれまで進めてきましたが、有効期限を過ぎると、誤って他人のロックを削除してしまう問題が発生する可能性があります。ロックの比較とロックの削除 このロジックは、それを解決するために使用されます。つまり、削除する前に現在のロックが自分のものであるかどうかを判断しますが、アトミック性の問題がまだ残っています。つまり、ロックの取得がアトミックであることは保証できません。最終的に、この問題は lua 式によって解決されますが、setnx を使用して分散ロックを実装する場合には、再入不可、再試行不可能など、まだ多くの問題があり、一般的に次のような場合に使用されます。企業は、Wheels を繰り返し作成することなく、分散ロックとして redisson を使用することができますが、私たちは setnx を通じて分散ロックを自分で実装します。これは、分散ロックの原理をより深く理解するのに役立ちます。