024 Apache JMeterの計測仕様を変更したら1.6倍くらいスループットが上がった


こんにちは、id:EC-OneのAkiです。

このところ、日によってすばらしい秋晴れの空を見ることが多く、それをより楽しむために、会社の裏にある川に沿った遊歩道を通って出勤しています。
ここは昼休みにはその辺のベンチが満席になる程の賑わいですが、さすがに朝は誰もいなくて爽やか... と思ったら、一人だけタバコを吸っている人が!

...と思ったら、弊社のM取締役でした。


さて、今回は社内のSNSで話題になっていたApache JMeterの計測仕様に関するお話です。

Apache JMeterのイケてない計測仕様?!

JavaアプリケーションやWebアプリケーションの性能測定するためのストレスツール、Apache JMeterはご存知ですか?

Apache JMeterロゴマーク

ウェブサイトの性能測定にこのJMeter(バージョンは2.3.4)を使ってみたところ、少しイケてない動作を見つけました。

あるECサイトにかかるストレスとスループットの測定のために、HTTPサンプラーの「すべてのイメージとアプレットを繰り返しダウンロードする」という機能を使って、ページとページ内の画像やCSS丸ごとを1ページビュー(PV)としてログインから商品購入までのシナリオを流しているのですが、なんだかすごく遅いんです。
商品画面を1ページ表示するのに1分以上かかったりすることがあります。

サーバのアクセスログをみながら原因を探していると、JMeterがページ内の画像やCSSをダウンロードしている部分に時間がかかっていることが判明しました。
画像なんかはまとめてファイルを取得すればいいのに、1個ずつダウンロード完了してから次のファイルをダウンロードしにいっているようです。

そこでソースコード修正したら1.6倍くらいスループットがあがりました。
以下、修正した部分を公開しますが、自社のサーバに負荷をかけてテストをするのが目的で修正したソースですので、その点をご了承ください。

JMeterのソースを3箇所修正してみたら...

修正したのは「jakarta-jmeter-2.3.4_src.zip」の、以下のファイルです。

jakarta-jmeter-2.3.4_src\src\protocol\http\org\apache\jmeter\protocol\http\sampler\HTTPSamplerBase.java

Java5.0から導入されたExecutors.newCachedThreadPool()使って一気に複数スレッドで取得するようにしました。

変更箇所1 - 先頭に以下のimportを追加*1
import java.util.concurrent.*;
import java.util.*;
変更箇所2 - 1110行目あたりに以下のstaticメンバを追加
    // スレッドキャッシュはJMeterのスレッドグループ内でそれぞれ生成
    private static ThreadLocal executorServiceThreadCache = new ThreadLocal() {
            protected Object initialValue() {
                return Executors.newCachedThreadPool();
            }
    	};
変更箇所3 - 1172〜1203行目のwhileループを削除し、代わりに以下を記述
            // 画像などのリソースファイルは複数スレッドで一度に取得
            List<Callable<HTTPSampleResult>> downloadTaskList = new ArrayList<Callable<HTTPSampleResult>>();
            while (urls.hasNext()) {
                Object binURL = urls.next();
                try {
                    URL url = (URL) binURL;
                    if (url == null) {
                        log.warn("Null URL detected (should not happen)");
                    } else {
                        String urlstr = url.toString();
                        String urlStrEnc = encodeSpaces(urlstr);
                        if (!urlstr.equals(urlStrEnc)) { // There were some spaces in the URL
                            try {
                                url = new URL(urlStrEnc);
                            } catch (MalformedURLException e) {
                                res.addSubResult(errorResult(new Exception(urlStrEnc + " is not a correct URI"), res));
                                res.setSuccessful(false);
                                continue;
                            }
                        }
                        // I don't think localMatcher can be null here, but check just in case
                        if (pattern != null && localMatcher != null && !localMatcher.matches(urlStrEnc, pattern)) {
                            continue; // we have a pattern and the URL does not match, so skip it
                        }

                        final int fframeDepth = frameDepth;
                        final URL furl = url;

                        Callable<HTTPSampleResult> callable = new Callable<HTTPSampleResult>() {
                            public HTTPSampleResult call() throws Exception {
                                return sample(furl, GET, false, fframeDepth + 1);
                            }
                        };
                        downloadTaskList.add(callable);
                    }
                } catch (ClassCastException e) {
                    res.addSubResult(errorResult(new Exception(binURL + " is not a correct URI"), res));
                    res.setSuccessful(false);
                    continue;
                }
            }
            try {
                ExecutorService executorService = (ExecutorService) executorServiceThreadCache.get();
                List<Future<HTTPSampleResult>> futureList = executorService.invokeAll(downloadTaskList);
                for (Future<HTTPSampleResult> future : futureList) {
                    try {
                        HTTPSampleResult binRes = future.get();
                        res.addSubResult(binRes);
                        res.setSuccessful(res.isSuccessful() && binRes.isSuccessful());
                    } catch (ExecutionException ee) {
                        res.addSubResult(errorResult(new Exception(" is not a correct URI"), res));
                        res.setSuccessful(false);
                    }
                }
            } catch (InterruptedException e) {
                res.addSubResult(errorResult(new Exception(" is not a correct URI"), res));
                res.setSuccessful(false);
            }

HTTP Cache Manager使用時の注意点

またJMeterを使うと同時に、HTTP Cache Managerを使ってコンテンツキャッシュのエミュレーションを行っているという場合には注意が必要です。
内部でThreadLocalを使っているので、新しいスレッドの中で動作させるとJMeterのスレッドグループのデータと分離されてしまうようです。
その場合には、org.apache.jmeter.protocol.http.control.CacheManagerの203行目くらいにあるThreadLocal宣言をInheritableThreadLocalに変更しないといけません。
あと、InheritableThreadLocalにすることでスレッドセーフではなくなるので、setCacheメソッドやclearCacheメソッドにはsynchronizedをつけた方がいいかも知れません。

jakarta-jmeter-2.3.4_src\src\protocol\http\org\apache\jmeter\protocol\http\control\CacheManager.java

の変更点は以下の通りです。

変更点1 - 203行目の「threadCache = new ThreadLocal(){」を以下に変更
        threadCache = new InheritableThreadLocal() {
変更点2 - 205行目の「return new HashMap();」を以下に変更
                return java.util.Collections.synchronizedMap(new HashMap());

これでスループットは1.6倍くらいに上がりますが...

今回書いたソースコードは、1クライアントが1サーバに対して張る接続数を無限にしています。
しかし本物のブラウザだったら「持続的な接続を2つ張る」等が多いでしょうから、あまり本物のブラウザを正確に模しているとは言えないですね。(もとのJMeterのソースは「持続的な接続を1つ」だったようですが...?)
RFCでは「HTTP/1.1では持続的な接続は2までにすべき」と書かれていて、「1クライアントが1サーバにかける負担はあまり高くすべきでない」という考えですが、今回は対象が自社のサーバで、敢えて負荷をかけることでテストをすることが目的ですので上記のソースとしています。それ以外の目的で利用することは以下のような理由であまりお勧めしません。

http://neta.ywcafe.net/000691.html
HTTP/1.1 の同時接続数について - daily dayflower
狐の王国 最大接続数を増やして速くなったと喜ぶ人々、再び


EC-Oneイーシー・ワン)のSNSはこんなニッチな(?)話題で盛り上がったりしています。


ナレッジセンターでは、Apache JMeterのような各種開発ツールについてのご質問、「品質管理の取り組みとしてテストを実施したいが、何から始めればよいのか分からない」といったご相談にもお応えしています。
トライアルで質問を無料で受け付けるキャンペーンも実施しておりますので、お気軽にお問い合わせ下さい!







JavaRuby及び周辺のOSSを用いた開発に関して、企業があらゆる悩みごとを相談できるのが、ナレッジセンターの「レスキューサービス」です。
どんな相談でも親身に受け付けますので、レスキューサービスってなに?もっと知りたい!と思った方はお気軽に問い合わせ下さい。
問い合わせ画像リンク

*1:ちょっと横着して*を使っちゃってます...