中階閱讀時間 3 分鐘

DynamoDB 中的單例項

單例項是一個具有固定、硬編碼鍵的單行,為你的 整個應用儲存狀態 —— 不是每個使用者或每個訂單一條記錄,而是 條記錄,僅此而已。特性開關、一個配置資料塊、一個全域性熔斷開關: 就是那類關係式應用會放在一張單行設定表裡的東西。

從 SQL 過來,你會伸手去用一張 config 表,id = 1,再來一句 SELECT * FROM config。在 DynamoDB 裡你用一個硬編碼的分割區 鍵做同樣的事 —— 而且因為你始終知道那個鍵,你用 GetItem 去讀它,而不是 QueryScan

什麼是 DynamoDB 中的單例項?

單例項是儲存在一個固定、硬編碼鍵下的單個 DynamoDB 行,它為你的整個應用儲存全域性狀態 —— 特性開關、一個配置資料塊、一個系統級版本號 —— 而不是每個使用者或訂單一條記錄。因為你始終知道這個鍵,你用 GetItem 讀它,用 加條件運算式更新它。

  • 單例是帶有一個恆定鍵的單個項。 你在程式碼裡硬編碼 PK/SK (例如 CONFIG#GLOBAL),而不是模板化地填入某個使用者或訂單 id。
  • GetItem 讀它,絕不用 Scan 你始終知道完整的鍵,所以一次 點讀具有平穩、可預期的成本(對一個小項最多 1 RCU)—— 無過濾器,無全表遍歷。
  • 它按定義就是一個 每個請求都可能觸及同一個分割區, 所以要快取它並讓項保持精簡;別把它變成一個寫入瓶頸。
  • 用更新運算式加條件運算式來安全地改動它,而不是在你的應用裡做讀-改-寫 —— 那正是丟失更新的競態藏身之處。

識別這個模式

當資料不隸屬於任何單個實體時,你就有了全域性狀態。有幾個訊號:

  • 一個對所有人都相同的開關(signup_enabled = false)。
  • 一塊你的應用在啟動時讀取的可調引數(限流閾值、預設配額)。
  • 一個針對整個系統、而非按行的計數器或版本號。

任何隸屬於某個使用者、租戶或訂單的東西都不是單例 —— 那是一個 以該實體 id 為鍵的普通項。單例是那個無處安放、剩下來的 全域性切片。

給它一個恆定的鍵

整個模式都繫於一個決定:鍵是一個字面量,而不是一個模板。 對於一個過載單表中的全域性特性開關項,選一個固定的 字首和一個固定的值:

PKSKattributes
SETTINGS#APPFLAGS#V1signup_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 PK=SETTINGS#APPSK=FLAGS#V1找到項了嗎?使用開關,本地快取回退到安全預設值

固定的鍵把一次全域性查詢變成了一次帶有內建預設路徑的點讀。

注意 分支:缺失的單例絕不應讓你崩潰。預設回退到 安全的值(特性、維護),這樣一次首發部署的空檔或一個壞的 鍵就會安全失敗,而不是危險敞開。

在無競態的情況下更新它

陷阱在於用你應用裡的讀-改-寫來更新單例:你 對開關做 GetItem,在記憶體裡翻轉一個,然後把整個東西 PutItem 寫回。 兩個並行的寫入方都讀到了舊項,第二個 Put 覆蓋了第一個的 改動。丟失更新。

DynamoDB 的兩個特性無需應用側加鎖就能消除這個競態:

  • 在伺服器端改動一個屬性,其餘原封 不動。無需把整個項重新 Put
  • 讓寫入僅在項仍然如你所期望的 那樣時才成功,因此一個陳舊的寫入會被拒絕,並丟擲 ConditionalCheckFailedExceptionAWS 條件運算式文件)。

要翻轉一個開關,用 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按實體做 GetItemQuery
作用域整個應用單個實體
用於全域性開關、配置、系統版本資料、訂單,任何按 id 的東西

如果你發現自己想要_兩個_同類的單例,那你就沒有一個 單例 —— 你有的是一個按實體項,而那個實體正是你忘了拿來 做鍵的東西(比如按租戶的配置)。

陷阱與後續步驟

  • 別對它做 Scan 你知道鍵;直接定址它。
  • 別對它做讀-改-寫。 用更新加條件運算式。
  • 別讓它悄無聲息地缺失。 快取未命中時預設回退到安全的值。
  • 別用高頻寫入去過載它。 那是一個分片計數器的活兒。

單例在單表設計裡安適地 棲身 —— 它無非是又一個帶有固定鍵、與你的實體行並列的項集合。

試用 DynoTable,按固定鍵瀏覽你的表、找到那個單例行, 在你構建寫入路徑的同時手動編輯開關。

已更新