はじめに
業務で、関連する複数のテーブルをまとめて更新する処理を実装する機会があった。
最初は「UPDATE文をそれぞれ実行すればよい」と考えていたが、実際に整理していくと、SQLそのものよりも更新処理全体の組み立て方を考える部分が多かった。
例えば、次のような疑問が出てきた。
- 2つ目の更新だけ失敗したらどうするか
- 大量データをそのままSQLに渡してよいのか
- MyBatisの
foreachをどこまで使うか - 一括処理の上限値をどこで取得するか
- ServiceやLogic、Mapperのどこに何を持たせるか
今回はその中でも、特に理解が深まった「トランザクション」と「大量更新の分割」を中心に整理する。
なお、掲載するコードやテーブル名は説明用に簡略化している。
1. 複数テーブルの更新を1つの処理として扱いたい
今回実装したのは、関連する2種類のデータを順番に更新する処理だった。
構成を簡略化すると、次のようになる。
Service
│
▼
TransactionService
├─ 更新処理A
└─ 更新処理B
更新処理Aと更新処理Bは、それぞれ異なるテーブルを更新する。
ただし、業務上は2つの更新がセットで完了する必要がある。
例えば、更新Aが成功したあとに更新Bが失敗した場合、更新AだけがDBに反映された状態になるのは困る。
そこで必要になったのがトランザクションだった。
トランザクションとは
トランザクションとは、複数のDB操作を1つの処理単位として扱う仕組みである。
例えば、今回のように2つのテーブルを更新する場合、正常に処理が完了すればCOMMITし、途中で失敗した場合にはROLLBACKする。
BEGIN
│
├─ UPDATE A → 成功
│
└─ UPDATE B → 成功
│
▼
COMMIT
一方、更新Bで失敗した場合は次のようになる。
BEGIN
│
├─ UPDATE A → 成功
│
└─ UPDATE B → 失敗
│
▼
ROLLBACK
│
▼
更新Aの変更も取り消す
これによって、関連するデータの整合性を保つことができる。
ここで重要だったのは、「更新メソッドが2つあること」と「トランザクションが2つあること」は別の話だという点だった。
2. 複数の更新を1つのトランザクションにまとめる
Spring Bootでは、@Transactionalを使用してトランザクションを管理できる。
今回は、2つの更新処理を呼び出す上位のメソッドに@Transactionalを付けた。
@Transactional(rollbackFor = Exception.class)
public void updateData(...) {
int batchSize = getBatchSize();
updateDataA(dataA, batchSize);
updateDataB(dataB, batchSize);
}
更新処理Aと更新処理Bは、それぞれ別のメソッドになっている。
ただし、それらを呼び出すupdateData()全体をトランザクション境界にすることで、AとBをまとめて1つの処理として扱える。
ここでいうトランザクション境界とは、「どこからどこまでのDB操作を1つの処理として保証するか」という範囲のこと。
rollbackFor = Exception.classは、チェック例外を含むException系の例外が発生した場合にもロールバックするために指定している。
なお、Springの宣言的トランザクションは、通常プロキシを介して適用される。そのため、同じクラス内から@Transactional付きメソッドを直接呼び出す場合などは、想定どおりにトランザクションが開始されないことがある。
また、複数のDB操作が同じトランザクションに参加していることが前提となる。
最初は「更新処理ごとにトランザクションを考える」という感覚があった。
しかし実際には、どこまでを業務上の1単位として成功・失敗させたいのかを先に考えた方が、トランザクション境界を整理しやすかった。
どの更新で失敗したのかは分かるようにする
一方で、すべての更新を同じトランザクションにしたからといって、エラー処理までまとめてしまうと原因を追いにくくなる。
そこで、各更新処理ではDBアクセス時の例外を個別に扱う。
概念的には次のような形になる。
try {
mapper.updateDataA(...);
} catch (DataAccessException e) {
// 更新Aで失敗したことが分かるログを出力
throw e;
}
更新処理Bについても同様にする。
例外を握りつぶさず上位へ伝播させることで、トランザクション全体をロールバックしながら、どの処理で失敗したかを把握できる。
つまり、
TransactionService
│
├─ AとBをまとめて成功・失敗させる
│
▼
各更新処理
│
└─ どの更新で失敗したかを特定する
という役割分担になる。
「まとめてロールバックすること」と「どこで失敗したか分かること」は両立できる。
3. 大量データをそのままMapperへ渡さない
もう一つ考えたのが、大量データへの対応だった。
更新対象が少数であれば問題になりにくいが、件数が増えると、すべてを1つのSQLへ詰め込む設計には不安がある。
特に、今回のようにMyBatisのforeachでWHERE条件を組み立てる場合、対象件数に応じてSQLが長くなり、バインドパラメータの数も増える。
そのため、一括処理できる件数を設定値として持ち、その値を使ってJava側でリストを分割することにした。
例えば、上限が500件の場合は次のようになる。
更新対象:1,500件
1回目:1〜500件
2回目:501〜1,000件
3回目:1,001〜1,500件
JavaではsubListを使用してリストの一部分を取り出せる。
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
List<Data> subList = list.subList(i, end);
mapper.updateData(subList);
}
このコードでは、設定された上限件数ごとにリストを分割し、Mapperを呼び出している。
Math.min()を使用しているのは、最後の分割でリストの要素数を超えないようにするためである。
なお、batchSizeには1以上の値を設定する必要がある。また、subListは元のリストを参照するビューを返すため、処理中に元のリストを構造的に変更しないよう注意が必要になる。
リストの分割とトランザクションの分割は別
ここで個人的に重要だったのは、「分割してSQLを複数回実行すること」と「トランザクションを分割すること」は別だという点だった。
例えば、2,000件のデータを500件ずつ更新するとする。
BEGIN
│
├─ 1〜500件 → 成功
│
├─ 501〜1,000件 → 成功
│
├─ 1,001〜1,500件 → 成功
│
└─ 1,501〜2,000件 → 失敗
│
▼
ROLLBACK
この場合、4回目の更新で失敗しても、すべてのMapper呼び出しが同じトランザクションに参加していれば、それまでの更新をまとめてロールバックできる。
つまり、
大量データ
│
▼
Javaでリストを分割
│
├─ 500件 → Mapper
├─ 500件 → Mapper
├─ 500件 → Mapper
└─ 500件 → Mapper
│
▼
全体を1つのトランザクションで管理
という構造にできる。
大量データを扱うために処理単位を分割しても、業務上の整合性を保証する単位まで分割する必要はない。
ただし、1つのトランザクションが長時間続くと、ロック保持時間やDBへの負荷が問題になる場合もある。
今回のように全件の整合性を優先する設計では、更新件数や実行時間も考慮する必要がある。
4. MyBatisのforeachは「一括UPDATE機能」ではない
今回もう一つ整理できたのが、MyBatisのforeachの役割だった。
最初は、foreachを使うこと自体が一括更新の仕組みだと捉えていた。
しかし、今回のSQLではforeachは更新処理そのものを繰り返すためではなく、複数の更新対象を特定するWHERE条件を組み立てるために使っている。
例えば、更新対象が複合キーで決まる場合を考える。
UPDATE sample_table
SET
status = '1',
updated_at = clock_timestamp()
WHERE
delete_flag = '0'
AND (
<foreach collection="list"
item="item"
separator=" OR ">
(
contract_id = #{item.contractId}
AND area_type = #{item.areaType}
AND condition_id = #{item.conditionId}
)
</foreach>
)
このSQLでは、foreachによって更新対象ごとの条件がORで連結される。
生成されるSQLのイメージは次のようになる。
UPDATE sample_table
SET
status = '1',
updated_at = clock_timestamp()
WHERE
delete_flag = '0'
AND (
(contract_id = ? AND area_type = ? AND condition_id = ?)
OR (contract_id = ? AND area_type = ? AND condition_id = ?)
OR (contract_id = ? AND area_type = ? AND condition_id = ?)
);
つまり、レコードごとに次のようなSQLを発行しているわけではない。
UPDATE sample_table ...;
UPDATE sample_table ...;
UPDATE sample_table ...;
1本のUPDATE文の対象条件をforeachで組み立てている。
ここを理解すると、Java側のリスト分割とMyBatis側のforeachの役割の違いが見えやすくなった。
Java側のsubListは、1回のMapper呼び出しで処理するデータ量を制御する。
一方、MyBatis側のforeachは、渡されたデータを使ってSQLの条件を組み立てる。
それぞれ異なる役割を担当している。
なお、この例では更新対象のすべての行に同じ値を設定している。行ごとに異なる値へ更新する場合は、別のSQL設計が必要になる。
固定条件までforeachへ入れない
SQLを組み立てる際には、レコードごとに異なる条件と、すべてのレコードに共通する条件を分けた。
例えば、
- 契約ID
- 地域区分
- データ識別子
など、対象レコードごとに変わるものはforeachの中に置く。
一方、
- レコード種別
- 更新区分
- 更新日時
のように、更新対象全体で共通するものはforeachの外へ出す。
UPDATE sample_table
SET
update_type = '01',
updated_at = clock_timestamp()
WHERE
record_type = '2'
AND (
<foreach collection="list"
item="item"
separator=" OR ">
(
contract_id = #{item.contractId}
AND area_type = #{item.areaType}
)
</foreach>
)
何でもforeachの中に書くのではなく、「レコードごとに変わる条件は何か」を先に考えた方がSQLの意図が読みやすかった。
また、更新対象が0件の場合は、空のリストをそのままMapperへ渡さず、Java側で更新をスキップするようにしておくと安全である。
5. 共通の設定値は上位で1回だけ取得する
今回、更新処理Aと更新処理Bでは、どちらも同じ一括処理上限を使用する。
最初は、それぞれの更新処理で設定値を取得する構成も考えられる。
更新A
└─ 上限値を取得
更新B
└─ 上限値を取得
ただし、同じ一連の処理の中で同じ設定値を使用するのであれば、上位で1回取得して渡した方が自然だった。
int batchSize = getBatchSize();
updateDataA(dataA, batchSize);
updateDataB(dataB, batchSize);
このようにすると、
TransactionService
│
▼
一括処理上限を取得
│
├─ 更新Aへ渡す
│
└─ 更新Bへ渡す
という構造になる。
同じ設定値を何度も取得する必要がなく、「今回の一連の処理ではこの上限値を使用する」ということもコードから分かりやすい。
また、処理の途中で設定値が変更されても、一連の処理では同じ値を使用できる。
ここでも、どのクラスがその情報を管理するべきなのかを考えることになった。
6. 「現在日時」も用途によって異なる
今回意外と気を付けたのが、日時の扱いだった。
処理の中では、次の2種類の日時を使用していた。
- 設定情報をどの時点のものとして参照するか
- DB上で実際にいつ更新されたか
前者には、システム側で管理している運用日時を使用する。
一方、DBレコードの更新日時にはPostgreSQLのclock_timestamp()を使用する。
UPDATE sample_table
SET
updated_at = clock_timestamp()
WHERE
...
PostgreSQLのclock_timestamp()は、関数が評価された時点の実際の時刻を返す。
一方、CURRENT_TIMESTAMPはトランザクション開始時刻を返すため、長いトランザクションの途中でも同じ値になる。
今回のように、実際の更新時刻を記録したい場合には、その違いも意識する必要がある。
また、clock_timestamp()は評価のたびに値が変わり得るため、1つのSQLで更新したすべての行に厳密に同じ時刻を設定したい場合は、別の方法を検討する必要がある。
どちらも一見すると「現在日時」だが、用途は異なる。
運用日時
└─ 業務上、どの時点として処理するか
DB更新日時
└─ 実際にDBが更新された時刻
日時についても、値そのものではなく、何を表す日時なのかを考えて使い分ける必要があると感じた。
7. 最終的な責務分担
最終的には、おおよそ次のように役割を分けた。
Service
│
│ 処理全体の順序を管理
│
▼
TransactionService
│
│ トランザクション境界
│ 共通設定値の取得
│
▼
Logic
│
│ リストの分割
│ Mapper呼び出し
│ DB例外の処理
│
▼
Mapper XML
│
└─ SQLと更新条件
最初からこの形が決まっていたわけではない。
実装しながら、「この処理はどのクラスが知っているべきなのか?」を考えて整理していった。
例えば、一括処理上限の取得やsubListによる分割をServiceへ置くこともできる。
ただし、そうするとServiceが処理の流れだけではなく、DB更新の具体的な事情まで知ることになる。
逆にMapperへ処理を寄せすぎると、Java側から更新単位が見えにくくなる。
今回は、上位ほど業務処理の流れを担当し、下位ほどDB更新の具体的な事情を担当すると考えると整理しやすかった。
もちろん、Service、Logic、TransactionServiceを必ずこのように分ける必要があるわけではない。
重要なのはクラス名やレイヤー数ではなく、それぞれがどの責務を持つかを明確にすることだと感じた。
8. テストでは更新結果だけでなく処理の境界も意識する
実装後は、単純に「更新できること」だけではなく、処理の境界を意識してテストした。
考えたテストケースは次のようなものだった。
| テストケース | 確認したいこと |
|---|---|
| 上限以下の件数 | 1回のMapper呼び出しで処理できること |
| 上限を超える件数 | 正しくリストが分割されること |
| 更新対象0件 | 不要なSQLを実行しないこと |
| 更新A成功 → 更新B成功 | 両方の更新が正常に完了すること |
| 更新A成功 → 更新B失敗 | エラーが上位へ伝播すること |
| 分割途中でMapperが失敗 | 途中で処理が中断されること |
| 更新途中で例外発生 | それまでの更新もロールバックされること |
| 同じ処理を再実行 | 再実行時の動作が仕様どおりであること |
今回、単体テストでは想定していたケースがすべて通った。
ただし、Mapperをモック化した単体テストだけでは、実際のDBトランザクションによるロールバックまで保証することはできない。
今後、実DBを使った結合テストでは、特に次のケースを確認したい。
更新A
│
└─ DB更新成功
│
▼
更新B
│
└─ DB更新失敗
│
▼
ROLLBACK
│
▼
更新Aの変更もDBに残っていないことを確認
更新が成功するケースだけでなく、途中で失敗した場合にデータがどうなるかまで確認することが重要だと感じた。
まとめ
今回の実装で一番理解が深まったのは、大量更新を考えるときに「SQLをどう書くか」だけを見ても足りないということだった。
特に、次の3つを分けて考えると整理しやすかった。
- トランザクション境界
- Java側のデータ分割
- SQL側の更新条件
subListは、大量データを一定件数ごとに分割するために使う。
MyBatisのforeachは、分割されたデータから更新対象を特定するSQLを組み立てるために使う。
そして@Transactionalは、複数回のDBアクセスをまとめて1つのトランザクションとして管理するために使う。
最初は、UPDATE文をどのように書くかに意識が向いていた。
しかし、実際に実装してみると、それ以上に「どこまでを1つの処理として保証するのか」「そのために各レイヤーへ何を担当させるのか」を考える必要があった。
今回の経験を通して、個々のSQLやAPIの使い方だけでなく、更新処理全体の設計を考えることの重要性を理解できた。