前面已經開始用 TypeORM 操作 PostgreSQL,也完成了 User 的註冊流程。
剛開始寫 API 時,一個功能通常只處理一件事情,建立資料、修改資料、刪除資料,各自分開看都很單純。功能慢慢增加之後,一個 API 背後可能會接連做很多次資料庫操作,原本的一個動作,也可能變成好幾個步驟。
拿轉帳來說,A 要轉 100 元給 B,系統需要完成扣款和入帳兩件事:
A 帳戶扣 100 元
B 帳戶增加 100 元
兩個動作都完成,這筆轉帳才算成立。假設 A 已經扣掉 100 元,B 卻沒有收到,資料庫裡就會留下不完整的結果。後端很怕這種只完成一半的狀況,Transaction 就是拿來處理這類問題。
資料庫裡的操作會按照順序執行,前一個操作成功之後,資料可能已經寫進資料庫,後一個操作再發生錯誤,前面的修改並不會自己消失。
轉帳可能變成:
A 帳戶扣 100 元
↓
成功
↓
B 帳戶增加 100 元
↓
發生錯誤
最後 A 少了 100 元,B 卻沒有增加,原本完整的一筆轉帳就被拆成了兩個結果。
註冊和建立訂單也可能碰到類似的狀況。註冊時 User 已經建立,後面的相關資料卻沒有成功;訂單建立完成,庫存卻還沒扣掉。每一步單獨看都沒有什麼問題,放進同一個流程裡,最後就可能留下不完整的資料。
有些操作本來就屬於同一件事情,不能只完成其中一段。這也是 Transaction 存在的原因。
Transaction 會把多個資料庫操作放進同一組,讓它們一起完成。還是用轉帳來看:
開始 Transaction
↓
A 扣 100 元
↓
B 加 100 元
↓
全部成功
↓
保存
兩個步驟都順利完成,這次修改才會留下來。中間如果發生錯誤,整組操作就會撤回:
開始 Transaction
↓
A 扣 100 元
↓
B 加 100 元
↓
發生錯誤
↓
撤回這次操作
A 剛剛扣掉的 100 元也會一起撤回,資料就不會停在只完成一半的狀態。
Transaction 的核心概念就是把原本分開的資料庫操作綁在一起,整組成功就留下來,中間出錯就一起撤回。
Transaction 裡常會看到 Commit 和 Rollback 這兩個詞。Commit 代表這組操作完成,把結果保存下來;Rollback 則是在中途發生錯誤時,把這次操作撤回。
全部成功
→ Commit
→ 保存
中途失敗
→ Rollback
→ 撤回
掌握這個關係,就能看懂最基本的 Transaction 流程。
前面操作資料庫時,我們會直接使用 Repository,例如:
await userRepository.save(user);
當幾個資料庫操作需要一起完成,就可以使用 TypeORM 提供的 transaction(),把它們放在同一個 Transaction 裡:
await AppDataSource.transaction(async (manager) => {
await manager.save(User, user);
await manager.save(UserSetting, setting);
});
transaction() 裡面的操作會被放在同一個 Transaction 裡。全部順利執行,就提交這次修改;中途發生錯誤,這組資料庫操作就會撤回。
進入 Transaction 後,資料庫操作要使用傳進來的 manager,像上面的 manager.save() 就是在這個 Transaction 裡操作資料庫。現在先把 manager 想成「這次 Transaction 裡負責操作資料庫的工具」就好,其他細節留到之後再慢慢認識。
前面做過的 Register,如果流程只有確認資料、Hash 密碼,再建立一筆 User,這種情況通常不需要特別加入 Transaction,因為整段流程只有一個主要的資料庫寫入操作:
const user = await userRepository.save({
email,
password: hashedPassword,
});
假設之後註冊流程增加了其他資料,除了建立 User,還需要一起建立一份預設設定:
建立 User
建立 User Settings
原本可能會分開執行:
const user = await userRepository.save({
email,
password: hashedPassword,
});
await userSettingRepository.save({
userId: user.id,
theme: "light",
});
第一個操作完成後,User 已經存在資料庫。第二個操作如果失敗,User 還是會留下來,註冊流程也就停在中間。
把這兩個操作放進 Transaction:
async function registerUser(
email: string,
password: string
) {
const hashedPassword = await bcrypt.hash(password, 10);
return AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, {
email,
password: hashedPassword,
});
await manager.save(UserSetting, {
userId: user.id,
theme: "light",
});
return user;
});
}
這次註冊會先完成密碼 Hash,再進入 Transaction,接著建立 User 和 User Settings。兩個操作都成功,資料才會留下來;建立設定時發生錯誤,前面的 User 也會一起撤回。
不是每個 API 都需要 Transaction。單純建立一筆 User,只有一個主要的資料庫操作:
await userRepository.save(user);
這種情況通常不用特別加入 Transaction。
比較適合使用 Transaction 的,是一個功能會連續修改好幾筆資料,而且少了其中一步,最後的結果就會不完整。
例如建立訂單時,可能會依序建立訂單、建立訂單明細,再扣除庫存:
建立訂單
建立訂單明細
扣除庫存
轉帳則需要同時修改兩個帳戶:
A 帳戶扣款
B 帳戶入帳
這些步驟放在一起,才算完成一件完整的事情。只要其中一個步驟失敗,就不適合讓前面的資料繼續留下來,因此這類流程就很適合使用 Transaction。
剛開始寫 API 時,資料庫操作通常一個一個看,功能變多之後,才會慢慢碰到好幾個操作需要一起完成的情況。其中一步失敗,前面的資料卻還留在資料庫裡,整個流程就可能停在一個不完整的狀態。
Transaction 做的事情很單純,把這些需要一起完成的操作放在同一組,全部成功就保存,中間出錯就撤回,讓資料維持完整。
後端做到後面,要注意的就不只是功能有沒有跑完,資料最後留下來的狀態也同樣重要。