Redis分布式锁-秒杀类锁不住及各种因为锁导致的“血案”现场全侦破代码详解

背景

继上文“详解Redis分布式锁在SpringBoot的@Async方法中没锁住的坑”不少读者发觉用了我的方法还是在并发的情况下有锁不住!

于是我和几个没有锁住的读者了解了它们的场景,才知道他们在认知上存在几个误区,同时也发现这一块内容、知识真的在网上普及的不多,很多人都是看了2016年左右用jedis的分布式锁套到了redisson上写的例子,100%都是错的。

特别当再有并发场景情况下,代码排查就更难了,如果没有安装商业级的APM或者是JProfile根本抓不到在并发(300-500并发甚至是上千,上万并发)情况下到底JVM内部发生了什么事,导致出了问题难排查,痛苦不堪。

因此,特再出此一文,用代码+jmeter并发(300并发/sec)彻底讲清redis锁的各种用法。

本文适合场景

我们在此文中会使用一个最最复杂的场景,这个场景在零售中叫“秒杀”,包括零售中还有一种就是“订单盖帽”即今天我就只有1,000单卖完了1,000单再下的订单全部返回“已售馨”这叫订单盖帽正是此场景。

用这个场景来讲redis分布式锁的正确使用姿势再恰当不过了。

锁的基本用法在上文“详解Redis分布式锁在SpringBoot的@Async方法中没锁住的坑”中已经讲了。有不少读者回去试了,也用了并发测试,结果发觉还是没有锁住。

好!

我们来看为什么!

为什么用我上篇文章中的代码没有锁住的详细分析

首先我这边要说一句,我给出的场景是一个锁的基本用法,而读者们用的并发是对着spring boot的controller层做并发测试。

这个场景是不适合我上一篇文章中的代码的。

这是因为以下几个因素:

  1. 我上一篇的代码是告诉了大家锁的基本用法,它属于一个很简单的业务场景,同时通过演示正反示例告诉了大家锁要锁的范围和锁的状态在并发时会产生乱序,我们把它统称为“竞态”。
  2. 它适合于单controller层+异步线程Service,而且这个单controller在设计时就不会发生controller并发的问题,这是因为我的这个controller在实际我的生产级代码中有一个AOP,这个AOP使用的是另一种机制来控制住了此controller不会也不可能产生并发;

这部分代码涉及到我们自己代码的商业机密,因此不便于展示,但是我仔细想了一下,可能就是因为没有把这一层说透,所以我用另一个真实零售中的秒杀(包括订单盖帽)的逻辑来讲读者就更清楚了。

通过代码来理解Controller层也并发的情况下,锁的机制

这边需要说一个核心,当controller层不可能(代码手段上防止它会产生并发)产生并发时+@Async的Service时,按照我上一篇做一点问题没有。

那么这次我们把我们的Service层去掉@Async,此时因为Service层已经不会有并发中的并发乱序了,所以我们此时可以把锁的全状态判断放到@Service方法里了。

下面来看代码

controller代码

 @RequestMapping(value = "/demo/service/takeCoupon", method = RequestMethod.POST)
    @ResponseBody
    public ResponseBean takeCoupon(@RequestBody JSONObject params) {
        ResponseBean resp = new ResponseBean();
        try {
            CouponBean couponBean = this.promotionService.takeCoupon();
            return new ResponseBean(ResponseCodeEnum.SUCCESS.getCode(), "success", couponBean);
        } catch (Exception e) {
            logger.error(">>>>>>Promotion API接口访问错误->{}", e.getMessage(), e);
            resp = new ResponseBean(ResponseCodeEnum.FAIL.getCode(), "Promotion API接口访问错误", null);
        }
        return resp;
    }

service代码

public CouponBean takeCoupon() {
        CouponBean couponBean = new CouponBean();
        RLock lock = redissonSentinel.getLock(lockName);
        try {
            if (!lock.isLocked()) {
                lock.tryLock(0, TimeUnit.SECONDS);// 上锁
                couponBean = new CouponBean();
                couponBean.setResult(0);
                couponBean.setMsg("抢券中");
                redisTemplate.opsForValue().set(redisName, couponBean);
                Thread.sleep(5000);//模拟一个业务动作需要5秒
                String couponId = RandomNumUtil.getUniqueSequence(8);
                couponBean.setResult(1);
                couponBean.setMsg("抢券成功");
                couponBean.setCouponId(couponId);
                redisTemplate.opsForValue().set(redisName, couponBean);
            } else {
                couponBean = new CouponBean();
                couponBean.setResult(2);
                couponBean.setMsg("前方抢券排队中");
                redisTemplate.opsForValue().set(redisName, couponBean);
            }
        } catch (Exception e) {
            logger.error(">>>>>>抢券服务方法发生了严重的问题->{}", e.getMessage(), e);
            couponBean = new CouponBean();
            couponBean.setResult(-1);
            couponBean.setMsg("系统错误");
            redisTemplate.opsForValue().set(redisName, couponBean);
        } finally {
            try {
                lock.unlock();//释放锁,一定外部要加try和空catch()
            } catch (Exception e) {
            }
        }
        return couponBean;
    }

API调用

然后我们通过这样的API调用

curl --location --request POST 'http://localhost:9180/demo/service/takeCoupon' \
--header 'User-Agent: Apifox/1.0.0 (https://apifox.com)' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: localhost:9180' \
--header 'Connection: keep-alive' \
--data-raw '{
    
}'

得到结果是对的。

于是我们手工多发几次,每次请求都是5秒后再触发。

于是我们用Jmeter

直接用Jmeter测试Controller(此时理念已经错误了)

如截图,我们模拟了100个并发,在1秒内同时发起请求。

成功可以进入锁的我们的返回状态为1否则都为错误,这是我们在Jmeter里对每个请求埋了一个json断言

100个请求并发下去得到了以下结果,成功了4次,有4个并发成功进入Service的最终锁方法。

什么都不修改的情况下,清除jmeter记录,再来一次,这一轮只有1个并发成功进入了抢券。

错误的理念导致了场景设计错误最后搞混了测试和开发自己

看到以下结果,一堆人开始挠头皮了,唉呀,这都锁住了呀,怎么一会4个成功、一会又变成了1个成功,多测几次还出现过21次成功、33次成功。

好,有了问题这才是好事,这才是寻求真理的正确道路。

以上的结果我先告诉大家,对也不对!

哈哈哈,看到这,不要急。

测试场景设计错误了

这是因为在上述这个代码的情况下,你用并发去测controller是想要测什么呢?你看一下这个jmeter,你觉得你心里的期望得到的正确结果是个什么样的值呢?

后台5秒处理一个请求,我在1秒内一口气发过去了100个,可是为什么这个值一会4、一会2、一会21、一会33?难道不应该是1个?

没有搞明白系统的并发

这边看清了哦,你要对controller进行并发锁的测试,这是一种“限流式”的场景,5秒处理一个后台你可以加上时间戳代码,肯定保证5秒一个并发,至于具体会多少次,嘿嘿,这取决于你系统当前的状态。下面关键的知识点来了:

  • 系统有时一口气一秒吃20个并发,那么就可以多处理一些(因为并行的处理,我在一瞬间可能已经处理掉50个请求了,隔5秒再处理剩下的请求)。
  • 系统有时开着音乐、看着视频、下着迅雷,那么此时处理能力就弱一些(因为并行的处理,我在一瞬间只可能处理19个,隔5秒再处理剩下的请求)。

而jmeter的并发线程池,只给了1秒种就中断请求了,因此此时后台返回的这个请求可以成功进入到锁的返回值就是那1秒内被处理掉的请求数量!

所以,这样的并发测试后说:还是没锁住?这。。。你到底锁什么、测什么呢?你的观察数据、指标又是什么?

因此如果我们用上述这样的场景来观察并发锁是否锁住本身就失去了测锁的意义!

那么怎么测锁才是真正正确的做法?

其实在我上一篇就如我所叙,我的Controller保证不会并发,并发的是我的Service,所以在那个场景,我只要在Service里起一个Thread Pool就可以马上发觉我的Service里的锁有没有锁对并保证全局只有一个任务。

而现在由于大多测试和开发的日常习惯性思维我们一定要从Controller即API层用Jmeter去测并发,所以呢,我们就需要重新设计一个场景了。

秒杀(包括订单盖帽、抢券)场景

这个场景就是秒杀场景,这是因为如果要测controller并发我们需要有一个“指标”,这个指标就是“消耗的量、指标可预判以及可观察”,所以让我们先来看业务逻辑。

业务逻辑

  1. 全局只有2单(包括2张券);
  2. 300个人每人点5次抢券按钮;
  3. 无论是谁得到券,全局券(订单数)始终不得超过2;

为了方便观察我们设计全局数量即这个TOTAL_COUPON_NUMS就只有2;

上代码

    private final Logger logger = LoggerFactory.getLogger(getClass());
    private final static String lockName = "com.xmall.promotion.coupon.lock";
    private final static String redisName = "com.xmall.promotion.coupon.status";
    private final static String couponAmountRedisName = "com.xmall.promotion.coupon.amount";
    private final static int TOTAL_COUPON_NUMS = 2;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedissonClient redissonSentinel;

反例

Controller层代码
    @RequestMapping(value = "/demo/service/secKillWrong", method = RequestMethod.POST)
    @ResponseBody
    public ResponseBean secKillWrong(@RequestBody JSONObject params) {
        ResponseBean resp = new ResponseBean();
        try {
            CouponBean couponBean = this.promotionService.secKillWrong();
            return new ResponseBean(ResponseCodeEnum.SUCCESS.getCode(), "success", couponBean);
        } catch (Exception e) {
            logger.error(">>>>>>Promotion API接口访问错误->{}", e.getMessage(), e);
            resp = new ResponseBean(ResponseCodeEnum.FAIL.getCode(), "Promotion API接口访问错误", null);
        }
        return resp;
    }
Service层代码
public CouponBean secKillWrong() {
        CouponBean couponBean = new CouponBean();
        int takedCouponNums = 0;
        try {

            couponBean = new CouponBean();
            couponBean.setResult(0);
            couponBean.setMsg("抢券中");

            /*判断券数量*/
            Object obj = redisTemplate.opsForValue().get(couponAmountRedisName);
            if (obj != null) {
                    takedCouponNums = (int) obj;
                    logger.info(">>>>>>takedCouponNums->{}",takedCouponNums);
                    if(takedCouponNums<TOTAL_COUPON_NUMS){
                        redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));
                        couponBean = new CouponBean();
                        couponBean = this.getCoupon();
                    }
                    else {
                        couponBean = new CouponBean();
                        couponBean.setResult(3);
                        couponBean.setMsg("你来晚了,券被抢完了");
                        return couponBean;
                    }
            }
        } catch (Exception e) {
            logger.error(">>>>>>抢券服务方法发生了严重的问题->{}", e.getMessage(), e);
            couponBean = new CouponBean();
            couponBean.setResult(-1);
            couponBean.setMsg("系统错误");
            redisTemplate.opsForValue().set(redisName, couponBean);
        }
        return couponBean;
    }

    private CouponBean getCoupon() throws Exception {
        CouponBean couponBean = new CouponBean();
        Thread.sleep(1000);
        couponBean = new CouponBean();
        couponBean.setResult(1);
        couponBean.setMsg("抢成功");
        return couponBean;
    }
Jmeter开测

我们设置好json断言,每个API返回中如果data.result=1那么就是抢券成功,从理论上来说我们甚至用到了redis的原子递增“redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));”,而且在递增前我们还做了当前redis内的TOTAL值是否已经达到2的判断,那么就一定可以避免多个并发的情况下递增超过2。

 实际结果

哈哈哈哈,完全错了!

我还看到过一次竟然抢成功了333次!

而系统全局只有2张券(包括订单)啊?

分析问题

这就是多并发情况下的“竞态”导至的问题。实际真正在后台jvm内部是这样的一种情况。

同时会存在1秒内多个并发一下涌入,此时全部在以下这条判断时得到的是<2

                if (obj != null) {
                    takedCouponNums = (int) obj;
                    logger.info(">>>>>>takedCouponNums->{}",takedCouponNums);
                    if(takedCouponNums<TOTAL_COUPON_NUMS){
                        redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));
                        couponBean = new CouponBean();
                        couponBean = this.getCoupon();
                    }
                    else {
                        couponBean = new CouponBean();
                        couponBean.setResult(3);
                        couponBean.setMsg("你来晚了,券被抢完了");
                        return couponBean;
                    }
                }

有多少条一下同时得到了<2的值就代表有多少条请求会去执行redis的increment方法。那么成功抢到券的就有多少个人!

如何解决?

此时就得上Redis分布式锁了,要把上述这个判断逻辑给它锁住,那么就对了。

自以为对的又一反例

什么叫自以为对呢?

因为这也是我们的小朋友在实际开发中没有理解透Redis锁导致的“血案”,而且错的相当经典,因此我在此一并贡献出来给到所有人通过这些例子真正理解透“redis分布式锁“。

secKill Service方法
public CouponBean secKill() {
        CouponBean couponBean = new CouponBean();
        RLock lock = redissonSentinel.getLock(lockName);
        int takedCouponNums = 0;
        try {
            if (!lock.isLocked()) {
                lock.tryLock(0, TimeUnit.SECONDS);// 上锁
                couponBean = new CouponBean();
                couponBean.setResult(0);
                couponBean.setMsg("抢券中");

                /*判断券数量*/
                Object obj = redisTemplate.opsForValue().get(couponAmountRedisName);
                if (obj != null) {
                    takedCouponNums = (int) obj;
                    logger.info(">>>>>>takedCouponNums->{}",takedCouponNums);
                    if(takedCouponNums<TOTAL_COUPON_NUMS){
                        redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));
                        couponBean = new CouponBean();
                        couponBean = this.getCoupon();
                    }
                    else {
                        couponBean = new CouponBean();
                        couponBean.setResult(3);
                        couponBean.setMsg("你来晚了,券被抢完了");
                        return couponBean;
                    }
                }
            } else {
                couponBean = new CouponBean();
                couponBean.setResult(2);
                couponBean.setMsg("前方抢券排队中");
                redisTemplate.opsForValue().set(redisName, couponBean);
            }
        } catch (Exception e) {
            logger.error(">>>>>>抢券服务方法发生了严重的问题->{}", e.getMessage(), e);
            couponBean = new CouponBean();
            couponBean.setResult(-1);
            couponBean.setMsg("系统错误");
            redisTemplate.opsForValue().set(redisName, couponBean);
        } finally {
            try {
                lock.unlock();//释放锁,一定外部要加try和空catch()
            } catch (Exception e) {
            }
        }
        return couponBean;
    }
getCoupon方法
    private CouponBean getCoupon() throws Exception {
        CouponBean couponBean = new CouponBean();
        Thread.sleep(1000);
        redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));
        couponBean = new CouponBean();
        couponBean.setResult(1);
        couponBean.setMsg("抢成功");
        return couponBean;
    }
开测

我们用同样的jmeter参数,300个并发1秒内发起,跑5轮。

结果如下:

看到这我已经不要看下去了,直接中断jmeter端测试了,这是因为我一共只有2张券,success超过2(即data.result的返回值1)就不对了。

错在哪?

嘿嘿,我们的小朋友还挺好,做了一个getCoupon的方法

    private CouponBean getCoupon() throws Exception {
        CouponBean couponBean = new CouponBean();
        Thread.sleep(1000);
        redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(1));
        couponBean = new CouponBean();
        couponBean.setResult(1);
        couponBean.setMsg("抢成功");
        return couponBean;
    }

我们来看这个方法内那一行:redis的increment方法。

自以为在主service方法secKill里这样调用

               if (obj != null) {
                    takedCouponNums = (int) obj;
                    logger.info(">>>>>>takedCouponNums->{}",takedCouponNums);
                    if (takedCouponNums>= TOTAL_COUPON_NUMS) {
                        couponBean = new CouponBean();
                        couponBean.setResult(3);
                        couponBean.setMsg("你来晚了,券被抢完了");
                        return couponBean;
                    } else{                        
                        couponBean = new CouponBean();
                        couponBean = this.getCoupon();
                    }
                }

就能锁住?

这里面涉及到了一个“业务原子性”的终极解释!

业务原子性的真正奥义
第1点对Redis锁错误上的认知

所谓业务原子性,即“同一个方法内”,而不是跨方法。虽然getCoupon()方法被套在了secKill方法内调用,但它还是跨方法了。

因此呢,当redis increment方法被调用时它“超脱”了redis分布式锁的控制了,因此就导致了这样的情况发生:

在一秒内可能有超过30-50个并发(取决于系统的吐吞能力)同时increment了一下并且没有跟随在if判断是否当前的消耗数量<TOTAL_COUPON_NUMS的后面。

第2点对Redis锁错误上的认知

我们通过先去redis取值,判断,如果满足<TOTAL_COUPON_NUMS那么就会做redis的increment。

于是判断<TOTAL_COUPON_NUMS和increment不在一个“原子”操作里,这是因为increment本身就是一个原子操作,如果要把判断<TOTAL_COUPON_NUMS和increment纳入一个原子操作你需要这么干:

Long takedCouponNums = redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(0));
if (takedCouponNums >= TOTAL_COUPON_NUMS) {
   couponBean.setResult(3);
   couponBean.setMsg("你来晚了,券被抢完了");
} else {
   // 增加优惠券数量
   redisTemplate.opsForValue().increment(couponAmountRedisName, 1);
   // 获取优惠券
   couponBean = this.getCoupon();
}
第3点对Redis锁错误上的认知
 RLock lock = redissonSentinel.getLock(lockName);
        int takedCouponNums = 0;
        try {
            if (!lock.isLocked()) {
                lock.tryLock(0, TimeUnit.SECONDS);// 上锁

上述这段代码是我在上一篇例子中即controller没有并发,Service层因为是@Async的有并发的情况下才能这么用。

而现在Controller层有并发了,此时如果再这么写就会产生“锁跳逸”,即锁的状态因为父层是并发的于是产生了状态混乱导致部分锁状态在一瞬间可能“!isLocked",进而导致期望值出错,我们需要这样写:

 RLock lock = redissonSentinel.getLock(lockName);
        try {
            if (lock.tryLock(0, TimeUnit.SECONDS)) {

以上3点就是为什么上了“锁”还是错了的原因所在。

如何解决-真正的正例

知道了“业务原子性”的终极奥义后,我们动手来改代码:

public CouponBean secKill() {
        CouponBean couponBean = new CouponBean();
        RLock lock = redissonSentinel.getLock(lockName);
        try {
            if (lock.tryLock(0, TimeUnit.SECONDS)) {
                couponBean = new CouponBean();
                couponBean.setResult(0);
                couponBean.setMsg("抢券中");
                /*判断券数量*/
                Long takedCouponNums = redisTemplate.opsForValue().increment(couponAmountRedisName, Long.valueOf(0));
                if (takedCouponNums >= TOTAL_COUPON_NUMS) {
                    couponBean.setResult(3);
                    couponBean.setMsg("你来晚了,券被抢完了");
                } else {
                    // 增加优惠券数量
                    redisTemplate.opsForValue().increment(couponAmountRedisName, 1);
                    // 模拟获取优惠券如取券号、把券号和用户的ID绑在一起等业务操作
                    couponBean = this.getCoupon();
                }
            } else {
                couponBean = new CouponBean();
                couponBean.setResult(2);
                couponBean.setMsg("前方抢券排队中");
                redisTemplate.opsForValue().set(redisName, couponBean);
            }
        } catch (Exception e) {
            logger.error(">>>>>>抢券服务方法发生了严重的问题->{}", e.getMessage(), e);
            couponBean = new CouponBean();
            couponBean.setResult(-1);
            couponBean.setMsg("系统错误");
            redisTemplate.opsForValue().set(redisName, couponBean);
        } finally {
            try {
                lock.unlock();//释放锁,一定外部要加try和空catch()
            } catch (Exception e) {
            }
        }
        return couponBean;
    }

用并发测试结果来检验我们的理论是否正确

此外,并发测试除了测性能测这种并发时数据是否对,一轮是不够的,至少要跑10轮才能说明并发场景下数据没问题(这是因为并发环境存在很大的随机性),于是我们开跑jmeter。

第一轮

 

再看redis里的值

 结果正确。

第二轮

跑前先把redis里的值改成0再跑,否则就是100%错误了即0个success。

结果正确。

第三轮

我们来个狠一点的,300个并发跑10轮。

嘿!怎么可能再错呢? 始终都是对的,这就是因为:

  1. 我们在后台用了分布式锁,锁住了判断券、订单总数以及消耗量计算这个逻辑。
  2. 同时我们的增加用的是redis的increment(原子增)。
  3. 我们还把一系列操作正确的纳入了到了原子操作。
  4. 同时我们还避免了上层方法是并发的情况下的“锁跳逸”问题。

因此整个过程都是“排它”的,因此随便你跑几轮或者多少个并发(只要系统撑得住http请求),现在这个数据总量是永远不会再错了!

好了!

到此为止,结束今天的博客。此处希望读者按照教程一个个代码+jmeter自己动动手去验证一下,才能真正的彻底的领会redis锁的精妙之处和那些个“坑”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TGITCIC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值