当前位置: 首页 > 科技观察

飞天茅台超卖事故:请谨慎使用Redis分布式锁!

时间:2023-03-21 12:46:13 科技观察

基于Redis使用分布式锁在今天已经不是什么新鲜事了。本文主要是根据我们实际项目中Redis分布式锁导致的事故分析和解决方案。图片来自Pexels背景。我们项目中的抢购订单是通过分布式锁来解决的。有一次,运营组织了一次飞天茅台的抢购活动,库存100瓶,却超卖了!要知道,飞天茅台在这个地球上的稀缺性!!!事故定为P0级重大事故……只能接受。整个项目组的业绩都被扣了~~事故发生后,CTO点名要我带头处理。好吧,赶紧的~经过对事故现场的一些了解,才知道这个抢购活动界面是从来没有出现过的。情况,但为什么这次超卖了?理由是:之前的抢购品都不是稀缺品,而这次的活动却是飞天茅台。通过嵌入数据的分析,所有数据基本翻了一番。活动的热烈程度可想而知!废话不多说,直接上核心代码,保密部分已经用伪代码进行了处理:opsForValue().setIfAbsent(key,"val",10,TimeUnit.SECONDS);if(lockFlag){//HTTP请求用户服务进行用户相关验证//用户活动验证//库存验证Objectstock=redisTemplate.opsForHash().get(key+":info","stock");assertstock!=null;if(Integer.parseInt(stock.toString())<=0){//业务异常}else{redisTemplate.opsForHash()。increment(key+":info","stock",-1);//生成订单//发布订单创建成功事件//构建响应VO}}}finally{//释放锁stringRedisTemplate.delete("key");//构造响应VO}returnresponse;}以上代码通过10s的分布式锁过期时间保证业务逻辑有足够的执行时间;try-finally语句块用于确保及时释放锁。还在业务代码中检查库存。看起来很安全!别着急,继续分析。..事故原因飞天茅台抢购活动吸引了大量新用户下载注册我们的APP。其中,不乏羊毛党通过专业手段注册新用户进行收毛、下单。当然,我们的用户系统已经提前防范,接入阿里云人机验证、三因素认证、自研风控系统等,阻断了大量非法用户。忍不住喜欢这里,但也正因为如此,用户服务一直运行在高运行负载下。抢购活动一开始,大量的用户验证请求就冲击了用户服务。因此,用户服务网关的响应延迟很短。有些请求的响应时间超过了10s,但是由于HTTP请求的响应超时,我们设置为30s。这会导致接口在用户验证时被阻塞。10s后,分布式锁已经过期。这时候有新的请求可以拿到锁,也就是说锁被覆盖了。这些阻塞的接口执行完之后,又会执行释放锁的逻辑,从而释放了其他线程的锁,导致新的请求去竞争锁~这真是一个极其糟糕的循环。这时候就只能靠库存校验了,但是库存校验不是非原子的,采用get比较的方式。超卖的悲剧就这样发生了~~~仔细分析事故分析,我们可以发现,在高并发场景下,抢购接口存在严重的安全隐患,主要集中在三个地方:①没有其他系统风险容错处理由于用户服务紧张,网关响应延迟,但没有办法处理,属于超卖导火索。②看似安全的分布式锁,其实一点都不安全。虽然采用了设置键值[EX秒][PX毫秒][NX|XX]的方式,但是如果线程A执行了很长时间,没来得及释放,就会释放锁。expired,此时线程B可以获得锁。当线程A执行完释放锁,其实就是释放了线程B的锁,此时线程C又可以获取到锁,而此时如果线程B执行完释放锁,其实就是线程B释放线程C设置的锁。这是超卖的直接原因。③非原子库存核查非原子库存核查导致并发场景下库存核查结果不准确。这是超卖的根本原因。通过上面的分析,问题的根源在于库存校验对分布式锁的依赖过大。因为在分布式锁正常set和del的情况下,库存校验是没有问题的。但是,当分布式锁不安全可靠时,库存验证就没有用了。解决方法知道原因后,才能对症下药。实现相对安全的分布式锁相对安全的定义:set和del是一一对应的,不会出现其他现成锁del的情况。从实际情况来看,即使能够实现set和del的一对一映射,也无法保证业务的绝对安全。因为锁的过期时间总是有界的,除非没有设置过期时间或者过期时间设置的很长,但是这样做也会引起其他问题。所以这没有意义。要实现相对安全的分布式锁,就必须依赖key的值。释放锁时,利用值的唯一性保证不会被删除。我们基于LUA脚本实现原子获取和比较,如下:.call('del',KEYS[1])endreturn'OK'"";RedisScriptredisScript=RedisScript.of(luaScript);redisTemplate.execute(redisScript,Collections.singletonList(key),Collections.singleton(val)));}我们使用LUA脚本来安全解锁。实现安全的库存验证。如果我们对并发有更深入的理解,我们会发现get和compare/read和save等操作是非原子的。如果要实现原子性,也可以使用LUA脚本来实现,但是在我们的例子中,由于一次抢购事件只能放置一个瓶子,所以不是基于LUA脚本,而是基于Redis本身的原子性,原因是://redis运行后会返回结果,这个过程是原子的LongcurrStock=redisTemplate.opsForHash().increment("key","stock",-1);发现没有,代码中的stockcheck完全是“多此一举”。经过上面对改进代码的分析,我们决定新建一个DistributedLocker类,专门处理分布式锁:;本身的原子性用来保证LongcurrStock=stringRedisTemplate.opsForHash().increment(key+":info","stock",-1);if(currStock<0){//表示库存已经扣除。//业务异常。log.error([下单]缺货");}else{//生成订单//发布订单创建成功事件//构造响应}}finally{distributedLocker.safedUnLock(key,val);//构造response}returnresponse;}深度思考①分布式锁有必要吗?是的。但是如果没有这一层锁,那么所有进来的请求都会走业务逻辑。由于对其他系统的依赖,此时其他系统的压力会增加。这样会增加性能损失和服务不稳定,得不偿失。基于分布式锁,可以在一定程度上拦截部分流量。②分布式锁的选择有人提出使用RedLock来实现分布式锁。RedLock更可靠,但以牺牲一些性能为代价。在这种场景下,可靠性的提升远不如性能的提升划算。对于可靠性要求极高的场景,可以使用RedLock来实现。③有必要再考虑一下分布式锁吗?由于BUG急需修复上线,我们在测试环境进行了优化和压力测试,并立即部署上线。事实证明,本次优化是成功的,性能略有提升,并且在分布式锁失效的情况下,没有出现超卖的情况。但是,有优化的空间吗?是的!由于服务部署在集群中,我们可以将库存分发到集群中的每台服务器,并通过广播的方式通知集群中的每台服务器。网关层使用基于用户标识的哈希算法来确定请求哪个服务器。这样就可以实现基于应用缓存的库存扣减和判断。性能进一步提升://通过消息提前初始化,使用ConcurrentHashMap实现高效的线程安全privatestaticConcurrentHashMapSECKILL_FLAG_MAP=newConcurrentHashMap<>();//通过消息提前设置。由于AtomicInteger本身是原子的,可以直接使用HashMapprivatestaticMapSECKILL_STOCK_MAP=newHashMap<>();...publicSeckillActivityRequestVOseckillHandle(SeckillActivityRequestVOrequest){SeckillActivityRequestVOresponse;LongseckillId=request.Seckill(LongseckillId=request.Seckill)(requestseckillId){//业务异常}//用户活动验证//库存验证if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet()<0){SECKILL_FLAG_MAP.put(seckillId,false);//业务异常}//生成订单//发布订单创建成功事件//构建响应returnresponse;}通过上面的改造,我们完全不需要依赖Redis。性能和安全性都可以进一步提高!当然,这个方案没有考虑到机器动态扩缩容等复杂场景。如果还考虑这些,不如直接考虑分布式锁的方案。总结超卖稀缺商品绝对是一场重大事故。如果超卖的数量很大,甚至会对平台造成非常严重的商业影响和社会影响。经过这次事故,我意识到项目中的任何一行代码都不能掉以轻心,否则在某些场景下,这些正常工作的代码会成为致命的杀手!对于开发人员来说,设计开发计划时,一定要考虑周全。怎样才能把计划考虑周全?只有不断的学习!作者:浪漫先生编辑:陶家龙来源:juejin.im/post/5f159cd8f265da22e425f71d