1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        公司新來一個同事,把優(yōu)惠券系統(tǒng)設(shè)計的爐火純青!

        共 8426字,需瀏覽 17分鐘

         ·

        2022-11-22 10:36

        問題拋出

        在近期的項目里面有一個功能是領(lǐng)取優(yōu)惠券的功能。

        問題描述:

        每一個優(yōu)惠券一共發(fā)行多少張,每個用戶可以領(lǐng)取多少張:

        如:A優(yōu)惠券一共發(fā)行120張,每一個用戶可以領(lǐng)取140張,當(dāng)一個用戶領(lǐng)取優(yōu)惠券成功的時候,把領(lǐng)取的記錄寫入到另外一個表中(這張表我們暫且稱為表B)

        <!--減優(yōu)惠券庫存的SQL-->
        <update id="reduceStock">
             update coupon set stock = stock - 1 where id = #{coupon_id}
        </update>

        上面的代碼按照我們的邏輯是沒有問題,我通過使用PostMan軟件測試也是沒有問題,但是上面的代碼確實是有問題的。

        往往我們寫的一些業(yè)務(wù)功能,在低并發(fā)的時候很多的問題會體現(xiàn)不出來。所以這個領(lǐng)取優(yōu)惠券的功能我通過Jmeter軟件來進行壓測。

        這里配置了一下,大概會發(fā)送500次請求,那來驗證下優(yōu)惠券會不會出現(xiàn)超發(fā)的問題

        執(zhí)行結(jié)果,里面沒有出現(xiàn)異常什么的,樣本為500。包括在匯總報告里面也出現(xiàn)了一些返回信息是優(yōu)惠券不足的信息,那來看下數(shù)據(jù)庫里面的優(yōu)惠券的總發(fā)行數(shù)量有沒有變成負數(shù)呢?也就是有沒有超發(fā)。

        在測試的時候是測試的id為19的這條數(shù)據(jù),測試完之后這里的總發(fā)行數(shù)量(stock)居然變成了-1(也就是超發(fā)了一張)。

        問題引發(fā)

        在解決這個問題之前,先來看下這個問題是如何引發(fā)出來的。


        上面這張圖是整個領(lǐng)取優(yōu)惠券的流程(上圖并沒有使用流程圖來畫,我覺的這樣畫可能表達更清楚一些),在藍色的框那里就是出現(xiàn)超扣減庫存的時候。為啥這樣說呢?

        如果同時來了兩個線程(你可以理解成是兩個請求),比如先來的那個請求通過了檢查(線程A),這時線程A還沒有扣減庫存,這時線程B經(jīng)過一翻操作也通過了這個檢查優(yōu)惠券是否可領(lǐng)取的方法,然后線程A和線程B依次扣減庫存或者是同時扣減庫存,這樣就會出現(xiàn)優(yōu)惠券超領(lǐng)的情況。

        清楚了問題引發(fā)的原因,那就來看看如何解決它們。

        推薦一個開源免費的 Spring Boot 最全教程:

        https://github.com/javastacks/spring-boot-best-practice

        解決方案一(Java代碼加鎖)

        在引起超發(fā)原因的那張圖內(nèi)可以看出,導(dǎo)致這一問題的根本原因是多個線程同時訪問這個領(lǐng)取優(yōu)惠券的方法,那只要保證在同一段只有一個線程進入到這個方法就可以了。

        上面貼的代碼就可以改成下面這樣:

        synchronized (this){
            LoginUser loginUser = LoginInterceptor.threadLocal.get();
            CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
                                            .eq("id", couponId)
                                            .eq("category", categoryEnum.name()));
            if(couponDO == null){
                throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
            }
            this.checkCoupon(couponDO,loginUser.getId());

            //構(gòu)建領(lǐng)券記錄
            CouponRecordDO couponRecordDO = new CouponRecordDO();
            BeanUtils.copyProperties(couponDO,couponRecordDO);
            couponRecordDO.setCreateTime(new Date());
            couponRecordDO.setUseState(CouponStateEnum.NEW.name());
            couponRecordDO.setUserId(loginUser.getId());
            couponRecordDO.setUserName(loginUser.getName());
            couponRecordDO.setCouponId(couponDO.getId());
            couponRecordDO.setId(null);

            int row = couponMapper.reduceStock(couponId);
            if(row == 1){
                couponRecordMapper.insert(couponRecordDO);
            }else{
                log.info("發(fā)送優(yōu)惠券失敗:{},用戶:{}",couponDO,loginUser);
            }
        }

        這樣,經(jīng)過Jmeter的壓測優(yōu)惠券并沒有出現(xiàn)超發(fā)的情況。

        雖然這樣可以解決超發(fā)的問題,但是在項目中我們不可以這樣寫,原因如下:

        • synchronized的作用范圍是單個JVM實例,如果是集群部署系統(tǒng)這里的加鎖你可以理解成失效
        • 在使用了synchronized加鎖后,就會形成串行等待的問題,當(dāng)一個線程A在領(lǐng)取優(yōu)惠券方法內(nèi)執(zhí)行過久時,其它線程會等待直到線程A執(zhí)行結(jié)束

        解決方案二(Sql層面解決超發(fā))

        <update id="reduceStock">
             update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0
        </update>

        Mysql默認使用的是InnoDB引擎,使用InnoDB時在修改某一個記錄的時候會將這條記錄上鎖,所以這個修改數(shù)據(jù)時不會出現(xiàn)多個線程同時修改數(shù)據(jù)。這樣也可以避免優(yōu)惠券超發(fā)。

        如果在業(yè)務(wù)中只要有庫存就可以發(fā)放優(yōu)惠券的可以使用上面這種方式。

        還有一種Sql的方式,可以將stock自身做為樂觀鎖。

        <update id="reduceStock">
             update product set stock=stock-1 where stock=#{上一次的庫存}  and id = 1 and stock>0
        </update>

        上面這種方式會存在ABA的問題,當(dāng)然如果業(yè)務(wù)不在意ABA問題可以使用上面的sql,不過性能可能差一點,如果stock不匹配,這條sql也就失效了。

        如果業(yè)務(wù)在意ABA問題的話也可以在表中加一個version的字段,每次修改數(shù)據(jù)的時候這個字段會加1,這樣就可以避免ABA問題

        <update id="reduceStock">
             update product set stock=stock-1,versioin = version+1 where  id = 1 and stock>0 and version=#{上一次的版本號}
        </update>

        上面的這三條Sql層面的代碼都可以解決優(yōu)惠券超發(fā)的問題,具體使用那種就根據(jù)業(yè)務(wù)來選擇了。

        解決方案三(通過Redis分布式鎖來解決問題)

        引入Redis后,當(dāng)領(lǐng)取優(yōu)惠券時會先去Redis里面去獲取鎖,當(dāng)鎖獲取成功后才可以對數(shù)據(jù)庫進行操作

        在分布式鎖中我們應(yīng)該考濾如下:

        • 排他性,在分布式集群中,同一個方法,在同一個時間只能被某一臺機器上的一個線程執(zhí)行
        • 容錯性,當(dāng)一個線程上鎖后,如果機器突然的宕機,如果不釋放鎖,此時這條數(shù)據(jù)將會被鎖死
        • 還要注意鎖的粒度,鎖的開銷
        • 滿足高可用,高性能,可重入

        我們可以使用Redis里面的setnx命令來設(shè)置鎖,因為setnx是原子性的操作不可被打斷

        當(dāng)這個命令執(zhí)行成功的時候會返回1,執(zhí)行失敗會返回0,我們就可以通過這個特性來判斷是否獲取到了鎖。

        先看下偽代碼:

        String key = "lock:coupon:" + couponId;
        try{
         if(setnx(key,"1")){
          //獲取到鎖
          //設(shè)置Key的時期時間
          exp(key,30,TimeUnit.MILLISECONDS);
          try{
           //業(yè)務(wù)邏輯
          }finally{
           del(key);
          }
            }else{
                //獲取鎖失敗,遞歸調(diào)用這個方法,或者使用for進行自旋獲取鎖
            }
        }

        這方法里面設(shè)置key的過期時間的原因是,當(dāng)機器突然的宕機后,即使沒有釋放掉鎖,他也會在一段時間后將這個鎖釋放,避免導(dǎo)致死鎖。

        雖然看上面的代碼是沒有問題的,但是它是存在一個誤刪除key的問題

        為了避免這個問題,可以將setnx命令設(shè)置的那個值,設(shè)置成當(dāng)前線程的ID,在刪除的時候判斷這個線程ID是不是與當(dāng)前線程的Id相同就可以了。

        String key = "lock:coupon:" + couponId;
        String threadId = Thread.currentThread().getId();
        try{
         if(setnx(key,threadId)){
          //獲取到鎖
          //設(shè)置Key的時期時間
          exp(key,30,TimeUnit.MILLISECONDS);
          try{
           //業(yè)務(wù)邏輯
          }finally{
           if(get(key) == threadId){
            del(key);
           }
          }
            }else{
                //獲取鎖失敗,遞歸調(diào)用這個方法,或者使用for進行自旋獲取鎖
            }
        }

        通過上面這種方法就可以解決誤刪除key的問題。

        在finally中的這個判斷和刪除key的代碼不是原子性的,我們可以通過lua腳本的方式來實現(xiàn)它們之間的原子性,將刪除key的代碼修改成如下:

        String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

        redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(key), threadId);

        這里的threadId其實也可以不用,寫成uuid也可以,但是在上面setnx的時候,那個值也要寫成uuid

        但是這樣還要存在一個鎖自動續(xù)期的問題,你可以開一個守護線程,每隔多久給他續(xù)期一次,或者是直接將這個過期時間延長一些。

        在Redis中也有一些官方推薦的分布式鎖的方式。我最后是使用的這種方式。

        解決方案四(使用Redis推薦的方式)

        官網(wǎng)地址:

        https://redis.io/docs/manual/patterns/distributed-locks/

        這個有多種實現(xiàn)方式,比如:Golang,Java,Php

        引入Redisson包

        <dependency>
           <groupId>org.redisson</groupId>
           <artifactId>redisson</artifactId>
           <version>3.17.4</version>
        </dependency>

        配置RedissoneClient

        @Configuration
        public class AppConfig {

            @Value("${spring.redis.host}")
            private String redisHost;

            @Value("${spring.redis.port}")
            private String redisPort;

            @Bean
            public RedissonClient redisson(){
                Config config = new Config();
                config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
                return Redisson.create(config);
            }
        }

        配置好RedissonClient后,通過getLock方法獲取到鎖對象后,在我們的Service層中就可以通過lock和unlock來進行加鎖和釋放鎖了,這樣還是很方便的。

        public JsonData addCoupon(long couponId, CouponCategoryEnum categoryEnum) {
            String key = "lock:coupon:" + couponId;
            RLock rLock = redisson.getLock(key);
            LoginUser loginUser = LoginInterceptor.threadLocal.get();
            rLock.lock();
            try{
               //業(yè)務(wù)邏輯
            }finally {
                rLock.unlock();
            }
            return JsonData.buildSuccess();
        }

        通過這種方法也可以解決優(yōu)惠券超發(fā)的問題 ,這也是Rediss官網(wǎng)推薦的一種方式。

        使用這種方式也無需關(guān)心key過期時間續(xù)期的問題,因為在Redisson一旦加鎖成功,就會啟動一個watch dog,你可以將它理解成一個守護線程,它默認會每隔30秒檢查一下,如果當(dāng)前客戶端還占有這把鎖,它會自動對這個鎖的過期時間進行延長。

        也可以通過下面的方法設(shè)置watch dog的檢測時間間隔

        Config config = new Config();
        config.setLockWatchdogTimeout();

        如上就是我在解決優(yōu)惠券超發(fā)時的一個思路。

        瀏覽 50
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            什么网站可以看毛片 | 双男主肉车文h | 男人操女人b视频 | 自拍一页| 《丰满的老女人》伦理 | 日韩理论片在线 | 一区二区三区日韩 | 国产精品国产三级国产普通 | A片在线观看网站 | www.iepai.com |