生产问题记录之重复订单以及Redisson分布式锁的使用和拓展

问题分析

先说一下背景,最近我们的系统出现了少部分用户对于限购一单的商品出现了重复下单的问题,造成了一定的损失,因此领导让我去排查一下下单接口为什么会出现这个问题。

再说说拿到代码后分析了一下下单接口,确实比较头疼,因为历史遗留原因,下单接口存在大量的冗余代码和远程调用。导致下单接口的执行时间非常的长,性能极低。另外分析数据库后发现出现重复订单的用户,其实都是非正常用户,就是通过下单接口存在的漏洞,用脚本刷下单才造成的重复订单问题。另外我们的下单接口加的锁有极大的缺陷。

就是因为上述两个原因的叠加。以至于后面的刷单重复请求过来,下单接口执行时间,超过了锁设置的时间,锁超时释放了。导致在第二次重复请求过来之前,第一次请求的代码还没有执行完毕,没有入库,而校验重复的判断,又依赖于上一次请求的入库。通俗来说就是查询是否重复的代码,是去数据库里查询该用户是否下单过。但此时第一次请求的订单由于接口执行时间原因还没入库,所以第二次请求的重复检验就失效了。所以导致了重复订单的问题

所以现在原因找到了,主要原因有二:

  • 订单接口性能过低,运行时间长。且代码逻辑不合适,校验重复依赖于上一次请求的入库
  • 公司使用的redis锁存在较大缺陷,导致锁时间失效之后就会被释放,请求重复涌入

先来说说第一个问题,根据代码分析,我们的下单接口存在大量的远程调用以及查询数据库操作,还有各种冗余代码。导致了下单接口奇慢无比,性能非常的低。另外判断的逻辑不合理。最开始的判断重复下单的逻辑要依赖于接口的最后的数据入库,一但接口执行速度过慢,高并发的情况下,就会导致校验无效,一般来说验重的逻辑其实可以放到redis来做的。以上说的这些都是历史遗留问题,在我来公司之前就已经存在了。并且因为时间紧迫,我又是刚接手,加上代码逻辑复杂,短时间内要完成订单接口的重构优化并不现实。并且下单接口的优化也不在本篇文章的讨论序列,这篇文章重心还是放在讨论锁上。

那既然重构和优化下单接口的速度在短时间内不可能完成,因此只好研究第二个问题。其实理论上来说,只要锁是有效的,即便前面的下单接口再慢再慢也不会出现重复的问题。但是问题偏偏就出现在这里,公司之前使用的redis锁的工具类一直是存在问题的,只为锁设置了过期时间,但是如果锁过期时间内代码还没有执行完,锁就失效了,后面的请求也就进来了。并且从各方面来说,替换redis锁的成本是比较低和省时的。

解决方案

既然找到了问题,和适合当前形式的解决方案,即替换掉有问题的redis锁。那接下来我们应该考虑技术选型,之前的redis锁为什么会出问题,很大程度上是因为之前写的redis锁的工具类都是前人自己写的工具类,主要思路是结合setnx和expire这两个命令,为锁设置过期时间的方式。并且由于单个人的思路是有局限性的,难免有考虑不周到的地方,所以就爆出来现在这个生产的问题。所以我为了尽可能的避免之前的问题,打算采用redis目前比较成熟的分布式锁解决方案redission,这种成熟的方案,出问题的概率小,并且场景配套解决方案齐全。

解决过程

说干就干,配置好redission相关的配置和环境,(相应的配置文件什么的可以去百度,本文主要是讲问题解决而不是环境搭建,故不赘述)。万事俱备,代码一写准备上测试压测.。

	//错误代码示例
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
		lock.lock(5, TimeUnit.SECONDS);
		//业务代码
		lock.unlock();
	}

压测结果让我比较傻眼,居然还是有重复的,并且看日志,频繁的有一个报错

attempt to unlock lock, not locked by current thread by node id: 9fb9c5fb-c505-4907-b01a-2cbc931a9d4f thread-id: 84

因此只好去翻文档。在查阅文档之后。发现之前对于redission的几个api有几个误解的地方,因此我在这里总结一下

redission三个常用api

无参lock()

首先是lock方法,无参的lock()方法应该是用的最多的一个方法。无参的lock()一定要配合unlock来使用。否则很可能会导致死锁。其实看到这里可能有人会问,这个锁不用设置过期时间,那我怎么知道它什么时候过期呢。其实不用担心这个问题。因为无参lock方法虽然没有传过期时间。但是会给对应的锁的key一个默认的过期时间,我自己亲测的话设置的过期时间是10秒。那么问题又来了,方法自动设置的过期时间太短,代码没有执行完,导致别的线程进来了怎么办。这个问题也不用担心。因为无参的lock方法有一个 “看门狗机制”,即“watch dog”,当他发现你的线程并没有中止,会自动的给你的锁续期。大概是会在时间还剩7秒的时候,重新续期到10s。通过这种续期的方式来保证锁的安全性。

但是,使用这个lock方法一定要配合unlock来使用,这一点非常重要,不然的话,不解锁,就会导致锁一直在被续期,从而导致除非redis宕机,否则锁一直不会被释放。导致死锁

	//最常用的方法,到期未执行完成可以自动续期
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
        try{
           lock.lock();
            //业务代码
        }finally{
            //必须unlock 否则会死锁
         lock.unlock();   
        }
	}

lock(long var1, TimeUnit var3)

这个方法就是我在上面代码最开始用的方法,一般用的不多,该方法无需续期,到期自动解锁,所以也无需调用unlock方法。由于他的到期自动解锁,所以这个方法需要谨慎的使用,假设锁到期之前,业务代码还没有执行完毕,那就比较危险了,意味着后面的线程可以拿到这把锁,进入到业务代码中。我上面的代码就是用错了这个方法,由于业务5s内没有执行完毕,然后锁自动被释放了,导致后面的线程进入到了业务代码中,又导致了订单的重复。而那个报错则是因为,业务代码没有5s没有执行完,锁被释放了,所以报错告诉你这个锁不存在,所以没有锁需要被解开。

从以上两个方法来看。虽然这两个方法名称一样,都是区别还是挺大的,并且,如果不是特殊的业务要求,一般不建议使用带参的lock方法,因为可能会存在安全问题,。除非是那种到期自动释放锁也不会影响后来线程的业务,才允许使用。但是这个方法的优点在于,可以避免死锁,,即便是代码执行期间所在的服务器挂了,因为无需手动解锁,所以到期锁自动被释放

	//到期自动释放 不会死锁,但是存在安全问题
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
		lock.lock(5, TimeUnit.SECONDS);
		//业务代码
		//锁会自动释放,所以无需手动unlock
	}

tryLock

这个方法也是用的比较多的一个方法,方法的完整参数为

	//第一个参数 等待锁的时间
    //第二个参数 锁的有效时间
    //第三个参数为单位
boolean tryLock(long var1, long var3, TimeUnit var5)
    

对于这个方法 ,假设我们这样调用

boolean isAccquire  = lock.trylok(15,60,TimeUnit.SECONDS)

那这个意思就是 最多设置一个锁有效期为60s,最多等待锁15s,如果获取到了返回true,15秒还没有获取到锁返回false。这个方法适用于,如果超时没有获取到锁,则做相应的处理,抛异常或者执行其他的代码都行。例如

	//也比较常用,如果没有获取到锁可做特殊处理 由于锁有过期时间,所以不会造成死锁
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
        
		boolean isAccquire  = lock.trylok(15,60,TimeUnit.SECONDS);
        if(!isAccquire){
            throw new Exception()
        }
        //获取到了继续执行下面的业务代码
        //解锁需要到finally里执行
        lock.unlock();
	}


总结

本篇文章通过记录这次解决的生产问题的过程为切入点,拓展了redission分布式锁的一些比较常规的使用,相应的机制以及使用的一些误区。同时也为排查相似的生产问题,提供了出现问题的原因和相关解决方案------即分布式锁。所以有问题还是需要多思考,多看文档。最后总结一下redission分布式锁三个常用方法

  • lock()常用,有自动续期机制,所以不会出现锁到期多个线程进入业务代码的情况。需要手动解锁,否则会死锁,效率上比trylok高。但是没有trylok灵活
  • lock(long var1, TimeUnit var3)不常用, 到期自动解锁,会出现锁到期多个线程进入业务代码的情况,但是不会死锁,因为到期自动解锁
  • trylok(long var1,long var2,TimeUnit t)常用,灵活度高,可以设置获取不到锁做相应的代码处理或异常处理,需要手动解锁
原文地址:https://www.cnblogs.com/blackmlik/p/15582263.html