iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0

「系統已經掛了。」

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 的架構圖:

主表                     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 在白板上顯示兩者的對比:

https://ithelp.ithome.com.tw/upload/images/20251007/20178813DDXGOxFJ9z.jpg

洛基問:「那應該建立幾個 GSI?」

「這取決於你的查詢需求,」大師說,「關鍵是要判斷『是否真的需要』。」


判斷是否需要 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 熱分區

大師提出另一個場景:

「現在你建立了一個 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年),程式碼範例為確保正確執行,使用對應的西元年份。


上一篇
Day 22:從關聯設計到索引設計的思維昇華
下一篇
Day 24:LSI 特殊定位與使用時機
系列文
DynamoDB銀河傳說首部曲-打造宇宙都打不倒的高效服務24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言