エムオーテックス株式会社が運営するテックブログです。

Amazon DynamoDB Transactions でキー項目以外の一意制約を実現する

Amazon DynamoDB Transactions でキー項目以外の一意制約を実現する

はじめに

こんにちは、サービス開発7課の大町です。

LANSCOPE エンドポイントマネージャー クラウド版では、Amazon DynamoDB を活用したデータ管理を行っています。

DynamoDB にはリレーショナルデータベースのようなユニーク制約は標準では用意されていません。
実際にテーブル設計を行う中で、「キー項目以外の属性に一意性を持たせる必要がある」という要件に直面しました。

今回は、Amazon DynamoDB Transactions を使ってキー項目以外の一意制約を実現する方法と、実装時の注意点を紹介します。

DynamoDB の一意制約の課題

DynamoDB では、プライマリキー(パーティションキーとソートキーの組み合わせ)の一意性は保証されています。
しかし、それ以外の属性については一意性を保証する仕組みが標準では提供されていません。

例えば、以下のような野球チームの選手管理テーブルを考えてみます。

表1: 野球チームの選手管理テーブルの例

team_id (PK) player_id (SK) player_name uniform_number position
team-001 player-001 アリス 18 ピッチャー
team-001 player-002 ボブ 18 キャッチャー

この場合、team_idplayer_id の組み合わせはプライマリキーとして一意性が保証されます。
一方、uniform_number(背番号)については DynamoDB 側で重複を防ぐ仕組みがないため、team-001 に背番号 18 の選手が2人存在してしまっています。

一般的に、同じチーム内で複数の選手が同じ背番号を持つことは避けるべきです。
このような重複を防ぐために、チーム内での背番号の一意性を保証する必要があります。

リレーショナルデータベースであれば UNIQUE 制約で簡単に実現できますが、DynamoDB ではアプリケーション側で制御する必要があります。

AWS 公式ドキュメントのアプローチ

この課題に対して、AWS は公式ブログで解決策を提示しています。

aws.amazon.com

このアプローチの基本的なアイデアは、一意性を保証したい属性値をプライマリキーとする専用のロックレコードを作成するというものです。

具体的には、以下のような構成になります。

表2: ロックレコードを含むテーブル構成

team_id SK player_id player_name uniform_number position
team-001 player#player-001 player-001 アリス 18 ピッチャー
team-001 uniform_number#18 - - - -
team-001 player#player-002 player-002 ボブ 19 キャッチャー
team-001 uniform_number#19 - - - -

実体レコード(ソートキー player#選手ID)に加えて、一意性を保証したい属性ごとにロックレコード(ソートキー uniform_number#背番号)を作成します。

このロックレコードの存在自体が「その値は使用中」であることを意味します。

Amazon DynamoDB Transactions による一意性保証

一意性を確実に保証するために、DynamoDB の TransactWriteItems を使用します。

docs.aws.amazon.com

TransactWriteItems は、すべての操作が成功するか、すべて失敗するかのどちらかになります

この原子性により、以下のような状況を防ぐことができます。

  • ロックレコードだけが作成され、実体レコードが作成されない
  • 実体レコードは削除されたが、ロックレコードが残る

さらに、各操作で ConditionExpression: 'attribute_not_exists(team_id) AND attribute_not_exists(SK)' を指定することで、既にレコードが存在する場合はトランザクション全体が失敗します。

これにより、複数の選手が同時に同じ背番号を登録しようとした場合でも片方は必ず失敗し、一意性が保証されます。

実装方法

テーブル設計

まず、テーブルの基本構成を定義します。

# CloudFormation テンプレート例
Resources:
  BaseballTeamTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: baseball-team-table
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: team_id
          AttributeType: S
        - AttributeName: SK
          AttributeType: S
      KeySchema:
        - AttributeName: team_id
          KeyType: HASH
        - AttributeName: SK
          KeyType: RANGE

新規作成時の実装

新規レコード作成時は、TransactWriteItems を使用して実体レコードとロックレコードを同時に作成します。

# Python での実装例
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.client('dynamodb')

def create_player(team_id: str, player_id: str, player_name: str, uniform_number: int, position: str) -> None:
    """選手を新規作成する"""
    try:
        dynamodb.transact_write_items(
            TransactItems=[
                # 1. uniform_number のロックレコードを作成(重複時は失敗)
                {
                    'Put': {
                        'TableName': 'baseball-team-table',
                        'Item': {
                            'team_id': {'S': team_id},  # チームIDをパーティションキーに設定
                            'SK': {'S': f'uniform_number#{uniform_number}'}  # ソートキーで一意性を保証
                        },
                        'ConditionExpression': 'attribute_not_exists(team_id) AND attribute_not_exists(SK)'  # 既存レコードがないことを確認(重複防止)
                    }
                },
                # 2. 実体レコードを作成
                {
                    'Put': {
                        'TableName': 'baseball-team-table',
                        'Item': {
                            'team_id': {'S': team_id},
                            'SK': {'S': f'player#{player_id}'},  # 実体レコードを示すプレフィックス
                            'player_id': {'S': player_id},
                            'player_name': {'S': player_name},
                            'uniform_number': {'N': str(uniform_number)},
                            'position': {'S': position}
                        },
                        'ConditionExpression': 'attribute_not_exists(team_id) AND attribute_not_exists(SK)'
                    }
                }
            ]
        )
    except ClientError as e:
        if e.response['Error']['Code'] == 'TransactionCanceledException':
            # ロックレコードが既に存在する場合は重複エラー
            raise ValueError("Uniform number already exists in this team")
        raise

更新時の実装

一意性を保証する属性を変更する場合は古いロックレコードを削除し、新しいロックレコードを作成します。

# uniform_number を変更する場合の実装例
def update_uniform_number(team_id: str, player_id: str, old_number: int, new_number: int) -> None:
    """背番号を更新する"""
    try:
        dynamodb.transact_write_items(
            TransactItems=[
                # 1. 旧 uniform_number のロックレコードを削除
                {
                    'Delete': {
                        'TableName': 'baseball-team-table',
                        'Key': {
                            'team_id': {'S': team_id},
                            'SK': {'S': f'uniform_number#{old_number}'}  # 古いロックレコードを特定
                        }
                    }
                },
                # 2. 新 uniform_number のロックレコードを作成(重複時は失敗)
                {
                    'Put': {
                        'TableName': 'baseball-team-table',
                        'Item': {
                            'team_id': {'S': team_id},
                            'SK': {'S': f'uniform_number#{new_number}'}  # 新しいロックレコードを作成
                        },
                        'ConditionExpression': 'attribute_not_exists(team_id) AND attribute_not_exists(SK)'  # 新しい値の重複チェック
                    }
                },
                # 3. 実体レコードを更新
                {
                    'Update': {
                        'TableName': 'baseball-team-table',
                        'Key': {
                            'team_id': {'S': team_id},
                            'SK': {'S': f'player#{player_id}'}
                        },
                        'UpdateExpression': 'SET uniform_number = :number',  # uniform_number 属性を更新
                        'ExpressionAttributeValues': {
                            ':number': {'N': str(new_number)}
                        }
                    }
                }
            ]
        )
    except ClientError as e:
        if e.response['Error']['Code'] == 'TransactionCanceledException':
            raise ValueError("New uniform number already exists in this team")
        raise

削除時の実装

削除時は、実体レコードとすべてのロックレコードを同時に削除します。

# 選手削除の実装例
def delete_player(team_id: str, player_id: str, uniform_number: int) -> None:
    """選手を削除する"""
    dynamodb.transact_write_items(
        TransactItems=[
            # 1. 実体レコードを削除
            {
                'Delete': {
                    'TableName': 'baseball-team-table',
                    'Key': {
                        'team_id': {'S': team_id},
                        'SK': {'S': f'player#{player_id}'}  # 実体レコードを特定
                    }
                }
            },
            # 2. uniform_number のロックレコードを削除
            {
                'Delete': {
                    'TableName': 'baseball-team-table',
                    'Key': {
                        'team_id': {'S': team_id},
                        'SK': {'S': f'uniform_number#{uniform_number}'}  # 背番号のロックを解放
                    }
                }
            }
        ]
    )

実装時の注意点

トランザクションの制限

TransactWriteItems には以下の制限があります。

表3: DynamoDB トランザクションの制限

制限項目 上限値
1トランザクションあたりの操作数 100
1トランザクションあたりのデータサイズ 4MB

一意制約を保証したい属性が多数ある場合は、この制限に注意が必要です。

コスト面の考慮

ロックレコードを使用するアプローチでは、実体レコード1件に対して複数のロックレコードが作成されます。

例えば、uniform_number に一意制約を設定する場合は1選手あたり2レコード(実体1 + ロック1)が必要になります。

DynamoDB のトランザクションでは、各項目に対して2回の読み取り/書き込み操作が実行されます。
1回はトランザクションの準備、もう1回はコミットのためです。

docs.aws.amazon.com

具体的には、以下のようなコストが発生します。

  • 書き込みトランザクション(TransactWriteItems
    • 各項目に対して 2WCU(Write Capacity Units)が必要
    • 例: 2項目(実体1 + ロック1)の作成では 4WCU を消費
  • ストレージコスト
    • ロックレコードの分だけストレージ使用量が増加

ただし、オンデマンドキャパシティーモードを使用している場合は実際のアクセスパターンに応じた課金となるため、アクセス頻度が低いテーブルではコスト増加は限定的です。

なお、今回の実装では読み取りトランザクション(TransactGetItems)は使用していないため、読み取り時の追加コストは発生しません。
実体レコードやロックレコードの読み取りには通常の GetItemQuery を使用します。

ロックレコードの検索利用

ロックレコードは一意性の保証を目的としており、検索用途には適していません。

例えば、「背番号から選手を検索する」といった用途には GSI(Global Secondary Index)の作成を推奨します。
ロックレコードには最小限の情報しか含まれていないため、検索に使用すると実体レコードへの追加クエリが必要になります。

GSI で uniform_number をキーに指定すると、この属性を持つ実体レコードのみが射影されます。
ロックレコードには uniform_number 属性が存在しないため GSI には含まれず、検索結果として実体レコードだけが効率的に取得できます

おわりに

DynamoDB でキー項目以外の一意制約を実現する方法を紹介しました。

このアプローチは AWS 公式ブログで紹介されている手法をベースにしており、以下のメリットがあります。

  • トランザクションの原子性により、競合状態でも一意性が保たれる
  • ロックレコードと実体レコードが同時に作成・削除されるため、不整合が発生しない
  • 複雑なロジックを書かずに、DynamoDB の標準機能で実現できる
  • DynamoDB の特性を活かした高いスケーラビリティを維持

一方で、コスト面やトランザクション制限といった注意点もあるため、実際の導入時には要件に応じた設計が重要です。

ここまでお読みいただきありがとうございます。
本内容がお役に立てれば幸いです。