中階閱讀時間 2 分鐘

DynamoDB Condition Expression

condition expression 是 DynamoDB 在提交你的寫入 之前,對既有 item 評估的一個述詞。如果述詞為假,寫入被拒絕,什麼也不會 改變。它是 DynamoDB 在寫入上最接近 WHERE 子句的東西 — 也是 強制一個不變式唯一安全的方式。

DynamoDB condition expression 如何運作?

condition expression 是 DynamoDB 在提交寫入前,於伺服器端對目前 item 評估的一個述詞。如果為真,寫入照常進行;如果為假,寫入會被 ConditionalCheckFailedException 拒絕,什麼也不會改變。它把檢查與變更折進單一原子操作,因此並發的呼叫者無法以陳舊的讀取造成競爭。

  • 它是守衛,不是過濾器。 ConditionExpression 在伺服器端對 目前的 item 執行;假的結果會以 ConditionalCheckFailedException 讓寫入失敗。
  • 它取代讀-然後-寫。 沒有 SELECTUPDATE 的往返 — 檢查 與變更是一個原子操作,所以兩個呼叫者無法競爭。
  • 拒絕免費,執行不免費。 一次失敗的條件式寫入仍會 消耗寫入容量。那個保證的成本跟它擋下的寫入相同。

從 SQL 過來,你會讀那一列、在應用程式碼裡檢查它,然後更新。在 DynamoDB 中,讀與寫之間那道縫隙,是一個等著某個並發呼叫者的 資料毀損 bug。condition expression 把那道縫隙關上。

它們適用於哪裡

你把一個 ConditionExpression 附加到 PutItemUpdateItemDeleteItem,以及 TransactWriteItems 內的每個動作。它 不是 QueryScan 的一部分 — 那些用 FilterExpression,那是讀取路徑上不同的東西。

那個區別會絆倒人,所以要精確:

ConditionExpressionFilterExpression
路徑寫入(Put/Update/Delete讀取(Query/Scan
失敗時的效果拒絕整個寫入把 item 從結果中丟掉
看到的目前的 item,寫入前每個候選 item,讀取後
成本失敗的寫入仍計費被過濾的 item 仍就讀取計費

兩者都在伺服器端執行。差別在於「假」的作為:一個條件中止一次 變更;一個過濾只是隱藏一列你已付費讀取的資料。 (AWS:Condition Expressions

你實際會用到的函式

condition 語言很小。主力:

  • attribute_exists(path) / attribute_not_exists(path) — 這個屬性在 item 上 存在嗎?「只在不存在時建立」/「只在存在時更新」的經典慣用法。
  • 比較運算子 — =<><<=>>= — 對一個值或另一個 屬性。
  • attribute_typebegins_withcontainssize — 型別與字串/集合檢查。
  • BETWEEN … AND …IN (…) — 範圍與成員檢查。
  • ANDORNOT、括號 — 把上述組合起來。

對 partition key 用 attribute_not_exists,是讓 PutItem 表現得像一個 不會覆蓋既有 item 的插入的標準方式 — DynamoDB 沒有獨立的「insert」操作,所以那個條件 就是 插入語意。 (AWS:Comparison Operator and Function Reference

演練範例:守衛一個帳本防止透支

拿一個銀行帳本。每個帳戶是一個 item:

PK = "ACCT#a7f3"
SK = "BALANCE"
clearedCents = 50000
holdCents    = 0

不變式:一筆扣款絕不能把可用餘額推到零以下,且你 絕不能扣一個不存在的帳戶。兩條規則,都能在寫入本身內 強制。

錯誤的方式(地雷)

GetItem ACCT#a7f3 / BALANCE     → clearedCents = 50000
if (50000 >= 30000) ...         ← 應用程式端檢查
UpdateItem  SET clearedCents = 20000

GetItemUpdateItem 之間,第二筆扣款可以讀到同一個 50000、通過它自己的檢查、也寫入。兩者都成功;帳戶變 負。這是一個讀-改-寫競爭,再多應用程式端驗證都 修不了它 — 檢查與寫入是分開的操作。

正確的方式

把檢查折進寫入。扣款 30000 cents,條件是帳戶 存在 持有足夠:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = clearedCents - :amt
  ConditionExpression:
    attribute_exists(PK) AND clearedCents >= :amt

搭配 :amt = 30000。如果餘額太低,或 item 從未被建立, DynamoDB 以 ConditionalCheckFailedException 拒絕寫入,而餘額 不被觸碰。並發的那筆扣款,要嘛看到原始餘額並被 對它檢查,要嘛看到更新後的 — 絕不是它據以動作的陳舊讀取。

你可以用 DynamoDB expression builder 建立並複製確切的 expression — 名稱、值,全部 — 而不必 手動組裝 ExpressionAttributeValues map。

在 DynoTable 中檢視那個守衛

當一次條件式寫入失敗時,你想看到 item 的真實狀態,而非 猜測它。把帳戶 item 拉出來,直接讀 clearedCents

DynoTable 中的帳本集合 — BALANCE 項目在帳戶的交易項目上方顯示 clearedCents。
DynoTable 中的帳本集合 — BALANCE 項目在帳戶的交易項目上方顯示 clearedCents。

讀懂那個拒絕,別盲目重試

ConditionalCheckFailedException 不是暫時性錯誤 — 重試同一個 寫入什麼也不會改變。它意味著一條業務規則觸發了:餘額不足、 重複建立、版本陳舊。把它呈現為一個領域結果,而非一個基礎設施 小毛病。

兩件事能讓失敗可除錯:

  • ReturnValuesOnConditionCheckFailure: ALL_OLD — DynamoDB 連同失敗 一併回傳目前的 item,這樣你不必第二次讀取就能顯示「餘額是 20000,你要求 30000」。 (AWS:Working with Items
  • 區分兩個失敗原因。 attribute_exists(PK) AND clearedCents >= :amt 把「沒有帳戶」與「沒有資金」合併成一個 例外。如果呼叫者需要分辨它們,拆成兩次寫入,或檢視 回傳的 item。

樂觀鎖定是同一個招式

版本號模式只是戴著不同帽子的 condition expression。 存一個 version 屬性;每次寫入都斷言你讀到的版本,並 把它加一:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = :new, version = :next
  ConditionExpression: version = :seen

如果另一個寫入者先動了,version = :seen 為假,寫入被拒絕, 而你重讀並重試。這就是 DynamoDB 在沒有鎖的情況下做並發 控制的方式 — 斷言你所見、若它動了就失敗。(AWS:Optimistic Locking with Version Number

陷阱與下一步

  • 與保留字衝突的名稱。 statussizename,以及約 570 個其他字是保留的。用 ExpressionAttributeNames#s = status) 替它們取別名,否則你的 expression 會悄悄無法解析。
  • 一個條件無法參照另一個 item。 它只看到正在被 寫入的 item。跨 item 的不變式需要 TransactWriteItems 搭配每個動作的 ConditionExpression,或對一個哨兵 item 的 ConditionCheck
  • 失敗的寫入仍花 WCU。 一個 90% 時間都拒絕的守衛,仍會就那些 拒絕計費。便宜的保險,但不免費。

要建模這些守衛所對之執行的 key,請見 single-table designQuery vs Scan。當你準備好對真實資料發出條件式 寫入時,下載 DynoTable 並對你自己的表執行它們。

已更新