問題:一筆業務事務、五個服務、沒有 ACID
一位旅客預訂一次旅行。航班、飯店、租車、機場接送、旅行保險這五項,分別住在不同的服務裡、不同的資料庫裡、不同的歸屬下。從旅客的視角看,這是一筆事務:要么這五段全都訂到了,否則這筆訂單不存在。從資料庫的視角看,沒有任何原子事務能跨過五個連線集區。
這不是疏漏。這是把單體拆成微服務之後的結構性後果。ACID 保證只在一個資料庫、一次連線、一個信任邊界內有效。一筆工作只要跨過服務邊界——哪怕是非常小的邊界——全域 ACID 事務就崩了。兩階段提交能讓多個資料庫保持步調一致,但沒法讓多個服務保持步調一致:對第三方航空公司 API 發一次 PREPARE 是一個盡力而為的非同步事件,不是一張事務性的投票。
雙寫問題
一種樸素的做法是寫兩次:一次寫進每個服務的本地事務,一次寫到一個「全域」反正規化儲存。每次寫入翻倍,第二次寫入可能獨立失敗,兩次寫入還會賽跑。這份反正規化成了記錄系統,意味著真相的來源被劈成了兩半,意味著之後每一次查詢都要協調兩條時間線。這比當初想要解決的問題還要糟。
於是 Saga 模式回答的問題就變得很務實:**當單一 ACID 事務不可能時,對這個問題做最小的改動,使得一筆業務結果仍然可能?**答案是:把業務事務拆成一連串本地事務,每一筆都 能 被撤銷,並接受「撤銷」本身也是一筆本地事務。
這個模式最早由 Hector Garcia-Molina 和 Kenneth Salem 在他們 1987 年的論文 Sagas 中形式化,背景是長執行資料庫事務。微服務時代的復興——本文要講的——來自 Chris Richardson 的 microservices.io 以及那些在生產裡把它真用起來的系統:AWS Step Functions、Camunda、Temporal。模式本身有四十歲了,應用場景是新的。
解決方案預覽:Saga 是一次有回程計劃的航行
一次航行有幾段航程。每一段都是一個有目的地的動作——一次航班、一次飯店、一次租車——而每一段都有一份 回程:如果後面任何一段失敗,把這次旅行撥回原點的對應動作。Saga 模式把這種航行的結構,作為業務工作的單元。
如果說 艙壁模式 是透過把資源分到獨立的池來隔離故障,那麼 Saga 模式就是在沒有共同資料庫的服務之間協調恢復。艙壁擋住爆炸,Saga 寫出把碎片拼回去的劇本。
形式上,Saga 是一連串 本地事務 ,每一筆都配一個 補償事務 。前向進度從左到右進行;在第一次失敗時,Saga 對每一筆已經完成的 反向執行對應的補償。補償 是一項真實的動作——CancelFlight、RefundPayment、ReleaseSeat——不是資料庫回滾。Saga 協調的是資料庫事務做不到的事。
本文覆蓋兩種頂層變體——編排(由一個中央協調器告訴每個服務做什麼)和協同(每個服務對匯流排上的事件做出反應)——外加三種實作形態、一節權衡討論、一個決策矩陣。文末的相關模式一節會指向 Circuit Breaker(斷路器) 和 Outbox Pattern(發件箱模式)——這是每一個真正在用的 Saga 最終都需要的兩個模式。
兩種變體:編排 vs 協同
同一種形狀,兩種控制邏輯。
編排式 Saga。 有一個服務——編排器——知道 Saga 的步驟。它依序呼叫 flight.reserve、hotel.reserve、car.reserve。每一步向編排器返回成功或失敗,編排器決定下一步。任何失敗發生時,編排器對已經完成的步驟反向發出補償呼叫。編排器維護一份 Saga 日誌:一份 per-Saga 的、僅追加的日誌,記錄哪些步驟已經完成、哪些已經補償;這樣一旦崩潰,可以重播日誌來恢復。
協同式 Saga。 沒有編排器。每個服務在訊息代理上發出事件(FlightReserved、HotelReserved),並監聽其它服務的事件。Saga 的前向進度,就是事件在系統中走過的路徑;補償則是監聽 TripBookingFailed 並做出反應的那些服務。Saga 的狀態住在代理的 offset 和每個參與者自己的本地儲存裡。沒有單一日誌;每個參與者擁有自己那一段。
沒有 Saga vs 有 Saga(問題框架)
單體是沒問題的。微服務版本是把單體拆分之後才會出現的情況——拆分的理由很充分:獨立部署、團隊邊界、擴縮策略。Saga 不是一筆更糟糕的交易,它是一種不同形狀的工作,有不同的失敗模式和不同的運維面。
編排:一條有向序列
編排器是 Saga 狀態的唯一所有者。每一步、每一次補償、每一次重試、每一次逾時,都是它的職責。參與服務對更大的 Saga 一無所知——flight.reserve 和從任何呼叫者那裡收到的請求是同一個呼叫。從參與者一側解耦,是編排器的好處。
協同:一次事件級聯
沒有中心服務擁有任何東西。Saga 的前向進度,就是代理投遞出來的事件序列。補償流是 另一串 事件,往回走。脆弱之處藏在跨服務的事件契約裡:TripBookingRequested 一改 schema,下游的三個消費者就要同步遷移。
「誰擁有這個 Saga?」是正確的問題
編排式 Saga 由編排器擁有。協同式 Saga 由 每一個處理相關事件的消費者 擁有——等團隊圖全了,「擁有者」就是代理掛掉那一刻恰好還醒著的那位工程師。挑那個你能撐到凌晨三點的歸屬故事的變體。
補償事務:Saga 的真正工作
模式的名字暗示的是前向進度。真正的工作是 按需的反向進度。對每筆前向事務 ,Saga 必須定義一個補償事務 ,在語意上撤銷 ——不是資料庫回滾,是一項動作。
在 microservices.io 經典的旅行預訂例子中,Saga 是這樣的:
| 步驟 | 前向 () | 補償 () |
|---|---|---|
| 1 | ReserveFlight | CancelFlightReservation |
| 2 | ReserveHotel | CancelHotelReservation |
| 3 | ReserveCar | CancelCarReservation |
| 4 | ReserveAirportTransfer | CancelAirportTransfer |
| 5 | ActivateTravelInsurance | CancelTravelInsurance |
這些補償不是 DELETE FROM booking WHERE id = ?。它們是 業務動作,用來撤銷前向步驟的業務效果:對航空公司 API 取消訂座,把房間歸還飯店的庫存,把車重新標記為可訂。一筆補償本身也會失敗——這是 Saga 最被忽視的失敗模式。
補償對戰的難題
想像 CancelFlightReservation 本身也失敗了。Saga 正在清理時,航空公司 API 返回 503。這時 Saga 卡在半補償狀態:航空公司系統裡航班訂座還在(錢已付),飯店和租車都取消了(OK),編排器(或協同的消費者)在補償上死循環重試。
應對這個問題的模式是:
- 冪等補償。 每一筆補償都帶一個
idempotency_key,由(saga_id, step_id, "compensation")衍生。第一次呼叫有效;後續用同一 key 的重試都是 no-op。補償的效果,無論是第一次就成功,還是第一百零一次才成功,都一樣。 - Saga 層的退避重試。 編排器(或協同的消費者)按計劃重試失敗的補償——指數退避加一個最大間隔,一直重試到補償成功,或人為逾時中止 Saga。
- 為 Saga 的日誌再套一層 Saga。 當連補償日誌都不靠譜時,升級到一個 meta-saga,監控主 Saga 的卡死補償並施加運維干預(人工補償、部分退款),把自動化解決不了的案例暴露給人。
- 參與者層面的冪等。 參與者服務是最後一道防線:如果它收到兩次帶相同冪等 key 的
CancelFlightReservation,第二次就是 no-op。Saga 沒有參與者層面的冪等就玩不轉。
一筆沒有補償的前向步驟,是一種設計異味
如果你在 Saga 裡發現一筆前向步驟沒有乾淨的補償,那這步大概不屬於這個 Saga——「我們沒法撤銷」對應的失敗模式不是「加大力度補償」,而是「在不可逆不再是問題之前,不要執行那個不可逆的動作」。真實例子:ActivateInsurancePolicy 步驟有一個補償(CancelPolicy),但前提是啟動之後還有一個 pending 視窗。過了視窗就沒有補償。修法是把啟動保持在一個有受控延遲的狀態機裡,而不是圍繞不可逆的情形寫一段聰明的 Saga。
部分補償的邊界情況——退款減去取消費、部分反轉、不可逆副作用步驟——本身就是一個豐富的話題,有不能乾淨歸入上述「完全撤銷」模型的失敗模式。本系列會有後續文章專門講補償語意——具體的位置見文末相關模式一節。完全撤銷假設是當下理解 Saga 實作的承重假設;部分補償是它的嚴格超集,需要更豐富的狀態模型。
實作策略:三種形態
模式是形狀,不是產品。下面這三種形態是生產程式碼裡會看到的——Temporal、Camunda、AWS Step Functions 都落在第三種;前兩種是手寫的。
形態 1:手寫編排器
最簡單、能上生產的實作是一個編排器服務,它把 Saga 的步驟圖跑在真實的參與者服務之上。Saga 日誌是編排器資料庫裡的一張表。步驟失敗觸發反向執行的補償。
// saga/orchestrator.ts —— 一個 90 行的 Saga 編排器,含重試、退避、
// 冪等和 Saga 日誌。生產程式碼會把 saga_log 持久化到資料庫;這裡的
// 形狀是承重的部分,不是線纜格式。
type SagaStep = {
readonly id: string;
execute: () => Promise<{ ok: true; result: unknown } | { ok: false; reason: string }>;
compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>;
};
type SagaLog = {
saga_id: string;
completed: string[]; // 依完成順序排列的步驟 id
compensations_run: string[];
state: 'started' | 'forward' | 'compensating' | 'completed' | 'failed';
};
async function runSaga(
steps: readonly SagaStep[],
log: SagaLog,
retry: { maxAttempts: number; backoffMs: (n: number) => number } = {
maxAttempts: 5,
backoffMs: n => Math.min(30_000, 500 * 2 ** n),
},
): Promise<{ ok: true } | { ok: false; failedStep: string }> {
for (const step of steps) {
let attempt = 0;
let result: Awaited<ReturnType<SagaStep['execute']>>;
for (;;) {
result = await step.execute();
if (result.ok) break;
attempt++;
if (attempt >= retry.maxAttempts) {
// 標記進入補償並落穿。
log.state = 'compensating';
await compensateInReverse(steps, log, retry);
return { ok: false, failedStep: step.id };
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
log.completed.push(step.id);
}
log.state = 'completed';
return { ok: true };
}
async function compensateInReverse(
steps: readonly SagaStep[],
log: SagaLog,
retry: SagaParameters['retry'],
): Promise<void> {
for (const step of [...log.completed].reverse().map(id => steps.find(s => s.id === id)!)) {
let attempt = 0;
for (;;) {
const r = await step.compensate();
if (r.ok) { log.compensations_run.push(step.id); break; }
attempt++;
if (attempt >= retry.maxAttempts) {
log.state = 'failed';
return; // saga-for-the-saga
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
}
log.state = 'failed';
}
type SagaParameters = Parameters<typeof runSaga>[2];
// 用法:
const steps: SagaStep[] = [
{ id: 'flight', execute: () => flight.reserve(...), compensate: () => flight.cancel(...) },
{ id: 'hotel', execute: () => hotel.reserve(...), compensate: () => hotel.cancel(...) },
{ id: 'car', execute: () => car.reserve(...), compensate: () => car.cancel(...) },
];
const log: SagaLog = { saga_id: 'trip-001', completed: [], compensations_run: [], state: 'started' };
const result = await runSaga(steps, log);
形狀是:一個步驟迭代器、每步一個重試/退避迴圈、一份由 Saga 日誌驅動的補償迴圈,以及日誌本身的一台狀態機。真正的實作會把 log 放進有樂觀並發的資料庫行;這段程式碼是概念骨架,不是生產檔案。加上 Saga 日誌持久化、錯誤落到死信佇列、再加一層給卡死補償的 meta-Saga,你就有一個能上生產的手寫編排器。
形態 2:事件驅動的協同
協同這種形態是 沒有編排器。每個參與者服務擁有 Saga 的自己那段,對訊息代理上的事件做出反應。Saga 的狀態是分散式的。
// flight-service.ts —— 協同式預訂 Saga 中的航班參與者。
// 訂閱 TripBookingRequested;發出 FlightReserved 或
// FlightReservationFailed。
import { EventEmitter } from 'node:events';
type TripBookingRequested = {
saga_id: string;
leg_id: string;
flight_offer: { airline: string; flight_no: string; iso: string };
trace: string; // 用於分散式追蹤的關聯 id
};
type SagaEvent =
| { type: 'TripBookingRequested'; payload: TripBookingRequested }
| { type: 'FlightReserved'; payload: { saga_id: string; conf: string } }
| { type: 'FlightReservationFailed'; payload: { saga_id: string; reason: string } }
| { type: 'TripBookingCancelled'; payload: { saga_id: string } };
const bus = new EventEmitter();
bus.on('TripBookingRequested', async (evt) => {
if (evt.payload.leg_id !== 'flight') return;
try {
const conf = await flightProvider.reserve(evt.payload.flight_offer, evt.payload.trace);
bus.emit('saga', { type: 'FlightReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('saga', {
type: 'FlightReservationFailed',
payload: { saga_id: evt.payload.saga_id, reason: String(err) },
} satisfies SagaEvent);
}
});
bus.on('TripBookingCancelled', async (evt) => {
const log = await readCompensationLog(evt.payload.saga_id);
if (!log.flightReserved) return;
try {
await flightProvider.cancel(log.conf, evt.payload.saga_id /* idempotency key */);
bus.emit('saga', { type: 'FlightReservationCancelled', payload: { saga_id: evt.payload.saga_id } } satisfies SagaEvent);
} catch (err) {
// 重試由代理的 redelivery + 死信佇列處理。
throw err;
}
});
emit('FlightReserved', { saga_id, conf });
這裡的承重形狀是:事件先向前流動,然後補償事件向回流動。每個消費者有自己一套冪等故事。所謂「Saga 狀態」,是任何一個消費者能從自己看到的事件裡推算出來的部分;不存在全域視圖。
// hotel-service.ts —— 對 FlightReserved 和 CarReservationFailed
// 做出反應的飯店參與者。這是從第二步內部看到的協同式 Saga 長這樣。
bus.on('FlightReserved', async (evt) => {
const saga = await readSaga(evt.payload.saga_id);
if (saga.legs.includes('hotel') && !saga.hotelReserved) {
try {
const conf = await hotelProvider.reserve(saga.hotelOffer, evt.payload.saga_id);
bus.emit('saga', { type: 'HotelReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('TripBookingCancelled', { payload: { saga_id: evt.payload.saga_id, reason: String(err) } } satisfies never as never as any);
// 生產裡還要發 FlightReservationFailed,讓航班補償器也能跑。
}
}
});
協同的人體工學代價恰好就在這裡:每個參與者都要知道要對哪些事件反應、對哪些事件忽略,而 Saga 裡每加一步,都要改其它每個消費者的邏輯。編排器把這表達成一個清單;協同把它表達成一張圖。
形態 3:工作流引擎(Temporal、Camunda、Step Functions)
工作流引擎把編排器外化出來。引擎持久化 Saga 日誌、排程重試、並開箱即用地提供對執行中 Saga 的可觀測性。團隊寫 Saga 的邏輯;引擎負責跑。
// saga.workflow.ts —— 同一個預訂 Saga 的 Temporal workflow。
// 注意:函式體就是一個普通的 async;workflow 拋出時,
// Temporal 會自動處理重試、Saga 日誌、逾時和補償呼叫。
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as acts from './activities';
const { reserveFlight, cancelFlight, reserveHotel, cancelHotel, reserveCar, cancelCar } =
proxyActivities<typeof acts>({
startToCloseTimeout: '30s',
retry: { maximumAttempts: 5, backoffCoefficient: 2 },
});
export async function bookTripSaga(legs: TripLegs): Promise<Trip> {
const conf: Partial<Trip> = {};
try {
conf.flight = await reserveFlight(legs.flight);
conf.hotel = await reserveHotel(legs.hotel);
conf.car = await reserveCar(legs.car);
return conf as Trip;
} catch (err) {
// Temporal 的 Saga 補償 API 負責反向呼叫。
// 在補償模式下,fail() 不會被重試——引擎
// 把這次補償記成 Saga 的一步。
if (ApplicationFailure.hasType(err, 'CarReservationFailed')) {
await Promise.allSettled([
conf.hotel && cancelHotel(conf.hotel),
conf.flight && cancelFlight(conf.flight),
]);
}
throw err;
}
}
這是和形態 1 一樣的形狀,只是編排器的「Saga 日誌」是 Temporal 的狀態儲存,「重試」是 activity 的重試策略,「補償」是 workflow 的 catch 塊。生產團隊在發現手寫版本那些失敗模式(Saga 日誌持久化、可觀測性、跨部署存活的長 Saga)開始比引擎本身還貴時,就會轉向引擎。
| 形態 | 什麼時候伸手拿它 | 權衡 |
|---|---|---|
| 手寫編排器 | 一個團隊,流程簡單,並且想讀懂 Saga 程式碼的每一行 | Saga 日誌的持久化、可觀測性、恢復故事都得自己扛 |
| 協同 | 參與者由不同團隊擁有;Saga 天生就是鬆耦合 | 可觀測性成本;跨團隊的事件 schema 協調 |
| 工作流引擎 | 長執行 Saga(數小時到數天)、Saga 變體數量大、需要扛過部署的持久執行 | 增加一個供應商 / 自架設依賴;引擎特定概念 |
權衡與可觀測性
Saga 模式不是免費的。三條權衡、三個可觀測面。
權衡
- 運維複雜度。 Saga 每一步要監控三件事(前向成功、補償成功、卡死補償)、每一步要有一份重試策略、Saga 日誌還得扛住進程重啟和資料庫遷移。單體有一筆事務;Saga 有一套運維流程。
- 最終一致性視窗。 在 Saga 前向步驟成功的那一刻,到所有下游服務看到結果狀態之間,Saga 是 不一致的。視窗可以短到毫秒級(同步編排器、快的代理),也可以長到分鐘級(協同、人工介入)。業務必須能對橫跨視窗的查詢進行推理——「航班訂上了嗎?」 這個問題的答案,要看飯店服務是否已經看到這次 Saga。
- 耦合權衡。 編排把 編排器 跟每個參與者的契約耦合在一起;協同把 每個參與者 跟其它每個參與者的事件 schema 耦合在一起。挑你想站的那一邊。
可觀測性
決定 Saga 在生產裡生死的三個觀測面:
- 每一步的冪等 key。 每次前向呼叫和每次補償都帶
idempotency_key = hash(saga_id, step_id, "compensation"|"forward")。按冪等 key 搜尋就能看到完整的 Saga 狀態,即使 Saga 日誌殘缺不全。 - 關聯 ID。 Saga 步驟的每一條日誌、追蹤和指標,都帶
saga_id和trace_id。單一查詢應該能從日誌和追蹤裡重建出 Saga 的歷史,不需要去查 Saga 日誌。 - Saga 狀態儀表板。 按狀態(
started、forward、compensating、completed、failed、stuck)對 Saga 計數,看時間趨勢。卡死補償是最重要的指標——它就是需要人介入的那種。
如果你的監控裡看不到卡死 Saga 狀態計數,你就沒有 Saga 可觀測性
一份沒暴露在 Runbook 裡的 Saga 日誌是不可見的。最簡單的儀表板是兩個計數器:前向進度(最近一小時 started → completed)和補償進度(最近一小時 compensating → compensated)。第二個數字飆起來說明有問題;第一個數字走平說明有更大的問題。
決策矩陣
伸手拿那個形狀合得上的變體。八條規則,每條一條試金石。
決策矩陣——編排 vs 協同,八條規則
規則 1 —— 當 Saga 的邏輯歸一個團隊所有時,伸手拿編排。 如果有一個團隊同時擁有工作流的所有步驟 和 每一個參與者服務,編排器把 Saga 表達成一個清單,團隊能讀懂它。耦合成本是局部的;可觀測性在一處。
規則 2 —— 當參與者歸多個團隊所有時,伸手拿協同。 如果航班服務、飯店服務、租車服務由各自獨立部署節奏的不同團隊所有,沒有編排器屬於它們中的任何一個。協同的事件契約是唯一誠實的 API 表面。
規則 3 —— 當 Saga 狀態必須全域可觀測時,伸手拿編排。 如果你需要用一次儀表板查詢回答 「現在有多少 Saga 卡在補償?」,編排的 Saga 日誌免費送你這個答案。協同給你的是一道分散式拼裝題。
規則 4 —— 當 Saga 沒有天然的「指揮者」服務時,伸手拿協同。 有些 Saga 沒有明顯的編排器候選——加一個就得寫一個新服務並負責運維。協同起步便宜,讓參與者各自獨立演進。
規則 5 —— 當 Saga 跑數小時到數天時,伸手拿工作流引擎。 手寫編排器扛不住部署。引擎(Temporal、Camunda、Step Functions)把 Saga 日誌持久化過部署、叢集故障、操作失誤;引擎成本靠你不用再處理的事故賺回來。
規則 6 —— 只有當你的事件帶全狀態時,才伸手拿協同。
協同式 Saga 的 FlightReserved 事件必須帶足飯店服務做它的事 而不回呼航班服務 所需的資訊。協同 + 多回呼 = 換了皮的編排,而且更糟。
規則 7 —— 當補償很常見時,伸手拿編排。 如果補償路徑在生產裡跑得比前向路徑還多,「每個消費者對事件做出反應」的成本就會變成每次改動的稅。編排把補償路徑變成一次函式呼叫。
規則 8 —— 當你的團隊已經在事件溯源上投入時,伸手拿協同。 協同 天然 建立在事件溯源系統之上——每一個狀態變化都已經是事件,每個消費者從同一份日誌讀。在沒有事件的有狀態資料庫上做協同,是彆扭且慢的。
規則 0(承重的那條)—— 當單一 ACID 事務能罩住時,兩個都不要拿。 如果一筆預訂的五段都能住在一個資料庫、一次連線裡,那 Saga 就是過度設計。Saga 給你換一種能力;用不到的時候別為它付費。
讀一遍,存到團隊的 wiki 裡,你下一次範圍裡的 Saga 特性就能一眼找到它該走的變體。
決策的兩軸圖
流程圖先看前三個問題向下讀;如果沒有清晰訊號就預設「兩種都行」。單團隊訊號是最強的;Saga 時長訊號壓過可觀測性,因為沒有哪個手寫編排器能跨部署活過一筆多日 Saga。
結語
Saga 模式就是當 ACID 跨不過服務邊界時協調工作長成的樣子。兩個變體——編排和協同——是同一種形狀的兩種控制邏輯:一個說「做這個,做這個,再做這個」;另一個說「對這個反應,對那個反應」。它們承擔相同的重量:前向事務配上補償,持久化在一份能恢復的 Saga 日誌裡,靠關聯 ID 和卡死 Saga 計數器觀測,前面接一套根據約束挑變體的決策程序。
和 艙壁模式 合在一起——它隔離故障——Saga 模式協調恢復。兩個模式是同一道答案的兩半:艙壁擋住爆炸,Saga 寫出劇本。本系列會有後續文章專門講補償語意,包括部分補償和不可逆副作用步驟;留意 saga-compensation-semantics 這一篇。
相關模式
每一個真正在用的 Saga,最終都至少還需要本系列裡的另外兩個模式。艙壁是架構上的前置條件;Circuit Breaker(斷路器) 和 Outbox Pattern(發件箱模式) 是運維原語。
- 艙壁模式 —— 反向引用。艙壁隔離故障,Saga 協調恢復。把它們配對:艙壁放在每個 Saga 步驟的執行緒池裡;Saga 跨 Saga 的各個服務。
- Circuit Breaker 模式(斷路器模式) —— 每一個呼叫外部服務的 Saga 步驟都需要斷路器,這樣某個壞掉的參與者不會把 Saga 的重試拖入死亡螺旋。
- Outbox Pattern(發件箱模式) (向前引用) —— 每一個協同式 Saga 的事件發佈都需要發件箱,這樣本地事務和事件發佈不會失同步。
- saga-compensation-semantics(Saga 補償語意) (向前引用) —— 部分補償、退款視窗、不可逆副作用步驟——經典「完全撤銷」模型沒能覆蓋的情況。留給另一篇單獨的文章。
留言
請接受「功能性」Cookie 類別以查看和發表留言。
留言載入失敗。您可以重試,或前往 GitHub 查看討論。
在 GitHub 上查看