iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

洛基推開門時,發現大師正在白板前寫字,這次不是 ER 圖,而是一個問題:

如果一個軍官執行了 1000 個任務
你要怎麼設計?

洛基走近白板:「昨天您說一對多不是嵌入或分離的問題。」他望著問題說,「那是什麼問題?」

大師轉身,指著那個數字 1000:「在 SQL 中你會怎麼處理一對多?」

洛基幾乎是反射性地回答,「兩個表,用外鍵連接。」他在白板上快速畫出結構:

-- SQL 的標準做法
CREATE TABLE Officers (
  officer_id VARCHAR PRIMARY KEY,
  name VARCHAR,
  rank VARCHAR
);

CREATE TABLE Missions (
  mission_id VARCHAR PRIMARY KEY,
  officer_id VARCHAR,  -- 外鍵指向 Officers
  mission_name VARCHAR,
  role VARCHAR,
  start_date DATE
);

-- 查詢軍官的任務
SELECT * FROM Missions
WHERE officer_id = 'alice';

大師看著洛基畫的結構,沒有說話。

洛基察覺到了:「這裡有陷阱?」

「不是陷阱,」大師說,「是選擇。」他在白板上寫下一個問題:「你打算怎麼查詢?」

「什麼怎麼查詢?」洛基不解,「不就是 SELECT * FROM Missions WHERE officer_id = 'alice' 嗎?」

大師搖頭:「我問的是,你需要的查詢是什麼?」他停頓了一下,「從軍官查任務?還是從任務查軍官?還是兩個都要?」

在 SQL 的世界裡,洛基從來不需要想這個問題——JOIN 讓兩個方向都一樣簡單。但現在...

發現關鍵問題:查詢方向

「讓我整理一下,」洛基走到白板前,開始寫:

查詢方向決定資料組織

方向一:從「一」查「多」
- 查詢:給我 Alice 的所有任務

方向二:從「多」查「一」
- 查詢:任務 Mars-001 的負責軍官是誰?

問題:你的主要查詢方向是哪個?

他盯著自己寫的字說:「在 SQL 裡,我從來不問這個問題,因為 JOIN 讓兩邊都一樣容易。」他轉向大師,「但在 DynamoDB,查詢方向會影響設計?」

「不只是影響,」大師說,「而是決定。」

方向一:從軍官查任務

大師展示第一種設計:

// 設計一:從軍官查任務(Officer -> Missions)

// 軍官的基本資料
const officer = {
  PK: 'OFFICER#alice',
  SK: 'METADATA',
  officerId: 'alice',
  name: 'Alice Johnson',
  rank: 'Captain'
};

// 任務記錄(屬於 alice)
const mission1 = {
  PK: 'OFFICER#alice',
  SK: 'MISSION#2210-03-15#mission001',
  missionId: 'mission001',
  missionName: 'Mars Exploration',
  role: 'commander',
  startDate: '2210-03-15',
  endDate: '2210-06-20',
  status: 'completed'
};

const mission2 = {
  PK: 'OFFICER#alice',
  SK: 'MISSION#2210-07-01#mission002',
  missionId: 'mission002',
  missionName: 'Jupiter Survey',
  role: 'pilot',
  startDate: '2210-07-01',
  status: 'active'
};

洛基觀察:「所有任務都用同一個 PK(OFFICER#alice),但 SK 不同...」

「正是,」大師說,「這樣查詢就很簡單。」

// 查詢軍官的所有任務
async function getOfficerMissions(officerId) {
  const result = await docClient.query({
    TableName: 'IntergalacticEvents',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
    ExpressionAttributeValues: {
      ':pk': `OFFICER#${officerId}`,
      ':skPrefix': 'MISSION#'
    }
  }).promise();

  return result.Items;
  // 一次查詢拿到所有任務!
}

洛基看著程式碼,點頭:「這個我懂,一次 Query 拿到所有任務。」他抬起頭,正要說什麼,突然停住。

「怎麼了?」大師問。

「等等,」洛基皺眉,「如果現在需求變了,我要反向查詢呢?比如說,查詢任務 mission001 是誰負責的...」他盯著設計看了幾秒,臉色變凝重。

發現單向設計的限制

「試試看,」大師說。

洛基開始在白板上寫,但寫到一半就停了:

// 嘗試反向查詢(會失敗)
async function getMissionOfficer(missionId) {
  // 問題:PK 是 OFFICER#alice,但我不知道 alice 是誰
  // SK 是 MISSION#..#mission001,但我不能只用 SK 查詢

  // 唯一的方法:Scan 整個表
  const result = await docClient.scan({
    TableName: 'IntergalacticEvents',
    FilterExpression: 'contains(SK, :missionId)',
    ExpressionAttributeValues: {
      ':missionId': missionId
    }
  }).promise();

  // 這需要掃描整個表!效率很差!
}

洛基放下筆,看著自己寫的程式碼。「Scan。」他的聲音很平靜,但聽得出一絲無奈,「我只能掃描整個表,用 FilterExpression 過濾。」

大師點頭。

洛基沉默了幾秒,然後說:「但這樣效率很差...」他停頓,重新整理思路,「所以如果反向查詢也很重要,這個設計就行不通?」

「對,」大師簡短地說,「讓我們看看另一個方向。」

方向二:從任務查軍官

大師展示第二種設計:

// 設計二:從任務查軍官(Mission -> Officer)

// 任務資料(包含軍官資訊)
const mission = {
  PK: 'MISSION#mission001',
  SK: 'METADATA',
  missionId: 'mission001',
  missionName: 'Mars Exploration',
  startDate: '2210-03-15',
  status: 'completed',

  // 包含軍官資訊
  officerId: 'alice',
  officerName: 'Alice Johnson',
  officerRank: 'Captain',
  role: 'commander'
};

洛基觀察:「任務資料裡包含了軍官的資訊...」

「對,」大師說,「這樣查詢任務就能直接知道是誰負責。」

// 查詢任務的負責軍官
async function getMissionOfficer(missionId) {
  const result = await docClient.get({
    TableName: 'IntergalacticEvents',
    Key: {
      PK: `MISSION#${missionId}`,
      SK: 'METADATA'
    }
  }).promise();

  return {
    officerId: result.Item.officerId,
    officerName: result.Item.officerName,
    officerRank: result.Item.officerRank,
    role: result.Item.role
  };
  // 一次查詢就拿到軍官資訊!
}

洛基看著程式碼,點頭:「這次反向查詢很簡單。」但他馬上意識到,「不過...如果我要查詢軍官的所有任務,又要 Scan 了?」

「正是,」大師說。

洛基站在白板前,手指輕輕敲著白板框。「所以不管選哪個方向,都只能支援單向查詢?」

「如果只用這種設計的話,」大師說,「是的。」

理解「包含」的設計邏輯

洛基停下腳步,回頭看設計二,他指著任務資料裡的軍官資訊,「如果使用 SQL,我只需要存 officer_id,然後 JOIN 就能拿到名字和職級。但現在要把 officerName、officerRank 都存在任務...」

「繼續,」大師鼓勵洛基繼續思考,洛基試著在白板寫下自己的思緒。

// SQL 的做法(JOIN)
SELECT m.*, o.name, o.rank
FROM Missions m
JOIN Officers o ON m.officer_id = o.officer_id
WHERE m.mission_id = 'mission001';

// DynamoDB 如果只存 officer_id
const missionOnlyId = {
  PK: 'MISSION#mission001',
  SK: 'METADATA',
  officerId: 'alice'  // 只有 ID
};

// 查詢後還需要再查一次
const mission = await getMission('mission001');
const officer = await getOfficer(mission.officerId);
// 兩次查詢!

洛基看著程式碼,思路逐漸清晰:「如果只存 officer_id,我查詢任務後還得再查一次軍官資料...」他抬起頭,「兩次查詢,兩倍延遲。」

「而且是序列查詢,」大師補充,「不能並行。」

洛基點頭:「所以乾脆把軍官的常用資訊——名字、職級——直接存在任務裡。一次查詢搞定。」

「這就是『包含』的邏輯,」大師說,「不是『引用』另一個資料,而是『包含』需要的部分。」

關鍵決策:你的主要查詢方向

大師在白板上總結:

一對多關係的設計決策

問題:主要從哪個方向查詢?

情況一:主要從「一」查「多」
→ 設計:「多」的項目用「一」的 ID 作為 PK
→ 範例:PK: OFFICER#alice, SK: MISSION#...

情況二:主要從「多」查「一」
→ 設計:「多」的項目用自己的 ID 作為 PK,內容包含「一」的資訊
→ 範例:PK: MISSION#001, SK: METADATA, {officerId, officerName, ...}

洛基問:「如果兩個方向都很重要呢?」

「那就是多對多的情境了,」大師說,「明天會談。今天先專注在一對多——確定主要方向。」

實戰演練:選擇設計方向

大師給洛基一個場景:

查詢統計:
- 查詢軍官的任務列表:每天 10,000 次
- 查詢任務的負責軍官:每天 100 次

洛基看著數字,想起第 8 天學的查詢分級框架。「10,000 次是核心級,100 次是重要級...」他抬起頭,「差距這麼大,應該為高頻查詢優化。」

「你會選擇哪種設計?」大師問。

洛基思考後回答:

// 洛基的決策
const decision = {
  選擇: '從軍官查任務(設計一)',

  設計: {
    PK: 'OFFICER#{officerId}',
    SK: 'MISSION#{startDate}#{missionId}',
    包含資訊: ['missionName', 'role', 'status', 'startDate']
  },

  理由: [
    '主要查詢(10,000次/天)是從軍官查任務',
    '次要查詢(100次/天)可以接受較慢的方案',
    '為高頻查詢優化最重要'
  ],

  次要查詢處理: '使用 GSI 或接受 Scan(因為頻率低)'
};

「很好的判斷,」大師讚許,「你抓到了重點:為最常用的查詢優化。」

SK 設計的重要性

洛基注意到一個細節:「為什麼 SK 要用 MISSION#{startDate}#{missionId} 而不是只用 MISSION#{missionId}?」

大師說:「還記得第 5 天學到的嗎?SK 會自動排序。」

// SK 包含日期的好處
const missions = [
  { SK: 'MISSION#2210-03-15#mission001' },  // 最早
  { SK: 'MISSION#2210-07-01#mission002' },
  { SK: 'MISSION#2210-12-10#mission003' }   // 最新
];

// 查詢時自動按時間排序!
async function getOfficerMissions(officerId) {
  const result = await docClient.query({
    TableName: 'IntergalacticEvents',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
    ExpressionAttributeValues: {
      ':pk': `OFFICER#${officerId}`,
      ':skPrefix': 'MISSION#'
    },
    ScanIndexForward: false  // 可以反向,最新的在前
  }).promise();

  return result.Items;  // 自動按日期排序!
}

洛基理解了:「第 5 天學的是 SK 的排序特性,今天學的是如何利用這個特性組織一對多關係。」

對比 SQL 的思維差異

大師做最後總結:

SQL 的一對多設計:
1. 看 ER 圖:一對多關係
2. 「多」的那邊加外鍵
3. JOIN 查詢
4. 雙向都可以,不用考慮方向

DynamoDB 的一對多設計:
1. 看 ER 圖:一對多關係
2. 分析:主要從哪個方向查詢?
3. 決定:誰當 PK,誰當 SK
4. 設計:「多」的項目要包含哪些「一」的資訊?
5. 考慮:SK 要包含什麼資訊以支援排序?

洛基看著白板上的對比,慢慢說:「SQL 是對稱的——不管從哪個方向查都一樣。DynamoDB 是不對稱的——你必須選一個主要方向。」

大師點頭:「而這個選擇,決定了 PK 和 SK 的結構。」

展望明天:當兩個方向都重要時

洛基正準備離開,大師叫住了他:「還記得你剛才問的嗎?如果兩個方向都很重要呢?」

洛基停下腳步:「那就...」他停頓,「要存兩份?」

「對,」大師在白板角落簡單寫下:

明天:多對多關係
→ 雙向查詢都重要
→ 需要雙向索引

「但是,」Hippo 的聲音突然響起,「存兩份怎麼保證一致性?」

洛基愣了一下。這確實是個問題。

「明天再說,」大師微笑。


走出茶室,洛基回想今天的學習。

一對多的核心是選擇主要方向——你不可能讓所有方向都完美,所以要判斷哪個查詢最重要。

這讓他想起過去做決策的經驗。有時候最難的不是執行,而是在多個選項中做出取捨。

他看著夜空中的星星,想起第 8 天學的優先級框架。那時學的是「哪些查詢值得優化」,今天學的是「怎麼組織資料支援查詢」。

兩者結合起來,才是完整的設計思維。


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


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

尚未有邦友留言

立即登入留言