トランザクションの巻き戻し
このレッスンでは、原子性という考え方を、実際に巻き戻す処理を書くことで理解します。
途中まで成功した状態が一番困ります
注文の処理が、次の3つの操作からできているとします。
- 注文を保存する
- 在庫を1つ減らす
- ポイントを付与する
2 番目まで成功して 3 番目で失敗したとき、何もしなければ「注文はあり、在庫は減っているが、ポイントは付いていない」という状態が残ります。これは失敗でも成功でもない、どちらとしても扱えない状態です。利用者からの問い合わせで発覚し、担当者が1件ずつ手で直すことになります。
こうならないための決まりが原子性です。一連の操作は、すべて成功するか、1つも実行されなかったことにするか、そのどちらかにする。中途半端な状態を作らない、という約束です。
Spring では宣言するだけです
この巻き戻しを自分で書く必要はありません。@Transactional を付ければ、失敗時にデータベースの変更がまとめて取り消されます。
Java
@Service
public class OrderService {
@Transactional
public void placeOrder(Long userId, Long itemId) {
orderRepository.save(new Order(userId, itemId));
stockRepository.decrease(itemId); // ここで失敗しても
pointRepository.add(userId, 10); // 注文の保存ごと取り消される
}
}書き手がすることは、境界を宣言することだけです。しかし裏側では、変更を記録しておき、失敗したら記録をたどって元に戻す、という作業が行われています。それを一度自分で書いておくと、@Transactional が何を肩代わりしているのかが具体的に分かります。
巻き戻す方法は2つあります
途中の状態を元に戻す方法は、大きく次の2通りです。
| 方法 | やり方 | 向いている場面 |
|---|---|---|
| 開始前の状態を控えておく | 処理前の値をコピーし、失敗したらそれで上書きする | 状態が小さく、まるごと保存できるとき |
| 逆の操作を積む | 適用した操作と反対の操作を記録し、失敗したら逆順に適用する | 状態が大きく、差分だけ扱いたいとき |
1 つ目は単純で確実です。2 つ目は補償トランザクションと呼ばれる考え方で、外部の決済サービスのように後から取り消せないものを扱うときに使われます。データベース1つに閉じている限りは、@Transactional に任せるのが最善です。
途中で止めるという判断も含まれます
もう1つ大事なのが、失敗を見つけた時点で残りの操作を実行しないことです。3 番目で失敗したのに 4 番目を続けてしまうと、巻き戻す対象が増えるうえ、失敗した状態の上に処理を重ねることになります。失敗を検知したら即座に中断し、それから戻す。この順番が原則です。
何を失敗と呼ぶかは業務が決めます
巻き戻しを実装するうえで、技術より先に決めておくことがあります。それは、何が起きたら失敗と見なすのかという線引きです。残高が 0 を割ることを失敗とするのか、マイナスを許して後から請求するのか。在庫が足りないときに 0 で止めるのか、注文自体を無効にするのか。これらはすべて業務側の決めごとで、@Transactional は答えを持っていません。フレームワークが引き受けるのは「失敗と決まったものを取り消す」ところからで、失敗かどうかの判定は Service に書く仕事です。
この線引きを Service の1か所に置いておくと、後から条件が変わったときに触る場所が1つで済みます。逆に判定が呼び出し側に散っていると、片方だけ緩められて中途半端な状態が生まれます。
演習で書くもの
演習では、操作の並びを順に適用し、途中で失敗条件に当たったら開始前の値に戻して返す関数を書きます。上の表の1つ目の方法、つまり開始前の状態を控えておいて上書きするやり方が素直です。失敗を見つけた時点で残りを実行しないこと、そして戻した後にどこで失敗したのかを呼び出し元へ伝えることの2つに注意してください。Spring が @Transactional の裏側でやっている判断を、自分の手で書くことになります。
要件
- すべて成功したら committed=<最終残高> を、途中で失敗したら rolledback=
,failedAt=<1 から数えた位置> を返すこと - 適用結果が 0 未満になる操作を失敗とし、その時点で残りの操作を実行しないこと
- ops が空文字列または null のときは committed=
を返すこと
入出力例
applyAll("+300,-100,+50", 1000) → "committed=1250"
applyAll("-500,-400,-200", 1000) → "rolledback=1000,failedAt=3"
applyAll("-1", 0) → "rolledback=0,failedAt=1"
applyAll("-1000,+9999", 1000) → "committed=9999"
applyAll("", 700) → "committed=700"
applyAll("+10,-20,+1000", 5) → "rolledback=5,failedAt=2"