前言

基于Redis应用分布式锁在当今曾经不是什么新鲜事了。本篇文章次要是基于咱们理论我的项目中因为redis分布式锁造成的事变剖析及解决方案。

背景 :
咱们我的项目中的抢购订单采纳的是分布式锁来解决的。有一次,经营做了一个飞天茅台的抢购流动,库存100瓶,然而却超卖了!要晓得,这个地球上飞天茅台的稀缺性啊!!!事变定为P0级重大事故…只能坦然承受。整个项目组被扣绩效了~~事变产生后,CTO指名点姓让我带头冲锋来解决,好吧,冲~

事故现场

通过一番理解后,得悉这个抢购流动接口以前素来没有呈现过这种状况,然而这次为什么会超卖呢?起因在于:之前的抢购商品都不是什么稀缺性商品,而这次流动竟然是飞天茅台 ,通过埋点数据分析,各项数据根本都是成倍增长,流动热烈水平可想而知!话不多说,间接上外围代码,秘密局部做了伪代码解决。。。

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

以上代码,通过分布式锁过期工夫有效期10s来保障业务逻辑有足够的执行工夫;采纳try-finally语句块保障锁肯定会及时开释。业务代码外部也对库存进行了校验。看起来很平安啊~ 别急,持续剖析。。。

事变起因

飞天茅台抢购流动吸引了大量新用户下载注册咱们的APP,其中,不乏很多羊毛党,采纳业余的伎俩来注册新用户来薅羊毛和刷单。当然咱们的用户零碎提前做好了防范,接入阿里云人机验证、三要素认证以及自研的风控系统等各种十八般武艺,挡住了大量的非法用户。此处不禁点个赞~

但也正因如此,让用户服务始终处于较高的运行负载中 。抢购流动开始的一瞬间,大量的用户校验申请打到了用户服务。导致用户服务网关呈现了短暂的响应提早,有些申请的响应时长超过了10s,但因为HTTP申请的响应超时咱们设置的是30s,这就导致接口始终阻塞在用户校验那里,10s后,分布式锁曾经生效了,此时有新的申请进来是能够拿到锁的,也就是说锁被笼罩了。这些阻塞的接口执行完之后,又会执行开释锁的逻辑,这就把其余线程的锁开释了,导致新的申请也能够竞争到锁~

这真是一个极其顽劣的循环。这个时候只能依赖库存校验,然而偏偏库存校验不是非原子性的,采纳的是get and compare 的形式,超卖的喜剧就这样产生了 ~~~

事变剖析

仔细分析下来,能够发现,这个抢购接口在高并发场景下,是有重大的安全隐患的,次要集中在三个中央:

  • 没有其余零碎危险容错解决因为用户服务吃紧,网关响应提早,但没有任何应答形式,这是超卖的导火索 。
  • 看似平安的分布式锁其实一点都不平安尽管采纳了set key value [EX seconds] [PX milliseconds] [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('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自身具备原子性,因而这里能够间接应用HashMapprivate 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了。性能和安全性两方面都能进一步失去晋升!当然,此计划没有思考到机器的动静扩容、缩容等简单场景,如果还要思考这些话,则不如间接思考分布式锁的解决方案。

总结

稀缺商品超卖相对是重大事故。如果超卖数量多的话,甚至会给平台带来十分重大的经营影响和社会影响。通过本次事变,让我意识到对于我的项目中的任何一行代码都不能漫不经心,否则在某些场景下,这些失常工作的代码就会变成致命杀手!对于一个开发者而言,则设计开发计划时,肯定要将计划思考周全。怎样才能将计划思考周全?唯有继续一直地学习!

起源:juejin.cn/post/6854573212831842311

欢送关注我的微信公众号「码农解围」,分享Python、Java、大数据、机器学习、人工智能等技术,关注码农技术晋升•职场解围•思维跃迁,20万+码农成长充电第一站,陪有幻想的你一起成长