前言

DynamoDB 長久以來一直有個非常強烈的最佳實踐:「盡可能把資料整合到一張表裡(single-table design)」。
不過近來在實務現場,開始出現一股反思:「真的一定要這樣嗎?」

而且就在去年底(2025 年 11 月),AWS 在 GSI(全域次要索引)中導入了多屬性複合鍵,使得過去在 single-table 設計背後默默支撐著的「某種職人技」正逐漸變得不再必要。

本文會透過同一個應用程式題材,同時實際設計 single-table 版與 multi-table 版,來看:

  • 為什麼過去會推薦 single-table
  • 為什麼現在 multi-table 的討論變得熱烈
  • GSI 多屬性鍵到底改變了什麼

題材:企業用技能評估服務

舉例來說,假設有這樣一個 BtoB 服務:

  • 企業(租戶)簽約,該企業底下的使用者(會員)接受診斷(評估)
  • 診斷結果會持續累積,並可從個人頁面與企業管理儀表板查閱

實體關係很單純,就是兩層的一對多。

而這個服務需要支援的存取模式如下 4 種:

#存取模式主要使用者1以 userId 取得使用者資訊整體應用2以 companyId 取得所屬使用者清單管理介面3取得某位使用者的診斷結果,並依新到舊排序個人頁面4以企業 × 診斷類型 × 狀態 × 期間 篩選結果管理儀表板重點是 #4。例如:「想看自家在『壓力檢測』診斷中『已完成』的結果,而且只看這個月的資料」這種把多個屬性用 AND 疊加起來搜尋的需求。這正是 single-table 時代最痛苦的部分,也會是後半段的主角。

1. 為什麼以前會說 single-table 才是正解

DynamoDB 沒有 JOIN。它無法像 RDB 那樣標準地「把使用者和該使用者的結果 join 起來查」。因此就產生了這個想法:「會一起取用的資料,就放在一起」。

把這個概念推到極致,就會形成一種設計:利用 PK(分區鍵)與 SK(排序鍵)針對不同實體做鍵值重疊使用(key overloading),讓多種資料型態共存於同一張表中。

single-table 版設計

只使用一張 AppTable。在 PK / SK 裡放入帶前綴的值來區分實體。

實體PKSKCompany(中繼資訊)COMPANY#{companyId}``#METAUserCOMPANY#{companyId}``USER#{userId}AssessmentResultUSER#{userId}``RESULT#{createdAt}#{resultId}實際塞入資料後,表內會像這樣。請注意:擁有相同 PK 的項目會形成一個資料集合(item collection)

AppTable
┌──────────────────────┬────────────────────────────────┬─────────────────────────────┐
│ PK                   │ SK                             │ 屬性(節錄)                │
├──────────────────────┼────────────────────────────────┼─────────────────────────────┤
│ COMPANY#c001         │ #META                          │ name: "Acme社"              │ ┐
│ COMPANY#c001         │ USER#u123                      │ name: "山田"                │ ├ item collection A
│ COMPANY#c001         │ USER#u456                      │ name: "佐藤"                │ ┘ (企業 c001 的集合)
├──────────────────────┼────────────────────────────────┼─────────────────────────────┤
│ USER#u123            │ RESULT#1781000000#r001         │ type:"stress", status:"done"│ ┐
│ USER#u123            │ RESULT#1781600000#r002         │ type:"skill",  status:"done"│ ├ item collection B
│ USER#u123            │ RESULT#1782200000#r003         │ type:"stress", status:"todo"│ ┘ (使用者 u123 的集合)
└──────────────────────┴────────────────────────────────┴─────────────────────────────┘

這種設計最令人愉快的地方,就是相關資料可以一次 Query 就拿到

📝 上圖中的 type 是為了節省版面,實際上是 assessmentType 的簡寫。另外本文程式碼以可讀性優先做了簡化,ddb 預設是 AWS SDK for JavaScript v3 的 DynamoDBDocumentDynamoDBDocument.from(client))實例。

// #2 取得企業 c001 所屬使用者清單 ── 以 1 次 Query 讀出 item collection A
await ddb.query({
  TableName: "AppTable",
  KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
  ExpressionAttributeValues: { ":pk": "COMPANY#c001", ":prefix": "USER#" },
});

// #3 取得使用者 u123 的結果,並依新到舊排序 ── 以 1 次 Query 讀出 item collection B
await ddb.query({
  TableName: "AppTable",
  KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
  ExpressionAttributeValues: { ":pk": "USER#u123", ":prefix": "RESULT#" },
  ScanIndexForward: false, // 新到舊
});

沒有 JOIN,也不需要多次讀取,只要 1 次 Query 就能把相關資料一起回傳。這種「把相關資料整批取出」的存取模式,讀取次數與延遲都能壓得很低這也是過去一直推薦 single-table 的最大理由。至少在這類模式下,single-table 確實比較有優勢。

痛點會出現在「想用多個屬性搜尋」時

問題出在 #4「企業 × 診斷類型 × 狀態 × 期間」。基礎表的 PK / SK 做不到這種查詢,因此就得建立 GSI。

但問題是,傳統 GSI 的分區鍵與排序鍵都只能各自擁有 1 個屬性。如果想用多個屬性來篩選,只能在應用程式端自己把這些屬性用 # 串起來,手工做出一個合成鍵(synthetic key)

// 寫入時:為了 GSI 查詢,得自己「製造」一個串接鍵
const item = {
  PK: "USER#u123",
  SK: `RESULT#${createdAt}#${resultId}`,
  // ↓ 只為了在 GSI1 上查詢 #4 而額外準備的屬性
  GSI1PK: `COMPANY#${companyId}`,
  // createdAt 本來是數值,但一旦串接,就只能轉成字串(← 痛點②的伏筆)
  GSI1SK: `${assessmentType}#${status}#${toYmd(createdAt)}`, // 例: "stress#done#2026-06-10"
  // ...實際資料
};
// #4 的查詢:靠串接字串做前綴比對撐住
await ddb.query({
  TableName: "AppTable",
  IndexName: "GSI1",
  KeyConditionExpression: "GSI1PK = :pk AND begins_with(GSI1SK, :sk)",
  ExpressionAttributeValues: {
    ":pk": "COMPANY#c001",
    ":sk": "stress#done#2026-06", // 期間最多只能很自然地做到月份等級…
  },
});

這種合成鍵做法,有 3 個雖然不起眼但很實際的痛點:

  1. 順序事後不能改。 assessmentType#status#createdAt 這種串接順序,直接把可查詢能力鎖死了。一旦想改成「只指定類型與期間,不指定狀態」之類的查詢,這種順序就查不到。
  2. 型別會被壓扁成字串。 createdAt 原本是數值(epoch 秒),但串接後就變字串了。這樣要查 2026-06-01 〜 06-15 這種範圍時,只能依賴對整個合成鍵做字串字典序的 BETWEEN(稍微混入不預期字元就容易出 bug),或者退而求其次,只能用前綴比對(begins_with)做像「月份」這種較粗的粒度
  3. 後補很麻煩。 如果這個 GSI 是之後才加上去的,還得讓應用程式把既有所有項目重新補上 GSI1SK,也就是要做一次 backfill。

能不能接受這種「合成鍵的職人技」,其實就是過去採用 single-table 的隱藏考題。

2. 為什麼現在 multi-table 討論變熱

這一兩年,「把所有東西硬塞進同一張表」這件事開始被更多人重新檢視。最具代表性的,就是 AWS 官方文件本身的語氣變化。single-table design 流行的 2019 年左右,官方文件曾有一個很強勢的斷言:「大多數設計良好的應用程式只需要一張表。」當時許多文章都大量引用這句話,也成了這波風潮的起點之一。不過現在再看官方文件,這種斷言已經不見了。雖然仍然主張「原則上表的數量應盡量維持精簡」,但也明確寫出「高吞吐量的時間序列資料、或存取模式差異很大的資料集是例外」,在另一頁也改成了較保留的說法,變成「建議考慮使用單一資料表」。AWS 本身也可解讀為,已經從原教旨式立場退了一步。

從實務判斷的角度,以下幾點也越來越被重視:

  • 團隊規模變大/多個團隊共用同一張表時,容易出事。 當你改了某個實體的鍵結構,使用同一張表的其他團隊查詢也可能被連帶弄壞。
  • 希望針對不同實體採用不同的運作設定。 容量模式、TTL、備份、表類型(Standard / Standard-IA)這些都是表層級設定;如果用 single-table,所有實體就只能共用同一套。
  • 存取模式還沒穩定。 在產品早期,「之後才會再增加查詢需求」幾乎是常態。multi-table 的好處是,可以把影響範圍限制在實體層級,新增或重做 GSI 也比較不會波及整體。

簡單說,就是在 single-table 的「一次 Query 取得相關資料」這種效能優勢,與 multi-table 的「可讀性、可維護性、運作獨立性」之間,依工作負載與團隊結構做取捨,這種思維越來越成熟了。

multi-table 版設計

把同一個應用程式改成 multi-table,會變成這樣:

表格PKSKGSICompanies``companyId——Users``userIdbyCompany:PK=companyId, SK=createdAt``AssessmentResults``resultIdbyUser:PK=userId, SK=createdAt 以及其他```
┌─ Companies ────────────────┐
│ PK: companyId │
│ name │
└────────────────────────────┘

┌─ Users ────────────────────┐
│ PK: userId │
│ companyId, name │
│ GSI "byCompany": │
│ PK=companyId, SK=createdAt ← 用來查企業底下的使用者
└────────────────────────────┘

┌─ AssessmentResults ────────┐
│ PK: resultId │
│ userId, assessmentType, │
│ status, createdAt │
│ GSI "byUser": │
│ PK=userId, SK=createdAt ← 用來查使用者的結果
│ GSI "byCompany"(#4用): │
│ PK=companyId, ... ← 管理儀表板的篩選
└────────────────────────────┘


每張表都彼此獨立,所以:

- **就算只在 `AssessmentResults` 上新增 #4 用的 GSI,也不會影響使用者查詢系統**
- 每張表都能各自設定容量、TTL、備份

但反過來,如果想把使用者與其結果一次拿到,就需要多次讀取(例如 `BatchGetItem`),**single-table 那種「一次全部拿齊」的魔法就消失了**。

這裡先整理一下論點:

> single-table 最大的動機是「**降低相關資料的組合成本**」。而讓這個組合成立的,是用 `#` 串接所形成的**合成鍵職人技**。

而在 2025 年 11 月,AWS 正面碰到了這個職人技。

[](#3-gsi%E5%A4%9A%E5%B1%AC%E6%80%A7%E9%8D%B5%E5%A4%A7%E5%A5%97%E4%BB%80%E9%BA%BC%E5%A4%89%E5%8C%96)3. GSI 多屬性鍵改變了什麼
======================================================================================================================================================================

就在去年底(2025 年 11 月 19 日),DynamoDB 開始支援 **GSI 最多 8 個屬性(分區鍵最多 4 個、排序鍵最多 4 個)的複合鍵**。也就是說,過去 GSI 鍵只能「一個屬性對一個屬性」的限制,現在可以**直接把多個屬性指定進去**。

[](#1%E7%AB%A0%E7%9A%84%E7%97%9B%E9%BB%9E%E5%A4%A7%E5%A4%9A%E9%83%A8%E5%88%86%E8%A2%AB%E8%A7%A3%E6%B1%BA)第一章的大多數痛點都被解掉了
---------------------------------------------------------------------------------------------------------------------------------------------------

第一章裡那個「為了 GSI 查詢而人工串接」的鍵,現在可以這樣寫。

這裡先以第二章的 multi-table 版(`AssessmentResults` 表)為例。即使是 single-table 版(`AppTable`),概念也相同:把重疊後的值(例如 `COMPANY#c001`)放到 `GSI1PK`,並把排序鍵改成 `[assessmentType, status, createdAt]` 這種多屬性鍵,就能用同樣方式查詢。過渡期的設計也能這樣做。

// 新版:不再串接,直接把屬性拿來當 GSI 鍵
// 將 GSI1 定義為 PK=companyId / SK=[assessmentType, status, createdAt]
const item = {
resultId,
userId,
companyId,
assessmentType: "stress",
status: "done",
createdAt: 1781072000, // 約 2026 年 6 月,可直接保留為數值!
// 不需要再手動組 GSI1PK / GSI1SK
};

// #4 的查詢:可以直接寫成彼此獨立的屬性條件
await ddb.query({
TableName: "AssessmentResults",
IndexName: "GSI1",
KeyConditionExpression:
"companyId = :c AND assessmentType = :t AND #st = :s AND createdAt BETWEEN :from AND :to",
ExpressionAttributeNames: { "#st": "status" },
ExpressionAttributeValues: {
":c": "c001",
":t": "stress",
":s": "done",
":from": 1780272000, // 2026-06-01,因為是數值,可以很自然地做範圍查詢
":to": 1782863999, // 2026-06-30
},
});


> 需要注意的是,這種寫法成立的前提是:**從左到右依序固定等值條件,範圍查詢(`BETWEEN` 等)只能放在最右邊那一個屬性**(細節後面「踩坑點」會再說)。

第一章提到的 3 個痛點裡,**其中 2 個在這裡被漂亮地解決了**(剩下 1 個後面會說)。

従來的痛點多屬性鍵後會怎樣②`createdAt` 會被壓成字串,難以做範圍搜尋**可保留數值型別**,而且可針對所指定的排序鍵屬性中最右邊的那個做 `BETWEEN`③後加 GSI 時(應用程式端)需要 backfill使用自然存在的屬性即可,**不需要再讓應用程式替所有項目補寫合成鍵**,因此更容易後補另外,寫入時原本那些手動串接/拆解 `GSI1SK` 的程式也消失了,應用程式實作會更單純。

> ⚠️ 這裡說的「不需要 backfill」,是指**不需要由應用程式替既有項目補寫合成鍵屬性**。DynamoDB 在建立新的 GSI 時,底層仍然會跑內部建構流程(AWS 也正是把這階段稱作 *Backfilling*)。表越大,建立所需時間與資源就越多,在變成 `ACTIVE` 之前也不能查詢該 GSI。它不是「一瞬間就長出 GSI」的魔法,這點要特別注意。

> ⚠️ 另外,「後補變容易」不等於「可以隨便做」。GSI 只會回傳**已投影(projected)**的屬性。若維持 `KEYS_ONLY`,查 GSI 時就只能拿到鍵值(加上索引鍵)而已;如果還需要其他屬性,就得**在應用程式端另外回頭讀 base table**。和 LSI 不同,**GSI 沒有從 base table 自動補非投影屬性的機制**。想在查詢結果中直接拿到的屬性,基本上仍要透過 `INCLUDE` 或 `ALL` 投影進去。

[](#%E4%BD%86%E6%98%AF%E4%B8%8D%E8%A6%81%E9%81%8E%E5%BA%A6%E6%A8%82%E8%A7%80%E5%93%88%E5%9D%91%E9%BB%9E)不過,別太樂觀(踩坑點)
----------------------------------------------------------------------------------------------------------------------------------------------------

如果把它解讀成「8 個屬性可以隨便查」,就會踩雷。實際使用後可以體會到 4 個限制:

- **改變的只有 GSI。** 基礎表的 `PK` / `SK` 還是像以前一樣,一次只能各放 1 個屬性。`USER#...` 這種串接字串並沒有從世界上消失(本文的 multi-table 版裡,基礎表鍵的設計也沒有改)。
- **排序鍵只能按照「從左到右」生效。** 多個排序鍵屬性會階層式地被評估,**只有查詢中指定的排序鍵屬性裡最右邊的那一個,可以使用範圍條件(例如 `BETWEEN`)**。如果 `SK=[assessmentType, status, createdAt]`,就代表要先把 `assessmentType` 與 `status` 都用等值固定住,然後再對右邊的 `createdAt` 做範圍搜尋。
- **不要把太多屬性塞進 PK。** **組成分區鍵的所有屬性都必須等值指定**。因此,像 `PK=[companyId, assessmentType]` 這種把屬性往 PK 端集中寫的方式,在這個案例裡其實是**壞選擇**。因為 `assessmentType` 會變成必須永遠指定,導致你無法再用「只靠 `companyId` 就列出企業所有結果(不管診斷類型)」這種查詢(第一章的合成鍵版之所以用 `GSI1PK=COMPANY#c001`,就是為了保留這種彈性)。**PK 應維持為單獨的 `companyId`,而想要篩選的屬性則放在排序鍵側(`SK=[assessmentType, status, createdAt]`)**,這才是這個案例的基本寫法。這樣就能保留企業層級查詢的彈性,同時實現 #4,而且前面那段 `KeyConditionExpression` 一個字都不用改。
- **而且第 1 點「順序不能事後改」其實還在。** 這是最重要的注意事項。即使屬性看起來彼此獨立,實際上的評估順序仍然被建立時的 key schema 固定住,**而且 GSI 的 key schema 之後無法修改**。如果你一開始用 `SK=[assessmentType, status, createdAt]` 建好之後,後來想改成「不要指定 `assessmentType`,只想用 `status` + 期間」的查詢,那最終還是得**再新增另一個 GSI**。這一點和合成鍵時代,本質上沒有差別。

不過,正因為有②③,效益才真正出現。就算之後想新增一個順序不同的新 GSI,也只要**指定既有屬性的組合**即可,不需要再讓應用程式去手工寫入合成鍵。也就是說,多屬性鍵並沒有消除「順序」這個問題,但它大幅降低了「當你想改順序時要付出的成本」,這樣理解最精確。

[](#%E8%A3%9C%E8%B6%B3multi-table-%E4%B8%8D%E6%98%AF%E6%8A%8A%E6%89%80%E6%9C%89%E6%9D%B1%E8%A5%BF%E9%83%BD%E6%8B%86%E9%96%8B)補充:multi-table 不是把所有東西都拆開
==========================================================================================================================================================================

看到這裡,你可能會想:「那 multi-table 不就是每個實體都開一張表嗎?」其實不是。multi-table 的關鍵在於**切分表的粒度**。

例如,假設這個服務之後新增更多診斷內容:`壓力檢測`、`技能診斷`、`適性診斷`……這種情況下,**按照診斷類型各自切表其實是反模式**。因為不管類型怎麼變,存取模式其實都一樣(「使用者結果清單」、「企業 × 類型 × 狀態 × 期間搜尋」)。

正確做法是,**把「診斷」這個領域抽成一張表(`AssessmentResults`),然後在表內用 `assessmentType` 屬性區分不同類型**。
             ┌─ Companies 表(企業領域)

依領域切分 ───────┼─ Users 表(使用者領域)
└─ AssessmentResults 表(診斷領域)

└─ 在這張表內集中管理
壓力檢測、技能診斷、適性診斷都共存
透過 assessmentType 屬性 + GSI 多屬性鍵來區分與搜尋



也就是說,實務上的設計會是:

- **在領域邊界切表**(這是 multi-table 的思路)
- **在領域內,把存取模式相同的資料放在一起,並用鍵來區分**(這又帶有 single-table 的思路)

這是一種**兩層式的取捨**。不是「全都放一張表」或「全都拆開」二選一。就算診斷類型從 3 個增加到 10 個,也不需要一直加表或加 GSI,只要多加屬性就能處理——多屬性鍵正好是支撐這種「在領域內集中管理」的工具。

[](#%E6%9C%80%E5%BE%8C%E8%A9%B2%E6%80%8E%E9%BA%BC%E9%81%B8)總結:最後到底怎麼選?
===============================================================================================================================

綜合新功能後,我現在用來判斷的準則大致如下:

粗略來說:

- **偏 single-table**:相關資料一定要一起取回/吞吐量超高/存取模式幾乎已固定
- **偏 multi-table**:會有多個團隊一起碰/希望不同實體有不同運作設定/存取模式還在變動

而且,**GSI 多屬性鍵並沒有推翻這套判斷**;但它確實減少了那種因為「合成鍵太麻煩,所以乾脆改 multi-table」或反過來「硬忍著職人技也要撐 single-table」這種**從痛苦出發的決策**。我把它理解成:現在終於可以不被技術限制綁住,而是更單純地依工作負載與團隊需求來選設計。這是很好的更新。

[](#%E6%9C%80%E5%BE%8C%E3%81%AB%E3%81%A1%E3%82%87%E3%81%A3%E3%81%A8%E3%81%97%E3%81%9F%E3%81%8A%E7%9F%A5%E3%82%89%E3%81%9B)最後...一個小通知
==========================================================================================================================================

2026 年 9 月 11 日(五)19:30 起,我們公司將舉辦交流活動「Open Sapeet!」。
這次會由包括 Sapeet 工程師在內的同仁登台,分享 AI、產品開發,以及未來的工作方式。
演講之後,也準備了能邊享用輕食與飲料、邊自在交流的時間。

如果你想了解新創團隊真實的開發現場,或想稍微看看 Sapeet 的氛圍,歡迎隨時來玩。
活動詳情與報名請見:<https://connpass.com/event/398374/>

[](#%E5%8F%83%E8%80%83)參考資料
=========================

- [Amazon DynamoDB now supports multi-attribute composite keys in global secondary indexes(AWS What's New, 2025-11-19)](https://aws.amazon.com/about-aws/whats-new/2025/11/amazon-dynamodb-multi-attribute-composite-keys-global-secondary-indexes/)
- [Multi-key support for Global Secondary Index in Amazon DynamoDB(AWS Database Blog)](https://aws.amazon.com/blogs/database/multi-key-support-for-global-secondary-index-in-amazon-dynamodb/)
- [Best practices for DynamoDB table design(AWS Developer Guide)](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-table-design.html)

---

原文出處:https://qiita.com/Suemura/items/ce4cb4290e4e711fdc98

精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝18   💬3   ❤️5
754
🥈
我愛JS
📝2   💬5   ❤️3
143
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登