iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0

洛基走進茶室時,諾斯克大師已經在白板上展示一張圖表。

「這是星際政府軍官管理系統的資料模型,」大師說,「今天的任務是:把它轉換成 DynamoDB 的設計。」

洛基仔細看著白板上的 ER 圖:

https://ithelp.ithome.com.tw/upload/images/20251001/20178813Q40IFy8emZ.png

洛基想起第 6 天學到的多視角設計,若有所思地說:「這個系統...我記得您在第 6 天教過我資料重複的策略。所以我應該為不同的查詢需求建立多個視角?」

大師微笑:「你確實記得那個概念。但今天的挑戰不一樣——這次要處理的是複雜的關聯關係,不是單純的活動查詢。」

洛基遲疑了:「我...知道要用多視角,但具體怎麼處理這些關聯...」

「正是,」大師說,「第 6 天你學會了『思維』——資料重複不是問題而是策略。但今天我們要建立『系統方法』——如何面對複雜的 ER 圖,系統性地轉換成 DynamoDB 設計。實作是很好的老師,讓我們從實際動手開始。」

洛基的第一次嘗試

洛基拿起筆記本,開始設計表格結構:

// Officers 表
const officer = {
  PK: 'OFFICER#alice',
  SK: 'METADATA',
  officerId: 'alice',
  name: 'Alice Johnson',
  rank: 'Captain',
  homeplanet: 'Earth'
};

// Missions 表
const mission = {
  PK: 'MISSION#mission001',
  SK: 'METADATA',
  missionId: 'mission001',
  name: 'Mars Exploration',
  status: 'active',
  startDate: '2210-03-15'
};

// Officer_Missions 表(關聯表)
const officerMission = {
  PK: 'OFFICER_MISSION#alice_mission001',
  SK: 'METADATA',
  officerId: 'alice',      // 外鍵
  missionId: 'mission001', // 外鍵
  role: 'commander'
};

洛基抬頭回覆大師:「就我理解,應該是這樣的結構。」

大師問:「現在我要查詢:Alice 參與了哪些任務?」

發現第一個問題:沒有 JOIN

洛基開始寫查詢邏輯:

// 洛基的查詢邏輯
async function getOfficerMissions(officerId) {
  // 步驟 1:查詢 Officer_Missions 關聯表
  const relations = await docClient.query({
    TableName: 'IntergalacticEvents',
    // 等等...怎麼查?PK 是 'OFFICER_MISSION#...'
    // 我要怎麼找到所有 alice 的關聯?
  }).promise();

  // 步驟 2:對每個關聯,查詢任務詳情
  // 但我要怎麼一次查詢多個任務?
  // 而且...這不就是 JOIN 嗎?
}

洛基困惑地停下來:「等等,我要查詢『所有 officerId = alice 的關聯』,但 PK 是 OFFICER_MISSION#alice_mission001...」

大師引導:「你發現問題了?」

「我無法用 officerId 來查詢,」洛基說,「因為 officerId 不是 PK 也不是 SK。」

「對,」大師說,「DynamoDB 只能用 PK(和 SK)來查詢。那你打算怎麼辦?」

洛基想了想:「改變 PK 設計?讓 PK = OFFICER#alice?」

// 洛基的第二版嘗試
const officerMission = {
  PK: 'OFFICER#alice',
  SK: 'MISSION#mission001',
  role: 'commander',
  missionId: 'mission001'  // 還需要這個來取得任務詳情
};

「好多了,」大師說,「現在你可以查詢 Alice 的所有任務關聯。但下一步呢?」

發現第二個問題:需要多次查詢

洛基繼續寫查詢邏輯:

async function getOfficerMissionsV2(officerId) {
  // 步驟 1:查詢所有關聯
  const relations = await docClient.query({
    TableName: 'IntergalacticEvents',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
    ExpressionAttributeValues: {
      ':pk': `OFFICER#${officerId}`,
      ':skPrefix': 'MISSION#'
    }
  }).promise();

  console.log('找到的關聯:', relations.Items);
  // [
  //   { PK: 'OFFICER#alice', SK: 'MISSION#mission001', missionId: 'mission001', role: 'commander' },
  //   { PK: 'OFFICER#alice', SK: 'MISSION#mission002', missionId: 'mission002', role: 'pilot' }
  // ]

  // 步驟 2:對每個任務 ID,查詢任務詳情
  const missionDetails = [];
  for (const relation of relations.Items) {
    const mission = await docClient.get({
      TableName: 'IntergalacticEvents',
      Key: {
        PK: `MISSION#${relation.missionId}`,
        SK: 'METADATA'
      }
    }).promise();

    missionDetails.push({
      ...mission.Item,
      officerRole: relation.role
    });
  }

  return missionDetails;
}

洛基執行測試:

找到的關聯: [
  { PK: 'OFFICER#alice', SK: 'MISSION#mission001', missionId: 'mission001', role: 'commander' },
  { PK: 'OFFICER#alice', SK: 'MISSION#mission002', missionId: 'mission002', role: 'pilot' }
]

查詢任務 mission001...
查詢任務 mission002...

完成!總耗時:45ms

「成功了!」洛基說,但隨即皺眉,「但是...」

大師問:「但是什麼?」

「這需要 3 次資料庫查詢,」洛基說,「1 次查關聯,2 次查任務詳情。如果 Alice 參與了 10 個任務,就需要 11 次查詢!」

「正是,」大師說,「在關聯式資料庫中,JOIN 操作讓你一次查詢就能拿到所有資料。但在 DynamoDB 中沒有 JOIN。」

理解分散式系統的本質限制

大師在白板上畫了一個簡單的圖:

傳統資料庫(單機):
┌─────────────────────┐
│   Officers Table    │
│   Missions Table    │  ← JOIN 在記憶體中完成
│   Relations Table   │     毫秒級速度
└─────────────────────┘

分散式資料庫(多機):
┌──────────┐      ┌──────────┐      ┌──────────┐
│ Officers │      │ Missions │      │ Relations│
│  (Node1) │      │  (Node2) │      │  (Node3) │
└──────────┘      └──────────┘      └──────────┘
     ↓                 ↓                 ↓
     └─────────網路傳輸(100-200ms)─────┘
                     ↓
                JOIN 需要:
                1. 從 Node3 讀取關聯
                2. 跨網路到 Node2 讀取 Missions
                3. 跨網路到 Node1 讀取 Officers
                = 300-600ms(還不包括處理時間)

「這就是為什麼分散式系統避免 JOIN,」大師解釋,「不是做不到,而是太慢、太不穩定。」

洛基問:「那 SQL 資料庫是怎麼做到快速 JOIN 的?」

「因為所有資料在同一台機器的記憶體中,」大師說,「但這意味著無法水平擴展。當資料量增長時...」

Hippo 補充:「當你的資料從 1GB 變成 1TB,單機資料庫就會氣喘噓噓了。但 DynamoDB 可以加機器,從 3 台變成 300 台。」

洛基的頓悟時刻

洛基若有所思:「所以這是一個取捨?」

「正是,」大師說,「讓我們看看這個取捨的本質:」

關聯式資料庫的世界觀:
┌─────────────────────────┐
│ 資料正規化(避免重複)    │
│ JOIN 操作(關聯查詢)    │
│ ACID 交易(強一致性)    │
│ 垂直擴展(加強單機)      │
└─────────────────────────┘
        ↓
    適合場景:
    - 資料量不大(< 1TB)
    - 複雜查詢需求
    - 強一致性要求
    - 單一資料中心

DynamoDB 的世界觀:
┌─────────────────────────┐
│ 資料重複(查詢優化)      │
│ 無 JOIN(單表設計)      │
│ 最終一致性(彈性)        │
│ 水平擴展(加機器)        │
└─────────────────────────┘
        ↓
    適合場景:
    - 大規模資料(TB-PB)
    - 簡單但高頻查詢
    - 可容忍短暫不一致
    - 全球分散部署

洛基問:「所以我不能用關聯式的思維來設計 DynamoDB?」

「不只是不能,」大師強調,「而是必須反過來思考。在關聯式資料庫中,你避免重複資料;在 DynamoDB 中,你擁抱重複資料。」

重新思考設計策略:從第 6 天的領悟到系統方法

大師引導洛基重新審視需求:「讓我們回到最原始的問題:你要查詢什麼?」

洛基列出查詢需求:

查詢需求(Access Patterns):
1. 查詢軍官參與的所有任務
2. 查詢任務的所有參與軍官
3. 查詢軍官在特定任務中的角色

洛基突然想起第 6 天的學習:「等等...我想起來了!在第 6 天,我們設計活動系統時,您說過要『從查詢需求開始設計』,而不是從實體開始。所以我應該把任務的詳細資料直接放在關聯記錄裡?」

「很好,你記起來了,」大師說,「但這次不同的是——第 6 天是單一實體的多視角查詢,而現在是多實體的關聯關係。你需要系統性地思考如何轉換每一種關聯類型。」

// 洛基的頓悟設計
const officerMissionWithDetails = {
  PK: 'OFFICER#alice',
  SK: 'MISSION#2210-03-15#mission001',

  // 關聯資訊
  role: 'commander',
  joinDate: '2210-03-10',

  // 任務詳情(重複資料!)
  missionId: 'mission001',
  missionName: 'Mars Exploration',
  missionStatus: 'active',
  startDate: '2210-03-15',

  // 現在一次查詢就能拿到所有資訊!
};

「對!」大師讚許,「這就是 DynamoDB 的思維方式。」

洛基點頭:「這個模式我在第 6 天有學過——資料重複來優化查詢。但那時只是在活動系統中重複一些基本資訊...現在面對複雜的 ER 關係,我要重複的資料規模和更新的複雜度都高很多。」

「正是,」大師說,「這就是系統性思維的關鍵。第 6 天你理解了『為什麼要重複』,但現在要理解『在複雜關聯中如何系統性地決定重複什麼、不重複什麼』。這就是取捨的藝術。」

深入理解為什麼 JOIN 行不通

Hippo 拿出一個實際的例子:「讓我們算算看成本。」

場景:查詢 Alice 參與的所有任務(Alice 參與了 10 個任務)

方案 A:關聯式設計(需要 JOIN)
┌────────────────────────────────┐
│ 查詢步驟:                      │
│ 1. Query Officer_Missions      │  20ms
│ 2. Get Mission#001              │  15ms
│ 3. Get Mission#002              │  15ms
│ 4. Get Mission#003              │  15ms
│ ... (共 10 次)                  │
│ 總計:20 + 15×10 = 170ms        │
│ 網路請求:11 次                 │
│ 消耗 RCU:1 + 10 = 11          │
└────────────────────────────────┘

方案 B:資料重複設計(無 JOIN)
┌────────────────────────────────┐
│ 查詢步驟:                      │
│ 1. Query OFFICER#alice          │  20ms
│    (返回 10 個帶完整資料的項目)  │
│ 總計:20ms                      │
│ 網路請求:1 次                  │
│ 消耗 RCU:1                    │
└────────────────────────────────┘

效能提升:
- 速度:170ms → 20ms(8.5 倍)
- 請求數:11 → 1(減少 91%)
- 成本:11 RCU → 1 RCU(減少 91%)

洛基訝異地說:「差這麼多!」

「而且這還是樂觀情況,」Hippo 說,「如果網路不穩定,11 次請求中任何一次失敗,整個查詢就失敗。」

大師補充:「在分散式系統中,每次網路請求都是風險。減少請求次數不只是優化,更是可靠性的保證。」

洛基嘗試應用新思維

洛基重新審視整個 ER 圖,開始用新的思維方式設計:

// 洛基的 DynamoDB 設計思維

// Access Pattern 1: 查詢軍官參與的所有任務
{
  PK: 'OFFICER#alice',
  SK: 'MISSION#2210-03-15#mission001',
  role: 'commander',
  missionId: 'mission001',
  missionName: 'Mars Exploration',
  missionStatus: 'active',
  startDate: '2210-03-15'
  // 一次查詢拿到所有資訊
}

// Access Pattern 2: 查詢任務的所有參與軍官
{
  PK: 'MISSION#mission001',
  SK: 'OFFICER#alice',
  role: 'commander',
  officerId: 'alice',
  officerName: 'Alice Johnson',
  officerRank: 'Captain',
  homeplanet: 'Earth'
  // 反向索引,同樣一次查詢解決
}

「我理解了!」洛基說,「每個 Access Pattern 都設計成一次查詢就能完成!」

大師點頭:「這就是 DynamoDB 設計的核心原則。」

面對資料一致性的挑戰

洛基問出了一直困擾他的問題:「但如果任務名稱改了,我要更新多少地方?」

大師拿出實際例子:

// 任務名稱更新的影響分析
async function updateMissionName(missionId, newName) {
  // 需要更新的地方:

  // 1. 任務的主記錄
  await docClient.update({
    TableName: 'IntergalacticEvents',
    Key: { PK: `MISSION#${missionId}`, SK: 'METADATA' },
    UpdateExpression: 'SET missionName = :name',
    ExpressionAttributeValues: { ':name': newName }
  }).promise();

  // 2. 查詢所有參與這個任務的軍官
  const participants = await docClient.query({
    TableName: 'IntergalacticEvents',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
    ExpressionAttributeValues: {
      ':pk': `MISSION#${missionId}`,
      ':skPrefix': 'OFFICER#'
    }
  }).promise();

  // 3. 更新每個軍官視角中的任務記錄
  for (const participant of participants.Items) {
    // participant.SK 包含完整的 SK,如 'OFFICER#alice'
    const officerId = participant.SK.replace('OFFICER#', '');

    // 需要找到對應的 OFFICER -> MISSION 記錄並更新
    // 這裡需要知道完整的 SK (包含日期),通常會在 participant 中儲存
    await docClient.update({
      TableName: 'IntergalacticEvents',
      Key: {
        PK: `OFFICER#${officerId}`,
        SK: `MISSION#${participant.startDate}#${missionId}`  // 使用完整 SK
      },
      UpdateExpression: 'SET missionName = :name',
      ExpressionAttributeValues: { ':name': newName }
    }).promise();
  }
}

「看起來很複雜,」洛基說。

「確實,」大師承認,「但問題是:任務名稱多久改一次?你多久查詢一次軍官的任務列表?」

洛基思考:「改名稱可能幾個月一次,但查詢可能每秒上千次...」

「這就是關鍵,」大師說,「在 DynamoDB 中,你優化的是讀取,而不是寫入。」

讀寫比例的現實:
┌─────────────────────────┐
│ 典型應用的讀寫比例:      │
│                          │
│ 讀取:90-99%             │
│ 寫入:1-10%              │
│                          │
│ DynamoDB 的策略:         │
│ "讓讀取極快,寫入可以慢"  │
└─────────────────────────┘

當資料一致性真的重要時

洛基問:「如果我就是需要強一致性呢?比如銀行帳戶餘額,不能有任何延遲或不一致?」

大師嚴肅地說:「那就是 DynamoDB 不適合的場景。」

選擇正確的工具:
┌─────────────────────────────────┐
│ 使用 DynamoDB:                  │
│ ✅ 大規模資料(TB-PB)           │
│ ✅ 讀多寫少                      │
│ ✅ 可容忍短暫不一致              │
│ ✅ 需要水平擴展                  │
│ ✅ 簡單查詢模式                  │
│                                  │
│ 使用關聯式資料庫:               │
│ ✅ 需要複雜 JOIN                │
│ ✅ 需要強一致性                  │
│ ✅ 資料量適中(< 1TB)           │
│ ✅ 寫入頻繁且複雜                │
│ ✅ 需要臨時查詢                  │
└─────────────────────────────────┘

「沒有完美的資料庫,」大師說,「只有適合的場景。」

洛基問:「那如果我既需要大規模擴展,又需要強一致性呢?」

「那你可能需要 DynamoDB Transactions,」大師說,「或者混合架構:DynamoDB 處理大量讀取,關聯式資料庫處理關鍵交易。」

洛基的思維轉變

經過一整天的學習,洛基在筆記本上寫下他的領悟:

關聯式思維 vs DynamoDB 思維

關聯式資料庫:
1. 從實體開始設計(Users, Orders, Products)
2. 建立關聯關係(外鍵)
3. 正規化資料(避免重複)
4. 用 JOIN 查詢資料
5. 優化:加索引、調整 SQL

DynamoDB:
1. 從查詢開始設計(Access Patterns)
2. 直接設計資料結構支援查詢
3. 擁抱資料重複(優化讀取)
4. 避免多次查詢
5. 優化:重新設計 PK/SK

核心差異:
關聯式:「我有什麼資料?」
DynamoDB:「我要怎麼查詢資料?」

大師看著洛基的筆記,滿意地點頭:「你已經理解最根本的差異了。」

洛基恍然大悟:「我現在明白為什麼第 6 天的時候,您說『Access Pattern 優先』。當時我只是記住了概念,但沒有真正理解它在面對複雜 ER 關係時的威力。現在我理解了——不是簡單的資料重複,而是系統性地從關聯關係中提取查詢需求,然後設計支援這些需求的資料結構。」

「正是如此,」大師說,「第 6 天你學會了『思維轉換』,今天你學會了『系統方法』。只有當你嘗試轉換複雜的 ER 圖時,才會真正理解這兩者的結合。」

實戰演練:軍官系統設計

大師給洛基一個挑戰:「現在設計這個查詢:查看 Captain 級別的所有軍官及其目前任務。」

洛基思考片刻:

// 洛基的設計方案

// 錯誤方案(關聯式思維):
// 1. Scan 所有軍官,篩選 rank = 'Captain'
// 2. 對每個軍官 Query 其任務
// 問題:Scan 很慢,而且需要 N+1 次查詢

// 正確方案(DynamoDB 思維):
// 使用 GSI 或設計一個聚合視圖

// 方案 A:GSI 設計
{
  PK: 'OFFICER#alice',
  SK: 'METADATA',
  rank: 'Captain',
  name: 'Alice Johnson',

  // GSI: 按職級查詢
  GSI1PK: 'RANK#Captain',
  GSI1SK: 'OFFICER#alice'
}

// 查詢 Captain 的所有軍官
const captains = await docClient.query({
  TableName: 'IntergalacticEvents',
  IndexName: 'GSI1',
  KeyConditionExpression: 'GSI1PK = :rank',
  ExpressionAttributeValues: {
    ':rank': 'RANK#Captain'
  }
}).promise();

// 然後對每個軍官查詢其任務
for (const captain of captains.Items) {
  const missions = await docClient.query({
    TableName: 'IntergalacticEvents',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': `OFFICER#${captain.officerId}`,
      ':sk': 'MISSION#'
    }
  }).promise();
}

「不錯,」大師說,「但還可以更好。如果你只需要目前的任務呢?」

洛基靈機一動:

// 優化方案:在軍官記錄中直接存儲目前任務
{
  PK: 'OFFICER#alice',
  SK: 'METADATA',
  rank: 'Captain',
  name: 'Alice Johnson',

  // 目前任務資訊(重複但優化查詢)
  currentMission: {
    missionId: 'mission001',
    missionName: 'Mars Exploration',
    role: 'commander',
    startDate: '2210-03-15'
  },

  GSI1PK: 'RANK#Captain',
  GSI1SK: 'OFFICER#alice'
}

// 現在一次查詢就能拿到所有 Captain 及其目前任務!

看完之後大師讚許,「這就是 DynamoDB 思維:為最常見的查詢優化資料結構。」

展望明天:關聯關係的設計選擇

大師看著洛基已經理解了核心概念:「今天你明白了『為什麼 ER 圖不能直譯』,明天我們要學習『如何做設計選擇』。」

「設計選擇?」洛基問。

「一對一、一對多、多對多,」大師說,「在 SQL 中,ER 圖上畫一條線就知道要開幾個表。但在 DynamoDB 中,同樣的一條線,卻有多種設計策略可以選擇。」

洛基若有所思:「所以重點不是『怎麼寫程式碼』,而是『怎麼選擇策略』?」

「正是,」大師微笑,「但我們不會從今天這個複雜的多對多開始。」他在白板上擦掉 Officers-Missions 的 ER 圖,重新畫了一個簡單的關係,「明天,我們從最簡單的一對一關係開始——學會判斷何時嵌入、何時分離。然後一步步推進到一對多、多對多。」

洛基點頭:「從基礎建立判斷框架。」

「對,」大師說,「今天用多對多讓你看見 SQL 思維的限制,明天開始,我們系統性地重建 DynamoDB 的設計思維。每種關係類型,都有它的決策點。」

走出茶室時,洛基回頭看了看白板上的 ER 圖。那些曾經熟悉的箭頭和關聯線,現在看起來像是問號——每條線都在問他:「你會怎麼設計我?」

他在心中默念:「不是『ER 圖說什麼我就做什麼』,而是『根據查詢需求做出選擇』。這就是 DynamoDB 的設計智慧。」


時間設定說明:故事中使用星際曆(SY210 = 西元2210年),程式碼範例為確保正確執行,使用對應的西元年份。


上一篇
Day 16:錯誤處理與除錯的發現之旅
下一篇
Day 18:一對一關係的策略分歧點
系列文
DynamoDB銀河傳說首部曲-打造宇宙都打不倒的高效服務19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言