在 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 沒辦法覆蓋它。
對一個必須讓 email 和 username 兩者都唯一的註冊,三個項目一起移動——在一個單表佈局裡設鍵(見單表設計):
| 項目 | PK | SK | 用途 |
|---|---|---|---|
| 帳號記錄 | ACCT#a1f9c3 | PROFILE | 真實帳號 |
| email 鎖 | UNIQ#EMAIL#ada@lovelace.io | LOCK | 保留那個 email |
| username 鎖 | UNIQ#HANDLE#ada | LOCK | 保留那個 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# 前綴。帳號和它的兩個鎖項目坐在一起,所以一次卡住的註冊(一個被搞砸的刪除留下的鎖)一眼就看得出來。

在變更與刪除時保持鎖的誠實
鎖不是一次性寫入的。它們鏡像那個活值,所以生命週期得讓它們保持同步——每一個碰到受保護屬性的操作也是一個交易。
- 更改 email。 一個交易:用
attribute_not_existsput 新的UNIQ#EMAIL#…鎖、刪掉舊鎖、更新帳號。相同的全有或全無保證。 - 刪除帳號。 在一個交易裡刪掉帳號項目以及兩個鎖項目,否則你會擱淺一個永遠擋住那個值的鎖。
- 安全地重試。 傳一個
ClientRequestToken,這樣一個重送的交易(在網路抖動之後)是冪等的,而不是一次雙重寫入。
陷阱是把鎖當成發了就不管。一個在註冊時建立、但在帳號移除時從未刪掉的鎖,是一個沒人能再重用的值——而且它不會現身,直到一個真實使用者沒辦法認領他自己的舊 handle。
下一步
唯一性標記是一個單表模式,所以它們天然地坐在你其他項目旁邊——讀單表設計了解鍵的佈局,並讀 Query 與 Scan,這樣你永遠不會為了檢查一個鎖而伸手去抓 Scan。這個模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 場次裡走過一遍的。
用 DynamoDB Expression Builder 草擬那些條件守衛的 put,然後試用 DynoTable,對你自己的表檢視那些鎖項目。


