嘔血回顧一次提高介面併發的經歷,很實用

林嘉瑜發表於2023-09-23

最近在開發一個打卡介面,其實只需要做些判斷,儲存一下打卡結果即可,預計同時段1000多人線上打卡,但是第一次寫完之後,壓測效果非常糟糕,可以看到只有十幾的併發,喝下的水都要噴出來了,那麼簡單的介面都能耗時那麼久的嗎,我預估100ms以內準可以的,那還有上百的併發才對。於是開始了我的最佳化之路。

看看主要程式碼,controller接收引數進來之後,執行一下服務方法就返回了,就是查詢活動,判斷活動時間,累計積分,儲存結果,併發這麼差我實屬是想不到,讓人搖頭搖頭鬱悶,我應該沒問題啊,部署的機器有問題?但是也都是還可以的伺服器的。

  @Override
  public RelationActivityRecord clock(String userId, ClockDto clockDto) {
    // 判斷活動是否有效
    RelationActivity relationActivity =
        relationActivityService.getRelationActivity(clockDto.getActivityId(),
                clockDto.getActivityType());
    Date now = new Date();
    if (now.before(relationActivity.getStartTime())) {
      throw new BadRequestException(ErrorEnum.WRONG_ARGUMENTS, "活動還未開始,請耐心等待~");
    }
    if (now.after(relationActivity.getEndTime())) {
      throw new BadRequestException(ErrorEnum.WRONG_ARGUMENTS, "活動已結束,可在主頁檢視個人作品哦~");
    }
    // 本次打卡獲得積分
    int score= 0;
    // 當天的0點時間
    Date toDay = DateUtil.beginOfDay(now);
    if (Objects.equals(clockDto.getActivityType(), 1)
        && Objects.equals(clockDto.getClockType(), 1)) {
      // 查詢累計打卡次數
      CurrentClockRecordVo clockRecordStat = this.currentRecord(userId,
              clockDto.getActivityId(), clockDto.getActivityType(), 
              );
      // 當天第一次的打卡,可以攢一個積分
      if (clockRecordStat.getLastTime() != null && clockRecordStat.getLastTime().before(toDay)
              && clockRecordStat.getNum() < 100) {
        score = 1;
      }
    } else if (Objects.equals(clockDto.getActivityType(), 1)) {
      // 其他的活動之類...
      throw new BadRequestException(ErrorEnum.WRONG_ARGUMENTS, "活動暫未開放");
    } else {
      throw new BadRequestException(ErrorEnum.WRONG_ARGUMENTS, "未知活動");
    }
    // 儲存打卡記錄
    RelationActivityRecord newClockRecord = new RelationActivityRecord();
    newClockRecord.setStudentId(userId);
    newClockRecord.setActivityId(clockDto.getActivityId());
    newClockRecord.setActivityType(clockDto.getActivityType());
    newClockRecord.setClockType(clockDto.getClockType());
    newClockRecord.setCreateTime(now);
    newClockRecord.setContent(clockDto.getContent());
    newClockRecord.setEnergy(energy);
    newClockRecord.setVideoId(clockDto.getVideoId());
    newClockRecord.setIntro(clockDto.getIntro());
    this.save(newClockRecord);
    // 有積分可以攢到學生賬戶上
    if (score> 0) {
      activityStudentService.addScore(clockDto.getActivityId(), userId, score);
    }
    return newClockRecord;
  }

冷靜冷靜再次喝口水,遇到問題先找找外部原因,是不是springboot預設的tomcat最大連線數之類的預設值太小,專案裡是沒有重新配置的,但是一想前些天看過一個影片說,預設的都是8192最大連線數,那不改配置也是嘎嘎夠用的。謹慎起見,寫一個隨便執行幾條語句,不連線資料庫的介面壓測一下,一開始有1000多併發,然後積攢的排隊請求多了之後,併發逐漸下降到600,正常合理,處理不過的請求會排隊沒辦法,除非加大執行緒數的配置:

突然想起來,專案啟動不是用的tomcat,而是更換成了undertow,這傢伙有問題嗎?但是求助搜尋引擎,說它要比tomcat更彪悍,至少預設是有8000併發的,看來也排除了。

那會不會是JVM的堆疊設定的太小,處理卡頓,看了下gc數是有點多。其他專案複製過來的啟動配置,才512M我去,馬上改成4G試試。然而,實際效果並無變化。其實gc雖然很多,但實際耗費的時間加起來都沒3s,可以排除是它的問題。(這裡還遇到了一個問題,就是即使改大了之後,專案一次啟動還是會有3次full-gc和幾次的young-gc,一下子搞不懂為什麼會這樣,有經驗的朋友可以給些排查方向)

為此,我判斷大機率是介面耗時太久導致併發起不來。

程式碼除了一些判斷語句,物件轉換,查詢插入更新資料庫,就沒有其他操作了。物件轉換有問題嗎,但是通篇都有物件轉換,這都有問題可能嗎。估計真的是資料庫有問題了。為了提高查詢速度,我只加了一個複合索引,這都那麼慢是很不符合常理的。

那我就用skywalking探測一下!

這。。真的是介面耗時符非常久,雖然壓測情況下,分配資源出來等了70ms,但介面執行的時候卻花費了308ms,在skywalking怎麼只能看到查詢語句的耗時,可是查詢語句只花了幾毫秒而已。

那我加下p6spy看下,每條語句的執行耗時吧。

吃驚,嚇人,這兩插入更新語句居然需要兩百毫秒。

咋回事,我想想,想不通,求助搜尋引擎和大模型了:

留意到第2點,沒有想到,我本來認為介面可以忍受不加事務帶來資料不一致性,以提高併發。既然這麼說了,那就加個事務試試。不加不知道,一加嚇一跳,加了基本0耗時了。

  @Transactional(rollbackFor = Exception.class)
  @Override
  public RelationActivityRecord clock(String userId, ClockDto clockDto) {
   ....
  }

再搜尋一下這是為什麼,有人說是加了事務之後,不會像之前一樣插入一次寫一次盤,然後在事務的干預下,攢夠一批再寫盤,這樣效率就高了。那這裡或者還能最佳化這一批能不能攢多點,也要衡量涉及到寫盤的時候,當時的單個介面請求就會慢了。

然後有了另一個疑問,我事務是不是加得太大了,畢竟是先查詢,再插入和更新資料,可以將它們分開的,那就分開試試,查詢不加事務,插入更新提出一個方法加事務,但是,奇了怪了,結果併發效果更差了。看來這裡面還有事。 同時發現這裡一開始壓測都壓同一個使用者,按理說真實情況是不同使用者的,同一個使用者可能競爭資源更加劇烈,所以對獲取使用者userId改了一下隨機生成一個。

這個時候感覺到8.x版本的skywalking實在是很難提供到更全面的消耗資訊,想到不久前看到skywalking9.x版本來了,那就下載一個試試吧。這裡我一開始下載了最新的版本,結果死活啟動不了一直閃退,什麼埠占用啊都改了也不行,大坑。然後直接選9.0版本,就正常啟動了。果然9.0版本就不一樣了,介面佈局舒服了,監控資料也活了,很贊。

而且,trace鏈路的耗時釋出總算是全面了很多!!資料庫連線都出來了。可以看到,看了下沒有加事務之前,每次查詢,寫入,都要單獨獲取一次資料庫連結!!!插入語句監控也能正常出來了!

查詢不加事務,查詢和更新用了事務,得獲取三次資料庫連線,第一次連線是為了查selelct 1判斷資料庫是否正常,可以去掉。

查詢和插入更新在同一個大事務,只獲取一次資料庫連線,效率自然更高了。

這裡基本也確定了,放一個事務裡,效率要高,減少獲取資料庫連線。

此外,透過檢視 skywalking連結追蹤,發現是getConnection的時候特別耗時,獲取一個連線要好幾秒。

目前連線池用的是druid,引數是預設配置,查了下預設配置的最大連線數並不高,那問題估計就是這裡了,加大試試。目前pg資料庫設定是250個連結,如果是生產環境,資料庫獨享,yml和pg配置的最大連線數還能大些,目前先試試200,如果併發能提高說明這裡也是能最佳化的點,當然實際配置還得看情況,不然會把資料庫拉胯。

設定一秒200併發,持續30秒,效果還不錯,平均700多ms一個請求,最後併發到250左右。

這裡的undertow和資料庫連線配置還需要逐步按實際情況不斷調整,以達到單機最佳效果。

總結一下:

  1. 併發低,有配置的問題,也有程式碼的問題。

  2. 事務的新增需要留意一下新增之後的效果,有時候並非是自己想的那樣,一開始覺得事務範圍大不合適,結果它效果比不加事務還要好。

  3. 一個好工具太重要了,例如skywalking9.0。

  4. 多點分析。

  5. 好好總結這次經驗,基本單機併發就這些問題比較多了。

相關文章