記事ディレクトリ
クーポンフラッシュセール
グローバルIDジェネレーター
- 最初のビットは符号ビットで、常に 0 です。
- 2 ~ 32 ビットはタイムスタンプの差の値であり、特定の瞬間から現在のタイムスタンプと最初のタイムスタンプの差が計算されることを指定します。これにより、ID の自己増加が保証されますが、必ずしも連続的である必要はありません。 。
- 最後の 32 ビットについては、パーティション + シリアル番号の方法が使用できます。(配布済み)
これは、mybatis-plus のスノーフレーク アルゴリズムと本質的に同じです。
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
// 此处的警告可以忽略,因为如果key不存在,会从0开始增长。
// 这里key前缀以日期构建的目的是为了避免自增超出序列号范围,同一天下单数量一定不会超出32位
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
// timestamp 左移 32位,变到高位,那么低32位为0,或上count即可,count一定不会超出32位范围。
return timestamp << COUNT_BITS | count;
}
}
テスト:
実行可能なタスク task を作成し、100 回ループし、自動インクリメント ID テストを実行します。
ワーカー スレッドの固定数 300 を使用してスレッド プールを構築し、ループ内でタスクをスレッド プールに送信します。最終的には、ID を 30,000 (100 * 300) 回自己インクリメントすることに相当します。
CountDownLatch を使用してタイミングを調整します。スレッド プールを使用するため、スレッド プールの実行は非同期であるため、単純に end - begin を使用します。実行が最後に達したときに、まだ完了していない非同期スレッドが存在する可能性があります。
CountDownLatch を使用すると、非同期スレッドをマークするのに役立ち、 latch.await(); はすべての非同期スレッドが完了するまで待機します。
@Resource
private RedisIdWorker redisIdWorker;
private final ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; ++i) {
long id = redisIdWorker.nextId("order");
System.out.println("id:" + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; ++i) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time cost:" + (end - begin));
}
クーポンを使って注文する
売られすぎ問題
1 つのスレッドが在庫を照会しますが、在庫が差し引かれていないため、もう 1 つのスレッドも在庫照会を実行します。前のスレッドには在庫を差し引く時間がなかったため、後のスレッドでも注文を実行できます。
オプティミスティック ロックを使用して、売られすぎの問題を解決します。データベース更新操作が成功するのは、残りの在庫がまだ 0 より大きい場合のみです。
public class IVoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
RedisIdWorker redisIdWorker;
// 因为设计两张表操作,使用事务保证操作连续性
@Transactional
@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("库存不足!");
}
// 5. 扣减库存
// 使用乐观锁解决超卖问题,仅当数据库更新操作时,剩余库存依旧大于0,才执行成功。
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 创建订单id,使用全局生成器
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
}
一人一人 一人
要件: クーポンフラッシュセールビジネスを変更するには、同じユーザーのみが注文できます。
主な問題点:
1. チケットを 1 人あたり 1 枚保証するためには、ユーザー ID とクーポン ID に従って注文が行われたかどうかを確認する必要があり、スレッドの安全性の問題を回避するためにこのプロセスをロックする必要があります。
2. ロック オブジェクトは、定数プールに格納されるユーザー ID の文字列形式にすることができます。
3. ロックのスコープはトランザクションがコミットされた後である必要があるため、メソッド全体をロックすることが最善です。
4. このクラスのメソッドを使用すると、トランザクションが失敗する可能性があります。解決策は、プロキシ オブジェクトのメソッドを使用することです。
(1) 依存関係を追加します。
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
(2) スタートアップ クラスでプロキシ オブジェクトを Spring コンテナに公開します。
(3) コンテナ内でプロキシ実行メソッドを使用します。
synchronized (userId.toString().intern()) {
// 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
完全なロジックは次のとおりです。
public class IVoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
RedisIdWorker redisIdWorker;
@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("库存不足!");
}
//用户id
Long userId = UserHolder.getUser().getId();
// 使用.intern(),使得从字符串常量池中取得唯一的id对象,作为锁对象
synchronized (userId.toString().intern()) {
// 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
// 因为设计两张表操作,使用事务保证操作连续性
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5 一人一单
Integer count = query()
.eq("user_id", UserHolder.getUser().getId())
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
// 用户已经秒杀过优惠券
return Result.fail("用户已经购买过一次!");
}
// 6. 扣减库存
// 使用乐观锁解决超卖问题,仅当数据库更新操作时,剩余库存依旧大于0,才执行成功。
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 创建订单id,使用全局生成器
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8. 返回订单id
return Result.ok(orderId);
}
}
ロック オブジェクトが文字列定数プール内のユーザー ID であるため、上記の解決策ではクラスター モードでは依然として問題が発生します。クラスター モードでは、サーバーごとに異なる JVM が存在するため、ロック オブジェクトは一意ではありません。
解決策は、分散ロックを使用することです。
分散ロック
分散ロック: 複数のプロセスから認識され、分散システムまたはクラスター モデルの下で相互に排他的なロック。
一般的な分散ロック実装スキームは 3 つあります。
- MySQL 自体に基づくミューテックス ロック メカニズム
- Redis ベースの setnx などの相互排他コマンド
- Zookeeper に基づいたノードの一意性と順序を利用する
Redisのsetnx命令による分散ロックを実現
サーバー クラスターがサードパーティの Redis を共有していると仮定すると、 Redis 上のlock
キーと値のペアを使用してthreadid
ロック オブジェクトを表すことができます。
ロックの取得をシミュレートします。
- 保証された相互排除により、1 つのスレッドのみがロックを取得できるようになります。
- ノンブロッキング: 1 回試行し、成功した場合は true、失敗した場合は false を返します。
- ロックの解放操作が失敗し、後続のシーケンスでロックを取得できなくなることを避けるために、ロックには有効期間を設定し、有効期限が経過すると自動的にロックが解放されるようにする必要があります。
Redis コマンド:
set lock threadId nx ex 10
ロックの解除をシミュレート:
直接削除するlock
だけです
del lock
Javaでの実装は以下の通りですが、注意点は以下の通りです。
- Redis 操作を準備し
StringRedisTemplate
、ビジネスごとに異なるロックを使用するには、ビジネス名をロック オブジェクトのキーに追加する必要がありname
、これら 2 つの変数はコンストラクターを通じて渡されます。 - ロック取得関数をシミュレートし
tryLock()
、ロックが正常に取得されたかどうかを表すブール値を返します。ロックの TTL を指定できます。.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
、ロックのキーと値のペアを指定します。キーはロック プレフィックス + ビジネス名、値はスレッド ID です。 - ロックの解放をシミュレートし
unlock()
、キーに従ってロックを表すキーと値のペアを直接削除します。
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate
.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
ロックを誤って削除してしまう問題を解決する
上記バージョンの分散ロック実装では、ロックが誤って削除される問題が発生する可能性があり、具体的な状況は次のとおりです。
- スレッド 1 がロックを取得し、業務ブロッキングによりブロッキング時間がロック自動解除時間を超えています。
- ロックが自動解除された後、スレッド 2 がロックを取得して業務を実行し、実行中にスレッド 1 が業務を完了してロックを解放します。
- スレッド 1 がロックを削除すると、スレッド 3 は引き続きロックを取得できます。この時点では、スレッド 2 とスレッド 3 はすでに並行して実行されており、ロックの相互排他に違反します。!!。
その場合の解決策は、ロック フィールドを削除することです。つまり、ロックを解放するときに、現在のロック解放が以前に自分で取得したロックであることを確認します。!。
主な変更点は 2 つあります。
- ロックを取得するときは、スレッドの一意の ID を保存します。クラスタの場合、異なるクラスタ内の異なるスレッド ID は同じである可能性があります。ID の一意性を保証するために、スレッド ID を結合するために UUID が使用されます。
- ロックを解除する際は、現在のスレッドIDと一致するかどうかを判断し、一致しない場合はロックを解除しません(誤って他のスレッドのロックを削除することを避けるため)。
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate
.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
Luaスクリプトによる複数命令のアトミック性を実現
ロック識別子が一致しているかどうかの判断とロックの解放はアトミックではないため、このギャップによりスレッドの安全性の問題が再び発生する可能性があります。
解決策は、lua スクリプトを使用して命令実行のアトミック性を確保することです。
Redis は Lua スクリプトを呼び出します
- Redis は
EVAL
スクリプトの実行に使用でき、Lua スクリプトはredis.call()
Redis 命令の実行に使用できます。 - このコマンドを使用する
EVAL
場合、スクリプトで操作する必要があるキー タイプのパラメーターの数を指定し、その後にキー リストと argv リストを指定すると、受信パラメーターをスクリプト内で直接使用できるようになります。Lua スクリプトでは、配列インデックスの添え字は 1 から始まるため、KEYS[1] は名前を意味し、ARGV[1] は Rose を意味することに注意してください。
JavaでLuaスクリプトを使用する
1. Resource ディレクトリにunlock.lua スクリプトを書き込みます。
2. Redis スクリプト呼び出しオブジェクトを構成しDefaultRedisScript
、スクリプト パスと戻り値のタイプを指定します。
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 指定脚本路径
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 设置返回类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
3. ロック解除で stringRedisTemplate を使用して UNLOCK_SCRIPT を実行し、lua スクリプトを呼び出して操作のアトミック性を確保します。
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
レディソン
Lua スクリプトに基づいて最適化された Redis 分散ロックは、ほとんどのシナリオでビジネス ニーズをすでに満たしていますが、まだいくつかの欠点があります。
- 1. ロックは再入可能ではありません
- 2. ロックを取得します。再試行はありません
- 3. タイムアウト解除によりデッドロックは回避されますが、業務の実行に時間がかかり、ロックが解除されるためセキュリティリスクが発生します。
- 4. マスター/スレーブの一貫性: Redis がマスター/スレーブ クラスター (読み取り操作、スレーブ ノードの使用、書き込み操作でマスター ノードの使用) を提供する場合、マスター/スレーブの同期に遅延が発生します。スレーブ ノードが同期されていないため、ロック相互排他障害が発生します。
上記の高度な機能を実現するには、Redis をベースとした分散ロック フレームワークである Redisson を使用できます。
公式サイトアドレス
レディソンのクイックスタート
- 依存関係を導入する
- Redisson クライアントを構成し、構成クラスで @Bean アノテーションを使用し、Redisson クライアント クラスを IoC コンテナに挿入して、管理のために Spring に渡します。
- Redisson を使用した分散ロック
Redisson リエントラント ロック原理
再入可能の原理は、同期などの再入可能ロックの原理と似ています。Redis で setnx を使用して、ハッシュ タイプのデータを保存します。フィールドはロックの値、値は現在ロックが取得されている回数です。
- まずロックが存在するかどうかを判断し、存在しない場合はロックを取得してスレッド ID を追加し、ロックの有効期間を設定します。
- すでにロックが存在する場合は、ロックIDからそのロックがスレッドに属するかどうかを判断し、存在する場合はロック数に1を加算し、存在しない場合はロックの取得に失敗します。
- 業務の実行が完了するとロック数は1減算され、0になるとロックが解除され、それ以外の場合はロックの有効期間がリセットされます。
- 上記のロジックは原子性を確保する必要があるため、すべての操作は Lua スクリプトを使用して実装する必要があります。
Redisson のロック再試行とウォッチドッグ メカニズム
- Redisson 分散ロックはロックの再取得を試行する機能を実装しており、ロックの取得時に最大待ち時間
wait_time
とロックの自動解放時間を渡すことができますlease_time
。 - ロックの取得を試みたとき、ロックの取得に成功した場合は null が返され、そうでない場合は最大残り待機時間 pttl がミリ秒単位で返されます。残りの最大待機時間が 0 より大きい場合は、サブスクライブして、ロックを解放する信号を待ちます。
- 同様に、ロックが解放されると、ロック解放メッセージが発行され、メッセージをサブスクライブするすべてのスレッドがそれを受信します。受信後、この時点でウェイトがタイムアウトしたかどうかを判断する必要があり、タイムアウトした場合はロックの取得に失敗し、そうでない場合は再度ロックの取得を試みます。
- ロックの自動解放時間が -1 でない場合、ロックが正常に取得されると、Redisson は内部的にウォッチドッグ メカニズムを採用し、ウォッチドッグ メカニズムを開き、ロックの有効期間を継続的に更新します (タスクを開くと、ロックの有効期間の 1/3)。ロック解除時間 (ロック解除時間) が長い時間後に実行されると、実行するタスク自体が再帰呼び出しされ、1/3 ごとに有効期間がリセットされます)、ロックが解除されると、このウォッチドッグ機構もキャンセルされます。
レディソンのマルチロック
複数の分散 Redis ノードを使用し、各 Redis にロックを構築します。ロックを取得する操作を行うたびに、同時に複数の Redis ノードからロックを正常に取得できる必要があり、成功したとみなされます。ロックの取得。
この方法は実際にはチェーンを構成しており、運用保守コストが高く、実装が複雑であるという欠点があります。
@BeforeEach は、ソフトウェア開発で一般的なテスト フレームワークで使用されるアノテーションです。これは、JUnit または他の同様の単体テスト フレームワークで、各テスト メソッドの前に実行されるセットアップ操作をマークするために一般的に使用されます。
使用:
雷の最適化
Redis キャッシュのデカップリング
本来のseckillビジネスは、まずseckillの在庫を判断し、注文を問い合わせて1人1注文を満たすかどうかを確認してseckillの資格をロックし、データベースを操作して在庫を修正して注文を作成する必要がある。
プロセス全体で一連の手順が多く、頻繁にデータベースを操作するため、応答が遅くなります。
実際、ビジネスは 2 つのステップに分解できます。フラッシュ クーポンのロックとフラッシュ クーポンの生成です。ロック フラッシュ クーポンのリクエストには、高い同時実行性に対するより厳しい要件があり、次のことが可能です。これは Redis キャッシュを介して実装されます。フラッシュ クーポンをロックした後、食事を注文してユーザーに小さなチケットを渡すのと同じです。この小さなチケットの情報はブロッキング キューに保存され、非同期スレッドが開かれて、ブロッキング キュー内の注文を消費し、対応する注文をデータベースに生成します。
具体的な実装では、lua スクリプトを使用して Redis 上で操作を実装し、コード実行のアトミック性を確保したり、データベースの接続パフォーマンスを参照して非同期スレッドによるブロックキューの処理を構築したりできます。
Redis メッセージキュー
ブロッキング キューに基づいて Redis によって生成されたクーポン注文を処理するには、大きな問題があります。高い同時実行性と高いクーポンが発行される場合、ブロッキング キューの長さは制限されますが、JVM のメモリ、ブロッキング キューの設定によって制限されます。が大きすぎるため、OOM が発生する可能性があります。
この目的のために、メッセージ キューを使用して、Redis によって生成されたクーポン注文メッセージを保存する必要があります。
大規模なメッセージ処理シナリオの場合は、kafka、rabbitMq、rocketMq を使用できます。
小規模なシナリオの場合は、Redis に付属のメッセージ キュー サービスを使用できます。
リスト構造に基づく
BRPOP、BLPOPを使用してブロック効果を実現します。
リスト メッセージ キューに基づく利点と欠点:
利点:
- JVM メモリの上限に制限されない Redis ストレージの使用
- Redis永続化メカニズムに基づいて、データのセキュリティが保証されます
- メッセージの順序を保証できる
欠点:
- メッセージ処理中に例外が発生すると、メッセージは失われます
- シングルコンシューマ モードのみがサポートされています。
PubSubベースのメッセージキュー
List 構造のメッセージ キューと比較して、PubSub に基づくメッセージ キュー フリクションにより、コンシューマは 1 つ以上のチャネルにサブスクライブでき、プロデューサが対応するチャネルにメッセージを送信した後、すべてのサブスクライバは関連するメッセージを受信できます。
ストリームベースのメッセージキュー
ブロック方法や&マークから最新ニュースを読むことができます。
ただし、メッセージが読み取られて消費されると、この期間中に複数のメッセージが受信されますが、読み取れるのは最後に送信されたメッセージのみであるため、メッセージが失われるリスクがあります。
ストリームベースのメッセージキュー - コンシューマグループ
コンシューマ グループ: 複数のコンシューマをグループに分割し、同じキューをリッスンします。次の特性があります。
- メッセージのシャント: キュー内のメッセージは、繰り返し消費されるのではなく、グループ内の異なるコンシューマにシャントされるため、メッセージの処理が高速化されます。
- メッセージ ID: コンシューマ グループは、最後に処理されたメッセージを記録する ID を維持します。コンシューマがクラッシュして再起動した場合でも、すべてのメッセージが確実に消費されるように、ID 以降のメッセージの読み取りを開始します。
- メッセージの確認: コンシューマがメッセージを取得した後、メッセージは保留状態になり、保留リストに保存されます。処理後、保留リストから削除される前に、XACK を通じてメッセージを確認し、メッセージを処理済みとしてマークする必要があります。リスト。