Featured image of post SpringとKtor、5つの構成を比較してみた

SpringとKtor、5つの構成を比較してみた

最近、Exposedが1.0からR2DBCを正式に扱えるようになったので、KtorでもDBアクセスまで含めて全体をノンブロッキングで構成しやすくなりました。もともとKtorはCoroutineベースで書けるので、ここにR2DBCまで揃えば「Kotlinらしい構成のまま、かなり素直に高い同時処理性能が出るのでは」と少し期待したくなりますね。

一方でSpringも、Spring Boot 3系から仮想スレッドをかなり扱いやすくなりました。そうなると、従来のSpring MVC + JDBCのようなオーソドックスな構成と、WebFlux + R2DBCのようなリアクティブ構成をどう見比べるべきかも気になります。さらにそこへKtor + JDBC、Ktor + R2DBCまで加えると、フレームワーク、シリアライザ、ORM、ドライバの違いがだいぶ見えやすくなりそうです。

なので今回は、同じPostgreSQLと同じHTTPシナリオを使って、以下の5つの構成を同じ条件で比較してみました。

比較対象

今回比較したのは、以下の5構成です。

構成主なスタック
Spring MVC JDBC (platform)Tomcat, Jackson, jOOQ, JDBC, HikariCP, platform threads
Spring MVC JDBC (virtual)Tomcat, Jackson, jOOQ, JDBC, HikariCP, virtual threads
Spring WebFlux R2DBCNetty, Jackson, jOOQ SQL builder, R2DBC PostgreSQL, R2DBC pool
Ktor JDBCNetty, kotlinx.serialization, Koin, Exposed JDBC, HikariCP
Ktor R2DBCNetty, kotlinx.serialization, Koin, Exposed R2DBC, R2DBC PostgreSQL

ここでSpring MVCはアプリ自体は同じで、仮想スレッドの有効・無効だけを切り替えています。Ktor側はJDBC版とR2DBC版を分けることで、「Ktorそのものの差」なのか、「R2DBC + Exposed R2DBCの差」なのかを見やすくしました。

個人的には、Ktor + kotlinx.serialization + ExposedのようなJetBrains公式ライブラリで揃った構成はかなり好きです。コード量も比較的抑えやすく、Kotlinで書いている感覚も強いですね。ただ、好き嫌いと性能はまた別の話なので、そこを一度ちゃんと数字で見ておきたいというのが今回の出発点です。

テストシナリオと測定方法

今回は、単に「どれが速いか」を見るというより、どのレイヤで差が出ているのかを切り分けやすいように、シナリオをいくつかの種類に分けて用意しました。DBを使わない軽いレスポンス、DBの単純な読み取り、大きいペイロード、検索や集計っぽい処理、そして書き込みと競合です。

シナリオの意図はざっくり以下の通りです。

分類シナリオ見たいこと
non-DBGET /healthもっとも軽いHTTP経路。ルーティングや最小限のレスポンス処理の差が出やすい
small payloadGET /api/books/bench/small-jsonDBなしの小さいJSON。フレームワークとシリアライザの素の軽さを見やすい
large payloadGET /api/books/bench/large-json?limit=200大きいJSONの組み立てとシリアライズの負荷を見る
point readGET /api/books/{id}主キーで1件読む典型的なCRUD read
list readGET /api/books?limit=50軽めの一覧取得
large list readGET /api/books?limit=500取得件数が多い時のマテリアライズとレスポンス生成
search readGET /api/books/search?q=Book&limit=20条件付き読み取り
aggregate-like readGET /api/books/bench/aggregate-report?limit=50集計風の読み取り経路
writePOST /api/checkouts通常の在庫減算 + checkout作成
write contentionPOST /api/checkouts (bookId=1 固定)同じ行を何度も更新した時の競合
distributed writePOST /api/checkouts (ランダム bookId)競合をある程度分散した書き込み

特にcheckout系は見方に少し注意が必要です。内部では「本を読む -> 在庫を確認する -> 在庫を減らす -> checkoutをinsertする」という書き込みトランザクションを走らせています。なので、単純なJSONレスポンスとは違って、コネクションプール、トランザクション、ロック競合の影響がかなり強く出ます。

測定条件は以下の通りです。

  • PostgreSQL 17 を毎回コンテナで起動し、各実行ごとに初期化
  • すべて同じベンチマークランナーから同じHTTPシナリオを実行
  • ウォームアップ1秒、計測3秒
  • 同時実行数は128と256の2パターン
  • Spring MVCはHikariCP最大32、Spring WebFluxとKtor R2DBCはR2DBC pool最大32、Ktor JDBCはHikariCP最大32

今回はスタック全体を比較したかったので、フレームワークだけではなく、シリアライザ、DI、ORM、ドライバも含めてそのまま測っています。つまりこれは「純粋なKtor vs Spring」の比較というより、「実際にこの組み合わせで組んだ時の全体差」の比較です。

測定結果

細かい数字は最後に紹介するHTMLサマリを見てもらうのが早いですが、傾向として面白かったところを先にまとめると以下のようになります。

1. 小さいJSONではWebFluxが強いが、大きいJSONではSpring MVCが強い

smallJsonではSpring WebFlux R2DBCが最も高く、同時実行256で約29,220 req/sでした。ここはDBアクセスがなく、フレームワークとシリアライズ経路の軽さが出やすいところなので、Nettyベースの構成が素直に強く出たのだと思います。

しかしlargeJsonになると話が変わります。同時実行256で見ると、Spring MVC platformが約4,011 req/s、Spring MVC virtualが約3,980 req/sに対して、Ktor JDBCは約2,741 req/s、Ktor R2DBCは約1,389 req/sでした。ここは以前から言われていた通り、kotlinx.serializationが大きいJSONではまだJacksonより不利な面を残しているように見えます。小さいペイロードではかなり健闘していますが、大きくなると差が出ますね。

2. DB読み取りでは、思った以上にSpring MVCが強い

本命だった読み取り系を見ると、bookByIdbookListbookList500bookSearchの多くでSpring MVCの二構成が上位でした。例えば同時実行256では以下のような結果です。

シナリオ1位2位Ktor JDBCKtor R2DBC
bookByIdSpring MVC virtual 14,903Spring MVC platform 14,9018,3437,448
bookListSpring MVC virtual 9,574Spring MVC platform 9,5535,7734,300
bookList500Spring MVC platform 2,526Spring MVC virtual 2,4931,427820
bookSearchSpring MVC platform 9,677Spring MVC virtual 9,4186,0205,359

最初の期待としては、CoroutineベースでR2DBCまで揃えたKtor R2DBCが、少なくとも読み取りでかなり強く出るかと思っていました。しかし結果はむしろ逆で、かなりオーソドックスなSpring MVC + JDBCが基準点として非常に強かったです。

仮想スレッドも面白くて、「常にplatformより速い」というわけではありませんでした。bookByIdbookListのようなところではvirtualが少し勝つ一方、healthbookSearchcheckoutCreateではplatformが勝つケースもあります。仮想スレッドは確かに有力ですが、入れた瞬間に何でも速くなる魔法ではない、というのはかなり実感しやすい結果でした。

3. Ktor JDBCは悪くないが、Ktor R2DBCはかなり苦しい

Ktor側だけを見ると、JDBC版とR2DBC版の差がかなりはっきり出ました。例えば同時実行256で、bookList500はKtor JDBCが1,427 req/s、Ktor R2DBCが820 req/s、largeJsonはKtor JDBCが2,741 req/s、Ktor R2DBCが1,389 req/sです。checkoutCreateでもKtor JDBC 3,540 req/sに対し、Ktor R2DBCは4,044 req/sで少し戻すものの、少なくとも「R2DBCにすると全体的に有利になる」という結果ではありませんでした。

この差を見ると、Ktorにおけるボトルネックは単純に「Ktorそのもの」ではなく、かなりの部分がExposed R2DBCを含む非同期DBアクセス経路にあるように見えます。Spring WebFluxも非DBの軽いパスでは非常に強い一方、DB readではJDBCのSpring MVCを明確に上回る場面は多くありませんでした。少なくとも今回のようなCRUD中心のワークロードでは、Reactive StreamsベースのDBアクセスが期待したほど素直な優位にはつながっていない、という見方が自然だと思います。

4. hotspot系はreq/sだけ見てもあまり意味がない

checkoutHotspotだけは少し特殊で、同じ本の在庫をひたすら減らすので、途中から失敗が大量に出ます。そのためここは純粋なreq/sより、どれだけ「有効な成功」を出せているかを慎重に見る必要があります。

数字だけ見ると同時実行256でKtor JDBCが約8,522 req/sと最も高いですが、失敗数も25,617と非常に多いです。Ktor R2DBCも6,417 req/sで失敗19,295、Spring WebFluxは2,883 req/sで失敗8,705でした。つまり、ここは「速い」の中に「速く失敗している」が大量に含まれているので、通常のシナリオとは少し別物として扱うべきですね。

5. 例外的にaggregateReportではKtor JDBCが目立った

aggregateReportではKtor JDBCが同時実行128で1,430 req/s、256でも1,439 req/sで最も高い結果でした。ただしこのシナリオについては、Ktor側がまだSpring側と完全に同じDBのGROUP BY経路ではないという注意書きがあるので、ここはあくまで参考値として見た方がよさそうです。

そこから得たこと

今回の結果で一番印象的だったのは、「ノンブロッキングだから高いRPSが出るはず」という期待が、そのまま素直には当たらなかったことです。もちろんノンブロッキングは重要ですし、特定のワークロードでは非常に有効でしょう。ただ、今回のように比較的オーソドックスなCRUD中心のベンチマークでは、Spring MVC + JDBCのような伝統的な構成がまだかなり強いです。

また、kotlinx.serializationは小さいJSONではかなり健闘する一方、大きいJSONではJacksonとの差が出やすいように見えました。以前から言われていた傾向ですが、今回の計測でもその差はある程度確認できました。

R2DBCについても、少なくともこの条件では「JDBCを明確に置き換えるほど速い」とは言いづらい結果でした。思想としては綺麗でも、DBアクセス込みの実アプリでは別のオーバーヘッドをきちんと払っている、という印象です。

最後に

個人的な好みで言えば、やはりKtorとkotlinx.serialization、ExposedのようなKotlinネイティブな構成はかなり魅力的です。コードスタイルも揃いますし、書いていて気持ちがよいところがあります。ただ、AIの助けでコードそのものを書くコストが以前より下がってきた今は、「どれだけ少ないコードで書けるか」だけでなく、「最終的にどれだけ速く動くか」を以前より重視した方がよさそうだ、とも思いました。

その意味では、性能重視であれば今でもSpring MVCのようなオーソドックスな構成はかなり有力です。一方で、コード量や読みやすさ、Kotlinらしさを優先するならKtorを選ぶのも十分ありだと思います。実際、Ktor JDBCは全体として悲観するほど悪い結果ではありませんでしたし、アプリケーションの性質次第では十分実用的でしょう。

測定結果の詳細については、BenchmarkリポジトリのREADMEと、そこで公開しているfive-stack-summary.htmlを見てもらうのが早いと思います。概要、目的、比較対象のスタック、測定方法はREADMEにまとまっているので、個別の数字やシナリオごとの傾向を追いたい場合はそちらを参照してください。

Built with Hugo
Theme Stack designed by Jimmy