Casha | 4. Saga Pattern 介紹 - 餐廳與帳號創建

Casha | 4. Saga Pattern 介紹 - 餐廳與帳號創建

Cash Pos 在微服務的分布式事務上, 使用了 Orchestration Saga, 也藉這個機會介紹和紀錄一下 Saga Pattern 實作的方法。

關於 Saga

在微服務架構中,傳統的 ACID 事務無法跨越多個服務,Saga 模式就為了解決這個問題而誕生。

Saga 的核心概念是:

把一個長交易切成一連串的本地交易 (Local Transaction)

若其中某一步失敗,就觸發對應的補償行為 (Compensation), 回滾已完成的步驟,達到最終一致性

目前主要有兩種實作的風格: Choreography Saga (協作式) & Orchestrated Saga (協調式)

Choreography Saga (協作式)

核心是事件驅動, 各服務靠事件彼此觸發 (每個服務完成工作後發布事件,其他服務監聽並反應), 優點是服務耦合度低, 但因為流程分散在事件之間, 遇到複雜流程的時候比較難處理。

由於每個服務只監聽自己關心的事件, 通常適用事件導向、步驟可獨立演進的情境。

好比用戶註冊的流程:

  1. 建立用戶帳號 (Auth Service)
  2. 發送歡迎郵件 (Mail Service)
  3. 建立用戶個人檔案 (User Service)
  4. 補償協調

建立用戶帳號

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());

// 1. 建立用戶帳號
User user = User.builder()
.userId(generateUserId())
.email(command.getEmail())
.password(command.getPassword())
.status("ACTIVE")
.createdAt(LocalDateTime.now())
.build();

userRepository.save(user);

// 2. 發布 UserRegistered 事件
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);
}
}

基本上可以總結:

  1. 事件導向
    • 每個服務只監聽自己關心的事件
    • 透過事件自然驅動流程
  2. 鬆散耦合
    • 郵件服務和個人檔案服務彼此不知道對方存在
    • 可以獨立修改業務邏輯和演進
  3. 彈性錯誤處理
    • 郵件失敗不影響主要流程
    • 個人檔案失敗才觸發完整回滾
    • 每個服務可以有自己的錯誤處理策略

Orchestrated Saga (協調式)

協調式通常會由 一個協調者(Orchestrator/BFF) 負責編排整個流程,逐一呼叫 A -> B -> C, 根據結果決定下一步, 做決策補償、處理重試等機制。

好處是流程清晰易於管理,複雜業務邏輯集中, 但協調器成為單點,服務耦合度較高。

而 Casha Pos 創建餐廳帳號的流程正是由 BFF(Admin Portal)發起的兩步驟長交易。選用的原因如下:

  1. 流程明確:先建餐廳, 再建帳號,簡單的順序執行
  2. 業務邏輯集中:密碼生成、錯誤處理等邏輯在 BFF 統一管理
  3. 易於監控除錯:所有狀態變化都在協調器中,便於追蹤問題
  4. 補償機制清晰:當帳號建立失敗時,需要明確的禁用餐廳操作

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 # 重試2次
waitDuration: 1s # 等待1秒
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);

核心流程

Orchestrated Saga

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) {

// 冪等:同 txnId 直接回快取
CreateRestaurantRs cached = sagaState.get(SAGA, rq.getTxnId(), CreateRestaurantRs.class);
if (cached != null) return cached;

// 1) 先建餐廳(store)
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";

// 2) 生成 8 碼臨時密碼(由 BFF 產生,支援冪等重送)
String tempPwd = genPassword(8);

// 3) 建管理員時綁預設角色(auth)
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) {
// Feign 異常 → 執行補償
log.info("AuthProvision Feign 異常: 執行補償: {}", e.getMessage());
compensateStore(restaurantId, rq.getTxnId(),
"AUTH_FEIGN_EXCEPTION:" + e.getMessage());
throw new AdminPortalException(
AdminPortalException.AdminPortalErrorType.SAGA_COMPENSATION_FAIL);
}

// 4) 成功 → 回傳一次性帳密,並存入 Saga 狀態(短 TTL)
CreateRestaurantRs rs = new CreateRestaurantRs(restaurantId, code, account, tempPwd);
sagaState.put(SAGA, rq.getTxnId(), rs, Duration.ofMinutes(30)); // 30 分鐘 TTL
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 {
// Resilience4j 對補償方法可設定 retry 2 次
storeClient.disable(drq);
} catch (Exception ex) {
log.error("補償禁用餐廳失敗,重試 {} 次後仍然失敗", 2, ex);

// 發送 mq 去處理手動補償事件
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 把複雜度集中在調節器上方,換來流程可控、語義穩定、易監控;代價是需要更嚴謹的狀態管理與韌性設計。


結語

在微服務架構中,沒有銀彈,只有合適的選擇。其實我在一開始設計的時候就有想過這個業務應該用哪種方法做比較好, 老實說兩種方法其實都可以, 但就需要考慮到產生臨時密碼並同步, 以及同步/非同步等問題。

無論選擇哪種模式,重要的是理解背後的取捨,讓技術架構真正服務於業務