
DynamoDBでプライマリーキーではない属性を一意に保つ方法
はじめに

サービス開発上、プライマリーキーではない属性に一意性を持たせたい状況は多々存在します。例えば、ユーザーのメールアドレスをログイン時に使用する場合、メールアドレスの重複が起こってしまうと、他人のアカウントにログインできてしまうといった危険性が生じます。このようなセキュリティ上の問題を回避するためには、一意性の確保が欠かせません。幸いにもMySQLやPostgreSQLといった主なRDB(リレーショナルデータベース)には、ユニーク制約といって、プライマリーキーではない属性の一意性を保証する機能があります。しかし、DynamoDBにはそのような機能を提供していないため、実装側で自ら仕組みを作らなければなりません。
本記事では、Amazon Web Services(以下、AWS)の提供するDynamoDBにおいて、プライマリーキーではない属性を一意に保つ実装の方法をご紹介します。DynamoDBの仕組みやプライマリーキーの設定についても、改めてその概要を確認しておきましょう。
DynamoDBの構成要素
DynamoDBは、主に
- テーブル(table)
- 項目(item)
- 属性(attribute)
という3つの構成要素によって成立しています。
テーブルは、データを保存するための場所を確保するための要素です。DynamoDBに限らず、あらゆるデータベースはテーブルの要素を軸として機能を提供しています。テーブルの中に特定の条件に基づき、インプットされた情報が仕分けされる仕組みです。
項目は、テーブルを構成する構成要素とも言えます。何らかの属性に則り、データを分類するために設けられています。複数の項目が集まることで、一つのテーブルを構成しているわけです。データベースにおける「行」の役割を果たす存在でもあります。
属性は、データを分割する際の最小単位です。DynamoDBにデータを格納する場合、属性に則ってデータを仕分けします。一般的なデータベースにおける「列」の役割を果たすとも言えるでしょう。そして全ての項目は、属性の集合体としての側面も持ちます。
基本的にはこれらの3つの構成要素によって、DynamoDBは機能しています。
DynamoDBにプライマリキーを設定する2つの方法
DynamoDBにおいてテーブルを作成する場合、テーブルにつける名前とは別途、プライマリキーを設定する必要があります。プライマリキーとは、テーブル内の各項目を識別するために設定しなければならず、データの読み書きはこれを使って行うのが一般的です。
プライマリキーを設定する際には、以下の2つの種類のどちらかを選んで実施します。
- パーティションキーを設定する
- パーティションキーとソートキーを複合して設定する
パーティションキーとは、一つの属性によって構成されているプライマリキーのことです。データをそれぞれのパーティションへどう配置するかを決めますが、各パーティションにおけるアクセスの均一性を保つことを求められます。また、2つの項目が同じ値のパーティションキーを持つことはできません。
パーティションキーとソートキーを複合して設定します。ソートキーとは、関連した情報を統合してクエリを実行できる、便利なものです。パーティションキーとソートキーの複合キーは、テーブル上の任意の項目へのアクセスのしやすさを確保できる点が評価されています。
プライマリキーの一意性を保てないNGパターン
例えば、以下のようなユーザーを管理するテーブルがあると仮定します。
PK | username | |
USER#92d088ba-8132-4f8b-adad-6894322ed9aa | taro | taro@example.com |
PKをパーティションキーとして設定しており、プライマリーキーはこの一つのパーティションキーで構成されます。
そして、以下のようなコードでユーザーの新規作成を行おうとしています。
from uuid import uuid4
import boto3
client = boto3.client('dynamodb')
TABLE_NAME = 'user’
EMAIL_INDEX_NAME = 'email-index'
def does_email_exist(email):
response = client.query(
TableName=TABLE_NAME,
IndexName=EMAIL_INDEX_NAME,
KeyConditionExpression='email = :email',
ExpressionAttributeValues={
':email': {'S': email}},
)
return len(response['Items']) > 0
def put_user(username, email):
client.put_item(
TableName=TABLE_NAME,
Item={
'PK': {'S': f'USER#{uuid4()}'},
'username': {'S': username},
'email': {'S': email}}
)
def main():
username = 'taro'
email = 'taro@example.com'
if does_email_exist(email):
print('The email is already in use.')
return
put_user(username, email)
main()
このコードでは、ユーザーの作成(put_user())を行う前にメールアドレスの重複を防ぐために存在確認(does_email_exist())をしています。
一見問題なく思えるコードですが、メールアドレスの存在を確認し、ユーザーのレコードを書き込むまでの間に、別スレッドで対象のメールアドレスをもつユーザーのレコードが書き込まれた場合、テーブル内で重複したメールアドレスが存在してしまいます。
また、条件付き書き込みを使用する方法も考えられますが、条件に使用できる属性はプライマリーキーに限られるため解決に至りません。
次のセクションでは、メールアドレスの一意性が保証されるユーザーの追加の方法をご紹介します。
もうひとつレコードを作ることで値の重複を防ぐ
ユーザー作成の際に、ユーザーのレコードに加え、emailをプライマリーキーにもつレコードを作成することで、メールアドレスの重複を防ぐことができます。
具体的には、トランザクションを用いて、ユーザーのレコードの書き込みとメールアドレスのレコードの書き込みを一つのスレッドで行います。この際、メールアドレスのレコードは「PK(メールアドレス)が存在しない」時のみ書き込みできるように条件付けを行います。
DynamoDBは既に存在するプライマリーキーで書き込みを行う際に上書きする仕様のため、既に同じメールアドレスが存在している場合、「PKが存在している」ということでエラーを発生させ、ユーザー作成の際にメールアドレスの重複を防ぐことができます。
前のセクションの例を踏襲すると、以下のようなテーブルになるでしょう。
PK | username | |
USER#92d088ba-8132-4f8b-adad-6894322ed9aa | taro | taro@example.com |
EMAIL#taro@example.com |
注意点
注意点として、メールアドレスのレコードはそのメールアドレスをもっているユーザーに対する変更に対応しなければなりません。そのため、ユーザーのレコードの追加の他にも、削除やメールアドレスの変更の際に、同様の方法でメールアドレスのレコードに変更を加える必要があります。
次のセクションでは、実際に動作するコードを載せています。
Pythonを使用した実装例
このセクションではPythonとBoto3を用いて、前セクションで説明した方法でユーザーを新規作成するコードを示します。
なお、以下の環境でコーディングを行っています。
対象 | バージョン |
Python | 3.9.5 |
Boto3 | 1.20.7 |
from uuid import uuid4
import boto3
client = boto3.client('dynamodb')
TABLE_NAME = ‘user’
EMAIL_INDEX_NAME = ‘email-index’
def put_user(username, email):
client.transact_write_items(
TransactItems=[
{
'Put': {
'TableName': TABLE_NAME,
'Item': {
'PK': {'S': f'USER#{uuid4()}'},
'username': {'S': username},
'email': {'S': email}}
},
},
{
'Put': {
'TableName': TABLE_NAME,
'Item': {
'PK': {'S': f'EMAIL#{email}'}},
'ConditionExpression': 'attribute_not_exists(PK)',
}
}])
def main():
username = 'taro'
email = 'taro@example.com'
try:
put_user(username, email)
except client.exceptions.TransactionCanceledException:
print('The email is already in use.')
main()
条件付き書き込みの条件に反していた場合、TransactionCanceledExceptionというエラーが生じるため、それをキャッチしてメールアドレスが既に存在していた場合の分岐処理を行なっています。
まとめ

今回はAWSのDynamoDBにて、プライマリーキーではない属性を一意に保つ方法をご紹介しました。
DynamoDBにおいて一意性を保つことは不可能ではないとはいえ、対象となる属性が増えるほどコードが煩雑になったり、途中からこの仕様にすることが難しかったりするものです。この状況を改善するには、DBの選定やテーブル設計の段階からこの問題を意識しておく必要があると言えるでしょう。
ご覧いただきありがとうございました。