DynamoDB 中的單例項
單例項是一個具有固定、硬編碼鍵的單行,為你的 整個應用儲存狀態 —— 不是每個使用者或每個訂單一條記錄,而是 一條記錄,僅此而已。特性開關、一個配置資料塊、一個全域性熔斷開關: 就是那類關係式應用會放在一張單行設定表裡的東西。
從 SQL 過來,你會伸手去用一張 config 表,id = 1,再來一句 SELECT * FROM config。在 DynamoDB 裡你用一個硬編碼的分割區
鍵做同樣的事 —— 而且因為你始終知道那個鍵,你用 GetItem 去讀它,而不是
Query 或 Scan。
什麼是 DynamoDB 中的單例項?
單例項是儲存在一個固定、硬編碼鍵下的單個 DynamoDB 行,它為你的整個應用儲存全域性狀態 —— 特性開關、一個配置資料塊、一個系統級版本號 —— 而不是每個使用者或訂單一條記錄。因為你始終知道這個鍵,你用 GetItem 讀它,用 加條件運算式更新它。
- 單例是帶有一個恆定鍵的單個項。 你在程式碼裡硬編碼
PK/SK(例如CONFIG#GLOBAL),而不是模板化地填入某個使用者或訂單 id。 - 用
GetItem讀它,絕不用Scan。 你始終知道完整的鍵,所以一次 點讀具有平穩、可預期的成本(對一個小項最多 1 RCU)—— 無過濾器,無全表遍歷。 - 它按定義就是一個。 每個請求都可能觸及同一個分割區, 所以要快取它並讓項保持精簡;別把它變成一個寫入瓶頸。
- 用更新運算式加條件運算式來安全地改動它,而不是在你的應用裡做讀-改-寫 —— 那正是丟失更新的競態藏身之處。
識別這個模式
當資料不隸屬於任何單個實體時,你就有了全域性狀態。有幾個訊號:
- 一個對所有人都相同的開關(
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 的兩個特性無需應用側加鎖就能消除這個競態:
- 在伺服器端改動一個屬性,其餘原封
不動。無需把整個項重新
Put。 - 讓寫入僅在項仍然如你所期望的
那樣時才成功,因此一個陳舊的寫入會被拒絕,並丟擲
ConditionalCheckFailedException(AWS 條件運算式文件)。
要翻轉一個開關,用 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 運算式構建器裡搭建名稱、值以及
這個確切的運算式形態。要更深入地瞭解這些運算子,參見
更新運算式慣用法指南。
留意熱鍵
單例按構造就是一個熱鍵 —— 你應用的每個部分都可能讀取 同一個分割區。若你有快取,這對讀取無妨,但它是這個模式唯一 真正的風險。
- 積極地快取。 每個程序(或每 N 秒)讀取一次開關,而不是 每個請求都讀。單例的值是最值得記憶化的東西。
- 別讓它成為一個寫入熱點。 一個管理員每天翻幾次的開關 不算什麼。而一個你在每個請求上都遞增的單例則是一個分割區吞吐量 瓶頸 —— 那是一個計數器問題,不是一個單例問題。
- 讓它保持精簡。 讀取成本按 4 KB 的塊隨項大小增長。一個臃腫的 配置資料塊會讓每次啟動都比它本該有的更昂貴。
如果你確實需要一個高寫入的全域性計數器,那單例就是錯誤的 形態 —— 把它分片到 N 個項上,在讀取時求和。那是另一個模式。
單例項與按實體項對比
界線只是_資料隸屬於什麼_。
| 單例項 | 按實體項 | |
|---|---|---|
| 鍵 | 硬編碼常量(SETTINGS#APP) | 用某個 id 模板化(USER#42) |
| 有多少個 | 恰好一個 | 每個使用者/訂單/租戶一個 |
| 典型讀取 | 對已知鍵做 GetItem | 按實體做 GetItem 或 Query |
| 作用域 | 整個應用 | 單個實體 |
| 用於 | 全域性開關、配置、系統版本 | 資料、訂單,任何按 id 的東西 |
如果你發現自己想要_兩個_同類的單例,那你就沒有一個 單例 —— 你有的是一個按實體項,而那個實體正是你忘了拿來 做鍵的東西(比如按租戶的配置)。
陷阱與後續步驟
- 別對它做
Scan。 你知道鍵;直接定址它。 - 別對它做讀-改-寫。 用更新加條件運算式。
- 別讓它悄無聲息地缺失。 快取未命中時預設回退到安全的值。
- 別用高頻寫入去過載它。 那是一個分片計數器的活兒。
單例在單表設計裡安適地 棲身 —— 它無非是又一個帶有固定鍵、與你的實體行並列的項集合。
試用 DynoTable,按固定鍵瀏覽你的表、找到那個單例行, 在你構建寫入路徑的同時手動編輯開關。