DynamoDB 中的單例項目
單例項目 是一個用固定、寫死的鍵的單一資料列,為你的整個應用程式 存放狀態——不是每位使用者或每筆訂單一筆記錄,而是 一 筆記錄,就這樣。功能旗標、一團設定、一個全域的緊急開關: 就是那種關聯式應用會放在單列設定表裡的東西。
從 SQL 過來,你會直覺去找一張 config 資料表,id = 1,再 SELECT * FROM config。在 DynamoDB 裡你用一個寫死的分區鍵做同一件事——
而且因為你永遠知道那個鍵,你用 GetItem來讀它,而不是Query或Scan。
DynamoDB 中的單例項目是什麼?
單例項目是一個存放在固定、寫死鍵下的單一 DynamoDB 資料列,為整個應用程式存放全域狀態——功能旗標、一團設定、系統版本——而非每位使用者或每筆訂單一筆記錄。因為你永遠知道那個鍵,所以用 GetItem 讀取它,並用 update 運算式搭配 condition 運算式來更新它。
- 單例就是一個用常數鍵的項目。 你在程式碼裡把
PK/SK寫死 (例如CONFIG#GLOBAL),而不是用使用者或訂單 id 套模板。 - 用
GetItem讀它,絕不用Scan。 你永遠知道完整的鍵,所以一次 點讀就是一筆一致、可預測的 RCU——沒有篩選、沒有走遍資料表。 - 它在定義上就是一個熱鍵。 每個請求都可能碰到同一個分區, 所以要快取它、讓項目保持小;別把它做成寫入瓶頸。
- 用 update + condition 運算式安全地變更它,而不是在你的應用裡 做讀取-修改-寫回——那正是更新遺失競態藏身之處。
認出這個模式
當資料不歸屬於任何單一實體時,你就有了全域狀態。幾個徵兆:
- 一個對每個人都一樣的旗標(
signup_enabled = false)。 - 一團你的應用開機時讀取的可調參數(速率限制、預設配額)。
- 一個整個系統共用的計數器或版本號,而不是每列一個。
任何歸屬於某位使用者、租戶或訂單的東西都不是單例——那是一個 由該實體 id 作鍵的普通項目。單例是那塊無處可去的剩餘全域 切片。
給它一個常數鍵
整個模式就懸在一個決定上:鍵是一個字面值,不是模板。 對於一個多載單一資料表中的全域功能旗標項目,挑一個固定的 前綴和一個固定的值:
| PK | SK | attributes |
|---|---|---|
| SETTINGS#APP | FLAGS#V1 | signup_enabled, maintenance_mode, ai_search_enabled |
PK = "SETTINGS#APP" 和 SK = "FLAGS#V1" 被烤進程式碼裡。沒有
使用者 id、沒有租戶 id——應用程式每次都要求剛好這一個項目。
那種可預測性正是重點:一個已知的鍵就是一次 GetItem,而一次 GetItem
是 DynamoDB 所提供最便宜、最一致的讀取。
V1 後綴是刻意的。如果旗標 schema 日後形狀變了,你就寫一個
FLAGS#V2 項目並把讀取端切過去,而不是就地變更那個上線中的項目。
為單例鍵加版本,買到一道乾淨的遷移接縫。
用 GetItem 讀它
因為鍵是完全已知的,對於單例你永遠不 Query、也永遠不 Scan。
Scan 會讀整張資料表再在用戶端篩選——這是經典的
Scan 地雷——而為了取一列你可以直接定址的資料,
它是荒謬的殺雞用牛刀。
對 SETTINGS#APP / FLAGS#V1 做一次 GetItem,會在單一一次
強一致或最終一致的讀取中回傳那些旗標。對於 ≤ 4 KB 的項目,AWS 把一次
GetItem 計為最終一致 0.5 RCU、或強一致 1 RCU
(AWS 讀寫容量文件)。
讓單例保持小,這個成本就永遠維持平穩。
讀取路徑就只是:應用開機或一個請求進來,你對固定鍵
GetItem,然後快取結果。流程如下。
固定鍵把一次全域查找變成一次有內建預設路徑的點讀。
注意那條 沒有 分支:一個缺失的單例絕不該讓你當機。退回到
安全的值(功能關、維護開),讓首次部署的空檔或一個壞掉的
鍵以「失敗即關閉」而非開啟收場。
在不發生競態的情況下更新它
陷阱是在你的應用裡用讀取-修改-寫回來更新單例:你
GetItem 旗標、在記憶體裡翻一個、再把整個 PutItem 寫回去。
兩個並行的寫入者都讀到舊項目,而第二個 Put 蓋掉了
第一個的更動。更新遺失。
兩個 DynamoDB 功能不靠應用端鎖就能殺掉這個競態:
- Update 運算式 在伺服器端變更單一屬性,其餘原封不動。
不需要把整個項目重新
Put。 - Condition 運算式 讓寫入只在項目仍如你預期那樣時才成功,
所以一個過期的寫入會被拒絕,並回傳
ConditionalCheckFailedException(AWS condition 運算式文件)。
要翻一個旗標,用一個 SET 只鎖定那個屬性,並用一次
版本遞增來護衛它,讓並行的寫入者無法互相踐踏:
# UpdateItem
Key PK=SETTINGS#APP SK=FLAGS#V1
UpdateExpression SET signup_enabled = :on, schema_version = :next
ConditionExpression schema_version = :current
如果兩個寫入者競爭,第二個的 schema_version = :current 檢查會失敗,
它就對著新鮮的值重試。你可以在
DynamoDB 運算式建構器裡,先搭好名稱、值
和這個確切的運算式形狀,再把它接進程式碼。想更深入了解這些運算子,
參見update 運算式慣用法指南。
留意熱鍵
單例在構造上就是一個熱鍵——你應用的每個部分都可能讀 同一個分區。如果你有快取,這對讀取沒問題,但這是這個模式 唯一真正的風險。
- 積極地快取。 每個行程(或每 N 秒)讀一次旗標,而不是 每個請求都讀。單例的值是最便宜可記憶的東西。
- 別把它做成寫入熱點。 一個管理員一天翻幾次的旗標 不算什麼。一個你每個請求都遞增的單例則是分區吞吐量的 瓶頸——那是計數器問題,不是單例問題。
- 保持它小。 讀取成本以 4 KB 為單位隨項目大小成長。一團臃腫的 設定使每次開機都比它需要的更貴。
如果你真的需要一個高寫入的全域計數器,單例就是錯的 形狀——把它分片到 N 個項目,並在讀取時加總。那是另一種模式。
單例 vs 每實體項目
界線單純就是 資料歸屬於什麼。
| 單例項目 | 每實體項目 | |
|---|---|---|
| 鍵 | 寫死的常數(SETTINGS#APP) | 用 id 套模板(USER#42) |
| 有幾個 | 剛好一個 | 每位使用者/訂單/租戶一個 |
| 典型讀取 | 對已知鍵做 GetItem | 依實體做 GetItem 或 Query |
| 範圍 | 整個應用程式 | 單一實體 |
| 用於 | 全域旗標、設定、系統版本 | 個人檔案、訂單、任何每 id 的東西 |
如果你發現自己想要同一種的 兩個 單例,那你就不是有一個 單例——你有的是每實體項目,而你忘了用來作鍵的那個實體 (比方說每租戶設定)。
陷阱與後續步驟
- 別
Scan它。 你知道鍵;直接定址。 - 別讀取-修改-寫回它。 用 update + condition 運算式。
- 別讓它悄悄缺失。 快取未命中時退回安全的值。
- 別用高頻寫入把它壓垮。 那是分片計數器的工作。
單例可以舒舒服服地住在 單一資料表設計裡——它只不過是 你的實體列旁邊,多出來的一個用固定鍵的項目集合。
試用 DynoTable瀏覽你的資料表、用固定鍵找出那一列 單例,並在你建構寫入路徑的同時手動編輯旗標。