「系統已經掛了。」
Hippo 的聲音突然在茶室裡響起,洛基還沒推開門就聽到了。
他加快腳步推門進去,看見大師正盯著一個投影螢幕,上面滿是紅色的錯誤訊息,像是警報般不斷跳動:
ThrottlingException: Rate exceeded for table IntergalacticEvents
ThrottlingException: Rate exceeded for table IntergalacticEvents
ThrottlingException: Rate exceeded for table IntergalacticEvents
...
「這是什麼?」洛基問,感受到一股緊張。
大師沒有轉頭說:「我模擬了一個發生災難的生產環境。」
洛基走近螢幕,看到系統配置:
表配置:
- 主表:10 RCU, 10 WCU
- GSI1 (按主題):10 RCU, 10 WCU
- GSI2 (按職級):10 RCU, 10 WCU
- GSI3 (按狀態):10 RCU, 10 WCU
- GSI4 (按地區):10 RCU, 10 WCU
- GSI5 (按時間):10 RCU, 10 WCU
... (總共 10 個 GSI)
寫入成本分析:
- 主表:1 WCU
- 每個 GSI:1 WCU
- 總計:1 + 10 = 11 WCU per write
月成本估算:
- 配置容量成本:10 個 GSI × $15 = $150/月
- 實際成本:比原本估計高 10 倍
大師切換螢幕,顯示錯誤發生的時間線:
14:00 - 系統正常運行
14:30 - 活動報名開始,寫入量增加
14:35 - 開始出現 ThrottlingException
14:40 - 大量請求失敗
14:45 - 使用者無法報名,客訴湧入
洛基感受到一股寒意——這是他差點犯的錯誤。
「昨天,」大師關掉螢幕,轉身看著洛基,「你理解了 GSI 存在的原因——解決主鍵只能支援一種查詢的限制。」
「對,」洛基點頭。
「但你看到了,」大師指著螢幕,「GSI 不是免費的午餐。每個 GSI 都有代價。」
他走到白板前,寫下今天的主題:
第23天:GSI 的設計原則與陷阱
核心問題:
1. GSI 的成本結構是什麼?
2. 如何判斷是否需要建立 GSI?
3. 過度建立索引會造成什麼後果?
洛基看著這些問題,理解今天的課題——不只是學會用 GSI,更要學會「何時不用」。
大師在白板上畫出 GSI 的架構圖:
主表 GSI
PK SK attributes GSI_PK GSI_SK projected_attrs
───────────────────── ────────────────────────────
寫入 → 自動複製 → 寫入
獨立的容量配置
獨立的儲存空間
最終一致性
「GSI 本質上是一個獨立的表,」大師說,「它有自己的 PK、SK、還有投影的屬性。」
洛基問:「投影是什麼意思?」
大師在白板上寫下三種投影類型:
// 投影類型 1:KEYS_ONLY(只包含索引鍵)
GSI_StatusIndex: {
GSI1PK: 'STATUS#ACTIVE',
GSI1SK: 'EVENT#SY210-03-15-001',
PK: 'LOCATION#MARS', // 主表的 PK
SK: 'EVENT#SY210-03-15-001' // 主表的 SK
// 沒有其他屬性
}
// 投影類型 2:INCLUDE(包含指定屬性)
GSI_StatusIndex: {
GSI1PK: 'STATUS#ACTIVE',
GSI1SK: 'EVENT#SY210-03-15-001',
PK: 'LOCATION#MARS',
SK: 'EVENT#SY210-03-15-001',
eventName: '火星科學大會', // 投影屬性
hostRank: 'Captain' // 投影屬性
}
// 投影類型 3:ALL(包含所有屬性)
GSI_StatusIndex: {
// 包含主表的所有屬性
GSI1PK: 'STATUS#ACTIVE',
GSI1SK: 'EVENT#SY210-03-15-001',
PK: 'LOCATION#MARS',
SK: 'EVENT#SY210-03-15-001',
eventName: '火星科學大會',
hostRank: 'Captain',
topic: 'Physics',
description: '...',
participants: 100,
// ... 所有屬性
}
「每種投影類型的成本不同,」大師說,「我們來看一個實際場景。」
他在白板上寫下需求:
需求:查詢所有 ACTIVE 狀態的活動,顯示活動名稱
主表設計:
{
PK: 'LOCATION#MARS',
SK: 'EVENT#SY210-03-15-001',
eventName: '火星科學大會',
status: 'ACTIVE',
hostRank: 'Captain',
description: '...',
// ... 其他 10 個屬性
}
每個項目大小:約 3KB
洛基分析:「需要建立 GSI,用 status 作為 GSI_PK。」
「對,」大師說,「但要選擇哪種投影類型?」
大師在白板上寫下三種方案的對比:
// 方案 A:KEYS_ONLY
const gsiKeysOnly = {
projection: "KEYS_ONLY",
查詢流程: `
1. Query GSI → 得到 PK 和 SK
2. BatchGet 主表 → 得到完整項目(包含 eventName)
`,
RCU消耗: "1 (GSI) + 1 (主表) = 2 RCU",
儲存成本: "最低(只存鍵)",
適用: "需要完整資料的查詢",
};
// 方案 B:INCLUDE ['eventName']
const gsiInclude = {
projection: "INCLUDE",
projectedAttributes: ["eventName"],
查詢流程: `
1. Query GSI → 直接得到 eventName
(不需要回查主表)
`,
RCU消耗: "1 RCU(只查 GSI)",
儲存成本: "中等(存鍵 + 指定屬性)",
適用: "只需要部分屬性的查詢",
};
// 方案 C:ALL
const gsiAll = {
projection: "ALL",
查詢流程: `
1. Query GSI → 得到所有屬性
`,
RCU消耗: "1 RCU",
儲存成本: "最高(存所有屬性)",
適用: "需要完整資料且查詢頻繁",
};
洛基思考:「如果查詢只需要 eventName,用 INCLUDE 最划算——RCU 最少,儲存成本適中。」
「正確,」大師說,「但如果未來需求變化,要顯示更多屬性呢?」
「就要修改 GSI 的投影設定...」洛基停頓,「但 GSI 一旦建立,投影設定可以改嗎?」
大師搖頭:「不能。要改投影設定,只能刪除 GSI 重建。」
洛基理解了:「所以投影設定是一個重要的設計決策,要仔細評估。」
「沒錯,」大師說,「這就是第一個陷阱——過度投影或投影不足。」
大師切換回開場時的災難場景:
// 真實案例:過度建立索引
const overIndexedTable = {
主表: { RCU: 100, WCU: 100 },
GSI列表: [
"GSI1_ByTopic", // 按主題查詢
"GSI2_ByHostRank", // 按主持人職級
"GSI3_ByStatus", // 按狀態
"GSI4_ByRegion", // 按地區
"GSI5_ByDate", // 按日期
"GSI6_ByCapacity", // 按容量
"GSI7_ByPrice", // 按價格
"GSI8_ByOrganizer", // 按主辦方
"GSI9_ByType", // 按類型
"GSI10_ByPopularity", // 按熱門度
],
寫入放大: {
計算: "每次寫入主表 = 1 + 10 = 11 WCU",
影響: "寫入成本是預期的 11 倍",
後果: "容量配置嚴重不足 → Throttling",
},
儲存成本: {
主表: "100MB",
每個GSI: "約 80MB(假設 ALL 投影)",
總計: "100 + (80 × 10) = 900MB",
倍數: "9 倍儲存成本",
},
};
洛基看著這個數字,驚訝:「所以建立太多 GSI,成本會爆炸?」
「對,」大師說,「這是 SQL 思維的影響。」
大師請 Hippo 在白板上顯示兩者的對比:
洛基問:「那應該建立幾個 GSI?」
「這取決於你的查詢需求,」大師說,「關鍵是要判斷『是否真的需要』。」
大師在白板上寫下決策框架:
是否需要 GSI?
步驟 1:分析查詢需求
├─ 查詢頻率(QPS)
├─ 查詢重要性(核心業務 vs 次要功能)
└─ 查詢的資料量
步驟 2:評估替代方案
├─ 主表設計能否支援?
├─ Scan 的成本可接受嗎?
├─ 應用層處理可行嗎?
└─ 快取能解決嗎?
步驟 3:成本權衡
├─ GSI 的 RCU/WCU 成本
├─ GSI 的儲存成本
├─ vs Scan 的成本
└─ vs 應用層處理的成本
決策:
- 高頻核心查詢 (>100 QPS) → 建立 GSI
- 中頻查詢 (10-100 QPS) → 評估成本後決定
- 低頻查詢 (<10 QPS) → 考慮 Scan 或應用層處理
洛基看著這個框架,問:「如果查詢頻率只有每天 100 次,要建 GSI 嗎?」
大師反問:「每天 100 次,換算成 QPS 是多少?」
洛基計算:「100 / (24 × 3600) ≈ 0.001 QPS。」
「如果用 Scan,」大師說,「假設表有 10,000 筆資料,每次 Scan 消耗 10,000 次讀取。每天 100 次 = 1,000,000 次讀取。」
洛基繼續算:「1,000,000 RCU/天...如果用 On-Demand,成本是 $0.25 per million = $0.25/天 × 30 = $7.5/月。」
「如果建立 GSI,」大師說,「假設 Provisioned 模式,10 RCU = $0.00013/小時 × 10 × 24 × 30 = $0.94/月。」
洛基恍然大悟:「建 GSI 反而便宜!因為低頻查詢雖然次數少,但 Scan 的成本還是跟資料量有關。」
「正確,」大師點頭,「所以判斷不只看頻率,還要看資料量和查詢模式。」
大師提出另一個場景:
「現在你建立了一個 GSI,用 status 作為 GSI_PK:」
{
GSI1PK: 'STATUS#ACTIVE', // 大部分活動都是 ACTIVE
GSI1SK: 'EVENT#SY210-03-15-001'
}
資料分佈:
- STATUS#ACTIVE: 9,000 個活動(90%)
- STATUS#DRAFT: 800 個活動(8%)
- STATUS#CANCELLED: 200 個活動(2%)
洛基看著這個設計:「狀態只有三種值,大部分資料都集中在 ACTIVE...」
「會發生什麼?」大師問。
「熱分區,」洛基想起第 22 天的討論,「所有對 ACTIVE 的查詢都打到同一個分區。」
「對,」大師說,「這叫做低基數屬性(low cardinality attribute)——屬性的可能值很少。」
他在白板上寫下:
低基數屬性的問題:
屬性:status
可能值:ACTIVE, DRAFT, CANCELLED (3 種)
如果用作 GSI_PK:
→ 所有資料只分散到 3 個分區
→ 大部分資料集中在 ACTIVE 分區
→ 熱分區問題
高基數屬性的例子:
- userId(每個使用者一個值)
- eventId(每個活動一個值)
- timestamp(每秒一個值)
→ 資料分散均勻
洛基問:「所以不能用 status 建 GSI?」
「可以,但要小心設計,」大師說,「可以結合其他屬性增加基數。」
他展示改進方案:
// 改進方案:複合 GSI_PK
{
GSI1PK: 'STATUS#ACTIVE#2210-03', // 狀態 + 年月
GSI1SK: 'EVENT#SY210-03-15-001'
}
// 或者:狀態 + 地區
{
GSI1PK: 'STATUS#ACTIVE#MARS', // 狀態 + 地點
GSI1SK: 'EVENT#SY210-03-15-001'
}
效果:
- 原本 3 個分區 → 現在 36 個分區(3 狀態 × 12 月份)
- 或 15 個分區(3 狀態 × 5 地點)
- 資料更分散,降低熱分區風險
洛基理解了:「所以 GSI 的 PK 設計,和主表一樣,需要考慮資料分佈。」
「完全正確,」大師說,「這是第二個陷阱。」
大師寫下第三個場景:
// 場景:建立活動後立即查詢
async function createAndQueryEvent() {
// 1. 寫入主表
await docClient.put({
TableName: "Events",
Item: {
PK: "LOCATION#MARS",
SK: "EVENT#SY210-03-15-001",
status: "ACTIVE",
eventName: "火星科學大會",
},
});
// 2. 立即查詢 GSI
const result = await docClient.query({
TableName: "Events",
IndexName: "GSI-Status",
KeyConditionExpression: "GSI1PK = :status",
ExpressionAttributeValues: {
":status": "STATUS#ACTIVE",
},
});
// 問題:result.Items 會包含剛才建立的活動嗎?
}
洛基想了想:「應該會吧?我們剛寫入的。」
「不一定,」大師說,「GSI 是最終一致性。」
他解釋:
最終一致性的時間線:
00:00.000 - 寫入主表成功
00:00.001 - 程式繼續執行
00:00.002 - 查詢 GSI
00:00.003 - GSI 可能還沒更新!
...
00:00.050 - GSI 更新完成(通常幾十毫秒)
在高負載時:
- GSI 更新可能延遲到數秒
- 甚至更久
影響:
- 剛建立的資料可能查不到
- 剛更新的資料可能還是舊值
洛基問:「那要怎麼處理?」
大師提供解決方案:
// 方案 1:應用層處理
async function createAndQueryEvent() {
const newEvent = {
PK: "LOCATION#MARS",
SK: "EVENT#SY210-03-15-001",
status: "ACTIVE",
eventName: "火星科學大會",
};
// 寫入
await docClient.put({
TableName: "Events",
Item: newEvent,
});
// 查詢 GSI
const result = await docClient.query({
TableName: "Events",
IndexName: "GSI-Status",
KeyConditionExpression: "GSI1PK = :status",
ExpressionAttributeValues: { ":status": "STATUS#ACTIVE" },
});
// 應用層補充:如果新建立的項目不在結果中,手動加入
const exists = result.Items.some((item) => item.SK === newEvent.SK);
if (!exists) {
result.Items.push(newEvent);
}
return result.Items;
}
// 方案 2:設計上避免依賴即時性
// - 不要在建立後立即查詢 GSI
// - 或使用主表查詢確認建立成功
「所以,」洛基總結,「GSI 適合查詢,不適合需要即時一致性的場景。」
「正確,」大師說,「這是第三個陷阱。」
大師在白板上寫下一個完整案例:
星際活動系統需求分析:
查詢 1:按地點查詢活動(1000 QPS)
→ 主表 PK: LOCATION#xxx
查詢 2:按主題查詢活動(200 QPS)
→ 需要 GSI?
查詢 3:按狀態查詢活動(50 QPS)
→ 需要 GSI?
查詢 4:按價格範圍查詢(10 QPS)
→ 需要 GSI?
查詢 5:按主辦方查詢(每天 100 次)
→ 需要 GSI?
「來,」大師看著洛基,「你來判斷每個查詢是否需要 GSI。」
洛基拿起白板筆,開始分析:
// 查詢 2:按主題查詢(200 QPS)
const query2Analysis = {
頻率: "200 QPS → 中高頻",
重要性: "核心功能(使用者主要查詢方式)",
決策: "建立 GSI",
設計: {
GSI1PK: "TOPIC#Physics",
GSI1SK: "EVENT#SY210-03-15-001",
projection: "INCLUDE", // 只投影常用屬性
attributes: ["eventName", "location", "date"],
},
};
// 查詢 3:按狀態查詢(50 QPS)
const query3Analysis = {
頻率: "50 QPS → 中頻",
問題: "status 是低基數屬性(3 種值)",
替代方案: "用 Scan + FilterExpression",
成本計算: {
Scan: "假設 10,000 筆 → 10,000 RCU/次 → 500,000 RCU/秒",
GSI: "避免熱分區需要複合 PK → 額外複雜度",
},
決策: "不建 GSI,用應用層快取 + Scan",
原因: "頻率不夠高到值得承受熱分區風險",
};
// 查詢 4:按價格範圍查詢(10 QPS)
const query4Analysis = {
頻率: "10 QPS → 低頻",
特性: "範圍查詢,但 GSI 的 SK 可以支援",
決策: "不建 GSI",
原因: "頻率太低,Scan 成本可接受",
替代: "應用層快取熱門價格區間的結果",
};
// 查詢 5:按主辦方查詢(每天 100 次)
const query5Analysis = {
頻率: "100/day ≈ 0.001 QPS → 極低頻",
但是: "表有 100,000 筆資料",
Scan成本: "100,000 RCU × 100 次/天 = 10M RCU/天",
GSI成本: "10 RCU provisioned = $0.94/月",
決策: "建立 GSI(KEYS_ONLY)",
原因: "雖然低頻,但 Scan 成本遠高於 GSI",
};
大師看著洛基的分析,點頭:「很好。你已經開始建立判斷力了。」
大師在白板上總結今天的核心原則:
GSI 設計的黃金原則:
1. 不是每個查詢都需要索引
- 評估頻率、資料量、成本
- 低頻查詢可能 Scan 更划算
2. 投影類型影響成本
- KEYS_ONLY:最省儲存,但可能需要回查
- INCLUDE:平衡選擇
- ALL:方便但貴
3. 避免低基數屬性作為 GSI_PK
- status、boolean 等容易造成熱分區
- 需要複合 PK 增加分散度
4. 記住最終一致性
- GSI 不保證即時更新
- 設計要考慮延遲
5. 寫入放大效應
- N 個 GSI = N 倍 WCU
- 控制 GSI 數量
6. 成本可見性
- 每個 GSI 都有容量和儲存成本
- 設計前先估算
洛基把這些原則抄進筆記本。
大師繼續:「還有一個重要的原則——」
他在白板上寫下:
索引的演進策略:
階段 1:上線初期
- 只建立核心 GSI(1-2 個)
- 觀察實際使用模式
階段 2:根據監控調整
- CloudWatch 分析查詢模式
- 識別真正需要優化的查詢
階段 3:按需新增
- GSI 可以隨時新增
- 不要預先建立「可能用到」的索引
重點:始於精簡,隨需擴增
洛基理解了:「不要一開始就建一堆索引,而是根據實際需求逐步新增。」
「對,」大師說,「這是避免過度設計的關鍵。」
洛基盯著投影螢幕上的災難場景,再看自己剛完成的分析。
「所以那個系統,」他說,「10 個 GSI 中可能有一半不需要。高頻低基數的查詢建了 GSI 導致熱分區,低頻查詢建了 GSI 浪費成本。」
「而且,」大師補充,「寫入時需要 11 WCU(主表 + 10 GSI),但配置只有 10 WCU。」
洛基點頭:「Throttling 是必然的。這不是技術問題,是設計問題。」
「正確,」大師說,「這就是 SQL 思維的慣性——習慣建很多索引,卻忽略了 DynamoDB 的成本結構。」
他關掉投影:「明天我們會學 LSI——Local Secondary Index。它和 GSI 有本質不同,限制更嚴格,但在特定場景下很有價值。」
「LSI 也會有這些陷阱嗎?」洛基問。
「不同的陷阱,」大師說,「LSI 必須在建表時定義,無法後續新增。而且有 10GB 的限制。」
洛基打開筆記本記下這些關鍵字,準備明天深入研究。
他現在理解了一個重要原則:不是「能不能建 GSI」,而是「該不該建 GSI」。
每個索引都有代價,判斷力比技術更重要。
時間設定說明:故事中使用星際曆(SY210 = 西元2210年),程式碼範例為確保正確執行,使用對應的西元年份。