<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)惠券系統(tǒng)設(shè)計的爐火純青!

          共 7832字,需瀏覽 16分鐘

           ·

          2022-11-15 14:21

          點擊關(guān)注公眾號,Java干貨 及時送達 d280f102f4cc5bc90715eee63920146f.webp

          號外,號外!

          棧長出品的《 Spring Cloud Alibaba?微服務(wù)實戰(zhàn)課 雙十一期間特惠價,報名鏈接:《 今天,可以抄底了??! ,活動價最后幾天,想學(xué)習(xí)提升的不要 錯過??!


          作者:鹽汽水
          鏈接:https://juejin.cn/post/7116401645323288613
          問題拋出

          在近期的項目里面有一個功能是領(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)

          60be105de5a3c9318c84c237e03cb096.webp15068a0396d8f6438d29d28352ae7fd6.webp
                <!--減優(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軟件來進行壓測。

          698eca4a25ab20b52a5805012e2cdff8.webp

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

          8c7db79e7723cc186815b427da4a16e8.webp

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

          eeeed78199abf75ea7ee54e819b3ebaf.webp

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

          問題引發(fā)

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

          3e562bbc9ec97b5e2cde2a8b1c08d9b0.webp

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

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

          0dd53f5e34b52ac8c342d33de1c0bc08.webp

          清楚了問題引發(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);
          ????}
          }
          415a92ec77685aa815efca6e70d8f3fe.webp

          這樣,經(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ù)來選擇了。另外,如果你近期準(zhǔn)備面試跳槽,建議在Java面試庫小程序在線刷題,涵蓋 2000+?道 Java 面試題,幾乎覆蓋了所有主流技術(shù)面試題。

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

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

          bd95852301843e2e3d9a9d54f879fe9d.webp

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

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

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

          2412fbfd13868b7766216d1e1f8d96e4.webp

          當(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的問題

          a712f6960b6d4a6920ea44803d09e7a0.webp

          為了避免這個問題,可以將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中也有一些官方推薦的分布式鎖的方式。我最后是使用的這種方式。

          另外,如果你近期準(zhǔn)備面試跳槽,建議在 Java面試庫 小程序在線刷題,涵蓋 2000+?道 Java 面試題,幾乎覆蓋了所有主流技術(shù)面試題。 解決方案四(使用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ā)時的一個思路。

          End


          Spring Boot 學(xué)習(xí)筆記,這個太全了!

          23 種設(shè)計模式實戰(zhàn)(很全)

          Nacos 2.1.1 正式發(fā)布,真心強!

          Spring Cloud Alibaba 最新重磅發(fā)布!

          Stream 中的 map、peek、foreach 方法的區(qū)別?

          ce41264ec4edbfa202690e7421b2a346.webpSpring Cloud 微服務(wù)最新課程!
          瀏覽 53
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

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

          手機掃一掃分享

          分享
          舉報
          <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>
                  亚洲欧美福利导航 | 九九草色播免费视频观看 | 夜夜爽夜夜高潮夜夜爽 | 超碰人人操在线 | 男人天堂AV电影 |