使用者下單後,畫面上的可用餘額要立刻更新,專案中透過前端與伺服器維持一條 WebSocket 連線,伺服器推到 /user/queue/balance,讓當事人收得到餘額更新通知。
但推播有一個重要規則:「推出去的內容,必須是資料庫裡已經成立的事實。」如果資料庫最後 rollback,使用者就不應該先收到「下單成功」的通知。
在一開始寫的初版中,下單流程沒有用 @Transactional 包住,因此 orderItemMapper.insert(item) 執行完成後就已經 commit,接著才查詢新的可用餘額並推播,所以當時「推播時資料已經成立」這個規則是成立的。
public void createUserOrder(CreateOrderItemRequest request, String userId) {
// ...驗證訂單、菜單、可用餘額
orderItemMapper.insert(item); // 寫入後立即 commit
Map<String, Object> updatedAccount = getUserAccount(userId);
long updatedAvailable = (long) updatedAccount.get("availableBalance");
notificationService.sendBalanceUpdate(userId, updatedAvailable,
"下單:" + menu.getProductName() + " x" + request.getQuantity());
}
後來為了防止同一人併發下單超支,下單流程需要先鎖住 users 那一列,因此方法加上 @Transactional,加上 @Transactional 後,這個方法裡的 SQL 不會每執行一條就立即 commit,而是等整個方法正常執行完,才一起 commit。
所以即使推播寫在方法的最後一行:
寫入訂單
↓
推播
↓
方法結束
↓
commit
推播發生時,transaction 其實還沒有 commit。
這會造成如果推播成功後,最後的 commit 卻失敗,使用者已經收到「下單成功、餘額變成 930」的通知,實際上在資料庫裡的訂單其實沒有成立。
因此,真正要處理的不是「推播是不是最後一行」,而是「推播是不是發生在 commit 之後」。
實際執行順序會變成:
─── TransactionInterceptor(proxy)──────────
開始交易
─── OrderService.createUserOrder ──────
UserMapper.selectForUpdate 鎖住 alice 的 users 列
OrderItemMapper.insert 寫入品項
NotificationService 推播「可用餘額 930」
return
commit 或 rollback,釋放列鎖
推播夾在 transaction 中間,就可能出現三種問題:
users 的列鎖一直持有到推播完成;此時 Alice 的另一筆下單如果也要執行 selectForUpdate,就只能等待第一筆交易 commit。問題因此不是「推播是不是最後一行」,而是推播發生時,交易是否已經成立。
既然推播必須建立在已成立的資料上,就不能讓 createUserOrder 在 transaction 裡直接推播。
方法結尾原本「查可用餘額、送出推播」的三行,改成只發布一個事件:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void createUserOrder(CreateOrderItemRequest request, String userId) {
// ...驗證訂單與菜單、selectForUpdate 鎖住 users 列、檢查可用餘額、組出 item
orderItemMapper.insert(item);
// 通知延到交易 commit 之後才送(見 BalanceChangeNotifier):
// 交易若回滾,使用者不該收到「下單成功」;也讓上面那把行鎖不必等推播。
eventPublisher.publishEvent(new BalanceChangedEvent(userId,
"下單:" + menu.getProductName() + " x" + request.getQuantity()));
}
BalanceChangedEvent事件本身只是一個資料容器,說明「誰的餘額變了、為什麼」:
發布事件不會立刻推播,真正推播的是 BalanceChangeNotifier,AFTER_COMMIT 指定它等到 commit 之後才執行:
@Slf4j
@Component
@RequiredArgsConstructor
public class BalanceChangeNotifier {
private final UserMapper userMapper;
private final OrderItemMapper orderItemMapper;
private final NotificationService notificationService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onBalanceChanged(BalanceChangedEvent event) {
try {
User user = userMapper.selectById(event.userId());
if (user == null) {
return;
}
long available = user.getBalance() - orderItemMapper.getFrozenAmount(event.userId());
notificationService.sendBalanceUpdate(event.userId(), available, event.reason());
} catch (Exception e) {
// 交易已經 commit,下單本身是成立的;推播失敗只記錄,不能反過來影響結果
log.error("Failed to send balance update for user={}", event.userId(), e);
}
}
}
這裡可以把兩個角色想成「發布者」和「接收者」。
OrderService 只負責發布 BalanceChangedEvent:
OrderService
↓ publishEvent()
BalanceChangedEvent
↓
BalanceChangeNotifier
↓ AFTER_COMMIT
查詢最新餘額
↓
WebSocket 推播
所以 OrderService 不需要知道 BalanceChangeNotifier 的存在,也不需要直接呼叫它。Spring 看到 BalanceChangedEvent 後,會找到接收這個事件的 @TransactionalEventListener,並在指定的 transaction 階段執行。
這樣做還有另一個好處:事件不需要帶「更新後的餘額」,而只需要告訴 listener「誰的餘額變了、為什麼」。
listener 等到 commit 後再重新查詢最新餘額:
insert 後再查一次,此時 users 那一列仍然鎖著。因此,推播執行時交易已經成立;即使推播失敗,也不會反過來讓已完成的下單 rollback。
commit 後才推播,雖然不會讓使用者收到已 rollback 的假消息,但也可能發生資料已經 commit,推播卻還沒送出就失敗的情況。
本專案選擇寧可漏通知,也不推假消息:漏掉的通知,使用者重新查詢仍能看到正確餘額;假消息則無法收回。
因此,這次的設計重點是:資料正確比通知一定送達更重要。