茶室裡,洛基翻開筆記本,指著昨天記下的 Hippo 的比喻:「手動檔汽車。」
大師正在泡茶,抬起頭看了他一眼。
「昨天 Hippo 說 DynamoDB 的 Transaction 像手動檔汽車,」洛基說,「我一直在想,具體要怎麼『換檔』?」
大師把茶杯遞給他:「先說說你理解的問題是什麼。」
洛基在筆記本上寫下兩個場景:
場景 1:報名時寫入失敗
- 第一次寫入:OFFICER#alice -> EVENT#conference ✅
- 網路中斷...
- 第二次寫入:EVENT#conference -> OFFICER#alice ❌
→ Alice 看到已報名,但活動名單沒有她
場景 2:活動改名時更新失敗
- 更新活動主記錄 ✅
- 更新 Alice 的報名記錄 ✅
- 更新 Bob 的報名記錄 ❌
- Charlie、David... (剩下 98 人還沒更新)
→ 有些人看到新名稱,有些人看到舊名稱
他抬起頭:「雙向查詢需要保證兩次寫入的一致性,淺層重複需要保證批量更新的一致性。」
「對,」大師說,「這兩個問題,本質上都是同一件事——」
洛基接話:「多個操作要麼全部成功,要麼全部失敗。」
大師走到白板另一側,指著之前的筆記:「還記得第 7 天嗎?」
洛基想了想:「多視角設計...」他翻開筆記本,找到那一頁,「當時提到一致性風險。」
「第 7 天我們發現了問題,」大師說,「今天要解決它。」
大師先對比 SQL:
-- SQL 的多對多報名(單一中介表)
INSERT INTO Registrations (officer_id, event_id, role)
VALUES ('alice', 'science-conference', 'speaker');
-- 一次寫入,ACID 自動保證一致性
洛基看著這段 SQL:「一個 INSERT,一筆資料。」他停頓,「要麼成功,要麼失敗,不會有中間狀態。」
「ACID 的原子性,」大師說,「你幾乎感覺不到它的存在,因為它是內建的。」
「但 DynamoDB 的雙向查詢...」洛基接話,「需要兩次寫入。」
// DynamoDB 的雙向查詢(兩次寫入)
await docClient.put({ /* 項目 1 */ }).promise(); // 可能成功
await docClient.put({ /* 項目 2 */ }).promise(); // 可能失敗
// 中間可能發生:網路中斷、服務錯誤、容量限制...
大師展示一個災難場景:
// 災難場景:部分失敗
async function registerWithoutTransaction(officerId, eventId, role) {
try {
// 寫入項目 1:成功 ✅
await docClient.put({
TableName: 'IntergalacticEvents',
Item: {
PK: `OFFICER#${officerId}`,
SK: `EVENT#${eventId}`,
role: role,
// ...
}
}).promise();
console.log('項目 1 寫入成功');
// 模擬網路問題
throw new Error('Network timeout');
// 寫入項目 2:失敗 ❌
await docClient.put({
TableName: 'IntergalacticEvents',
Item: {
PK: `EVENT#${eventId}`,
SK: `OFFICER#${officerId}`,
role: role,
// ...
}
}).promise();
} catch (error) {
console.log('錯誤:', error.message);
}
}
洛基看著這段程式碼,沒有說話。他在腦中推演:第一個寫入成功,然後網路中斷,第二個寫入失敗...
大師等他想清楚。
「資料庫會處於不一致狀態,」洛基最後說,「軍官的記錄有,活動的記錄沒有。」
「在分散式系統中,」大師說,「這種問題更常見。」
洛基點頭:「那解決方案是什麼?」
大師展示 Transaction 的用法:
// 使用 Transaction 保證原子性
async function registerWithTransaction(officerId, eventId, role) {
const registrationDate = new Date().toISOString().split('T')[0];
await docClient.transactWrite({
TransactItems: [
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: `OFFICER#${officerId}`,
SK: `EVENT#${eventId}`,
role: role,
registeredDate: registrationDate
}
}
},
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: `EVENT#${eventId}`,
SK: `OFFICER#${officerId}`,
role: role,
registeredDate: registrationDate
}
}
}
]
}).promise();
// 兩個項目:要麼都成功,要麼都失敗!
}
洛基盯著 transactWrite
這個函數名,看了看程式碼結構。「把兩個 Put 操作包在 TransactItems 陣列裡...」他抬起頭,「這樣就能保證原子性?」
「是的,」大師說,「要麼兩個都成功,要麼兩個都失敗。」
洛基立刻測試:
// 測試 1:正常情況
async function test1() {
try {
await registerWithTransaction('alice', 'science-conference', 'speaker');
console.log('✅ 報名成功');
// 驗證:兩邊都有資料
const officerEvents = await getOfficerEvents('alice');
const eventOfficers = await getEventOfficers('science-conference');
console.log('軍官的活動:', officerEvents.length); // 1
console.log('活動的軍官:', eventOfficers.length); // 1
} catch (error) {
console.log('❌ 報名失敗:', error.message);
}
}
執行結果:
✅ 報名成功
軍官的活動:1
活動的軍官:1
洛基點頭:「正常情況下可以保證一致。」他停頓,「那如果其中一個操作本身有問題,比如重複報名?」
「讓我們測試失敗的情況,」大師說。
// 測試 2:防止重複報名
async function test2() {
try {
// 第一次報名:成功
await docClient.transactWrite({
TransactItems: [
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: 'OFFICER#bob',
SK: 'EVENT#tech-summit',
role: 'attendee'
},
// 條件:此組合鍵不存在(防止重複)
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
},
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: 'EVENT#tech-summit',
SK: 'OFFICER#bob',
role: 'attendee'
},
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
}
]
}).promise();
console.log('✅ 第一次報名成功');
// 第二次報名:嘗試重複報名
await docClient.transactWrite({
TransactItems: [
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: 'OFFICER#bob',
SK: 'EVENT#tech-summit', // 已存在!
role: 'speaker'
},
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
},
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: 'EVENT#tech-summit',
SK: 'OFFICER#bob',
role: 'speaker'
},
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
}
]
}).promise();
console.log('第二次報名成功'); // 不會執行到這裡
} catch (error) {
console.log('❌ 第二次報名失敗:', error.message);
// 驗證:資料庫狀態正確
const officerEvents = await getOfficerEvents('bob');
const eventOfficers = await getEventOfficers('tech-summit');
console.log('Bob 的活動數量:', officerEvents.length); // 1(不是 2)
console.log('Tech Summit 的參與者:', eventOfficers.length); // 1
}
}
執行結果:
✅ 第一次報名成功
❌ 第二次報名失敗:Transaction cancelled, please refer cancellation reasons for specific reasons
Bob 的活動數量:1
Tech Summit 的參與者:1
洛基看著結果,慢慢說:「第一次報名成功,兩邊都有記錄。第二次因為 ConditionExpression 檢查到已存在,整個 Transaction 被取消。」他抬起頭,「所以資料還是一致的,Bob 只有一筆報名記錄。」
大師做對比:
SQL 的 Transaction:
- 預設:所有操作都在 Transaction 中
- 範圍:可以包含任意多個操作
- 使用:BEGIN -> 操作 -> COMMIT/ROLLBACK
- 感知:開發者幾乎感覺不到它的存在
DynamoDB 的 Transaction:
- 預設:每個操作都是獨立的
- 範圍:最多 25 個操作,總大小 < 4MB
- 使用:明確呼叫 transactWrite
- 感知:需要明確設計
洛基看著對比,思考了一會兒:「SQL 的 Transaction 像自動駕駛,你不用管它怎麼運作。DynamoDB 的 Transaction 像手動檔,你得明確告訴它什麼時候啟動。」
「而且有限制,」大師補充。
大師列出重要限制:
// DynamoDB Transaction 的限制
const transactionLimits = {
最多操作數: 25,
總大小限制: '4 MB',
不支援: ['Scan', 'Query'],
只支援: ['Get', 'Put', 'Update', 'Delete', 'ConditionCheck'],
最佳實踐: [
'只在必要時使用(有一致性需求)',
'保持 Transaction 精簡(操作數越少越好)',
'使用條件表達式防止衝突',
'處理 TransactionCanceledException'
]
};
洛基看著這些限制,特別是「最多 25 個操作」和「總大小 < 4MB」。「為什麼會有這些限制?」
「分散式系統的代價,」大師說,「要確保多個操作的原子性,需要額外的協調機制。限制數量和大小,是為了確保 Transaction 能在合理時間內完成。」
洛基點頭:「所以不能濫用。」
「只在真正需要一致性的地方使用,」大師說。
洛基整合所有學習,實作完整的報名功能。
「讓我試試看,」他開始寫程式碼。
// 完整的報名系統
async function completeRegistrationSystem(officerId, eventId, role) {
// 先查詢資訊
const [officer, event] = await Promise.all([
docClient.get({
TableName: 'IntergalacticEvents',
Key: { PK: `OFFICER#${officerId}`, SK: 'METADATA' }
}).promise(),
docClient.get({
TableName: 'IntergalacticEvents',
Key: { PK: `EVENT#${eventId}`, SK: 'METADATA' }
}).promise()
]);
if (!officer.Item || !event.Item) {
throw new Error('軍官或活動不存在');
}
const registrationDate = new Date().toISOString().split('T')[0];
// 使用 Transaction 寫入雙向查詢的兩個項目
await docClient.transactWrite({
TransactItems: [
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: `OFFICER#${officerId}`,
SK: `EVENT#${event.Item.eventDate}#${eventId}`,
role: role,
registeredDate: registrationDate,
// 淺層重複:活動的基本資訊
eventName: event.Item.eventName,
eventDate: event.Item.eventDate,
eventLocation: event.Item.location
// 不包含:活動描述、講者陣容、狀態
},
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
},
{
Put: {
TableName: 'IntergalacticEvents',
Item: {
PK: `EVENT#${eventId}`,
SK: `OFFICER#${registrationDate}#${officerId}`,
role: role,
registeredDate: registrationDate,
// 淺層重複:軍官的基本資訊
officerName: officer.Item.name,
officerRank: officer.Item.rank,
officerEmail: officer.Item.email
// 不包含:完整履歷、獎章記錄
},
ConditionExpression: 'attribute_not_exists(PK) AND attribute_not_exists(SK)'
}
}
]
}).promise();
return {
message: `${officer.Item.name} 成功報名 ${event.Item.eventName}`,
registration: {
officer: officer.Item.name,
event: event.Item.eventName,
role: role,
date: registrationDate
}
};
}
寫完,洛基看著這段程式碼。大師走過來看了看。
「這裡包含的欄位,」大師指著 eventName、eventDate、eventLocation,「是昨天決定的淺層重複策略?」
洛基點頭:「列表顯示需要的基本資訊,更新頻率低。活動狀態不包含,因為改變太頻繁。」
「很好,」大師說,「你已經理解如何整合這兩天學的東西了。」
洛基繼續實作取消功能:
// 取消報名(也需要 Transaction)
async function unregisterFromEvent(officerId, eventId) {
// 取得報名日期(需要用於構造完整的 SK)
const registration = await docClient.get({
TableName: 'IntergalacticEvents',
Key: {
PK: `OFFICER#${officerId}`,
SK: `EVENT#${eventId}` // 這裡簡化了,實際要用完整 SK
}
}).promise();
if (!registration.Item) {
throw new Error('未找到報名記錄');
}
const registrationDate = registration.Item.registeredDate;
// 使用 Transaction 刪除兩邊
await docClient.transactWrite({
TransactItems: [
{
Delete: {
TableName: 'IntergalacticEvents',
Key: {
PK: `OFFICER#${officerId}`,
SK: `EVENT#${eventId}`
},
ConditionExpression: 'attribute_exists(PK)'
}
},
{
Delete: {
TableName: 'IntergalacticEvents',
Key: {
PK: `EVENT#${eventId}`,
SK: `OFFICER#${registrationDate}#${officerId}`
},
ConditionExpression: 'attribute_exists(PK)'
}
}
]
}).promise();
return { message: '取消報名成功' };
}
洛基看著自己寫的程式碼,感覺有些不同。這不只是技術的實作,更是對一致性的承諾。
大師在白板上總結第 17-21 天的完整脈絡:
關聯式思維的現實撞擊(第 17 天)
→ SQL 思維在 DynamoDB 中需要重新思考
一對一關係的策略分歧點(第 18 天)
→ 學會第一個設計選擇:嵌入 vs 分離
一對多關係的階層組織(第 19 天)
→ 選擇查詢方向決定資料組織
多對多關係的雙向查詢(第 20 天)
→ 淺層重複策略:平衡查詢與更新
Transaction 保證一致性(第 21 天)
→ 從 SQL 的自動到 DynamoDB 的手動設計
洛基看著這個脈絡,慢慢說:「每一天都在學一個選擇。一對一是選嵌入或分離,一對多是選查詢方向,多對多是選重複深度,今天則是選擇如何保證一致性。」
「設計就是一連串的選擇,」大師說,「關聯關係的基礎,你已經掌握了。GSI、Streams、效能優化...這些進階主題都建立在這五天的基礎上。」
洛基點頭。他明白——先把基礎打穩,才能往上建。
走出茶室,夜已經深了。洛基回想這五天的學習路徑:從 ER 圖的撞擊,到第一個策略選擇,再到理解查詢方向的重要性,然後是淺層重複的平衡,最後學會用 Transaction 保證一致性。
他突然想起自己剛轉到開發工作時的掙扎。那時候他習慣快速決策、立即行動,但常常發現自己走錯了方向。
這五天學的不只是 DynamoDB,更是一種思考方式:先分析需求,再選擇策略,然後面對後果。
「慢下來,想清楚,再行動,」他對自己說。
這不只適用於設計資料庫,也適用於他的人生。
時間設定說明:故事中使用星際曆(SY210 = 西元2210年),程式碼範例為確保正確執行,使用對應的西元年份。