シンプルな Java サービスのパフォーマンス最適化により、圧力テストの QPS が 2 倍に

バックグラウンド

少し前に、私たちのサービスはパフォーマンスのボトルネックに遭遇しました。初期段階では緊急の需要があったため、この領域の最適化に注意を払っていませんでした。技術的負債を返済する時期が来たとき、非常に苦痛でした。

QPS 圧力が非常に低い場合、サーバーの負荷は 10 ~ 20 に達する可能性があり、CPU 使用率は 60% を超え、トラフィックがピークに達するたびに、インターフェイスから大量のエラーが報告されます。サービス融合フレームワーク Hystrix は、使用すると、融合後にサービスが遅延します。回復できません。変更がオンラインになるたびに、私はさらに不安になり、それがラクダのラクダを打ち砕く最後の藁となり、サービスの雪崩を引き起こすのではないかと心配しています。

需要が最終的に鈍化した後、リーダーはサービス パフォーマンスの問題を 2 週間以内に完全に解決するという目標を設定しました。過去 2 週間の調査と整理で、複数のパフォーマンスのボトルネックが発見および解決され、システム融合スキームが変更され、サービスが処理できる QPS が 2 倍になり、非常に高い QPS (3 ~ 4 倍) の圧力下でのサービスが可能になりました。以下に、いくつかの問題の調査と解決プロセスを示します。

サーバーの CPU 高負荷、高負荷

解決すべき最初の問題は、このサービスによりサーバー全体の負荷が高くなり、CPU の使用率が高くなるということです。

私たちのサービス全体は、特定のストレージまたはリモート呼び出しからデータのバッチを取得し、このデータのバッチに対してさまざまな高度な変換を実行して、最終的にそれを返すように要約できます。データ変換には長いプロセスがかかり、多くの操作が行われるため、システムの CPU が高くなるのは通常のことですが、通常の状況では、CPU の使用率は 50% を超えており、これはまだ少し誇張されています。

top コマンドを使用して、サーバー上のシステム内の各プロセスの CPU とメモリの使用量をクエリできることは誰もが知っています。しかし、JVM は Java アプリケーションの領域です。JVM の各スレッドのリソース使用量を確認するにはどのツールを使用すればよいでしょうか?

jmcも可能ですが、使い方が面倒で一連の設定が必要です。もう 1 つのオプションとして、jtop を使用する方法があります。jtop は単なる jar パッケージであり、そのプロジェクト アドレスは yujikariki/jtop にあり、簡単にサーバーにコピーできます。Java アプリケーションの PID を取得した後、java - jar jtop を使用します。 .jar [オプション] は、JVM の内部統計を出力できます。

jtop はデフォルトのパラメータ -stack n を使用して、最適な CPU の 5 つのスレッド スタックを出力します。

次のような形状:

Heap Memory: INIT=134217728  USED=230791968  COMMITED=450363392  MAX=1908932608
NonHeap Memory: INIT=2555904  USED=24834632  COMMITED=26411008  MAX=-1
GC PS Scavenge  VALID  [PS Eden Space, PS Survivor Space]  GC=161  GCT=440
GC PS MarkSweep  VALID  [PS Eden Space, PS Survivor Space, PS Old Gen]  GC=2  GCT=532
ClassLoading LOADED=3118  TOTAL_LOADED=3118  UNLOADED=0
Total threads: 608  CPU=2454 (106.88%)  USER=2142 (93.30%)
NEW=0  RUNNABLE=6  BLOCKED=0  WAITING=2  TIMED_WAITING=600  TERMINATED=0

main  TID=1  STATE=RUNNABLE  CPU_TIME=2039 (88.79%)  USER_TIME=1970 (85.79%) Allocted: 640318696
    com.google.common.util.concurrent.RateLimiter.tryAcquire(RateLimiter.java:337)
    io.zhenbianshu.TestFuturePool.main(TestFuturePool.java:23)

RMI TCP Connection(2)-127.0.0.1  TID=2555  STATE=RUNNABLE  CPU_TIME=89 (3.89%)  USER_TIME=85 (3.70%) Allocted: 7943616
    sun.management.ThreadImpl.dumpThreads0(Native Method)
    sun.management.ThreadImpl.dumpAllThreads(ThreadImpl.java:454)
    me.hatter.tools.jtop.rmi.RmiServer.listThreadInfos(RmiServer.java:59)
    me.hatter.tools.jtop.management.JTopImpl.listThreadInfos(JTopImpl.java:48)
    sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

    ... ...

スレッド スタックを観察することで、最適化するコード ポイントを見つけることができます。

私たちのコードでは、大量の JSON シリアル化と逆シリアル化、および CPU の位置をコピーする Bean が見つかりました。その後、コードの最適化により、Bean の再利用率を向上させたり、JSON の代わりに PB を使用したりすることで、CPU 負荷が大幅に軽減されました。 。

Fuse フレームワークの最適化

サービス融合フレームワークとしてHystrixを選択しましたが、メンテナンス終了が発表されていますが、resilience4jとAliのオープンソースセンチネルの使用を推奨しますが、部門内の技術スタックはHystrixであり、明らかな欠点はありますが、引き続き使用していきます。

まず基本的な状況を紹介します. コントローラーインターフェースの最外部と内部の RPC 呼び出しに Hystrix アノテーションを追加しました. 分離方法はスレッドプールモードです. インターフェースでのタイムアウト期間は 1000ms に設定され, 最大スレッド数は 1000ms です. 2000。タイムアウトは 200 ミリ秒に設定され、スレッドの最大数は 500 です。

異常な応答時間

解決すべき最初の問題は、インターフェースの異常な応答時間です。インターフェイスのアクセス ログを観察すると、インターフェイスには 1200 ミリ秒かかるリクエストがあり、中には 2000 ミリ秒を超えるリクエストもあることがわかります。スレッド プール モードにより、メイン スレッドが待機している間、Hystrix は非同期スレッドを使用して実際のビジネス ロジックを実行します。待機がタイムアウトすると、メイン スレッドはすぐに戻ることができます。そのため、インターフェースにタイムアウト時間以上の時間がかかり、Hystrix フレームワーク層、Spring フレームワーク層、またはシステム層で問題が発生する可能性があります。

このとき、実行時のスレッドスタックを解析できるので、jstackを使ってスレッドスタックを出力し、複数回出力した結果をフレームグラフにして観察します。
ここに画像の説明を挿入
上の図に示すように、LockSupport.park(LockSupport.java:175) で多くのスレッドが停止しており、これらのスレッドはすべてロックされていることがわかります。ソースを見ると、HystrixTimer.addTimerListener(HystrixTimer.java: 106)、次に以下は当社のビジネスコードです。

Hystrix コメントでは、これらの TimerListeners は非同期スレッドのタイムアウトを処理するために HystrixCommand によって使用され、呼び出しがタイムアウトすると実行され、タイムアウト結果が返されると説明されています。呼び出しの量が多い場合、これらの TimerListeners の設定はロックによりブロックされ、インターフェイスによって設定されたタイムアウト期間が有効になりません。

次に、TimerListener への呼び出しが非常に多い理由を確認します。

サービスは複数の場所で同じ RPC 戻り値に依存するため、平均インターフェイス応答は同じ値を 3 ~ 5 回取得することになるため、インターフェイスは LocalCache を RPC 戻り値に追加します。コードを確認すると、LocalCache の get メソッドに HystrixCommand が追加されていることがわかります。そのため、スタンドアロン QPS が 1000 の場合、このメソッドは Hystrix を通じて 3000 ~ 5000 回呼び出され、大量の Hystrix TimerListeners が生成されます。

コードは次のようになります。

   @HystrixCommand(
            fallbackMethod = "fallBackGetXXXConfig",
            commandProperties = {
    
    
                    @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "200"),
                    @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")},
            threadPoolProperties = {
    
    
                    @HystrixProperty(name = "coreSize", value = "200"),
                    @HystrixProperty(name = "maximumSize", value = "500"),
                    @HystrixProperty(name = "allowMaximumSizeToDivergeFromCoreSize", value = "true")})
    public XXXConfig getXXXConfig(Long uid) {
    
    
        try {
    
    
            return XXXConfigCache.get(uid);
        } catch (Exception e) {
    
    
            return EMPTY_XXX_CONFIG;
        }
    }

この問題を解決するには、コードを変更し、HystrixCommand を localCache のロード メソッドに変更します。さらに、Hystrix フレームワークのパフォーマンスへの影響をさらに軽減するために、Hystrix 分離戦略がセマフォ モードに変更され、インターフェイスの最大消費時間が安定しました。また、Hystrix スレッド プールのメンテナンスやメイン スレッドと Hystrix スレッド間のコンテキストの切り替えを行わずに、メソッドがすべてメイン スレッドで実行されるため、システムの CPU 使用率はさらに減少しました。

ただし、セマフォ分離モードを使用する場合の問題にも注意する必要があります。セマフォは、メソッドが実行に入ることができるかどうかを制限することしかできず、メソッドが戻ってタイムアウトを処理した後にインターフェイスがタイムアウトするかどうかを判断し、タイムアウトを処理できますが、インターフェイスに介入することはできません。すでに実行されているメソッド。これにより、リクエストがタイムアウトすると、セマフォが常に占有されているにもかかわらず、フレームワークがそれを処理できなくなる可能性があります。

サービスの分離とダウングレード

もう 1 つの問題は、サービスが期待どおりにサービスの低下と融合を実行できないことです。トラフィックが非常に多い場合は融合を継続する必要があると考えられますが、Hystrix では時折融合が発生します。

最初にHystrixヒューズのパラメータをデバッグする際、ログ観察方式を使用しましたが、ログが非同期に設定されているため、リアルタイムのログを見ることができず、エラー情報の干渉が多く、プロセスが非効率で不正確です。 。Hystrix のビジュアル インターフェイスの導入後、デバッグ効率が向上しました。

Hystrix 可視化モードはサーバーとクライアントに分かれています。サーバーは監視したいサービスです。サービスに hystrix-metrics-event-stream パッケージを導入し、メトリクス情報を出力するインターフェイスを追加して、hystrix を起動する必要があります。 -ダッシュボードクライアントとサーバーアドレスを入力するだけです。
ここに画像の説明を挿入
上図と同様のビジュアルインターフェイスを通じて、Hystrix の全体的なステータスが非常に明確に表示されます。

上記の最適化により、インターフェイスの最大応答時間は完全に制御可能となり、インターフェイス メソッドの同時実行性を厳密に制限することでインターフェイスの融合戦略を変更できます。許容できる最大インターフェイスの平均応答時間が 50 ミリ秒で、サービスが受け入れることができる最大 QPS が 2000 であると仮定すると、適切なセマフォ制限は 2000*50/1000=100 によって取得できます。拒否されたエラーが多い場合は、冗長性を追加できます。

このようにして、トラフィックが急激に変化した場合、一部のリクエストを拒否することでインターフェースが受け付けるリクエストの総数を制御することができ、その総リクエストのうち最大消費時間は厳密に制限されており、エラーが多すぎる場合は、インターフェースが受け付けるリクエストの総数が制限されます。を融合することでダウングレードすることもでき、複数の戦略が同時に実行され、インターフェイスの平均応答時間を保証できます。

溶断時高負荷のため復帰不可

次のステップは、インターフェイスが切断されるとサービス負荷が増加し続けるが、QPS 圧力が低下した後はサービスを復元できないという問題を解決することです。

特にサーバー負荷が高い場合、サービスの内部状態を各種ツールを使って監視することは、ポイント収集方式が一般的であり、サービスを監視しながらサービスが変更されるため、信頼性が低い。たとえば、jtop を使用して高負荷時に最も CPU を使用するスレッドを表示する場合、得られる結果は常に JVM TI 関連のスタックです。

ただし、サービスの外部を観察すると、この時点で大量のエラー ログが出力されることがわかります。多くの場合、サービスが長期間安定していた後でも、以前のエラー ログがまだ出力されており、遅延の単位は分単位でも測定されます。エラーログが大量にあるとI/O負荷がかかるだけでなく、スレッドスタックの取得やログメモリの割り当てなどもサーバへの負荷を増大させます。さらに、大量のログのためにサーバーが非同期ログに変更されたため、I/O によるスレッドのブロックの障壁がなくなりました。

その後、サービス内のログ レコード ポイントを変更し、ログ出力時に例外スタックを出力しなくなり、Spring フレームワークの ExceptionHandler を書き換えてログの出力量を完全に削減します。結果は期待どおりでした。エラーの量が非常に多い場合、ログ出力も正常範囲内に制御されるため、ヒューズが切れた後は、ログによってサービスへの負荷が増大することはなくなります。QPS 負荷が低下すると、ログ出力がサービスへの負荷を増大させることはなくなります。ヒューズ スイッチがオフになり、すぐにサービスが利用可能になります。通常の状態に戻ります。

Springデータバインディング例外

さらに、jstack が出力したスレッドスタックを見ていたら、奇妙なスタックにも遭遇しました。

at java.lang.Throwable.fillInStackTrace(Native Method)
at java.lang.Throwable.fillInStackTrace(Throwable.java:783)
  - locked <0x00000006a697a0b8> (a org.springframework.beans.NotWritablePropertyException)
  ...
org.springframework.beans.AbstractNestablePropertyAccessor.processLocalProperty(AbstractNestablePropertyAccessor.java:426)
at org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:278)
  ...
at org.springframework.validation.DataBinder.doBind(DataBinder.java:735)
at org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:197)
at org.springframework.web.bind.ServletRequestDataBinder.bind(ServletRequestDataBinder.java:107)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:161)
 ...
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:991)

jstackの出力では、複数スレッドのスタックの先頭がSpringの例外処理に留まっていることがわかりますが、この時点ではログ出力はなく、業務上例外は発生していません。 、春は予期せず例外を密かにキャッチし、何もしません。

  List<PropertyAccessException> propertyAccessExceptions = null;
  List<PropertyValue> propertyValues = (pvs instanceof MutablePropertyValues ?
      ((MutablePropertyValues) pvs).getPropertyValueList() : Arrays.asList(pvs.getPropertyValues()));
  for (PropertyValue pv : propertyValues) {
    
    
    try {
    
    
      // This method may throw any BeansException, which won't be caught
      // here, if there is a critical failure such as no matching field.
      // We can attempt to deal only with less serious exceptions.
      setPropertyValue(pv);
    }
    catch (NotWritablePropertyException ex) {
    
    
      if (!ignoreUnknown) {
    
    
        throw ex;
      }
      // Otherwise, just ignore it and continue...
    }
    ... ...
  }

コード コンテキストと組み合わせると、Spring がコントローラー データ バインディングを処理しており、処理されるデータはパラメーター クラス ApiContext の 1 つであることがわかります。

コントローラーのコードは次のようなものです。

@RequestMapping("test.json")
 public Map testApi(@RequestParam(name = "id") String id, ApiContext apiContext) {
    
    }

通常のルーチンに従って、この ApiContext クラスにパラメーター リゾルバー (HandlerMethodArgumentResolver) を追加する必要があります。これにより、Spring はこのパラメーターを解析するときに、このパラメーター リゾルバーを呼び出して、メソッドに対応するタイプのパラメーターを生成します。しかし、そのようなパラメータパーサーがない場合、Spring はそれをどのように処理するのでしょうか?

答えは、上記の「奇妙な」コードを使用し、最初に空の ApiContext クラスを作成し、すべての受信パラメーターをこのクラスに順番に設定しようとすることです。セットが失敗した場合は、例外をキャッチして実行を続行し、セットは成功します。つまり、ApiContext クラスのプロパティのパラメーター バインディングが完了します。

残念ながら、インターフェースの上位層は 30 または 40 のパラメーターを一律に渡すため、毎回大量の「バインドの試行」が実行され、その結果生じる例外と例外処理により、パフォーマンスが大幅に低下します。パラメーター パーサーがこの問題を解決した後、インターフェイスのパフォーマンスは 10 分の 1 近く改善されました。

まとめ

パフォーマンスの最適化は一夜にして実現するものではなく、問題を解決するために最後の部分まで技術的負債を積み上げることは決して良い選択ではありません。通常はコードの記述に注意を払いますが、ブラックテクノロジーを使用する場合は、隠れた穴がないかどうかに注意してください。

おすすめ

転載: blog.csdn.net/KRYST4L123/article/details/130082857
おすすめ