洛基推開門時,發現大師正在白板前寫字,這次不是 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 要用 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 的一對多設計:
1. 看 ER 圖:一對多關係
2. 「多」的那邊加外鍵
3. JOIN 查詢
4. 雙向都可以,不用考慮方向
DynamoDB 的一對多設計:
1. 看 ER 圖:一對多關係
2. 分析:主要從哪個方向查詢?
3. 決定:誰當 PK,誰當 SK
4. 設計:「多」的項目要包含哪些「一」的資訊?
5. 考慮:SK 要包含什麼資訊以支援排序?
洛基看著白板上的對比,慢慢說:「SQL 是對稱的——不管從哪個方向查都一樣。DynamoDB 是不對稱的——你必須選一個主要方向。」
大師點頭:「而這個選擇,決定了 PK 和 SK 的結構。」
洛基正準備離開,大師叫住了他:「還記得你剛才問的嗎?如果兩個方向都很重要呢?」
洛基停下腳步:「那就...」他停頓,「要存兩份?」
「對,」大師在白板角落簡單寫下:
明天:多對多關係
→ 雙向查詢都重要
→ 需要雙向索引
「但是,」Hippo 的聲音突然響起,「存兩份怎麼保證一致性?」
洛基愣了一下。這確實是個問題。
「明天再說,」大師微笑。
走出茶室,洛基回想今天的學習。
一對多的核心是選擇主要方向——你不可能讓所有方向都完美,所以要判斷哪個查詢最重要。
這讓他想起過去做決策的經驗。有時候最難的不是執行,而是在多個選項中做出取捨。
他看著夜空中的星星,想起第 8 天學的優先級框架。那時學的是「哪些查詢值得優化」,今天學的是「怎麼組織資料支援查詢」。
兩者結合起來,才是完整的設計思維。
時間設定說明:故事中使用星際曆(SY210 = 西元2210年),程式碼範例為確保正確執行,使用對應的西元年份。