注意が必要なSpring Batchの使い方

こんにちは。
アソビュー!でバックエンドエンジニアをしている山野です。

今回は弊社で起きた事例からSpring Batchの実装において、
注意しなければいけない点について書きたいと思います。

弊社ではSpring Batchを利用し、日次の集計処理を行っています。
このバッチ処理を本番稼働させてしばらく経った後、
別チームから集計結果が正しくないという指摘を受け確認したところ、
何故か日によって一部のデータ集計が正しくされていない事象が発生していました。

当初は集計の計算処理が原因と考え調査をしましたが、
全件正しく集計される日もあり、実装についても問題がなかったことから更に詳しく調査を実施。
その結果、Spring BatchとO/Rマッピングフレームワークとして利用していたMyBatis側の処理に原因があることが分かりました。

この調査結果からSpring BatchMyBatisを使ったバッチ処理を実装する場合に、
どのような点に気を付けなればいけないか、書きたいと思います。

Spring Batchにはチャンクモデル・タスクレットモデルという2つの処理タイプがあります。
このうちチャンクモデルを使った実装において注意が必要です。

チャンクモデルは一定件数のデータごとにまとめて
Read(データ読み込み)Process(加工)Write(出力)を行う処理タイプをであり、
具体的には以下のような流れになっています。

spring-batchフロー
spring batchフロー

このうちReadの処理でMyBatisのマッピング機能の一つである、
ResultMap -collectionを利用した複雑なマッピング処理を行うと、
一部のデータが欠損しまう事象が発生します。

具体的には以下のリレーションを持つテーブルとマッピング処理をした場合に発生します。

[テーブル]
- 親子関係にあるテーブル
- 親のレコードに対して子レコードが複数存在(1:N関係)
[マッピング処理]
- 親子テーブルを一括で取得
- 取得したデータを親のレコード単位で子のレコードを纏めて、オブジェクトにマッピング

上記の説明だけでは分かりづらいため、実際に例を示したいと思います。

実装例

今回の例では記事情報を管理するarticlesテーブル(親)と
記事に対するコメントを管理するcommentsテーブル(子)という
1:N関係にあるテーブルを用意し、以下のバッチ処理を実行しています。

1. articlesテーブルとcommentsテーブルテーブルから一括で情報を取得
2. 各記事のコメント件数を集計
3. 集計結果を管理する article_summariesテーブルに記事情報 + コメント数を格納
[テーブル定義・リレーション]

テーブル定義・リレーション
テーブル定義・リレーション

[テストデータ]

◆articlesテーブル: 4件の記事のデータ

◆commentsテーブル: 各記事(article_id)に対して2件のコメントのデータ

Batchの実装は以下のようになっています。

[環境情報]
- Java 11
- Spring Boot 2.4.3
- Spring Batch 4.3.1
- mybatis-spring-boot-starter 2.1.4
[MyBatisのSQLマッピング定義]

resultMapを利用し、各記事(親)単位にコメント情報(子)を纏め、 Articleクラスにマッピングをします。

gist.github.com

[Articleクラス]

gist.github.com

[Batchの実行設定(抜粋)]

Readの実行はMyBatisCursorItemReaderを利用し、 3件のデータをReadしたらProcess/Writeの処理を行う設定としています。

gist.github.com

(※全ソースについてはgithubをご確認ください。)

期待する結果は以下のように各記事に対してコメント数2件と登録されることです。
(number_of_commentsがコメント数を表します。)

しかしながらバッチの実行結果は、
ご覧の通り第3件目の結果だけコメント件数が正しくありません。

原因と対処方法

この事象が発生する原因としては、
Mybatis(MyBatisCursorItemReader)のデータをマッピングするタイミングが関係しています。
マッピングは以下のような仕組みとなっています。

[1回目のRead]
1番目の親レコードのデータと
1番目の親レコードに紐づく子レコードのうち第1番目のデータのみマッピングする。
[2回目のRead]
2番目の親レコードのデータと
2番目の親レコードに紐づく子レコードのうち、第1番目のデータのみマッピング
+
1番目の親レコードに紐づく子レコード全てをマッピング
[3回目のRead]
3番目の親レコードのデータと
3番目の親レコードに紐づく子レコードのうち、第1番目のデータのみマッピング
+
2番目の親レコードに紐づく子レコード全てをマッピング
:
:

このN番目の親レコードに紐づく子レコードのマッピングが、
(N + 1)番目の親レコードの処理で行われる形となっていることにより、
ReadからProcess/Writeの処理にデータを連携する件数に達した時に、
最後にReadしたデータは子レコードの第1番目のデータしかマッピングしていない状態のまま
Process/Writeにデータの連携します。

実装例では「3件のデータをReadしたらProcess/Writeを行う」という
処理にしていました。

上記の問題から3回目のReadの処理をし終わった際に、
3番目の記事レコード(親)に紐づくコメント(子)のデータは、
1番目のデータしかマッピングされていない状態ですが、
そのままProcess/Writeの処理が連携された。
これにより、第3番目の記事のコメント数は1件として登録されました。

この事象についてはSpring Batchの共同代表者 の方も以下で言及しており、
対処方法についてもコメントされています。

stackoverflow.com

対処方法について、
今回の例に置き換えると以下のように実装を修正する必要があります。

[Readの処理]
- 記事テーブルのみのデータ取得
[Processの処理]
- Readで取得した情報を元にコメントテーブルを検索
- 記事情報と取得したコメントの件数をオブジェクトに格納

この事象については今後改善される可能性もありますが、
現時点でSpring BatchのチャンクモデルとMyBatisを組み合わせた実装する際は
今回書いた点についてご留意いただければ幸いです。

また処理内容との兼ね合いもありますが、
MyBatisのResultMapの機能を利用したい場合は、
タスクレットモデルでの実装を検討してみると事も一つと思います。

今回は以上となります。

最後に

ブログを読んでいただき、有難うございました。
アソビュー!では一緒に働くメンバーを募集しています。
興味がありましたらお気軽にご応募ください!

www.wantedly.com