DynamoDB で複数の属性に一意性を強制する
DynamoDB が一意性を保証するのは正確に1つだけ: プライマリキーです。UNIQUE (email) 制約も、UNIQUE (username) も、2つの属性にまたがるものもありません。SQL の出身者には、その不在が最初の驚きであり — そして人々が静かに競合状態を出荷する最初の場所です。
DynamoDB で複数の属性に一意制約を強制するにはどうすればよいですか?
DynamoDB にはプライマリキー以外の UNIQUE 制約がないため、一意性は自分で強制します。保護したい値ごとにキーがその値そのものであるマーカーアイテムをモデル化し、レコードとすべてのマーカーを1つの TransactWriteItems にまとめて書き込み、各 put を attribute_not_exists でガードします。エンジンがすでに強制しているキーの衝突が、そのまま一意制約になります。
- 一意制約はない — エンジンが一意を強制するのはプライマリキーだけです。他のすべての「一意でなければならない」属性は、あなたの仕事です。
- 各一意性ルールを独自のアイテムとしてモデル化する。 キーが保護したい値そのものである専用のマーカーアイテムは、「このメールアドレスは使われているか?」を、エンジンが既に強制するキーの衝突に変えます。
TransactWriteItemsでアトミックに書き込む。 1つのトランザクション、各 put をattribute_not_existsでガードし、すべてのマーカーと本物のレコードがまとめてコミットされるか、まったくされないかにします。- check-then-write をしない。 挿入前の読み取りは教科書的な競合です。2つの並行サインアップが両方「空き」を読み、両方が書き込みます。
なぜ明白なアプローチが間違っているのか
直感は、メールアドレスを Query(もっと悪いと Scan)して、何もないのを見て、それから新しいアカウントを PutItem することです。それが check-then-act の競合です。
2人が同じミリ秒に ada@lovelace.io を登録します。両方の読み取りが空を返します。両方の書き込みが成功します。あなたは今、1つのメールアドレスに2つのアカウントを持っていて — テーブル内にそれを示すものは何もありません。
email に対する GSI も救ってくれません。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つのオールオアナッシングの単位として適用します。各 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 Expression Builder は各 put の条件と型付けされた Item を出力するので、正しいトランザクションを SDK 呼び出しにそのまま貼り付けられます。
失敗を推測せず、読む
トランザクションがキャンセルされると、DynamoDB は CancellationReasons 配列を位置的に返します — アイテムごとに1エントリ、リクエスト順で。スロット1の ConditionalCheckFailed はメールアドレスが使われていることを、スロット2はユーザー名がそうであることを意味します。スロットを、汎用的な「サインアップ失敗」ではなく、正確なフィールドレベルのエラーに対応付けましょう。
DynoTable でロックを検査する
マーカーアイテムはアプリの UI では見えません — 配管です。サインアップが不可解に失敗したとき、ロックが実際に存在するかどうかを見る必要があります。
DynoTable でテーブルを開き、UNIQ# プレフィックスを Query します。アカウントとその2つのロックアイテムがまとまって並ぶので、引っかかったサインアップ(失敗した削除によって残されたロック)が一目で明らかになります。

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


