在 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 就無法覆寫它。
對於一個必須同時保持 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 運算式建構器為每個 put 產生條件與帶型別的 Item,讓你可以把一筆正確的交易直接貼進你的 SDK 呼叫。
讀取那個失敗,別去猜它
當交易被取消,DynamoDB 會依位置傳回一個 CancellationReasons 陣列——每個項目一筆,依請求順序排列。位置 1 的 ConditionalCheckFailed 意味著 email 被占用;位置 2 意味著 username 被占用。把位置對映回一個精確、欄位層級的錯誤,而不是一個籠統的「註冊失敗」。
在 DynoTable 中檢視這些鎖
標記項目在你應用的 UI 裡是隱形的——它們是水電管線。當一個註冊莫名其妙地失敗,你需要看到那個鎖到底存不存在。
在 DynoTable 中打開表,並 Query UNIQ# 前綴。帳號和它的兩個鎖項目坐在一起,所以一個卡住的註冊(一個因刪除搞砸而遺留的鎖)一眼就看得出來。

在變更與刪除時讓鎖保持誠實
鎖不是寫一次就算了。它們鏡射著現行的值,所以整個生命週期都得讓它們保持同步——每一個碰到受保護屬性的操作也都是一筆交易。
- 更換 email。 一筆交易:用
attribute_not_existsput 新的UNIQ#EMAIL#…鎖、刪除舊鎖、更新帳號。同樣的全有或全無保證。 - 刪除帳號。 在一筆交易裡刪除帳號項目以及兩個鎖項目,否則你會擱淺一個永遠擋住那個值的鎖。
- 安全地重試。 傳一個
ClientRequestToken,讓一筆重送的交易(在網路閃斷之後)是冪等的,而不是一次重複寫入。
陷阱在於把鎖當成放了就不管。一個在註冊時建立、卻在帳號移除時從未刪除的鎖,是一個誰都無法再重用的值——而它不會現身,直到一個真實使用者無法認領自己的舊 handle。
後續步驟
唯一性標記是一種單表模式,所以它們自然地坐在你其他項目旁邊——讀單表設計了解索引鍵配置,以及 Query vs Scan這樣你永遠不會為了檢查一個鎖而伸手去用 Scan。這個模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 議程中走過一遍的。
用 DynamoDB 運算式建構器草擬帶條件保護的 put,然後試用 DynoTable對你自己的表檢視那些鎖項目。


