メインコンテンツにスキップ

5件の投稿件の投稿が「spring」タグ付き

すべてのタグを見る

1台のサーバーアプリケーションは同時にどれだけのリクエストを処理できるか?

· 22分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

概要

Spring MVCのWebアプリケーションはどれだけの同時ユーザーを受け入れられるのか?🤔

多くのユーザーを受け入れながら安定したサービスを提供するために、サーバーが処理しなければならないユーザー数を推定するために、本記事ではSpring MVCのTomcat設定に焦点を当ててネットワークトラフィックの変化を探ります。

利便性のため、以下の文章は会話調で書かれています 🙏

情報

技術的な誤り、誤字脱字、または不正確な情報があれば、コメントでお知らせください。フィードバックは大変ありがたいです 🙇‍♂️

Spring Batch 5.0の変更点

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

ここでは、Spring Batch 5.0の変更点についてまとめます。

新しい点は?

@EnableBatchProcessingは推奨されなくなりました

@AutoConfiguration(after = { HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class })
@ConditionalOnClass({ JobLauncher.class, DataSource.class, DatabasePopulator.class })
@ConditionalOnBean({ DataSource.class, PlatformTransactionManager.class })
@ConditionalOnMissingBean(value = DefaultBatchConfiguration.class, annotation = EnableBatchProcessing.class) // 5.0から追加されました。
@EnableConfigurationProperties(BatchProperties.class)
@Import(DatabaseInitializationDependencyConfigurer.class)
public class BatchAutoConfiguration {
// ...
}

以前は、@EnableBatchProcessingアノテーションを使用してSpring BatchのSpring Boot自動構成を有効にすることができました。しかし、現在ではこれを削除してSpring Bootの自動構成を使用する必要があります。 @EnableBatchProcessingを指定したり、DefaultBatchConfigurationを継承すると、Spring Bootの自動構成が後回しにされ、アプリケーション設定のカスタマイズに使用されます。

そのため、@EnableBatchProcessingDefaultBatchConfigurationを使用すると、spring.batch.jdbc.initialize-schemaのようなデフォルト設定が機能しなくなります。さらに、Bootが起動したときにジョブが自動的に実行されなくなるため、Runnerの実装が必要です。

複数ジョブの実行がサポートされなくなりました

以前は、バッチ内に複数のジョブがある場合、それらを一度に実行することができました。しかし、現在ではBootが単一のジョブを検出するとそれを実行します。コンテキストに複数のジョブがある場合、Bootを起動する際にspring.batch.job.nameを使用して実行するジョブを指定する必要があります。

JobParameterのサポートが拡張されました

Spring Batch v4では、ジョブパラメータはLongStringDateDoubleの型のみを使用できましたが、v5ではコンバータを実装することで任意の型をJobParameterとして使用できるようになりました。しかし、Spring Batchのデフォルトの変換サービスは依然としてLocalDateLocalDateTimeをサポートしておらず、例外が発生します。デフォルトの変換サービスに対してコンバータを実装することでこれを解決できますが、JobParametersBuilderが関連メソッドを提供しているにもかかわらず、実際には変換が行われず例外が発生するのは問題です。 この問題についてはissueがオープンされており、5.0.1で修正される予定です。

JobParameters jobParameters = jobLauncherTestUtils.getUniqueJobParametersBuilder()
.addLocalDate("date", LocalDate.now()) // このメソッドを使用すると、提供されているにもかかわらず例外が発生します。
.toJobParameters();

image

この問題は2023-02-23にリリースされた5.0.1で解決されました。

initializeSchema

spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres?currentSchema=mySchema
username: postgres
password: 1234
driver-class-name: org.postgresql.Driver
batch:
jdbc:
initialize-schema: always
table-prefix: mySchema.BATCH_
sql:
init:
mode: always

正しく機能させるためにcurrentSchemaオプションを指定してください。

参考文献

Spring Batchで複合キーを使用したページネーションの最適化

· 11分の読み時間
Haril Song
Owner, Software Engineer at 42dot

この記事では、Spring Batchを使用して数百万件のデータを持つテーブルをクエリする際に直面した問題とその解決策について説明します。

環境

  • Spring Batch 5.0.1
  • PostgreSQL 11

問題

JdbcPagingItemReaderを使用して大規模なテーブルをクエリしていると、時間の経過とともにクエリのパフォーマンスが著しく低下することに気付き、コードを詳細に調査することにしました。

デフォルトの動作

PagingQueryProviderによって自動生成され実行されるクエリは次の通りです:

SELECT *
FROM large_table
WHERE id > ?
ORDER BY id
LIMIT 1000;

Spring Batchでは、JdbcPagingItemReaderを使用する際にオフセットを使用する代わりに、ページネーションのためのwhere句を生成します。これにより、数百万件のレコードを持つテーブルからでも遅延なくデータを高速に取得できます。

ヒント

LIMITを使用しても、OFFSETを使用すると以前のデータをすべて再度読み込むことになります。そのため、読み込むデータ量が増えるとパフォーマンスが低下します。詳細については、この記事1を参照してください。

複数のソート条件を使用する場合

複合キーを使用するテーブルをクエリする際に問題が発生します。3つのカラムからなる複合キーをソートキーとして使用すると、生成されるクエリは次のようになります:

SELECT *
FROM large_table
WHERE ((create_at > ?) OR
(create_at = ? AND user_id > ?) OR
(create_at = ? AND user_id = ? AND content_no > ?))
ORDER BY create_at, user_id, content_no
LIMIT 1000;

しかし、where句にOR操作が含まれるクエリはインデックスを効果的に利用できません。OR操作は複数の条件を実行する必要があり、オプティマイザが正確な判断を下すのが難しくなります。explainの出力を調べたところ、次のような結果が得られました:

Limit  (cost=0.56..1902.12 rows=1000 width=327) (actual time=29065.549..29070.808 rows=1000 loops=1)
-> Index Scan using th_large_table_pkey on large_table (cost=0.56..31990859.76 rows=16823528 width=327) (actual time=29065.547..29070.627 rows=1000 loops=1)
" Filter: ((""create_at"" > '2023-01-28 06:58:13'::create_at without time zone) OR ((""create_at"" = '2023-01-28 06:58:13'::create_at without time zone) AND ((user_id)::text > '441997000'::text)) OR ((""create_at"" = '2023-01-28 06:58:13'::create_at without time zone) AND ((user_id)::text = '441997000'::text) AND ((content_no)::text > '9070711'::text)))"
Rows Removed by Filter: 10000001
Planning Time: 0.152 ms
Execution Time: 29070.915 ms

クエリの実行時間が30秒近くかかり、インデックス上でフィルタリング中にほとんどのデータが破棄され、不要な時間が浪費されています。

PostgreSQLは複合キーをタプルとして管理しているため、タプルを使用してクエリを書くことで、複雑なwhere句でもインデックススキャンの利点を活用できます。

SELECT *
FROM large_table
WHERE (create_at, user_id, content_no) > (?, ?, ?)
ORDER BY create_at, user_id, content_no
LIMIT 1000;
Limit  (cost=0.56..1196.69 rows=1000 width=327) (actual time=3.204..11.393 rows=1000 loops=1)
-> Index Scan using th_large_table_pkey on large_table (cost=0.56..20122898.60 rows=16823319 width=327) (actual time=3.202..11.297 rows=1000 loops=1)
" Index Cond: (ROW(""create_at"", (user_id)::text, (content_no)::text) > ROW('2023-01-28 06:58:13'::create_at without time zone, '441997000'::text, '9070711'::text))"
Planning Time: 0.276 ms
Execution Time: 11.475 ms

フィルタリングによってデータを破棄することなく、インデックスを通じて直接データが取得されていることがわかります。

したがって、JdbcPagingItemReaderが実行するクエリがタプルを使用する場合、複合キーをソートキーとして使用しても、非常に迅速に処理を行うことができます。

では、コードに入りましょう。

PagingQueryProviderの修正

分析

前述のように、クエリ生成の責任はPagingQueryProviderにあります。私はPostgreSQLを使用しているため、PostgresPagingQueryProviderが選択され使用されています。

image 生成されるクエリは、group by句を含むかどうかによって異なります。

SqlPagingQueryUtilsbuildSortConditionsを調べると、問題のあるクエリがどのように生成されるかがわかります。

image

ネストされたforループ内で、ソートキーに基づいてクエリが生成される様子がわかります。

buildSortConditionsのカスタマイズ

クエリ生成の責任を持つコードを直接確認した後、このコードを修正して望ましい動作を実現することにしました。しかし、このコードを直接オーバーライドすることはできないため、PostgresOptimizingQueryProviderという新しいクラスを作成し、このクラス内でコードを再実装しました。

private String buildSortConditions(StringBuilder sql) {
Map<String, Order> sortKeys = getSortKeys();
sql.append("(");
sortKeys.keySet().forEach(key -> sql.append(key).append(", "));
sql.delete(sql.length() - 2, sql.length());
if (is(sortKeys, order -> order == Order.ASCENDING)) {
sql.append(") > (");
} else if (is(sortKeys, order -> order == Order.DESCENDING)) {
sql.append(") < (");
} else {
throw new IllegalStateException("Cannot mix ascending and descending sort keys"); // タプルの制限
}
sortKeys.keySet().forEach(key -> sql.append("?, "));
sql.delete(sql.length() - 2, sql.length());
sql.append(")");
return sql.toString();
}

テストコード

新しく実装した部分が正しく動作することを確認するために、テストコードを通じて検証しました。

@Test
@DisplayName("Offsetの代わりに生成されるWhere句は(create_at, user_id, content_no) > (?, ?, ?)です。")
void test() {
// given
PostgresOptimizingQueryProvider queryProvider = new PostgresOptimizingQueryProvider();
queryProvider.setSelectClause("*");
queryProvider.setFromClause("large_table");

Map<String, Order> parameterMap = new LinkedHashMap<>();
parameterMap.put("create_at", Order.ASCENDING);
parameterMap.put("user_id", Order.ASCENDING);
parameterMap.put("content_no", Order.ASCENDING);
queryProvider.setSortKeys(parameterMap);

// when
String firstQuery = queryProvider.generateFirstPageQuery(10);
String secondQuery = queryProvider.generateRemainingPagesQuery(10);

// then
assertThat(firstQuery).isEqualTo("SELECT * FROM large_table ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 10");
assertThat(secondQuery).isEqualTo("SELECT * FROM large_table WHERE (create_at, user_id, content_no) > (?, ?, ?) ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 10");
}

image

正常に実行されることを確認し、バッチを実行しました。

image

Guy: "終わったのか?"

Boy: "黙れ、そんなこと言ったらまた起きるって!"

しかし、out of rangeエラーが発生し、クエリが変更されたことが認識されていないことがわかりました。

image

クエリが変更されたからといって、パラメータの注入部分が自動的に認識されるわけではないようなので、再度デバッグしてパラメータの注入部分を見つけましょう。

JdbcOptimizedPagingItemReader

パラメータはJdbcPagingItemReaderによって直接作成され、JdbcPagingItemReadergetParameterListを繰り返してSQLに注入するパラメータの数が増えることがわかりました。

image

このメソッドをオーバーライドするだけで済むと思いましたが、残念ながらそれは不可能です。多くの考えの末、JdbcPagingItemReader全体をコピーし、getParameterList部分だけを修正しました。

JdbcOptimizedPagingItemReaderでは、getParameterListメソッドが次のようにオーバーライドされています:

private List<Object> getParameterList(Map<String, Object> values, Map<String, Object> sortKeyValue) {
// ...
// where句に設定する必要があるパラメータを増やさずに返します。
return new ArrayList<>(sortKeyValue.values());
}

sortKeyValueを追加する必要はないので、直接parameterListに追加して返します。

では、再度バッチを実行してみましょう。

最初のクエリはパラメータを必要とせずに実行されます、

2023-03-13T17:43:14.240+09:00 DEBUG 70125 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL query [SELECT * FROM large_table ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 2000]

次のクエリ実行は前のクエリからパラメータを受け取ります。

2023-03-13T17:43:14.253+09:00 DEBUG 70125 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT * FROM large_table WHERE (create_at, user_id, content_no) > (?, ?, ?) ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 2000]

クエリは意図した通りに実行されました! 🎉

1000万件以上のレコードを持つページネーション処理では、以前は約30秒かかっていたクエリが0.1秒程度で実行されるようになり、約300倍のパフォーマンス向上を実現しました。

image

これで、データ量に関係なく、パフォーマンスの低下を心配することなくミリ秒単位でクエリを読み取ることができます。 😎

結論

この記事では、複合キーを持つ環境でSpring Batchを最適化する方法を紹介しました。しかし、この方法には欠点があります。それは、複合キーを構成するすべてのカラムが同じソート条件を持たなければならないことです。複合キーによって生成されるインデックス条件内でdescascが混在している場合、この問題を解決するために別のインデックスを使用する必要があります 😢

今日の内容を一行でまとめて記事を締めくくりましょう。

「複合キーの使用をできるだけ避け、ビジネスに関連しない代替キーを使用する。」

参考文献


Footnotes

  1. https://jojoldu.tistory.com/528

JdbcItemReaderでsortKeysを設定する際の注意点

· 6分の読み時間
Haril Song
Owner, Software Engineer at 42dot

PostgreSQLで大量のデータを取得する際に遭遇した問題について共有したいと思います。

問題

Spring BatchのJdbcPagingItemReaderを使用している際、以下のようにsortKeysを設定しました:

...
.selectClause("SELECT *")
.fromClause("FROM big_partitioned_table_" + yearMonth)
.sortKeys(Map.of(
"timestamp", Order.ASCENDING,
"mmsi", Order.ASCENDING,
"imo_no", Order.ASCENDING
)
)
...

現在のテーブルのインデックスはtimestampmmsiimo_noの複合インデックスとして設定されているため、データ取得時にインデックススキャンが行われると期待していました。しかし、実際にはSeqスキャンが発生しました。対象のテーブルには約2億件のレコードが含まれており、バッチ処理が完了する兆しが見えなかったため、最終的にバッチを強制終了する必要がありました。なぜインデックス条件でクエリを実行しているのにSeqスキャンが発生したのでしょうか?🤔

PostgreSQLでは、以下のような場合にSeqスキャンが発生します:

  • テーブルのデータ量が少ないため、オプティマイザがSeqスキャンの方が速いと判断した場合
  • クエリ対象のデータ量が多すぎる(テーブルの10%以上)場合、オプティマイザがインデックススキャンよりもSeqスキャンの方が効率的だと判断した場合
    • このような場合、limitを使用してデータ量を調整し、インデックススキャンを実行することができます

このケースでは、select *を使用していたため、大量のデータをクエリすることでSeqスキャンが発生する可能性がありました。しかし、chunk sizeのためにクエリはlimit付きで実行されていたため、インデックススキャンが連続して発生すると考えていました。

デバッグ

正確な原因を特定するために、実際に実行されているクエリを確認しましょう。YAML設定を少し変更することで、JdbcPagingItemReaderが実行するクエリを観察できます。

logging:
level.org.springframework.jdbc.core.JdbcTemplate: DEBUG

バッチプロセスを再実行して、クエリを直接観察しました。

SELECT * FROM big_partitioned_table_202301 ORDER BY imo_no ASC, mmsi ASC, timestamp ASC LIMIT 1000

order by句の順序が奇妙に見えたので、再度実行しました。

SELECT * FROM big_partitioned_table_202301 ORDER BY timestamp ASC, mmsi ASC, imo_no ASC LIMIT 1000

実行ごとにorder by条件の順序が変わっていることが明らかでした。

インデックススキャンを確実に行うためには、ソート条件を正しい順序で指定する必要があります。一般的なMapsortKeysに渡すと順序が保証されないため、SQLクエリが意図した通りに実行されません。

順序を維持するためには、LinkedHashMapを使用してsortKeysを作成することができます。

Map<String, Order> sortKeys = new LinkedHashMap<>();
sortKeys.put("timestamp", Order.ASCENDING);
sortKeys.put("mmsi", Order.ASCENDING);
sortKeys.put("imo_no", Order.ASCENDING);

この調整を行い、バッチを再実行したところ、ソート条件が正しい順序で指定されていることを確認できました。

SELECT * FROM big_partitioned_table_202301 ORDER BY timestamp ASC, mmsi ASC, imo_no ASC LIMIT 1000

結論

Seqスキャンがインデックススキャンの代わりに発生する問題は、アプリケーションのテストコードでは検証できないため、潜在的なバグに気づくことができませんでした。実際の運用環境でバッチ処理の大幅な遅延を観察して初めて、何かが間違っていることに気づきました。開発中には、Mapデータ構造によってソート条件の順序が変わる可能性を予想していませんでした。

幸いにも、大量のデータをクエリするためにインデックススキャンが発生しない場合、LIMITクエリでバッチ処理が大幅に遅くなるため、問題に気づきやすくなります。しかし、データ量が少なく、インデックススキャンとSeqスキャンの実行速度が似ている場合、問題に気づくまでに時間がかかるかもしれません。

この問題を事前に予測し対処する方法について、さらに検討が必要です。order by条件の順序が重要な場合が多いため、可能な限りHashMapではなくLinkedHashMapを使用することをお勧めします。

テスト実行の高速化、Springコンテキストのモッキング

· 5分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

プロジェクトごとにテストコードを書くことは一般的になっています。プロジェクトが成長するにつれて、テストの数も増え、全体のテスト実行時間が長くなります。特にSpringフレームワークに基づいたプロジェクトでは、Spring Beanコンテキストの読み込みによってテスト実行が大幅に遅くなることがあります。この記事では、この問題に対処する方法を紹介します。

すべてのテストをユニットテストとして書く

テストは速くなければなりません。テストが速ければ速いほど、頻繁に実行することに躊躇しなくなります。すべてのテストを一度に実行するのに10分かかる場合、フィードバックは10分後にしか得られません。

Springでテストを高速化するためには、@SpringBootTestを使用しないことが重要です。すべてのBeanを読み込むと、必要なBeanを読み込む時間が圧倒的に長くなり、ビジネスロジックをテストするコードの実行時間よりも長くなります。

@SpringBootTest
class SpringApplicationTest {

@Test
void main() {
}
}

上記のコードは、Springアプリケーションを実行するための基本的なテストコードです。@SpringBootTestによって構成されたすべてのBeanが読み込まれます。では、テストに必要なBeanだけをどのように注入するのでしょうか?

アノテーションやMockitoの活用

特定のアノテーションを使用することで、関連するテストに必要なBeanだけが自動的に読み込まれます。これにより、コンテキスト読み込みを通じてすべてのBeanを読み込むのではなく、本当に必要なBeanだけを読み込むことで、テスト実行時間を最小限に抑えることができます。

いくつかのアノテーションを簡単に見てみましょう。

  • @WebMvcTest: Web MVC関連のBeanのみを読み込みます。
  • @WebFluxTest: Web Flux関連のBeanのみを読み込みます。WebTestClientを使用できます。
  • @DataJpaTest: JPAリポジトリ関連のBeanのみを読み込みます。
  • @WithMockUser: Spring Securityを使用する場合、偽のユーザーを作成し、不要な認証プロセスをスキップします。

さらに、Mockitoを使用することで、複雑な依存関係を簡単に解決してテストを書くことができます。これらの2つの概念を適切に活用することで、ほとんどのユニットテストはそれほど難しくありません。

警告

過度なモッキングが必要な場合、依存関係の設計に問題がある可能性が高いです。モッキングの多用には注意が必要です。

SpringApplicationはどうする?

SpringApplicationを実行するには、SpringApplication.run()を実行する必要があります。このメソッドの実行を確認するためにすべてのSpringコンテキストを非効率的に読み込むのではなく、コンテキスト読み込みが発生するSpringApplicationをモックし、run()が呼び出されるかどうかだけを確認することができます。

class DemoApplicationTests {  

@Test
void main() {
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
when(SpringApplication.run(DemoApplication.class)).thenReturn(null);

DemoApplication.main(new String[]{});

springApplication.verify(
() -> SpringApplication.run(DemoApplication.class), only()
);
}
}
}

結論

ロバート・C・マーティンの『Clean Code』の第9章では「FIRST原則」について議論しています。

この記事で述べたように、最初の文字FはFast(速い)を意味します。テストの速さの重要性を再度強調し、次の引用で締めくくります。

テストは十分に速くなければならない。 - ロバート・C・マーティン

参考