使用者下單、刪除品項,或訂單結算扣款後,伺服器透過 WebSocket 推送一則訊息,讓前端更新畫面上的可用餘額,然而,通知並不保證送達,在交易 commit 後、訊息送出前,機器可能會當掉,且跨機器轉送使用的 Redis Pub/Sub 並不保存訊息,使用者離線或正在重連時也可能漏收。
因此,畫面上的可用餘額必須能在收到後續通知時恢復正確,而不能因為漏掉一則訊息就一直算錯。
如果訊息只帶變動量,由前端自行加減,漏掉任何一則都會影響後續計算:
舉例:
| 事件 | 訊息 | 前端畫面 | 資料庫 |
|---|---|---|---|
| 初始 | 5000 | 5000 | |
| 下單 300 | -300(漏掉) |
5000 | 4700 |
| 下單 200 | -200 |
4800 | 4500 |
漏掉第一則後,前端就會一直差 300。
因此,本專案初期版本的 BalanceMessage 傳遞的是計算完成的可用餘額,而不是變動量:
@Data
public class BalanceMessage {
private final String type = "BALANCE_UPDATED";
private long availableBalance;
private String reason;
public BalanceMessage(long availableBalance, String reason) { ... }
}
當時的 BalanceChangeNotifier 會在交易 commit 後重新從資料庫計算可用餘額,再送出通知:
long available = user.getBalance() - orderItemMapper.getFrozenAmount(event.userId());
notificationService.sendBalanceUpdate(event.userId(), available, event.reason());
前面解決了漏訊息造成的累積誤差,卻還有另一個問題:「後送出的訊息,不一定比較新。」
假設 Alice 的可用餘額是 5000,接著連續下單兩筆各 1000,下單時會鎖住 users 的資料列,因此兩筆交易依序 commit,但是 commit 後重新計算餘額與送出通知,並不在這個鎖的保護範圍內:

結果是舊的 4000 晚於新的 3000 抵達前端,畫面反而被更新成錯誤的數字。
即使解決這個問題,訊息送出後仍有其他可能打亂順序的地方,通知從計算餘額、經過 Redis 傳遞,到最後送達瀏覽器,會經過多個處理環節,不同訊息可能由不同執行緒處理,因此即使較新的餘額先送出,也不能保證它一定會先抵達前端。

至於 preservePublishOrder(Spring 用來控制同一個 WebSocket session 的訊息發布順序的設定),目前專案沒有啟用。因此,只調整其中一處的處理順序,還不足以保證整條傳送路徑都不會亂序。
既然通知可能亂序,就不應直接相信訊息裡的餘額,於是改成讓推播只通知前端「餘額已變動」,不再附帶數字:
@Data
public class BalanceMessage {
private final String type = "BALANCE_CHANGED";
private final String reason;
}
前端收到 BALANCE_CHANGED 後,再呼叫 /order/get_user_account 查詢最新的可用餘額。即使較舊的信號晚到,查詢取得的仍是資料庫當下的餘額,不會因為信號順序不同,就把舊數字寫回畫面。
為了統一處理方式,下單、刪除品項與結算扣款後,都會發布 BalanceChangedEvent,再由 BalanceChangeNotifier 等待交易 commit 後送出信號。即使漏掉一則信號,只要後續還有信號觸發查詢,前端就能重新取得正確的餘額,當然了,這麼做付出的代價是每次收到信號,都會多一次 HTTP 查詢。
而結算結果通知則有不同的需求:考量到使用者可能離線,錯過的通知不能只靠下一次餘額變動補回,因此,結算成功或失敗的通知會在對應的資料庫交易中寫入 notifications 表,推播時附上 notificationId。使用者重新連線後,可以呼叫 /user/get_unread_notifications 取得尚未讀取的通知,再由前端利用 notificationId 去除重複訊息。
推播負責提醒前端資料有變化,實際數值以重新查詢的結果為準;需要保留的結算通知則存入資料庫,確保離線後仍有機會補回。