中階閱讀時間 3 分鐘

DynamoDB 中的單例項目

單例項目 是一個用固定、寫死的鍵的單一資料列,為你的整個應用程式 存放狀態——不是每位使用者或每筆訂單一筆記錄,而是 筆記錄,就這樣。功能旗標、一團設定、一個全域的緊急開關: 就是那種關聯式應用會放在單列設定表裡的東西。

從 SQL 過來,你會直覺去找一張 config 資料表,id = 1,再 SELECT * FROM config。在 DynamoDB 裡你用一個寫死的分區鍵做同一件事—— 而且因為你永遠知道那個鍵,你用 GetItem來讀它,而不是QueryScan

DynamoDB 中的單例項目是什麼?

單例項目是一個存放在固定、寫死鍵下的單一 DynamoDB 資料列,為整個應用程式存放全域狀態——功能旗標、一團設定、系統版本——而非每位使用者或每筆訂單一筆記錄。因為你永遠知道那個鍵,所以用 GetItem 讀取它,並用 update 運算式搭配 condition 運算式來更新它。

  • 單例就是一個用常數鍵的項目。 你在程式碼裡把 PK/SK 寫死 (例如 CONFIG#GLOBAL),而不是用使用者或訂單 id 套模板。
  • GetItem 讀它,絕不用 Scan 你永遠知道完整的鍵,所以一次 點讀就是一筆一致、可預測的 RCU——沒有篩選、沒有走遍資料表。
  • 它在定義上就是一個熱鍵。 每個請求都可能碰到同一個分區, 所以要快取它、讓項目保持小;別把它做成寫入瓶頸。
  • 用 update + condition 運算式安全地變更它,而不是在你的應用裡 做讀取-修改-寫回——那正是更新遺失競態藏身之處。

認出這個模式

當資料不歸屬於任何單一實體時,你就有了全域狀態。幾個徵兆:

  • 一個對每個人都一樣的旗標(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、也永遠不 ScanScan 會讀整張資料表再在用戶端篩選——這是經典的 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 功能不靠應用端鎖就能殺掉這個競態:

  • Update 運算式 在伺服器端變更單一屬性,其餘原封不動。 不需要把整個項目重新 Put
  • Condition 運算式 讓寫入只在項目仍如你預期那樣時才成功, 所以一個過期的寫入會被拒絕,並回傳 ConditionalCheckFailedExceptionAWS 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依實體做 GetItemQuery
範圍整個應用程式單一實體
用於全域旗標、設定、系統版本個人檔案、訂單、任何每 id 的東西

如果你發現自己想要同一種的 兩個 單例,那你就不是有一個 單例——你有的是每實體項目,而你忘了用來作鍵的那個實體 (比方說每租戶設定)。

陷阱與後續步驟

  • Scan 它。 你知道鍵;直接定址。
  • 別讀取-修改-寫回它。 用 update + condition 運算式。
  • 別讓它悄悄缺失。 快取未命中時退回安全的值。
  • 別用高頻寫入把它壓垮。 那是分片計數器的工作。

單例可以舒舒服服地住在 單一資料表設計裡——它只不過是 你的實體列旁邊,多出來的一個用固定鍵的項目集合。

試用 DynoTable瀏覽你的資料表、用固定鍵找出那一列 單例,並在你建構寫入路徑的同時手動編輯旗標。

已更新