ホームページ  >  記事  >  データベース  >  将来の落とし穴を避けるために、Redis 分散ロックによって引き起こされた重大な事故を覚えておいてください。

将来の落とし穴を避けるために、Redis 分散ロックによって引き起こされた重大な事故を覚えておいてください。

Java学习指南
Java学习指南転載
2023-07-26 16:25:281329ブラウズ

はじめに

Redis に基づく分散ロックの使用は、今日では新しいことではありません。この記事は主に、実際のプロジェクトにおける Redis 分散ロックによって引き起こされた事故の分析と解決策に基づいています。

背景: 私たちのプロジェクトにおける急ぎの注文は、分散ロックを使用して解決されています。

かつて、運営は飛天茅台の在庫を100本持って急遽セールイベントを開催しましたが、売れすぎてしまいました。ご存知のように、この地球上で飛天茅台は希少です。 ! !この事故はP0重大事故に分類された…冷静に受け止めるしかない。プロジェクト チーム全体のパフォーマンスが差し引かれました~~

事故の後、CTO が私を指名し、私が責任者の先頭に立って対応するように頼んできました~~

事故現場

ある程度理解した後、このスナップアップアクティビティインターフェイスはこれまでにこのような状況を見たことがなかったことがわかりました。しかし、今回はなぜ過剰に売られているのでしょうか?

理由は、前回のラッシュセール商品は希少品ではありませんでしたが、今回のイベントは実は飛天茅台であり、埋もれたデータの分析により、すべてのデータが基本的に2倍になり、イベントの熱気を高めることができます。そして知ってください!早速、コア コードに直接進みましょう。機密部分は疑似コードで処理されています。 。 。

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
    String key = "key:" + request.getSeckillId;
    try {
        Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
        if (lockFlag) {
            // HTTP请求用户服务进行用户相关的校验
            // 用户活动校验
            
            // 库存校验
            Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
            assert stock != null;
            if (Integer.parseInt(stock.toString()) <= 0) {
                // 业务异常
            } else {
                redisTemplate.opsForHash().increment(key+":info", "stock", -1);
                // 生成订单
                // 发布订单创建成功事件
                // 构建响应VO
            }
        }
    } finally {
        // 释放锁
        stringRedisTemplate.delete("key");
        // 构建响应VO
    }
    return response;
}

上記のコードは、10 秒の分散ロック有効期限までビジネス ロジックが十分な実行時間を確保できるようにします。try-finally ステートメント ブロックを使用して、ロックが時間内に解放されるようにします。在庫もビジネスコード内で検証されます。とても安全ですね~心配しないで、分析を続けてください。

DD によって書かれた SpringBoot の基本チュートリアルのウェアハウスをお勧めします。助けていただければ幸いです: https://gitee.com/didispace/ SpringBoot-Learning /tree/master/2.1.x

事故の原因

Feitian Moutai のラッシュセールイベントに多数の参加者が殺到アプリをダウンロードして登録する新規ユーザーの数 中には、専門的な方法で羊毛を収穫するための新規ユーザーを登録する羊毛愛好家もたくさんいます。もちろん、当社のユーザーシステムは事前に準備されており、Alibaba Cloudの人間と機械の検証に接続されており、三要素認証、独自開発のリスク管理システム、あらゆる格闘技が多数の不正ユーザーをブロックしてきました、これが好きで仕方ありません~

しかし、これがあるからこそ、ユーザー サービスは常に高い動作負荷にさらされています。

駆け込み購入活動が開始された瞬間、大量のユーザー認証リクエストがユーザー サービスに到達しました。これにより、ユーザー サービス ゲートウェイでの短い応答遅延が発生しました。一部のリクエストの応答時間は 10 秒を超えましたが、HTTP リクエストの応答タイムアウトのため、30 秒に設定しました。これにより、ユーザー認証でインターフェイスがブロックされます。10 秒を過ぎると、分散ロックの有効期限が切れます。この時点で、新しいリクエストは可能です。ロックを取得します。これは、ロックが上書きされることを意味します。これらのブロックされたインターフェイス 実行後、ロックを解放するロジックが再度実行され、他のスレッドのロックが解放され、新しいリクエストがロックをめぐって競合します。これは実際には非常に悪いサイクルです。

現時点では、在庫検証のみに頼ることができますが、在庫検証は非アトミックではありません。Get and Compare メソッドを使用します。売られすぎの悲劇が起こりました~~~

#事故分析##注意深く分析した結果、このスナップアップ インターフェイスには、同時実行性が高いシナリオにおいて重大なセキュリティ リスクがあり、主に次の 3 つの場所に集中していることがわかりました。 #その他のシステムリスク耐障害処理なし

ユーザーサービスが逼迫しているため、ゲートウェイの応答が遅れているが、応答方法がないため、売られすぎのトリガーとなっている。

一見安全に見える分散ロックは、実際にはまったく安全ではありません

set key value [EX 秒] [PX ミリ秒] [NX| XX] メソッドですが、スレッド A の実行に時間がかかり、解放する時間がない場合、ロックが期限切れになり、その時点でスレッド B がロックを取得できます。スレッド A が実行を完了してロックを解放すると、スレッド B のロックも実際に解放されます。

この時点で、スレッド C は再びロックを取得できます。このとき、スレッド B が実行を終了してロックを解放すると、実際に解放されるのはスレッド C によって設定されたロックです。これが売られ過ぎの直接の原因です。

非アトミック インベントリ検証

非原子性的库存校验导致在并发场景下,库存校验的结果不准确。这是超卖的根本原因。

通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常set、del的情况下,库存校验是没有问题的。但是,当分布式锁不安全可靠的时候,库存校验就没有用了。

解决方案

知道了原因之后,我们就可以对症下药了。

实现相对安全的分布式锁

相对安全的定义:set、del是一一映射的,不会出现把其他现成的锁del的情况。从实际情况的角度来看,即使能做到set、del一一映射,也无法保障业务的绝对安全。

因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置的很长,但这样做也会带来其他问题。故没有意义。

要想实现相对安全的分布式锁,必须依赖key的value值。在释放锁的时候,通过value值的唯一性来保证不会勿删。我们基于LUA脚本实现原子性的get and compare,如下:

public void safedUnLock(String key, String val) {
    String luaScript = "local in = ARGV[1] local curr=redis.call(&#39;get&#39;, KEYS[1]) if in==curr then redis.call(&#39;del&#39;, KEYS[1]) end return &#39;OK&#39;"";
    RedisScript<String> redisScript = RedisScript.of(luaScript);
    redisTemplate.execute(redisScript, Collections.singletonList(key), Collections.singleton(val));
}

我们通过LUA脚本来实现安全地解锁。

实现安全的库存校验

如果我们对于并发有比较深入的了解的话,会发现想 get and compare/ read and save 等操作,都是非原子性的。如果要实现原子性,我们也可以借助LUA脚本来实现。

但就我们这个例子中,由于抢购活动一单只能下1瓶,因此可以不用基于LUA脚本实现而是基于redis本身的原子性。原因在于:

// redis会返回操作之后的结果,这个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);

发现没有,代码中的库存校验完全是“画蛇添足”。

改进之后的代码

经过以上的分析之后,我们决定新建一个DistributedLocker类专门用于处理分布式锁。

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
    String key = "key:" + request.getSeckillId();
    String val = UUID.randomUUID().toString();
    try {
        Boolean lockFlag = distributedLocker.lock(key, val, 10, TimeUnit.SECONDS);
        if (!lockFlag) {
            // 业务异常
        }

        // 用户活动校验
        // 库存校验,基于redis本身的原子性来保证
        Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
        if (currStock < 0) { // 说明库存已经扣减完了。
            // 业务异常。
            log.error("[抢购下单] 无库存");
        } else {
            // 生成订单
            // 发布订单创建成功事件
            // 构建响应
        }
    } finally {
        distributedLocker.safedUnLock(key, val);
        // 构建响应
    }
    return response;
}

深度思考

分布式锁有必要么

改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。

分布式锁的选型

有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。

再次思考分布式锁有必要么

由于bug需要紧急修复上线,因此我们将其优化并在测试环境进行了压测之后,就立马热部署上线了。实际证明,这个优化是成功的,性能方面略微提升了一些,并在分布式锁失效的情况下,没有出现超卖的情况。

然而,还有没有优化空间呢?有的!

由于服务是集群部署,我们可以将库存均摊到集群中的每个服务器上,通过广播通知到集群的各个服务器。网关层基于用户ID做hash算法来决定请求到哪一台服务器。这样就可以基于应用缓存来实现库存的扣减和判断。性能又进一步提升了!

// 通过消息提前初始化好,借助ConcurrentHashMap实现高效线程安全
private static ConcurrentHashMap<Long, Boolean> SECKILL_FLAG_MAP = new ConcurrentHashMap<>();
// 通过消息提前设置好。由于AtomicInteger本身具备原子性,因此这里可以直接使用HashMap
private static Map<Long, AtomicInteger> SECKILL_STOCK_MAP = new HashMap<>();

...

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;

    Long seckillId = request.getSeckillId();
    if(!SECKILL_FLAG_MAP.get(requestseckillId)) {
        // 业务异常
    }
     // 用户活动校验
     // 库存校验
    if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet() < 0) {
        SECKILL_FLAG_MAP.put(seckillId, false);
        // 业务异常
    }
    // 生成订单
    // 发布订单创建成功事件
    // 构建响应
    return response;
}

通过以上的改造,我们就完全不需要依赖redis了。性能和安全性两方面都能进一步得到提升!

当然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,如果还要考虑这些话,则不如直接考虑分布式锁的解决方案。

概要

希少商品の過剰販売は間違いなく重大な事故です。売れすぎた数量が多ければ、プラットフォームに非常に深刻な経営上の影響や社会的影響を与えることさえあります。この事故の後、プロジェクト内のどのコード行も軽視してはいけないことに気付きました。そうしないと、シナリオによっては、これらの正常に動作するコードが致命的な致命傷になる可能性があります。

開発者にとって、開発計画を設計するときは、計画を慎重に検討する必要があります。計画を徹底的に検討するにはどうすればよいでしょうか?ただ学び続けるだけです!

以上が将来の落とし穴を避けるために、Redis 分散ロックによって引き起こされた重大な事故を覚えておいてください。の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はJava学习指南で複製されています。侵害がある場合は、admin@php.cn までご連絡ください。