進階閱讀時間 2 分鐘

在 DynamoDB 中對多個屬性強制唯一性

DynamoDB 只為一件事保證唯一性:。沒有 UNIQUE (email) 約束、沒有 UNIQUE (username),也沒有任何跨兩個屬性的東西。從 SQL 過來,這種缺席是第一個驚訝——也是人們悄悄埋下競態條件的第一個地方。

在 DynamoDB 中要如何對多個屬性強制唯一性約束?

DynamoDB 除了之外沒有 UNIQUE 約束,所以唯一性得由你自己來強制:把每個要保護的值建模成它自己的標記項目,其索引鍵就是那個值,然後在一次 TransactWriteItems 裡把記錄和每個標記一起寫入,每個 put 都用 attribute_not_exists 保護。引擎本就強制的碰撞,變成了你的約束。

  • 沒有唯一性約束——只有主索引鍵由引擎強制唯一。其他每一個「必須唯一」的屬性都是你自己的責任。
  • 把每條唯一性規則建模成它自己的項目。 一個專用的標記項目,其索引鍵就是你要保護的值,把「這個 email 被用過了嗎?」變成一次引擎本就強制的索引鍵碰撞。
  • TransactWriteItems 原子性地寫入它們。 一筆,每個 put 都用 attribute_not_exists 保護,所以全部標記與真正的記錄一起提交,否則全部不提交。
  • 不要先查再寫。 先讀後插是教科書等級的競態;兩個並行的註冊都讀到「空的」,然後都寫入。

為什麼那個顯而易見的做法是錯的

直覺是對 email Query(或更糟,Scan)一下、什麼都沒看到,然後 PutItem 新帳號。那是一個先檢查再動作的競態。

兩個人在同一毫秒註冊 ada@lovelace.io。兩次讀取都傳回空。兩次寫入都成功。你現在一個 email 上有兩個帳號——而表裡沒有任何東西標記出這件事。

email 上加一個 也救不了你。GSI 是最終一致的,所以那個把關你寫入的讀取,在設計上就可能是過時的。解法不是更快的檢查;而是讓寫入本身拒絕落在一個被占用的值上。

把每條約束建模成標記項目

引擎已經免費強制了一條唯一性規則:你不能寫入兩個索引鍵相同的項目。所以把每一條唯一性規則都編碼成一個索引鍵。

在真正的帳號項目旁邊,每個受保護的屬性寫一個標記項目。標記的分割區索引鍵就是那個帶命名空間的值。如果值被占用了,索引鍵就存在,而一個帶保護的 put 就無法覆寫它。

對於一個必須同時保持 emailusername 唯一的註冊,三個項目一起移動——以單表配置設定索引鍵(見單表設計):

項目PKSK用途
帳號記錄ACCT#a1f9c3PROFILE真正的帳號
Email 鎖UNIQ#EMAIL#ada@lovelace.ioLOCK保留 email
Username 鎖UNIQ#HANDLE#adaLOCK保留 username

帳號自己的 PK 是一個產生出來的 id(ACCT#a1f9c3)——絕不是 email——所以使用者日後可以更換 email 而不必重寫主索引鍵。鎖項目不帶任何個人資料;它們存在,只是為了讓其索引鍵被占用。

原子性地寫入這三個

TransactWriteItems把最多 100 個寫入當成一個全有或全無的單位來套用。用 attribute_not_exists(PK) 保護每個 put,這樣如果那個索引鍵已存在它就會失敗。

如果任何一個條件失敗——email 鎖、handle 鎖,或帳號本身——DynamoDB 就把整筆交易回捲,並拋出 TransactionCanceledException。沒有半途而廢的註冊,沒有孤兒鎖。

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "ACCT#a1f9c3"},
          "SK": {"S": "PROFILE"},
          "email": {"S": "ada@lovelace.io"},
          "username": {"S": "ada"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#EMAIL#ada@lovelace.io"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#HANDLE#ada"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    }
  ]
}

條件就是整個機制。沒有 attribute_not_exists,第二個用同一 email 的註冊就會悄悄覆寫第一個鎖。有了它,那個 put 就會拒絕、交易取消,你的應用就浮現「email 已被使用」。

用手把 ConditionExpression 和值對映組出來,正是打字錯誤悄悄潛入的地方。DynamoDB 運算式建構器為每個 put 產生條件與帶型別的 Item,讓你可以把一筆正確的交易直接貼進你的 SDK 呼叫。

讀取那個失敗,別去猜它

當交易被取消,DynamoDB 會依位置傳回一個 CancellationReasons 陣列——每個項目一筆,依請求順序排列。位置 1 的 ConditionalCheckFailed 意味著 email 被占用;位置 2 意味著 username 被占用。把位置對映回一個精確、欄位層級的錯誤,而不是一個籠統的「註冊失敗」。

在 DynoTable 中檢視這些鎖

標記項目在你應用的 UI 裡是隱形的——它們是水電管線。當一個註冊莫名其妙地失敗,你需要看到那個鎖到底存不存在。

在 DynoTable 中打開表,並 Query UNIQ# 前綴。帳號和它的兩個鎖項目坐在一起,所以一個卡住的註冊(一個因刪除搞砸而遺留的鎖)一眼就看得出來。

DynoTable 掃描資料表——帳號項目與它們的 UNIQ#EMAIL 和 UNIQ#HANDLE 鎖項目交錯排列。
DynoTable 掃描資料表——帳號項目與它們的 UNIQ#EMAIL 和 UNIQ#HANDLE 鎖項目交錯排列。

在變更與刪除時讓鎖保持誠實

鎖不是寫一次就算了。它們鏡射著現行的值,所以整個生命週期都得讓它們保持同步——每一個碰到受保護屬性的操作也都是一筆交易。

  • 更換 email。 一筆交易:用 attribute_not_exists put 新的 UNIQ#EMAIL#… 鎖、刪除舊鎖、更新帳號。同樣的全有或全無保證。
  • 刪除帳號。 在一筆交易裡刪除帳號項目以及兩個鎖項目,否則你會擱淺一個永遠擋住那個值的鎖。
  • 安全地重試。 傳一個 ClientRequestToken,讓一筆重送的交易(在網路閃斷之後)是冪等的,而不是一次重複寫入。

陷阱在於把鎖當成放了就不管。一個在註冊時建立、卻在帳號移除時從未刪除的鎖,是一個誰都無法再重用的值——而它不會現身,直到一個真實使用者無法認領自己的舊 handle。

後續步驟

唯一性標記是一種單表模式,所以它們自然地坐在你其他項目旁邊——讀單表設計了解索引鍵配置,以及 Query vs Scan這樣你永遠不會為了檢查一個鎖而伸手去用 Scan。這個模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 議程中走過一遍的。

DynamoDB 運算式建構器草擬帶條件保護的 put,然後試用 DynoTable對你自己的表檢視那些鎖項目。

已更新