关于java:P0级重大事故超卖了100瓶飞天茅台整个项目组慌得一逼

40次阅读

共计 4884 个字符,预计需要花费 13 分钟才能阅读完成。

前言

基于 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 自身具备原子性,因而这里能够间接应用 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 了。性能和安全性两方面都能进一步失去晋升!当然,此计划没有思考到机器的动静扩容、缩容等简单场景,如果还要思考这些话,则不如间接思考分布式锁的解决方案。

总结

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

起源:juejin.cn/post/6854573212831842311

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

正文完
 0