Vert.x:ハイ・パフォーマンスI/O Toolkitを軽く触ってみた
目次
はじめに
こんにちは、システムエンジニアのキュウです。今回は個人プロジェクトの開発によく使われているオープンソースライブラリ「Vert.x」を紹介します。
Vert.xとは
Vert.xはNettyをベースに構築したJVM上のノンブロッキングI/O処理ライブラリです。使いやすいハイ・パフォーマンスI/O Toolkitとして、WebやGUIやミドルウェアなどのアプリケーション構築によく使われています。
大きな特徴はEvent Bus、VerticleとFutureの3つです。詳しい紹介と使い方はドキュメントが充実した公式サイトを参照してください。
パフォーマンスが良い理由
Vert.xはなぜハイ・パフォーマンスなのでしょうか?アーキテクチャやソース解析をすべて説明するのは難しいですが、効率的にI/Oイベントを処理するために重要なポイントについて、自分の理解を簡単に説明します。
VerticleとThread
VerticleインスタンスはJavaのThreadと連携して、Single thread風のコーディングスタイルを作っているのが一番面白い特性だと思います。Event Busに流されたイベントを消化するため、Vert.xはThread poolとVertical poolから、それぞれ1つのインスタンスを拾ってバインドします。処理する時、VerticleとThreadインスタンス間の関係は以下のようなイメージです。
ThreadインスタンスはVerticleインスタンスと1対多、VerticleインスタンスはThreadと1対1の関係です。いわゆるVert.xのスケジューリングアルゴリズムで、1つのVerticleインスタンスは処理開始から終了まで、必ず同じThreadインスタンスとバインドされます。
その結果、Verticleインスタンス内の共有資源(ステータスやカウンターなどのフィールド変数)に対して、同期処理(Java Locks、synchronizedなど)を考える必要は無くなり、実装者は、処理ロジックを考えることに集中できます。
Verticleインスタンス間の通信方式
複数ProcessやThread間の安全な通信方式の1つとして、ベーシックなActorモードを取り上げます。このモードでは、処理ユニット(Actor)間の情報交換はメッセージオブジェクトで行います。他Actorからのメッセージを受信したら処理を開始する仕組みとなり、以下のような通信ネットワークを作ります。
Vert.xの場合、Verticle間通信のメカニズムはEvent Bus経由で複雑な通信ネットワークを簡潔に構築しています。
I/Oイベントの処理方式
例えば、Webアプリのリクエストからレスポンスを返すまでの流れは、「read -> decode -> compute -> encode -> send」の5つのステップとなります。
少数のThreadを利用して大量のリクエストを処理する方式の1つとして、ベーシックなReactorモードを取り上げます。このモードでは、下図のようにAcceptorがクライアントからのリクエストを拾ってReactorに渡します。そしてReactor内部のDispatcher経由で効率的に各処理のHandlerに割り当てます。
Vert.xの場合、以下のイメージのような拡張実装となります。
Event Bus、Verticle、Actorモード、複数のポイントを合わせると、I/Oの各処理は以下のイメージとなります。
テストしてみる
メカニズム的な話のみだとピンとこないかもしれません。そこで、Vert.xはどのぐらいの性能になるか、Spring BootとVert.x Webの2つのフレームワークを使って、簡単なテストで比較してみました。(Vert.x WebはVert.xをベースにしたWebフレームワークで、Spring Bootよりかなりシンプルです)
上記は、ランダムな数値をDatabaseから抽出して、ヒットした回数を計上して、クライアント側へ結果を返却するケースです。
1 2 3 4 5 6 7 |
GET http://server:8090/randomNumber Host: server Connection: keep-alive User-Agent: Mozilla/5.0 (X11; Linux x86_64) Gecko/20130501 Firefox/30.0 AppleWebKit/600.00 Chrome/30.0.0000.0 Trident/10.0 Safari/600.00 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Connection: keep-alive |
1 2 3 4 5 6 7 |
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Tue, 31 Jul 2022 12:37:42 GMT Keep-Alive: timeout=60 Connection: keep-alive {"randomNumber":2729,"hitCount":3} |
1.実験環境
Client:
Window 11 laptop, JMeter
Server:
1C2G Ubuntu 22.04 VM on NAS Server(自宅のNASではこのスペックのVMを作るのが、せいいっぱいでした、ごめんなさい~)
Web Framework:
Spring Boot 2.7.0
Vertx-Web 4.3.2
Database: PostgreSQL 14
JDK:OpenJDK-17
1 2 3 4 5 6 7 8 |
create table "number_world" ( id integer not null, hit_count integer not null default 0, primary key (id) ); insert into "number_world" (id) select x.id from generate_series(1, 10000) as x(id); |
2.テストコード
簡単なテストなので、Spring Bootでの実装コードは割愛します。
すべてのコードではありませんが、Vert.x Webの方のみ、重要な部分を記載します。(両方ともコーディング上のチューニングはせずフレームワークのUnboxing状態のままで機能を実装しています。全ソースコードはこちら参照)
1 2 3 4 5 6 7 8 9 |
public static void main(String[] args) { VxDemoApplication app = new VxDemoApplication(); Runtime.getRuntime().addShutdownHook(new Thread(/* 終了処理など */)); app.init() // 初期化処理(設定ファイル読む、DB接続など) .compose(v -> app.deployService()) .compose(v -> app.deployHttpServer()) .onSuccess(ar -> /* 起動成功時処理など */) .onFailure(ar -> /* 起動失敗時の処理など */); } |
1 2 3 4 5 6 7 8 9 |
private Future deployService() { return Future.future(promise -> { // インスタント数を複数に設定出来る var options = new DeploymentOptions().setWorker(true).setInstances(1); vertx.deployVerticle(RandomNumberVerticle::new, options) .onSuccess(ar -> promise.complete()) .onFailure(promise::fail); }); } |
1 2 3 4 5 6 7 8 9 |
private Future deployHttpServer() { return Future.future(promise -> { Router router = Router.router(vertx); router.get("/randomNumber").handler(new RandomNumberHandler()); vertx.createHttpServer().requestHandler(router).listen(8090) .onSuccess(server -> promise.complete()) .onFailure(promise::fail); }); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class RandomNumberHandler implements Handler { @Override public void handle(RoutingContext context) { context.vertx().eventBus().request("acquire_random_number", "", // イベント名で呼び出されたverticleの処理結果(非同期処理) (AsyncResult<Message<JsonObject>> ar) -> { if (ar.succeeded()) { HttpServerResponse response = context.response(); response.putHeader("content-type", "application/json"); response.end(ar.result().body().toString()); } else { context.fail(ar.cause()); } } ); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
public class RandomNumberVerticle extends AbstractVerticle { // フィールド変数定期など ... @Override public void start() throws Exception { super.start(); // イベント名と処理Handlerをバインドする vertx.eventBus().consumer("acquire_random_number", this::acquireRandomNumber); // 他初期化処理 ... } @Override public void stop() throws Exception { super.stop(); // 他終了処理 ... } private void acquireRandomNumber(Message msg) { // apache.commons.math3のRandomDataGeneratorを使用してます int randomInt = _gen.nextInt(1, 10000); queryData(randomInt).compose(this::updateData) .onSuccess(rn -> msg.reply(rn.toJsonObject())) .onFailure(ar -> msg.reply(_errData.toJsonObject())); } private Future queryData(int id) { String sql = "select * from number_world where id = $1"; return Future.future(promise -> { getPool().preparedQuery(sql).execute(Tuple.of(id)) .onSuccess(rows -> { if (rows.size() == 1) { rows.forEach(row -> promise.complete(new RandomNumber(row.getInteger(0),row.getInteger(1)))); } else { promise.complete(_errData); }}) .onFailure(promise::fail); }); } private Future updateData(RandomNumber rn) { String sql = "update number_world set hit_count = hit_count + 1 where id = $1"; return Future.future(promise -> getPool().getConnection() .onSuccess(conn -> conn.begin() .compose(tx -> conn.preparedQuery(sql).execute(Tuple.of(rn.randomNumber)) .compose(ret -> tx.commit()) .eventually(v -> conn.close()) .onSuccess(v -> promise.complete(rn)) .onFailure(promise::fail)) )); } } |
3.実験結果
下表はJMeterで5,000リクエストを並行してテストした結果です。
Verticleインスタンス×5(1つのVerticleクラスを5つのインスタンスで並列実行)以外、すべて実行エラーが発生してしまいました。しかし、エラーとなったリクエストを除けば、Vert.x WebのTPSは明らかに高いことが分かります。
起動時間については、Standby状態になるまで(JVMの起動時間を含む)、Spring BootよりVert.x Webの方がかなり速いです。
最後に、参考としてCPUとメモリの使用率について、以下の計測結果を参照すれば、結果がひと目で分かります。
感想
Vert.xは効率的なライブラリと言われていますが、実際の使用には注意すべきポイントがあります。Futrueは非同期処理の粒度を簡単にコントロールでき、性能面が優れています。ただし、複雑なロジックの場合、Java lambdaの大量使用によってコールバック地獄に陥りやすく、ソースの理解と今後のメンテナンスに悪影響を与えます。コーディングスタイル上の工夫も重要です。また、業務系開発に便利な内蔵機能やツールなどはほぼ提供されていません。
個人的な意見ですが、大規模な業務系アプリの開発だと、Spring系以外では、RedHat社がVert.xをベースに開発したQuarkusフレームワークがより良い選択肢かもしれません。