L'utilisation de verrous distribués basés sur Redis n'a rien de nouveau aujourd'hui. Cet article est principalement basé sur l'analyse et les solutions des accidents causés par les verrous distribués Redis dans nos projets actuels.
Contexte : Les commandes urgentes de notre projet sont résolues à l'aide de verrous distribués.
Une fois, l'opération a organisé une vente urgente pour Feitian Moutai, avec 100 bouteilles en stock, mais elle a été survendue ! Vous savez, la rareté de Feitian Moutai sur cette terre ! ! ! L'accident a été classé accident majeur P0... on ne peut que l'accepter sereinement. La performance de toute l'équipe du projet a été déduite~~
Après l'accident, le CTO m'a nommé et m'a demandé de prendre les devants pour s'en occuper, d'accord, charge~
Après une certaine compréhension, j'ai appris ceci. Cette situation ne s'est jamais produite dans l'interface d'activité d'achat précipitée auparavant, mais pourquoi est-elle survendue cette fois-ci ?
La raison est que les produits en vente précipitée précédents n'étaient pas des produits rares, mais cet événement est en fait Feitian Moutai. Grâce à l'analyse des données enfouies, toutes les données ont pratiquement doublé. Sans plus attendre, passons directement au code core, et la partie confidentielle a été traitée avec du pseudo code. . .
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; }
Le code ci-dessus garantit que la logique métier a un temps d'exécution suffisant jusqu'au délai d'expiration du verrou distribué de 10 s ; le bloc d'instruction try-finally est utilisé pour garantir que le verrou sera libéré à temps. L'inventaire est également vérifié dans le code commercial. Cela a l'air très sûr ~ Ne vous inquiétez pas, continuez l'analyse.
"
Recommandez un entrepôt pour les tutoriels de base SpringBoot écrits par DD, je vous souhaite un coup de main : https://gitee.com/didispace/SpringBoot-Learning/tree/master/2.1.x
L'événement de vente précipitée Feitian Moutai a attiré un grand nombre de nouveaux utilisateurs pour télécharger et enregistrer notre application. Parmi eux, de nombreux passionnés de laine utilisent des méthodes professionnelles pour enregistrer de nouveaux utilisateurs afin de récolter la laine. Bien sûr, notre système utilisateur a pris des précautions à l'avance. L'accès à l'authentification homme-machine d'Alibaba Cloud, l'authentification à trois facteurs et le système de contrôle des risques auto-développé et d'autres compétences diverses ont bloqué un grand nombre d'utilisateurs illégaux. mais donnez un coup de pouce ~
Mais c'est aussi grâce à cela que les utilisateurs peuvent en profiter. Le service a été soumis à une charge de fonctionnement élevée
Au début de l'activité de vente urgente, un grand nombre de demandes de vérification des utilisateurs ont frappé. service utilisateur, ce qui entraîne un court délai de réponse sur la passerelle de service utilisateur et le temps de réponse de certaines requêtes dépasse 10 secondes. Cependant, en raison du délai d'attente de réponse de la requête HTTP, nous l'avons fixé à 30 secondes, ce qui a provoqué un dysfonctionnement de l'interface. bloqué dans la zone de vérification de l'utilisateur.Après 10 secondes, le verrou distribué a expiré. À ce moment-là, les nouvelles demandes peuvent obtenir le verrou lorsqu'elles arrivent. C'est-à-dire que le verrou est écrasé une fois ces interfaces bloquées exécutées. une logique de libération sera exécutée, ce qui libère les verrous des autres threads, provoquant la concurrence pour le verrou de nouvelles demandes. C'est vraiment un cycle extrêmement mauvais .
.Pour le moment, nous ne pouvons nous fier qu'à la vérification des stocks, mais la vérification des stocks n'est pas non atomique. Elle utilise la méthode d'obtention et de comparaison. analyse, on peut constater que cette interface snap-up présente de sérieux risques de sécurité dans les scénarios de concurrence élevée, principalement concentrés à trois endroits :
Bien que la méthode
soit adoptée, si le thread A met beaucoup de temps à s'exécuter et n'a pas le temps de se libérer, le verrou expirera et le thread B pourra acquérir à ce moment-là À la serrure. Lorsque le thread A termine l'exécution et libère le verrou, le verrou du thread B est réellement libéré. À ce moment-là, le thread C peut à nouveau acquérir le verrou. À ce moment-là, si le thread B termine son exécution et libère le verrou, c'est en fait le verrou défini par le thread C qui est libéré. C’est la cause directe de la survente.Vérification des stocks non atomiques
非原子性的库存校验导致在并发场景下,库存校验的结果不准确。这是超卖的根本原因。
通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常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('get', KEYS[1]) if in==curr then redis.call('del', KEYS[1]) end return 'OK'""; 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了。性能和安全性两方面都能进一步得到提升!
当然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,如果还要考虑这些话,则不如直接考虑分布式锁的解决方案。
La survente de biens rares est définitivement un accident majeur. Si la quantité survendue est importante, cela aura même un impact opérationnel et social très grave sur la plateforme. Après cet accident, j'ai réalisé qu'aucune ligne de code du projet ne peut être prise à la légère, sinon dans certains scénarios, ces codes normalement fonctionnels deviendront des tueurs mortels !
Pour un développeur, lors de la conception d'un plan de développement, le plan doit être soigneusement étudié. Comment pouvons-nous examiner le projet de manière approfondie ? Continuez seulement à apprendre !
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!