iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

茶室裡,洛基翻開筆記本,指著昨天記下的 Hippo 的比喻:「手動檔汽車。」

大師正在泡茶,抬起頭看了他一眼。

「昨天 Hippo 說 DynamoDB 的 Transaction 像手動檔汽車,」洛基說,「我一直在想,具體要怎麼『換檔』?」

大師把茶杯遞給他:「先說說你理解的問題是什麼。」

洛基在筆記本上寫下兩個場景:

場景 1:報名時寫入失敗
- 第一次寫入:OFFICER#alice -> EVENT#conference ✅
- 網路中斷...
- 第二次寫入:EVENT#conference -> OFFICER#alice ❌
→ Alice 看到已報名,但活動名單沒有她

場景 2:活動改名時更新失敗
- 更新活動主記錄 ✅
- 更新 Alice 的報名記錄 ✅
- 更新 Bob 的報名記錄 ❌
- Charlie、David... (剩下 98 人還沒更新)
→ 有些人看到新名稱,有些人看到舊名稱

他抬起頭:「雙向查詢需要保證兩次寫入的一致性,淺層重複需要保證批量更新的一致性。」

「對,」大師說,「這兩個問題,本質上都是同一件事——」

洛基接話:「多個操作要麼全部成功,要麼全部失敗。」

回顧:第 7 天的擔憂成真了

大師走到白板另一側,指著之前的筆記:「還記得第 7 天嗎?」

洛基想了想:「多視角設計...」他翻開筆記本,找到那一頁,「當時提到一致性風險。」

「第 7 天我們發現了問題,」大師說,「今天要解決它。」

SQL 的一致性是如何實現的

大師先對比 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);
  }
}

洛基看著這段程式碼,沒有說話。他在腦中推演:第一個寫入成功,然後網路中斷,第二個寫入失敗...

大師等他想清楚。

「資料庫會處於不一致狀態,」洛基最後說,「軍官的記錄有,活動的記錄沒有。」

「在分散式系統中,」大師說,「這種問題更常見。」

洛基點頭:「那解決方案是什麼?」

DynamoDB 的 Transaction

大師展示 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 陣列裡...」他抬起頭,「這樣就能保證原子性?」

「是的,」大師說,「要麼兩個都成功,要麼兩個都失敗。」

測試 Transaction 的保護

洛基立刻測試:

// 測試 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

洛基點頭:「正常情況下可以保證一致。」他停頓,「那如果其中一個操作本身有問題,比如重複報名?」

Transaction 的失敗回滾

「讓我們測試失敗的情況,」大師說。

// 測試 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

大師做對比:

SQL 的 Transaction:
- 預設:所有操作都在 Transaction 中
- 範圍:可以包含任意多個操作
- 使用:BEGIN -> 操作 -> COMMIT/ROLLBACK
- 感知:開發者幾乎感覺不到它的存在

DynamoDB 的 Transaction:
- 預設:每個操作都是獨立的
- 範圍:最多 25 個操作,總大小 < 4MB
- 使用:明確呼叫 transactWrite
- 感知:需要明確設計

洛基看著對比,思考了一會兒:「SQL 的 Transaction 像自動駕駛,你不用管它怎麼運作。DynamoDB 的 Transaction 像手動檔,你得明確告訴它什麼時候啟動。」

「而且有限制,」大師補充。

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年),程式碼範例為確保正確執行,使用對應的西元年份。


上一篇
Day 20:多對多關係的雙向查詢
系列文
DynamoDB銀河傳說首部曲-打造宇宙都打不倒的高效服務21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言