每次訂團購的時候,團主都要像債主般的追著跟大家要錢,所以在做這個專案的時候,就刻意地把規格設計成「訂單扣款時,只要有一個人儲值帳戶裡面的錢不夠扣款,就不能扣款」,要嘛給錢、要嘛取消不要訂,讓團主好做事,確認款項都收齊後才打電話給店家。
因此專案目前的結算規則是:
一張訂單裡有多位參加者,必須確認所有人的餘額都足夠,才能一起完成結算。
假設小明餘額 500 元、小華餘額 100 元,同一筆訂單小明需扣 400 元、小華須扣 200 元。小明的扣款會先成功,但小華扣款失敗。如果沒有額外處理,小明的 400 元就會被留下來,整張訂單卻無法完成結算。
因此,這裡需要保證的是:
所有人都扣款成功,整張訂單才算成功;只要其中一人失敗,前面已經完成的扣款也必須全部復原。
Day 17 的 CAS 保證了「單筆扣款不會把餘額扣成負數」,但管不到多筆扣款之間的關係。
這時候,問題就從單列的 CAS,變成了交易(Transaction)的原子性。
專案的做法是把整張訂單的扣款迴圈包在同一個 @Transactional 裡:
@Transactional
public void executePayment(Order order) {
Map<String, Long> userTotals = /* 按使用者加總品項小計 */;
for (Map.Entry<String, Long> entry : userTotals.entrySet()) {
int affected = userMapper.casDebit(entry.getKey(), entry.getValue());
if (affected == 0) {
throw new InsufficientBalanceException(entry.getKey() + " 餘額不足");
}
transactionMapper.insert(/* 流水帳 */);
}
order.setStatus(OrderStatus.SETTLED);
orderMapper.updateById(order);
}
第三人 affected == 0 拋出例外,交易 rollback,前兩人的扣款與流水帳一併復原,訂單狀態也不會變成 SETTLED。
所以這裡其實是兩層保護:
這裡會出現另一個問題:
扣款失敗時,扣款資料要 rollback,但「這張訂單結算失敗了」這件事卻不能被 rollback。
如果把 order.setStatus(FAILED) 寫進 executePayment 的 transaction 裡,當扣款失敗觸發 rollback 時,這筆狀態更新也會一起被撤銷,訂單最後仍會停在 CLOSED。
因此,標記 FAILED 的動作必須放在交易之外:
public void payOrder(String orderId) {
// ...狀態前置檢查
try {
paymentService.executePayment(order); // 交易在這一層之內
} catch (InsufficientBalanceException e) {
// 交易已 rollback,此處的寫入不受影響
order.setStatus(OrderStatus.FAILED);
orderMapper.updateById(order);
throw e;
}
}
payOrder 本身沒有 @Transactional,因此 executePayment rollback 完成後,外層仍然可以獨立把訂單標記為 FAILED。
把必須「全部成功或全部失敗」的資料更新,放進同一個 transaction;而用來記錄「交易失敗」的結果,不能跟著 transaction 一起 rollback。
前面已經知道,第三個人扣款失敗時,要靠 @Transactional 把前面已經完成的扣款一起 rollback,但這件事有一個重要前提:「例外必須離開 transaction,讓 Spring 知道這次交易失敗了」。
如果在 executePayment 裡把例外 catch 住並吞掉:
try {
// 扣款
} catch (InsufficientBalanceException e) {
// 什麼都不做
}
對 Spring 來說,executePayment() 已經正常結束,transaction 就可能直接 commit,前兩個人的扣款也就會被留下來,因此扣款失敗時,例外必須繼續往外拋:

executePayment 一定要從另一個 Bean 呼叫?前面提到的 rollback 還有一個前提:「@Transactional 必須真的有啟動 transaction」,Spring 是透過 Proxy 攔截 @Transactional 方法,在方法執行前開啟 transaction:

因此,如果在同一個 class 裡直接執行this.executePayment(),就會繞過 Spring Proxy,那麼@Transactional 就不會生效。
本專案的結算通知功能因此排在扣款完成之後才發送,不放在 executePayment 裡。
最後,這次的 transaction 邊界可以濃縮成:
Transaction 負責保證資料庫操作的原子性;例外負責觸發 rollback;交易之外則負責記錄「這次結算失敗」。