Heim  >  Artikel  >  Datenbank  >  Denken Sie an einen schweren Unfall, der durch die verteilte Redis-Sperre verursacht wurde, um künftige Fallstricke zu vermeiden!

Denken Sie an einen schweren Unfall, der durch die verteilte Redis-Sperre verursacht wurde, um künftige Fallstricke zu vermeiden!

Java学习指南
Java学习指南nach vorne
2023-07-26 16:25:281329Durchsuche

Vorwort

Die Verwendung verteilter Sperren auf Basis von Redis ist heute nichts Neues. Dieser Artikel basiert hauptsächlich auf der Analyse und Lösung von Unfällen, die durch verteilte Redis-Sperren in unseren tatsächlichen Projekten verursacht wurden.

Hintergrund: Die Eilaufträge in unserem Projekt werden durch verteilte Sperren gelöst.

Einmal veranstaltete der Betrieb einen Eilverkauf für Feitian Moutai, bei dem 100 Flaschen auf Lager waren, aber die Flaschen waren überverkauft! Wissen Sie, die Knappheit von Feitian Moutai auf dieser Erde! ! ! Der Unfall wurde als P0-Schwerunfall eingestuft... das können wir nur gelassen hinnehmen. Die Leistung des gesamten Projektteams wurde abgezogen~~

Nach dem Unfall benannte mich der CTO und bat mich, die Leitung zu übernehmen, um damit umzugehen, okay, verantwortlich~

Der Unfallort

Nach einigem Verständnis habe ich Folgendes erfahren: Diese Situation ist in der Benutzeroberfläche für Eilkaufaktivitäten noch nie aufgetreten, aber warum ist sie dieses Mal überverkauft?

Der Grund ist: Die bisherigen Rush-Sale-Produkte waren keine Mangelware, aber diese Veranstaltung ist tatsächlich Feitian Moutai. Durch die Analyse der vergrabenen Daten haben sich alle Daten im Grunde verdoppelt. Sie können sich die Begeisterung für die Veranstaltung vorstellen. Es gibt nicht viel zu sagen, gehen wir direkt zum Kerncode und der vertrauliche Teil wurde mit Pseudocode verarbeitet. . .

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;
}

Der obige Code verwendet eine Ablaufzeit der verteilten Sperre von 10 Sekunden, um sicherzustellen, dass die Geschäftslogik ausreichend Ausführungszeit hat. Ein try-finally-Anweisungsblock wird verwendet, um sicherzustellen, dass die Sperre rechtzeitig freigegeben wird. Der Lagerbestand wird auch innerhalb des Geschäftscodes überprüft. Es sieht sehr sicher aus ~ Keine Sorge, fahren Sie mit der Analyse fort.

"

Empfehlen Sie ein Warehouse für SpringBoot-Basis-Tutorials von DD. Ich wünsche Ihnen eine helfende Hand: https://gitee.com/didispace/SpringBoot-Learning/tree/master/2.1.x

Ursache des Unfalls

Die Feitian Moutai Rush Sale-Veranstaltung lockte eine große Anzahl neuer Benutzer an, unsere APP herunterzuladen und zu registrieren. Darunter sind viele Wollbegeisterte, die professionelle Methoden nutzen, um neue Benutzer für die Wollernte zu registrieren. Natürlich hat unser Benutzersystem im Voraus Vorkehrungen getroffen, um eine große Anzahl illegaler Benutzer zu blockieren, z. B. die Mensch-Computer-Authentifizierung von Alibaba Cloud, die Drei-Faktor-Authentifizierung und das ist es Auch deshalb ist der Dienst einer hohen Betriebslast ausgesetzt Aufgrund des Antwortzeitlimits der HTTP-Anfrage haben wir es jedoch auf 30 Sekunden festgelegt, was dazu führte, dass die Schnittstelle im Benutzerüberprüfungsbereich blockiert wurde Die verteilte Sperre ist abgelaufen. Zu diesem Zeitpunkt können neue Anforderungen die Sperre erhalten, wenn sie eingehen. Das heißt, die Sperre wird überschrieben, nachdem diese blockierten Schnittstellen ausgeführt wurden, wodurch die Sperren freigegeben werden andere Threads, was dazu führt, dass neue Anfragen um die Sperre konkurrieren

Derzeit können wir uns nur auf die Bestandsüberprüfung verlassen, aber die Bestandsüberprüfung ist nicht atomar und verwendet die Get-and-Compare-Methode. Die Tragödie des Überverkaufs ist passiert Es kann festgestellt werden, dass diese Snap-up-Schnittstelle in Szenarien mit hoher Parallelität schwerwiegende Sicherheitsrisiken aufweist, die sich hauptsächlich an drei Stellen konzentrieren:

Es gibt keine andere Systemrisiko-Fehlertoleranz Aufgrund strenger Benutzerdienste ist das Gateway A Eine verzögerte Reaktion, aber keine Möglichkeit, damit umzugehen, ist ein Auslöser für Überverkauf.

Die scheinbar sichere verteilte Sperre ist eigentlich überhaupt nicht sicher

Obwohl die

-Methode übernommen wird, läuft die Sperre ab, wenn die Ausführung von Thread A lange dauert und keine Zeit zum Freigeben hat, und Thread B kann sie erwerben es zu diesem Zeitpunkt Zum Schloss. Wenn Thread A die Ausführung abschließt und die Sperre aufhebt, wird die Sperre von Thread B tatsächlich aufgehoben.

Zu diesem Zeitpunkt kann Thread C die Sperre erneut erlangen. Wenn Thread B die Ausführung beendet und die Sperre aufhebt, wird tatsächlich die von Thread C festgelegte Sperre freigegeben. Dies ist die direkte Ursache für den Überverkauf.

Nicht-atomare Bestandsüberprüfung

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

通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常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了。性能和安全性两方面都能进一步得到提升!

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

Zusammenfassung

Der Überverkauf knapper Güter ist definitiv ein schwerer Unfall. Wenn die überverkaufte Menge groß ist, hat dies sogar sehr schwerwiegende betriebliche Auswirkungen und soziale Auswirkungen auf die Plattform. Nach diesem Unfall wurde mir klar, dass keine Codezeile im Projekt auf die leichte Schulter genommen werden darf, sonst werden diese normal funktionierenden Codes in manchen Szenarien zu tödlichen Killern!

Für einen Entwickler muss der Plan beim Entwerfen eines Entwicklungsplans sorgfältig geprüft werden. Wie können wir den Plan gründlich prüfen? Lerne einfach weiter!

Das obige ist der detaillierte Inhalt vonDenken Sie an einen schweren Unfall, der durch die verteilte Redis-Sperre verursacht wurde, um künftige Fallstricke zu vermeiden!. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Dieser Artikel ist reproduziert unter:Java学习指南. Bei Verstößen wenden Sie sich bitte an admin@php.cn löschen