這世界沒有什麼事情是不會變的,多數應用程式也會隨著時間需要調整架構,而修改資料庫的 schema 在關聯式資料庫世界中,通常被視為需要慎重為之的大事與挑戰。
作為 No SQL Database 的一員,DynamoDB 一樣採用 無綱要設計 (schemaless design),意即建立表格時只需要定義主鍵屬性,其他的屬性可以在過程中隨時加入。看起來充滿彈性,然而世上的事都有 tade-off,既要又要的情況很少能存在,想加就加的快樂想像,也只是想像,實務上,我們仍然要透過一些方法來維持系統的穩定和順暢運作。
簡單說,不用停機 DynamoDB 就可以輕易調整 schema,但想要不弄壞既有的應用程式或是達到預期的功能,雖然不用齋戒沐浴三天才能去執行,但該有的謹慎態度才及適當的升級策略仍是必要的。
資料模型演進的應對策略
一般來說,DynamoDB 有三種方式來更新資料模型:
新增屬性 (Adding New Attributes)
隨著應用程式的發展,可能會需要儲存新的資料屬性。由於 DynamoDB 的 schemaless 特性,你可以在寫入新資料時,直接新增欄位,無需修改資料表的結構。
但對於舊資料來說,這些欄位並不存在,如果應用程式本身可以接受缺少該欄位的情況,那只需要在應用層提供缺值時的預設處理邏輯即可。然而,若新增的屬性在功能上是必要的,就需要在舊資料上補完這些欄位。
常見的一種策略是在資料變動時順便加入新欄位。例如,在會員系統中新增了「訂閱電子報」的功能。新用戶註冊時會直接選擇是否訂閱,因此自然具備這個欄位資訊;但對於既有會員,則可以延後處理,例如等到舉辦舊會員回流活動時,再引導他們設定訂閱偏好,屆時補上資料即可。
但也存在某些情境,新增欄位對於所有資料都是「必填」的。例如:原本所有會員沒有等級概念,後來系統升級導入了消費等級制度,每位會員都必須指定特定等級,例如「鑽石會員」、「白金會員」等。若會員缺少等級資訊,系統可能無法正確運作。在這種情況下,就需要全面性地回補資料(backfill)。
這種情境就可以透過 AWS Step Functions 或 AWS Glue 等工具,執行如根據會員過去一年的消費金額,自動計算並賦予相應的角色,批次補上資料。
不過要特別注意,大規模資料更新會有處理時間差。而 DynamoDB 又是設計為不中斷服務的系統,實務上無法也不應該為了資料遷移而停機。因此,如何設計應用程式邏輯以容忍這段資料「不一致」的過渡期,是升級設計中非常重要的一環。
常見的處理的策略如:
-
在讀取資料時補預設值:若欄位不存在,可在應用程式層自動套用安全預設值,避免異常行為。
-
條件限制寫入:可在寫入時使用條件檢查,若新欄位不存在,則拒絕資料更新或新增。
-
流量切換策略:透過流量分批導入、Feature Flag 或藍綠部署,只讓已完成升級的用戶使用新功能。
以上都是常見的處理方法,關鍵在於理解資料更新會伴隨時間差而有不一致狀態,應用程式必須做好彈性處理與容錯設計,才能穩定提供服務。
此外,進行大規模資料補齊時,由於 DynamoDB 對於批次操作有嚴格的限制,同時大量寫入也可能觸發 throttling,影響正常業務流量。建議採用漸進式 backfill 策略,規劃好批次更新的數量、同時進行的程式數量、失敗重試次數、批次間短暫暫停等,都有助於順利完成任務。
新增實體 (Adding New Entities)
由於 DynamoDB 沒有 Join 的機制,因此新增功能時,情境如果是兩個不同實體產生關係,不像關聯式資料庫,開個新表單、指定個外部鍵就結束。
例如原本商城想加入最愛商品的機制,通常就是讓用戶有個最愛商品的清單。也許有人會想到,我就新增一個 favorite_list 的屬性,把商品的 id 加進去就好啦。
雖然這個作法不是行不通,但存在一個挑戰,我們無法預測用戶究竟會加入多少東西進去清單中,DynomoDB 有單筆項目 400 KB 的限制,萬一用戶本身的資料就已經存了不少個人的資訊,再加上這個增長可能沒有限制的屬性,勢必存在風險。
因此更好的作法,應當是透過 DynamoDB 常見在同一個資料表中存放多種實體的方法來達成。
假設我們原來的用戶資料長這樣:
{
"PK": "CUSTOMER#123",
"SK": "CUSTOMER#123",
"firstname": "John",
"lastname": "Doe",
"fullname": "John Doe",
"email": "johndoe@example.com",
"address": "somewhere out there",
"createdAt": "2025-07-24T10:00:00Z",
"entityType": "Customer"
}
我們新的實體可以這樣設計:
{
"PK": "CUSTOMER#123",
"SK": "FAVORITE#456",
"itemId": "456",
"label": "V60 漂白濾紙",
"createdAt": "2025-07-24T11:00:00Z",
"entityType": "Favorite"
},
{
"PK": "CUSTOMER#123",
"SK": "FAVORITE#789",
"itemId": "789"
"label" "衣索比亞-西達摩-G1",
"createdAt": "2025-07-24T11:00:00Z",
"entityType": "Favorite"
}
這樣一來,就可以透過 DynamoDB 的Query,用以下的方式取得所有的最愛商品清單。
PK = "CUSTOMER#123"
begins_with(SK, "FAVORITE#")
這是 DynamoDB 很常見的 Single Table Design 的技巧,透過存取模式來設計資料模型的內在關聯,這對於高效能存取以及彈性地擴充應用程式都非常重要。對於習慣關聯式資料庫的 Join 手法開發者來說,也是必須要打破的一個思維習慣。
新增 GSI(Global Secondary Index)
SQL 世界裡,我們經常為提升查詢效能而建立索引,而本身效能就極高的 DynamoDB ,比起解決效能問題,GSI 更通用的情況是為了處理不同的存取模式而登場。
如果沒有意識到這個根本性的差異,就會造成資料模型設計上的一大陷阱,除了很容易去碰觸到一個資料表只能使用 20 個 GSI 上限之外,更糟的是在成本迅速高漲。
因此成本意識的培養是關鍵的第一步。每當你想建立新的 GSI 時,必須問自己:「這個 GSI 會讓我的儲存成本增加多少?」、「是否真的需要為了這 5% 的查詢需求,增加 100% 的成本?」。
為什麼和 SQL 資料庫相比,成本會有這麼大的差異,這值得進一步解釋。
如果我們在關聯式資料庫的 users 表單上,建一個 email 的索引,例如:
CREATE INDEX idx_email ON users(email);
這個索引的內容會是:
email (索引鍵) | row_id (指標) |
---|---|
“alice@example.com” | 0x1A2B3C4D |
”bob@domain.org” | 0x2B3C4D5E |
”charlie@test.com” | 0x3C4D5E6F |
索引儲存的內容包含索引鍵值 (email),是實際的 email 字串以及行指標 (row_id),是指向原始資料列的物理位置。它並沒有存下其他額外的欄位值,甚至是主鍵 id。在資料量成本上算是輕量,而存取過程中,因為指過原始資料的位置來存取,因此回傳速度也極快。
但 DynamoDB 卻不是這樣運作的,GSI 實際上就是製作一個資料的複本。
例如你原本使用 User 的 ID 作為 Primary key,現在需要使用 User 的 email 來查詢,所以你建立了一個 idx_email 的 GSI,如果你又沒有限定哪些欄位寫過去這個索引,當你寫入資料到主資料表時,也要同時再寫一份一模一樣的資料到索引,當查詢時是直接在這索引中作業,不會再回到主表中。這樣資料儲存成本不但高,更是浪費了不必要的寫入以及之後的讀取成本。因此使用 GSI 時,仔細地規劃 GSI projection,只擇用必要欄位到索引中,是必要且良好的習慣。
在看 DynamoDB 的成本時,我們更是要時時將 WCU/RCU 放在心上。
我們看個實際的例子:
await dynamodb.putItem({
TableName: 'orders',
Item: {
PK: 'ORDER#12345',
SK: 'METADATA',
GSI1PK: 'USER#123', // 觸發 GSI1 寫入
GSI1SK: 'ORDER#2024-01-15',
GSI2PK: 'PRODUCT#456', // 觸發 GSI2 寫入
GSI2SK: 'ORDER#2024-01-15',
GSI3PK: 'STATUS#pending', // 觸發 GSI3 寫入
GSI3SK: 'ORDER#2024-01-15',
// ... 其他屬性
}
}).promise();
// 實際 WCU 消耗:
// 主表: 1 WCU
// GSI1: 1 WCU
// GSI2: 1 WCU
// GSI3: 1 WCU
// 總計: 4 WCU (成本增加 300%)
查詢時的 RCU 也是一樣,例如你查詢 email 時,其實只需要用戶的 id 和姓名回傳,但卻將所有的用戶資料帶上,就可能會增加不必要的 RCU。
更關鍵的是當某個 GSI 的 WCU 耗盡時,影響的不是只有它自己這個索引,而是連同主表與其他索引的寫入行為通通會被拒絕,直到有騰出新的 WCU 容量為止。即便是沒有指定 WCU 上限的 On-Demand 模式,也有突發容量的限制,例如初始時有4000 WCU/RCU 上限,而隨著存取容量改變,也會有一些規則限制上限。
因此 GSI 雖然看起來非常好用,但卻非常容易濫用。而避免濫用除了謹記成本去妥善規劃之外,更重要的是理解 GSI 與主資料表一樣,良好規劃的 Primay key 是可以容納不同的實體,同一個索引是可以服務不同的查詢,重用思維則是設計的核心。
舉例來說:
// 主表結構
{
PK: "ORDER#12345",
SK: "METADATA",
GSI1PK: "USER#user123",
GSI1SK: "ORDER#2024-01-15#12345",
GSI2PK: "STATUS#pending",
GSI2SK: "2024-01-15#ORDER#12345",
// 其他屬性...
}
// 同一筆訂單的商品項目
{
PK: "ORDER#12345",
SK: "ITEM#item456",
GSI1PK: "PRODUCT#prod789",
GSI1SK: "ORDER#2024-01-15#12345",
GSI2PK: "CATEGORY#electronics",
GSI2SK: "2024-01-15#ITEM#item456"
}
如同 PK 與 SK 存入了不同的實體,GSI1PK、GSI1SK 和 GSI2PK、GSI2SK 一樣可以同時容納不同的實體,透過這個特性,我們就能在同一個索引中查詢不一樣的內容。
這樣的設計讓 GSI1 可以支援:
- 查詢用戶的所有訂單
- 查詢特定商品的所有訂單
- 按時間排序的查詢
而 GSI2 可以支援:
- 按狀態查詢訂單
- 按類別查詢商品項目
另外,前面提及的 Projection 的問題,在建立索引時有 ALL、KEYS_ONLY 和 INCLUDE 三種作法。
通常 ALL 不會是好的選擇,KEYS_ONLY 則是只投射主鍵和 GSI 鍵值,在成本上最經濟也最彈性,但查詢時需要額外的步驟回主表獲取完整資料。
INCLUDE 則是讓你選擇必要的欄位投射到索引中,因此查詢時可以直接獲得所需資料,無需回主表查詢。但如果要讓同一個 GSI 容納不同的實體,INCLUDE 指定的欄位可能在某些實體中不存在,雖然 DynamoDB 不會因欄位缺失而報錯,但在應用程式層面仍需做好缺值處理和預設值管理,避免產生意外錯誤。
Projection 類型詳細說明
KEYS_ONLY | INCLUDE | ALL | |
---|---|---|---|
儲存 | 主鍵和 GSI 鍵 | 指定的非鍵屬性 | 所有屬性 |
優點 | 儲存成本最低,但需要額外的 GetItem 或 BatchGetItem 操作 | 平衡了成本和功能性 | 成本最高,但查詢最方便 |
適用 | 存在性檢查、計數、分頁等輕量查詢 | 已知固定查詢模式,可預測需要哪些欄位 | 查詢模式多變且效能要求極高的場景 |
優秀的 DynamoDB 開發人員會像建築師一樣思考:如何用最少的結構支撐最多的功能需求。他們會預期應用程式的演進,設計具有彈性的 GSI 結構,讓新的查詢需求可以搭便車使用現有的索引。
DynamoDB 東京地區 (ap-northeast-1) WCU/RCU 定價參考(2025 年 7 月)
Provisioned 模式
項目 | 價格 | 說明 |
---|---|---|
Write Capacity Unit (WCU) | $0.000715 USD/小時 | 每小時每個 WCU |
Read Capacity Unit (RCU) | $0.000143 USD/小時 | 每小時每個 RCU |
On-Demand 模式
項目 | 價格 | 說明 |
---|---|---|
Write Request Unit | $1.375 USD/百萬次 | 每百萬次寫入請求 |
Read Request Unit | $0.275 USD/百萬次 | 每百萬次讀取請求 |
儲存費用
項目 | 價格 | 說明 |
---|---|---|
標準儲存 | $0.285 USD/GB/月 | 包含主表和 GSI |
當你的應用流量較小時,On-Demand 模式較為經濟;但隨著流量增長,Provisioned 模式的成本優勢會變得明顯。更重要的是,每增加一個 GSI,寫入成本就會線性增加,這也是為什麼 GSI 重用設計在成本上佔有重要性。