進階閱讀時間 2 分鐘

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

DynamoDB 只對一件事保證唯一性:主鍵。沒有 UNIQUE (email) 限制,沒有 UNIQUE (username),也沒有任何橫跨兩個屬性的東西。從 SQL 過來,那個缺席是第一個驚訝——也是人們悄悄上線一個競態條件的第一個地方。

如何在 DynamoDB 中對多個屬性強制唯一性限制?

DynamoDB 除了主鍵之外沒有 UNIQUE 限制,因此唯一性必須由你自己來強制:把每個受保護的值建模成它自己的標記項目,以該值作為鍵,再把記錄和所有標記一起放進一個 TransactWriteItems,每個 put 都用 attribute_not_exists 守衛。引擎已經強制的鍵碰撞,就成了你的限制。

  • 沒有唯一性限制——只有主鍵被引擎強制為唯一。每一個其他「必須唯一」的屬性都是你的事。
  • 把每一條唯一性規則建模成它自己的項目。 一個專用的標記項目,它的鍵就是你正在保護的那個值,把「這個 email 被佔了嗎?」變成引擎早已強制的一次鍵碰撞。
  • TransactWriteItems 原子地寫入它們。 一個交易,每個 put 都用 attribute_not_exists 守衛,這樣所有標記和真實記錄要嘛一起提交、要嘛全都不提交。
  • 別先檢查再寫入。 一次插入前的讀取是教科書般的競態;兩個並行的註冊都讀到「空的」、都寫入。

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

那個直覺是去 Query(或更糟,Scan)找那個 email,看不到東西,然後 PutItem 那個新帳號。那是一次「檢查-然後-動作」的競態。

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

一個在 email 上的 GSI 也救不了你。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 Expression Builder 為每個 put 產出條件和帶類型的 Item,這樣你就能把一個正確的交易直接貼進你的 SDK 呼叫。

讀那個失敗,別去猜它

當交易被取消時,DynamoDB 按位置回傳一個 CancellationReasons 陣列——每個項目一個條目,依請求順序。槽位 1 的 ConditionalCheckFailed 代表 email 被佔了;槽位 2 代表 username。把槽位映射回一個精確的、欄位層級的錯誤,而不是一個籠統的「註冊失敗」。

在 DynoTable 裡檢視那些鎖

那些標記項目在你應用的 UI 裡是看不見的——它們是管線。當一次註冊神祕地失敗時,你需要看看那個鎖是否真的存在。

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

DynoTable Scan 表格 — 帳號項目與其 UNIQ#EMAIL 及 UNIQ#HANDLE 鎖項目交錯排列。
DynoTable Scan 表格 — 帳號項目與其 UNIQ#EMAIL 及 UNIQ#HANDLE 鎖項目交錯排列。

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

鎖不是一次性寫入的。它們鏡像那個活值,所以生命週期得讓它們保持同步——每一個碰到受保護屬性的操作也是一個交易。

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

陷阱是把鎖當成發了就不管。一個在註冊時建立、但在帳號移除時從未刪掉的鎖,是一個沒人能再重用的值——而且它不會現身,直到一個真實使用者沒辦法認領他自己的舊 handle。

下一步

唯一性標記是一個單表模式,所以它們天然地坐在你其他項目旁邊——讀單表設計了解鍵的佈局,並讀 Query 與 Scan,這樣你永遠不會為了檢查一個鎖而伸手去抓 Scan。這個模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 場次裡走過一遍的。

DynamoDB Expression Builder 草擬那些條件守衛的 put,然後試用 DynoTable,對你自己的表檢視那些鎖項目。

已更新