iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0

大師的投影螢幕上顯示著一個複雜的系統架構圖。

洛基還沒坐下,就被這個圖吸引住了。圖上標示著多個表、多個索引、以及它們之間的關係,還有密密麻麻的容量配置數據和成本估算。

「這是什麼系統?」洛基問。

「星際協作平台,」大師說,「支援多個星球的科學家協同工作。我需要你幫我設計索引策略。」

洛基有點意外:「我?」

「這三天你學了索引的原理、GSI 的權衡、LSI 的限制,」大師說,「今天是實戰——把知識整合成完整的設計能力。」

他切換投影,顯示系統需求:

星際協作平台需求:

實體關係:
- 使用者(User)
- 專案(Project)
- 任務(Task)
- 文件(Document)
- 評論(Comment)

核心查詢:
1. 查詢使用者的所有專案(1000 QPS,Queries Per Second)
2. 查詢專案的所有任務(500 QPS)
3. 查詢任務的所有評論,按時間排序(200 QPS)
4. 查詢任務的所有評論,按讚數排序(50 QPS)
5. 查詢特定狀態的專案(100 QPS)
6. 查詢特定優先級的任務(80 QPS)
7. 查詢使用者建立的所有文件(30 QPS)
8. 查詢待審核的文件(每天 500 次)

洛基看著這些需求,感覺壓力驟增。這不是單一查詢的設計,而是整個系統的索引規劃。

「從哪裡開始?」洛基問。

大師微笑:「從主表設計開始。」


主表設計的核心決策

大師在白板上寫下:

步驟 1:識別核心查詢
步驟 2:設計主表 PK/SK
步驟 3:評估次要查詢
步驟 4:決定索引策略

「首先,」大師說,「八個查詢中,哪個是最核心的?」

洛基分析:「查詢 1,使用者的專案,1000 QPS 最高。」

「對,」大師說,「這決定了主表的 PK 設計。」

他開始在白板上設計:

// 主表設計方向 A:以使用者為中心

{
  PK: 'USER#alice',
  SK: 'PROJECT#SY210-03-15#project-001',
  projectName: '火星地質研究',
  status: 'ACTIVE',
  priority: 'HIGH'
}

// 支援查詢:
// ✓ 查詢 1:使用者的專案(直接 Query)
// ✓ 查詢 2:專案的任務(需要考慮)

洛基問:「但查詢 2 也很重要,500 QPS。如果主表用 USER 作為 PK,要查『專案的任務』會很困難。」

「正確,」大師說,「這就是矛盾點。我們來看另一個方向。」

// 主表設計方向 B:以專案為中心

{
  PK: 'PROJECT#project-001',
  SK: 'TASK#SY210-03-15#task-001',
  taskName: '採集樣本',
  status: 'IN_PROGRESS',
  priority: 'HIGH'
}

// 支援查詢:
// ✓ 查詢 2:專案的任務(直接 Query)
// ✗ 查詢 1:使用者的專案(需要 GSI)

洛基陷入思考:「方向 A 優化查詢 1,方向 B 優化查詢 2。但查詢 1 的 QPS 更高...」

「所以?」大師問。

「選擇方向 A,」洛基說,「用主表支援最高頻的查詢,用 GSI 支援次高頻的查詢。」

大師點頭:「這就是第一個原則——主表優先支援最高頻查詢。」


多實體表的設計挑戰

但洛基發現問題:「如果主表用 PK: USER#alice,那專案資料、任務資料、文件資料都要放在同一個表嗎?」

「對,」大師說,「這就是 Single Table Design 的核心——用 SK 區分不同實體。」

他展示完整設計:

// 單表設計:所有實體放在同一個表

// 使用者的專案
{
  PK: 'USER#alice',
  SK: 'PROJECT#SY210-03-15#project-001',
  type: 'Project',
  projectName: '火星地質研究',
  status: 'ACTIVE'
}

// 使用者的文件
{
  PK: 'USER#alice',
  SK: 'DOCUMENT#SY210-03-16#doc-001',
  type: 'Document',
  docName: '研究報告',
  reviewStatus: 'PENDING'
}

// 專案的任務
{
  PK: 'PROJECT#project-001',
  SK: 'TASK#SY210-03-15#task-001',
  type: 'Task',
  taskName: '採集樣本',
  assignedTo: 'USER#bob',
  priority: 'HIGH'
}

// 任務的評論
{
  PK: 'TASK#task-001',
  SK: 'COMMENT#SY210-03-16#14:30#comment-001',
  type: 'Comment',
  content: '樣本已採集',
  author: 'USER#bob',
  likes: 5
}

洛基看著這個設計:「所以不同實體用不同的 PK 前綴,但都在同一個表裡。」

「對,」大師說,「這樣可以用 PK 來高效查詢。現在來看查詢支援狀況。」

查詢支援分析:

✓ 查詢 1:USER#alice → 所有 PROJECT#* (主表 Query)
✓ 查詢 2:PROJECT#001 → 所有 TASK#* (主表 Query)
✓ 查詢 3:TASK#001 → 所有 COMMENT#*,按時間排序
          (SK 已按時間排序,主表 Query)
✗ 查詢 4:TASK#001 → 所有 COMMENT#*,按讚數排序
          (需要索引)
✗ 查詢 5:查詢特定狀態的專案
          (status 不在 PK/SK)
✗ 查詢 6:查詢特定優先級的任務
          (priority 不在 PK/SK)
✓ 查詢 7:USER#alice → 所有 DOCUMENT#* (主表 Query)
✗ 查詢 8:查詢待審核的文件
          (reviewStatus 不在 PK/SK)

「主表支援了 5 個查詢,」洛基說,「還有 3 個需要索引。」

「很好,」大師說,「現在來設計索引策略。」


索引決策的系統化思考

大師在白板上寫下分析框架:

索引需求分析:

查詢 4:按讚數排序評論(50 QPS)
├─ 特性:同一任務範圍內,不同排序
├─ 候選:LSI 或 GSI
├─ LSI 評估:
│  ✓ 相同 PK (TASK#xxx)
│  ✓ 只是改變排序(按 likes)
│  ✓ 資料量:平均 50 評論/任務 × 2KB = 100KB(遠低於 10GB)
│  ✓ 需求明確(按讚數排序不會變)
│  → LSI 適合
│
└─ LSI 設計:
   {
     PK: 'TASK#task-001',
     LSK: 'LIKES#0005#COMMENT#comment-001'  // 補零確保排序
   }

查詢 5:按狀態查專案(100 QPS)
├─ 特性:新查詢維度(不同 PK)
├─ 只能用 GSI
├─ 問題:status 是低基數屬性(ACTIVE, COMPLETED, ARCHIVED)
├─ 風險:熱分區
└─ 解決方案:複合 PK

查詢 6:按優先級查任務(80 QPS)
├─ 特性:新查詢維度
├─ 只能用 GSI
├─ 問題:priority 是低基數屬性(HIGH, MEDIUM, LOW)
└─ 解決方案:複合 PK

查詢 8:查待審核文件(500 次/天 = 0.006 QPS)
├─ 特性:極低頻查詢
├─ 候選方案:
│  1. GSI
│  2. Scan + Filter
│  3. 應用層處理
└─ 成本分析決定

洛基看著這個分析,問:「查詢 8 這麼低頻,應該不需要 GSI 吧?」

大師說:「讓我們算一下。」


低頻查詢的成本權衡

大師展示計算過程:

// 查詢 8:待審核文件(500 次/天)

// 資料估算
const documentStats = {
  總文件數: 100000,
  待審核比例: '5%',
  待審核文件數: 5000,
  單筆大小: '3KB'
};

// 方案 A:Scan + FilterExpression
const scanCost = {
  每次Scan: '100,000 items × 3KB = 300,000 KB',
  RCU計算: '300,000 KB / 4KB = 75,000 RCU',
  每日成本: '75,000 RCU × 500 次 = 37,500,000 RCU/天',
  月成本_OnDemand: '37.5M × 30 / 1M × $0.25 = $281/月',
  備註: '即使只回傳 5000 筆,仍要掃描全表'
};

// 方案 B:GSI (KEYS_ONLY)
const gsiCost = {
  投影: 'KEYS_ONLY',
  RCU需求: '500 次/天 / 86400 ≈ 0.006 QPS → 1 RCU provisioned',
  WCU需求: '假設每天新增 100 文件 → 1 WCU provisioned',
  月成本: '1 RCU × $0.00013/小時 × 24 × 30 = $0.09',
  儲存成本: '100,000 × 100 bytes (KEYS_ONLY) = 10MB × $0.25/GB = $0.003',
  總成本: '$0.09/月'
};

// 決策
const decision = {
  結論: '建立 GSI',
  原因: 'Scan 成本 $281 >> GSI 成本 $0.09',
  關鍵: '低頻不代表便宜,資料量才是關鍵'
};

洛基恍然大悟:「所以即使查詢頻率很低,如果資料量大,Scan 還是很貴。」

「正確,」大師說,「這就是第23天學到的——判斷要綜合考慮頻率和資料量。」


稀疏索引的進階應用

大師繼續:「查詢 8 還有另一個優化空間——稀疏索引。」

洛基好奇:「什麼是稀疏索引?」

大師解釋:「GSI 的一個特性——只有包含索引鍵屬性的項目,才會出現在 GSI 中。」

他展示設計:

// 主表:所有文件
{
  PK: 'USER#alice',
  SK: 'DOCUMENT#SY210-03-16#doc-001',
  docName: '研究報告',
  reviewStatus: 'APPROVED'
  // 沒有 GSI_ReviewPK 屬性
}

{
  PK: 'USER#bob',
  SK: 'DOCUMENT#SY210-03-17#doc-002',
  docName: '實驗數據',
  reviewStatus: 'PENDING',
  GSI_ReviewPK: 'REVIEW#PENDING',  // ← 只有待審核文件有這個屬性
  GSI_ReviewSK: 'DOCUMENT#SY210-03-17#doc-002'
}

// GSI_ReviewIndex:只包含待審核文件
// - 100,000 個文件
// - 只有 5,000 個(5%)有 GSI_ReviewPK
// - GSI 只存 5,000 筆,不是 100,000 筆

洛基理解了:「所以 GSI 的大小比主表小很多?」

「對,」大師說,「這就是稀疏索引的優勢。」

// 稀疏索引的成本優勢

const sparseIndexBenefit = {
  主表大小: '100,000 items × 3KB = 300MB',

  密集索引: {
    描述: '所有項目都有 GSI 鍵',
    GSI大小: '100,000 items × 100 bytes = 10MB',
    儲存成本: '$2.5/月'
  },

  稀疏索引: {
    描述: '只有待審核項目有 GSI 鍵',
    GSI大小: '5,000 items × 100 bytes = 0.5MB',
    儲存成本: '$0.125/月',
    省下: '95% 儲存成本'
  },

  查詢效能: {
    密集索引: '需要 FilterExpression 過濾 95% 的資料',
    稀疏索引: 'Query 直接回傳所需資料,無需過濾'
  }
};

「更重要的是,」大師說,「稀疏索引的查詢更快——不需要 FilterExpression,直接 Query 就得到結果。」

洛基興奮起來:「所以對於『只需要查詢一部分資料』的場景,稀疏索引是最優解?」

「對,」大師點頭,「這是進階技巧。」


實作稀疏索引的設計模式

大師展示實作細節:

// 模式 1:寫入時動態設定 GSI 鍵

async function createDocument(userId, docName, reviewStatus) {
  const item = {
    PK: `USER#${userId}`,
    SK: `DOCUMENT#${Date.now()}#${generateId()}`,
    docName,
    reviewStatus
  };

  // 只有待審核文件才設定 GSI 鍵
  if (reviewStatus === 'PENDING') {
    item.GSI_ReviewPK = 'REVIEW#PENDING';
    item.GSI_ReviewSK = item.SK;
  }

  await docClient.put({ TableName: 'Platform', Item: item });
}

// 模式 2:狀態變更時維護 GSI 鍵

async function approveDocument(pk, sk) {
  await docClient.update({
    TableName: 'Platform',
    Key: { PK: pk, SK: sk },
    UpdateExpression:
      'SET reviewStatus = :approved REMOVE GSI_ReviewPK, GSI_ReviewSK',
    ExpressionAttributeValues: {
      ':approved': 'APPROVED'
    }
  });
  // 移除 GSI 鍵 → 項目從 GSI 中消失
}

// 模式 3:查詢待審核文件

async function getPendingDocuments() {
  return await docClient.query({
    TableName: 'Platform',
    IndexName: 'GSI-Review',
    KeyConditionExpression: 'GSI_ReviewPK = :review',
    ExpressionAttributeValues: {
      ':review': 'REVIEW#PENDING'
    }
  });
  // 只查詢 GSI → 只掃描待審核文件,不掃描全表
}

洛基看著這個模式:「所以關鍵是在狀態變更時,動態新增或移除 GSI 鍵?」

「正確,」大師說,「這就是稀疏索引的維護策略。Hippo 請展示一下適用場景的比較圖。」

「馬上來!」Hippo立刻接話。

https://ithelp.ithome.com.tw/upload/images/20251009/20178813mf9wcZvfZq.jpg


完整索引策略設計

大師回到投影螢幕,展示完整設計:

// 最終索引策略

const indexStrategy = {
  主表設計: {
    說明: '支援 5 個核心查詢',
    PK_patterns: ['USER#xxx', 'PROJECT#xxx', 'TASK#xxx'],
    支援查詢: [1, 2, 3, 7]
  },

  LSI設計: {
    LSI_CommentLikes: {
      PK: 'TASK#xxx',
      LSK: 'LIKES#nnnn#COMMENT#xxx',
      支援查詢: [4],
      原因: '同實體不同排序,資料量小',
      資料量評估: '50 評論/任務 × 2KB × 2 (主表+LSI) = 200KB << 10GB'
    }
  },

  GSI設計: {
    GSI_ProjectStatus: {
      PK: 'STATUS#ACTIVE#SY210-03',  // 複合 PK 避免熱分區
      SK: 'PROJECT#xxx',
      支援查詢: [5],
      投影: 'INCLUDE [projectName, createdDate]',
      容量: '100 RCU, 50 WCU'
    },

    GSI_TaskPriority: {
      PK: 'PRIORITY#HIGH#PROJECT#xxx',  // 複合 PK
      SK: 'TASK#xxx',
      支援查詢: [6],
      投影: 'INCLUDE [taskName, assignedTo, dueDate]',
      容量: '80 RCU, 40 WCU'
    },

    GSI_PendingReview: {
      PK: 'REVIEW#PENDING',
      SK: 'DOCUMENT#xxx',
      支援查詢: [8],
      投影: 'KEYS_ONLY',  // 最小投影
      容量: '1 RCU, 1 WCU',
      特性: '稀疏索引(只有 5% 項目)'
    }
  },

  成本估算: {
    主表: '100 RCU, 100 WCU = $15/月',
    LSI: '共用主表容量 = $0',
    GSI1: '100 RCU, 50 WCU = $11/月',
    GSI2: '80 RCU, 40 WCU = $9/月',
    GSI3: '1 RCU, 1 WCU = $0.15/月',
    總計: '$35.15/月 (vs Scan 方案 $300+/月)'
  },

  設計原則總結: {
    1: '主表優先最高頻查詢',
    2: 'LSI 用於同實體排序 + 資料量可控',
    3: 'GSI 低基數屬性用複合 PK',
    4: '低頻大資料量查詢仍需 GSI',
    5: '稀疏索引優化部分資料查詢',
    6: '投影類型根據查詢需求選擇',
    7: '成本估算驗證設計合理性'
  }
};

洛基仔細看著這個完整設計,感覺所有片段都連接起來了。

「這就是索引策略的系統化思考,」大師說,「不是單點優化,而是整體平衡。」


索引演進策略

大師繼續:「但設計不是一次性的。系統會成長,需求會變化。」

他展示演進策略:

索引演進的三階段:

階段 1:MVP(最小可行產品)
├─ 只建立絕對必要的索引
├─ 核心查詢:1, 2, 3 (主表支援)
├─ 次要查詢:暫時用 Scan 或應用層處理
└─ 觀察實際使用模式

階段 2:優化(基於監控數據)
├─ CloudWatch 分析:
│  - 哪些查詢 QPS 超出預期?
│  - 哪些 Scan 成本過高?
│  - 哪些查詢延遲過高?
├─ 根據數據決定:
│  - 新增 GSI_ProjectStatus (查詢 5 實際 QPS = 150)
│  - 新增 LSI_CommentLikes (查詢 4 實際 QPS = 80)
└─ 漸進式新增索引

階段 3:重構(需求重大變化)
├─ 檢視:
│  - 哪些索引使用率低?
│  - 哪些查詢模式改變了?
│  - 是否有更優的設計?
└─ 重構策略:
   - 刪除未使用的 GSI
   - 調整投影類型
   - 考慮表分割或合併

洛基理解了:「所以不要一開始就建一堆索引,而是根據實際數據逐步優化。」

「對,」大師說,「這就是第23天提到的『始於精簡,隨需擴增』。」


監控與診斷

大師展示監控面板:

// 關鍵監控指標

const monitoringMetrics = {
  容量監控: {
    指標: 'ConsumedReadCapacityUnits / ProvisionedReadCapacityUnits',
    警報: '> 80% 持續 5 分鐘 → 考慮擴容',
    目的: '避免 Throttling'
  },

  索引使用率: {
    GSI1_QueryCount: '平均 120 QPS',
    GSI2_QueryCount: '平均 85 QPS',
    GSI3_QueryCount: '平均 0.006 QPS',
    分析: 'GSI3 使用率極低但成本也極低,保留合理'
  },

  成本分析: {
    主表成本: '$15/月',
    GSI成本: '$20/月',
    總成本: '$35/月',
    vs預算: '符合預期'
  },

  效能分析: {
    查詢延遲_P99: {
      主表Query: '5ms',
      GSI1_Query: '8ms',
      GSI2_Query: '7ms',
      評估: '符合 SLA (<50ms)'
    }
  },

  異常檢測: {
    ThrottlingCount: '0',
    SystemErrors: '0',
    UserErrors: '偶發 ValidationException (查詢參數錯誤)'
  }
};

// 基於監控的決策

const monitoringDecisions = {
  發現: 'GSI2 的 ConsumedWCU 持續 90%',
  原因: '任務建立頻率高於預期',
  行動: '擴容 GSI2 WCU: 40 → 60',
  結果: 'Throttling 風險消除'
};

「監控不只是看數字,」大師說,「更重要的是根據數據做決策。」


從設計到智慧

洛基看著完整的設計方案,心中有種成就感。

他從一個不知道索引為何物的學習者,到現在能夠設計完整的索引策略——這三天的學習密度很高,但每一步都有意義。

大師問:「現在如果讓你總結索引設計的核心,你會怎麼說?」

洛基想了想:

「第一,索引的目的是優化查詢,但不是每個查詢都需要索引。要權衡頻率、資料量、成本。」

「第二,主表設計優先支援最核心的查詢,次要查詢用 GSI/LSI 補強。」

「第三,GSI 適用於新查詢維度,LSI 適用於同實體不同排序。LSI 限制多,預設選 GSI。」

「第四,低基數屬性要小心熱分區,用複合 PK 增加分散度。」

「第五,稀疏索引可以優化部分資料查詢,省成本也快。」

「第六,投影類型影響成本和效能,要根據實際查詢需求選擇。」

「第七,設計不是一次性的,要根據監控數據持續優化。」

大師滿意地點頭:「很完整。你已經建立起索引設計的思維框架了。」


洛基把筆記本闔上。

四天的索引學習,從理解「為什麼需要索引」開始,到 GSI 的成本權衡,再到 LSI 的特殊定位,最後整合成完整的設計決策樹。

他發現自己的思考方式在改變。

以前看到查詢需求,第一反應是「怎麼實現」。現在會先問「是否需要索引」「用什麼類型」「成本如何」「有沒有更好的方案」。

從單純的技術實現,到成本意識、到權衡思維、到演進策略。

大師走到窗邊:「索引設計告一段落。接下來幾天,我們會學習資料生命週期管理——TTL、Streams、備份還原。這些是讓系統長期穩定運行的關鍵。」

「TTL?」洛基問。

「Time To Live,資料過期自動刪除,」大師說,「你有想過資料該怎麼消失嗎?還是打算讓所有資料永久存在?」

洛基愣了一下。他確實沒想過這個問題。

「明天見,」大師說,「準備好面對一個成本災難的案例。」

洛基帶著新的疑問離開茶室。資料的生命週期...這又是一個他從未深入思考過的領域。


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


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

尚未有邦友留言

立即登入留言