iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

使用者下單後,畫面上的可用餘額要立刻更新,專案中透過前端與伺服器維持一條 WebSocket 連線,伺服器推到 /user/queue/balance,讓當事人收得到餘額更新通知。

但推播有一個重要規則:「推出去的內容,必須是資料庫裡已經成立的事實。」如果資料庫最後 rollback,使用者就不應該先收到「下單成功」的通知。

問題:推播放在 transaction 裡,真的代表「最後一步」嗎?

在一開始寫的初版中,下單流程沒有用 @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 中間,就可能出現三種問題:

  1. 推播之後 commit 失敗:alice 看到「下單成功、餘額 930」,事實上資料庫裡並沒有這筆品項。
  2. Redis 斷線,推播拋出例外:交易 rollback,下單因為「通知失敗」而失敗。
  3. Redis 回應變慢:推播放在 transaction 裡,會讓 users 的列鎖一直持有到推播完成;此時 Alice 的另一筆下單如果也要執行 selectForUpdate,就只能等待第一筆交易 commit。

問題因此不是「推播是不是最後一行」,而是推播發生時,交易是否已經成立。

解決方法:把推播移到 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 後再重新查詢最新餘額:

  1. 避免在持鎖期間多做一次查詢。 如果在 transaction 裡取得更新後的餘額,就必須在 insert 後再查一次,此時 users 那一列仍然鎖著。
  2. 讓可用餘額只由一個地方計算。 下單、刪除品項都只需要說「這個人的餘額變了」,實際怎麼計算交給 listener。

因此,推播執行時交易已經成立;即使推播失敗,也不會反過來讓已完成的下單 rollback。

代價:資料不會錯,但通知可能漏掉

commit 後才推播,雖然不會讓使用者收到已 rollback 的假消息,但也可能發生資料已經 commit,推播卻還沒送出就失敗的情況。

本專案選擇寧可漏通知,也不推假消息:漏掉的通知,使用者重新查詢仍能看到正確餘額;假消息則無法收回。

因此,這次的設計重點是:資料正確比通知一定送達更重要。


上一篇
Day 23|結算失敗後,重試要從哪裡繼續?
系列文
做一個團購後端,順便搞懂那些事 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言