Cash Pos 在微服務的分布式事務上, 使用了 Orchestration Saga, 也藉這個機會介紹和紀錄一下 Saga Pattern 實作的方法。
關於 Saga
在微服務架構中,傳統的 ACID 事務無法跨越多個服務,Saga 模式就為了解決這個問題而誕生。
Saga 的核心概念是:
把一個長交易切成一連串的本地交易 (Local Transaction)
若其中某一步失敗,就觸發對應的補償行為 (Compensation), 回滾已完成的步驟,達到最終一致性
目前主要有兩種實作的風格: Choreography Saga (協作式) & Orchestrated Saga (協調式)
Choreography Saga (協作式)
核心是事件驅動, 各服務靠事件彼此觸發 (每個服務完成工作後發布事件,其他服務監聽並反應), 優點是服務耦合度低, 但因為流程分散在事件之間, 遇到複雜流程的時候比較難處理。
由於每個服務只監聽自己關心的事件, 通常適用事件導向、步驟可獨立演進的情境。
好比用戶註冊的流程:
- 建立用戶帳號 (Auth Service)
- 發送歡迎郵件 (Mail Service)
- 建立用戶個人檔案 (User Service)
- 補償協調
建立用戶帳號
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Service @Slf4j public class UserService { @Autowired private ApplicationEventPublisher eventPublisher; public void registerUser(RegisterUserCommand command) { log.info("開始註冊用戶: {}", command.getEmail()); User user = User.builder() .userId(generateUserId()) .email(command.getEmail()) .password(command.getPassword()) .status("ACTIVE") .createdAt(LocalDateTime.now()) .build(); userRepository.save(user); UserRegisteredEvent event = UserRegisteredEvent.builder() .userId(user.getUserId()) .email(user.getEmail()) .timestamp(LocalDateTime.now()) .build(); eventPublisher.publishEvent(event); log.info("用戶註冊成功,發布事件: {}", user.getUserId()); } @EventListener public void handleRegistrationFailed(RegistrationFailedEvent event) { log.info("收到註冊失敗事件,刪除用戶: {}", event.getUserId()); userRepository.deleteById(event.getUserId()); } }
|
發送歡迎郵件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Service @Slf4j public class EmailService { @EventListener public void handleUserRegistered(UserRegisteredEvent event) { log.info("收到用戶註冊事件,發送歡迎郵件: {}", event.getEmail()); try { sendWelcomeEmail(event.getEmail(), event.getUserId()); log.info("歡迎郵件發送成功: {}", event.getEmail()); } catch (Exception e) { log.error("發送歡迎郵件失敗: {}", event.getEmail(), e); EmailFailedEvent failedEvent = EmailFailedEvent.builder() .userId(event.getUserId()) .email(event.getEmail()) .reason("郵件發送失敗: " + e.getMessage()) .timestamp(LocalDateTime.now()) .build(); eventPublisher.publishEvent(failedEvent); } } }
|
建立用戶個人檔案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Service @Slf4j public class UserService { @EventListener public void handleUserRegistered(UserRegisteredEvent event) { log.info("收到用戶註冊事件,建立個人檔案: {}", event.getUserId()); try { UserProfile profile = UserProfile.builder() .userId(event.getUserId()) .email(event.getEmail()) .displayName(event.getEmail().split("@")[0]) .createdAt(LocalDateTime.now()) .build(); profileRepository.save(profile); log.info("個人檔案建立成功: {}", event.getUserId()); } catch (Exception e) { log.error("建立個人檔案失敗: {}", event.getUserId(), e); ProfileCreationFailedEvent failedEvent = ProfileCreationFailedEvent.builder() .userId(event.getUserId()) .email(event.getEmail()) .reason("個人檔案建立失敗: " + e.getMessage()) .timestamp(LocalDateTime.now()) .build(); eventPublisher.publishEvent(failedEvent); } } @EventListener public void handleRegistrationFailed(RegistrationFailedEvent event) { log.info("收到註冊失敗事件,刪除個人檔案: {}", event.getUserId()); profileRepository.deleteByUserId(event.getUserId()); } }
|
補償協調
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Service @Slf4j public class RegistrationCompensationCoordinator { @Autowired private ApplicationEventPublisher eventPublisher; @EventListener public void handleEmailFailed(EmailFailedEvent event) { log.warn("郵件發送失敗,但繼續流程,不觸發補償: {}", event.getUserId()); } @EventListener public void handleProfileCreationFailed(ProfileCreationFailedEvent event) { log.error("個人檔案建立失敗,觸發整體補償: {}", event.getUserId()); RegistrationFailedEvent failedEvent = RegistrationFailedEvent.builder() .userId(event.getUserId()) .email(event.getEmail()) .reason("個人檔案建立失敗: " + event.getReason()) .timestamp(LocalDateTime.now()) .build(); eventPublisher.publishEvent(failedEvent); } }
|
基本上可以總結:
- 事件導向
- 每個服務只監聽自己關心的事件
- 透過事件自然驅動流程
- 鬆散耦合
- 郵件服務和個人檔案服務彼此不知道對方存在
- 可以獨立修改業務邏輯和演進
- 彈性錯誤處理
- 郵件失敗不影響主要流程
- 個人檔案失敗才觸發完整回滾
- 每個服務可以有自己的錯誤處理策略
Orchestrated Saga (協調式)
協調式通常會由 一個協調者(Orchestrator/BFF) 負責編排整個流程,逐一呼叫 A -> B -> C, 根據結果決定下一步, 做決策補償、處理重試等機制。
好處是流程清晰易於管理,複雜業務邏輯集中, 但協調器成為單點,服務耦合度較高。
而 Casha Pos 創建餐廳帳號的流程正是由 BFF(Admin Portal)發起的兩步驟長交易。選用的原因如下:
- 流程明確:先建餐廳, 再建帳號,簡單的順序執行
- 業務邏輯集中:密碼生成、錯誤處理等邏輯在 BFF 統一管理
- 易於監控除錯:所有狀態變化都在協調器中,便於追蹤問題
- 補償機制清晰:當帳號建立失敗時,需要明確的禁用餐廳操作
Create Restaurant 實作
前提
Orchestrated Saga 實現在 Admin Portal 這個 BFF 的 CreateRestaurantUseCaseImpl 方法內。
其中錯誤狀態用 Global Exception Handler 補中自定義的錯誤, 詳情可參考 Casha 異常處置
微服務間以 Spring Cloud OpenFeign 溝通, 並設定 Resilience4j 重試。
1 2 3 4 5 6 7 8 9 10 11 12
| resilience4j: retry: instances: compensation-retry: maxAttempts: 2 waitDuration: 1s retryExceptions: - org.springframework.web.client.HttpServerErrorException - java.net.ConnectException - java.io.IOException - feign.RetryableException - feign.FeignException
|
1 2 3
| @PostMapping("/store/restaurant/disable") @Retry(name = "compensation-retry") ApiResponse<Void> disable(@RequestBody StoreDisableRestaurantRq rq);
|
核心流程

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| @Service @RequiredArgsConstructor @Slf4j public class CreateRestaurantUseCaseImpl implements CreateRestaurantUseCase {
private static final String SAGA = "CreateRestaurant"; private static final char[] ALPHANUM = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789abcdefghjkmnpqrstuvwxyz".toCharArray();
private final StoreRestaurantClient storeClient; private final AuthProvisionClient authClient; private final SagaStatePort sagaState; private final CompensationEventPublisher eventPublisher;
private final SecureRandom rnd = new SecureRandom();
@Override public CreateRestaurantRs create(CreateRestaurantRq rq) {
CreateRestaurantRs cached = sagaState.get(SAGA, rq.getTxnId(), CreateRestaurantRs.class); if (cached != null) return cached;
StoreCreateRestaurantRq srq = new StoreCreateRestaurantRq(); srq.setTxnId(rq.getTxnId()); srq.setName(rq.getName()); srq.setTaxId(rq.getTaxId()); srq.setIsActive(rq.getIsActive());
ApiResponse<StoreCreateRestaurantRs> sResp = storeClient.create(srq); if (!"00000".equals(sResp.getResponseCode())) { throw new AdminPortalException(AdminPortalException.AdminPortalErrorType.STORE_CLIENT_FAIL); }
String restaurantId = sResp.getData().getRestaurantId(); String code = sResp.getData().getCode(); String account = code + "@casha.com";
String tempPwd = genPassword(8);
AuthProvisionAdminRq arq = new AuthProvisionAdminRq(); arq.setTxnId(rq.getTxnId()); arq.setRestaurantId(restaurantId); arq.setAccount(account); arq.setTempPassword(tempPwd); arq.setScope("RESTAURANT");
try { ApiResponse<AuthProvisionAdminRs> aResp = authClient.provision(arq); if (!"00000".equals(aResp.getResponseCode())) { log.info("AuthProvision 業務錯誤: 執行補償"); compensateStore(restaurantId, rq.getTxnId(), "AUTH_PROVISION_FAILED:" + aResp.getMsg()); throw new AdminPortalException( AdminPortalException.AdminPortalErrorType.SAGA_COMPENSATION_FAIL); } } catch (FeignException e) { log.info("AuthProvision Feign 異常: 執行補償: {}", e.getMessage()); compensateStore(restaurantId, rq.getTxnId(), "AUTH_FEIGN_EXCEPTION:" + e.getMessage()); throw new AdminPortalException( AdminPortalException.AdminPortalErrorType.SAGA_COMPENSATION_FAIL); }
CreateRestaurantRs rs = new CreateRestaurantRs(restaurantId, code, account, tempPwd); sagaState.put(SAGA, rq.getTxnId(), rs, Duration.ofMinutes(30)); return rs; }
private String genPassword(int n) { char[] buf = new char[n]; for (int i = 0; i < n; i++) buf[i] = ALPHANUM[rnd.nextInt(ALPHANUM.length)]; return new String(buf); }
private void compensateStore(String restaurantId, String txnId, String reason) { StoreDisableRestaurantRq drq = new StoreDisableRestaurantRq(); drq.setTxnId(txnId); drq.setRestaurantId(restaurantId); drq.setReason(reason);
try { storeClient.disable(drq); } catch (Exception ex) { log.error("補償禁用餐廳失敗,重試 {} 次後仍然失敗", 2, ex);
StoreRestaurantDisablePayload payload = StoreRestaurantDisablePayload.builder() .txnId(drq.getTxnId()) .restaurantId(drq.getRestaurantId()) .build();
CompensationFailedEvent event = CompensationFailedEvent.builder() .transactionId(txnId) .serviceName(ModelType.ST) .reason("STORE_DISABLE:" + reason) .failedAt(LocalDateTime.now()) .payload(payload) .build();
eventPublisher.publishCompensationFailed(event); } } }
|
可以看出來, 協調式 Saga 把複雜度集中在調節器上方,換來流程可控、語義穩定、易監控;代價是需要更嚴謹的狀態管理與韌性設計。
結語
在微服務架構中,沒有銀彈,只有合適的選擇。其實我在一開始設計的時候就有想過這個業務應該用哪種方法做比較好, 老實說兩種方法其實都可以, 但就需要考慮到產生臨時密碼並同步, 以及同步/非同步等問題。
無論選擇哪種模式,重要的是理解背後的取捨,讓技術架構真正服務於業務