<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          優(yōu)惠券超發(fā)事故:扣了我3個月績效...

          共 9510字,需瀏覽 20分鐘

           ·

          2022-07-09 00:08

          文章來源:https://c1n.cn/Xz8kf


          目錄
          • 問題拋出

          • 問題引發(fā)

          • 問題解決


          問題拋出


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


          問題描述:每一個優(yōu)惠券一共發(fā)行多少張,每個用戶可以領(lǐng)取多少張,如:A 優(yōu)惠券一共發(fā)行 120 張,每一個用戶可以領(lǐng)取 140 張。


          當一個用戶領(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ā)的原因,那就來看看如何解決它們。


          問題解決


          | 解決方案 1(Java 代碼加鎖)

          在引起超發(fā)原因的那張圖內(nèi)可以看出,導致這一問題的根本原因是多個線程同時訪問這個領(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 加鎖后,就會形成串行等待的問題,當一個線程 A 在領(lǐng)取優(yōu)惠券方法內(nèi)執(zhí)行過久時,其它線程會等待直到線程 A 執(zhí)行結(jié)束。


          | 解決方案 2(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 的問題,當然如果業(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ù)來選擇了。


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

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

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

          • 排他性,在分布式集群中,同一個方法,在同一個時間只能被某一臺機器上的一個線程執(zhí)行

          • 容錯性,當一個線程上鎖后,如果機器突然的宕機,如果不釋放鎖,此時這條數(shù)據(jù)將會被鎖死

          • 還要注意鎖的粒度,鎖的開銷

          • 滿足高可用,高性能,可重入


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

          當這個命令執(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 的過期時間的原因是,當機器突然的宕機后,即使沒有釋放掉鎖,他也會在一段時間后將這個鎖釋放,避免導致死鎖。


          雖然看上面的代碼是沒有問題的,但是它是存在一個誤刪除 key 的問題。
          為了避免這個問題,可以將 setnx 命令設(shè)置的那個值,設(shè)置成當前線程的 ID,在刪除的時候判斷這個線程 ID 是不是與當前線程的 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 中也有一些官方推薦的分布式鎖的方式。我最后是使用的這種方式。


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

          官網(wǎng)地址:
          https://redis.io/docs/reference/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 秒檢查一下,如果當前客戶端還占有這把鎖,它會自動對這個鎖的過期時間進行延長。


          也可以通過下面的方法設(shè)置 watch dog 的檢測時間間隔:
          Config config = new Config();
          config.setLockWatchdogTimeout();


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


          往期推薦

          SQL優(yōu)化的魅力!從 30248s 到 0.001s


          消息隊列消息丟失和消息重復發(fā)送的處理策略


          CentOS 停服!我們有哪些頂流的國產(chǎn)操作系統(tǒng)




          有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)

          歡迎大家關(guān)注Java之道公眾號


          好文章,我在看??

          瀏覽 64
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

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

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  97成人网站 | 欧美日韩中文字幕在线观看 | 豆花视频无码在线 | 日本久久香蕉 | 久久久三级视频 |