DynamoDB で複数の属性に一意性を課す
DynamoDB がただ 1 つ一意性を保証するのは、だけです。UNIQUE (email) 制約も、UNIQUE (username) も、2 つの属性にまたがるものもありません。SQL から来ると、この欠如が最初の驚きであり — そして人々がひっそりと競合状態を仕込む最初の場所でもあります。
DynamoDB で複数の属性に一意制約を課すにはどうすればよいですか?
DynamoDB にはを超える UNIQUE 制約がないため、一意性は自分で強制します。保護すべき各値を、そのキー そのものが その値であるような独自のマーカー項目としてモデル化し、レコードとすべてのマーカーを 1 つの TransactWriteItems でまとめて書き込み、各 put を attribute_not_exists でガードします。エンジンがすでに強制している衝突が、あなたの制約になります。
- 一意制約は存在しない — エンジンによって一意が強制されるのはプライマリキーだけです。それ以外の「一意でなければならない」属性はすべて自分の仕事です。
- 各一意性ルールを独自の項目としてモデル化する。 保護したい値 そのものが キーである専用のマーカー項目は、「このメールは使われているか?」を、エンジンがすでに強制しているキー衝突に変えます。
TransactWriteItemsでアトミックに書き込む。 1 つので、各 put をattribute_not_existsでガードすれば、すべてのマーカーと本物のレコードがまとめてコミットされるか、まったくされないかのどちらかになります。- チェックしてから書き込まない。 挿入前の読み取りは教科書どおりの競合です。2 つの同時サインアップが両方とも「空き」を読み取り、両方とも書き込みます。
なぜ一見わかりやすいアプローチが間違っているのか
直感は、メールを Query(もっと悪いと Scan)して何も見つからなければ、新しいアカウントを PutItem することです。それはチェックしてから実行する競合です。
2 人が同じミリ秒に ada@lovelace.io で登録します。どちらの読み取りも空を返します。どちらの書き込みも成功します。あなたは今、1 つのメールに 2 つのアカウントを抱えています — そしてテーブルにはそれを示すものが何もありません。
email への も助けにはなりません。GSI は結果整合性なので、書き込みを門番する読み取りは設計上、古くなっていることがあります。対処法は速いチェックではなく、書き込み自体が使用済みの値に着地するのを拒むようにすることです。
各制約をマーカー項目としてモデル化する
エンジンはすでに 1 つの一意性ルールを無料で強制しています。同じキーで 2 つの項目を書き込むことはできません。だからすべての一意性ルールをキーとしてエンコードします。
本物のアカウント項目と並べて、保護する属性ごとに マーカー項目 を 1 つ書き込みます。マーカーのパーティションキー そのものが 名前空間化された値です。値が使用済みなら、そのキーが存在するので、ガード付きの put はそれを上書きできません。
email と username の両方を一意に保たなければならないサインアップでは、3 つの項目が一緒に動きます — シングルテーブルのレイアウトでキー付けされます(シングルテーブル設計を参照)。
| 項目 | PK | SK | 目的 |
|---|---|---|---|
| アカウントレコード | ACCT#a1f9c3 | PROFILE | 本物のアカウント |
| メールロック | UNIQ#EMAIL#ada@lovelace.io | LOCK | メールを予約する |
| ユーザー名ロック | UNIQ#HANDLE#ada | LOCK | ユーザー名を予約する |
アカウント自身の PK は生成された id(ACCT#a1f9c3)であって、メールではありません — なので、ユーザーは後でプライマリキーを書き換えることなくメールを変更できます。ロック項目はプロフィールデータを持ちません。それらは キー が占有されていることだけのために存在します。
3 つすべてをアトミックに書き込む
TransactWriteItems は最大 100 件の書き込みを 1 つの all-or-nothing の単位として適用します。各 put を attribute_not_exists(PK) でガードして、そのキーがすでに存在すれば失敗するようにします。
いずれか 1 つの条件が失敗すれば — メールロック、ハンドルロック、あるいはアカウントそのもの — 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 がなければ、同じメールでの 2 回目のサインアップが最初のロックを静かに上書きします。それがあれば、put は拒否され、トランザクションはキャンセルされ、アプリは「メールはすでに使用されています」と表示します。
ConditionExpression と値マップを手で組み立てるのは、タイプミスが忍び込む場所です。DynamoDB 式ビルダーは各 put の条件と型付き Item を出力するので、正しいトランザクションをそのまま SDK 呼び出しに貼り付けられます。
失敗を読み取る、推測しない
トランザクションがキャンセルされると、DynamoDB は CancellationReasons 配列を位置対応で返します — 項目ごとに 1 エントリ、リクエスト順です。スロット 1 の ConditionalCheckFailed はメールが使用済みであることを意味し、スロット 2 はユーザー名が使用済みであることを意味します。スロットを正確なフィールドレベルのエラーに対応づけましょう — 一般的な「サインアップに失敗しました」ではなく。
DynoTable でロックを調べる
マーカー項目はアプリの UI では見えません — 配管です。サインアップが不可解に失敗したとき、ロックが実際に存在するかどうかを見る必要があります。
DynoTable でテーブルを開き、UNIQ# プレフィックスを Query します。アカウントとその 2 つのロック項目が一緒に並ぶので、詰まったサインアップ(不完全な削除で置き去りにされたロック)が一目でわかります。

変更と削除でロックを正しく保つ
ロックは書き込んだら終わりではありません。ライブの値を反映するので、そのライフサイクルを同期させ続けなければなりません — 保護された属性に触れるすべての操作もまたトランザクションです。
- メールを変更する。 1 つのトランザクション: 新しい
UNIQ#EMAIL#…ロックをattribute_not_existsで put し、古いロックを削除し、アカウントを更新します。同じ all-or-nothing の保証です。 - アカウントを削除する。 アカウント項目 と 両方のロック項目を 1 つのトランザクションで削除します。さもないと、その値を永遠にブロックするロックが取り残されます。
- 安全にリトライする。
ClientRequestTokenを渡せば、(ネットワークの瞬断後に)再送されたトランザクションは二重書き込みではなく冪等になります。
罠は、ロックを撃ちっぱなしのものとして扱うことです。サインアップで作られたのにアカウント削除で決して削除されないロックは、誰も二度と再利用できない値です — そしてそれは、実際のユーザーが自分の古いハンドルを取り戻せなくなるまで表面化しません。
次のステップ
一意性マーカーはシングルテーブルのパターンなので、他の項目のすぐ隣に自然に収まります — キーのレイアウトについてはシングルテーブル設計を、ロックをチェックするために Scan に手を伸ばさないように Query vs Scan を読んでください。このパターンは AWS の re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions のセッションで初めて解説されました。
条件付きの put を DynamoDB 式ビルダーで下書きし、DynoTable を試して自分自身のテーブルに対してロック項目を調べてみてください。


