Article

🔒 DynamoDBのConditionExpressionで更新競合を防ぐ:Javaで楽観ロックを実装する

aws, dynamodb, java, backend

DynamoDBで同じアイテムを複数の処理が同時に更新すると、後から書いた処理が先の変更を上書きする「更新消失」が起きることがあります。

この対策に使えるのが、ConditionExpressionを使った楽観ロックです。楽観ロックは「通常は競合しない前提で処理し、書き込み時だけデータが途中で変わっていないか確認する」方法です。

version属性で競合を検出する

次の注文を2つの処理が同時に読んだとします。

{
  "orderId": "order-001",
  "status": "PENDING",
  "version": 7
}

両方がversion = 7を読んでも、書き込み時に「まだversionが7なら更新する」という条件を付ければ、先に成功した処理だけがversionを8へ進められます。

AWS SDK for Java 2.xで実装する

import java.util.Map;

import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;

public void updateStatus(
        DynamoDbClient dynamoDbClient,
        String orderId,
        String newStatus,
        long expectedVersion) {

    var request = UpdateItemRequest.builder()
            .tableName("Orders")
            .key(Map.of(
                    "orderId",
                    AttributeValue.builder().s(orderId).build()))
            .updateExpression(
                    "SET #status = :newStatus, #version = :nextVersion")
            .conditionExpression(
                    "#version = :expectedVersion")
            .expressionAttributeNames(Map.of(
                    "#status", "status",
                    "#version", "version"))
            .expressionAttributeValues(Map.of(
                    ":newStatus",
                    AttributeValue.builder().s(newStatus).build(),
                    ":expectedVersion",
                    AttributeValue.builder()
                            .n(Long.toString(expectedVersion))
                            .build(),
                    ":nextVersion",
                    AttributeValue.builder()
                            .n(Long.toString(expectedVersion + 1))
                            .build()))
            .returnValuesOnConditionCheckFailure(
                    ReturnValuesOnConditionCheckFailure.ALL_OLD)
            .build();

    try {
        dynamoDbClient.updateItem(request);
    } catch (ConditionalCheckFailedException e) {
        throw new IllegalStateException(
                "別の処理が先に更新しています。最新データを再取得してください。",
                e);
    }
}

重要なのは次の条件です。

#version = :expectedVersion

条件がtrueなら更新し、falseならDynamoDBはConditionalCheckFailedExceptionを返します。条件判定と更新を同じ書き込み操作で行えるため、更新直前の競合を検出できます。

ReturnValuesOnConditionCheckFailureを使う

ReturnValuesOnConditionCheckFailure.ALL_OLDを指定すると、条件に失敗した時点のアイテムを例外から取得できます。

catch (ConditionalCheckFailedException e) {
    if (e.hasItem()) {
        var currentVersion = e.item().get("version");
        System.out.println(currentVersion.n());
    }
}

AWS公式では、この返却のために追加のRead Capacity Unitは消費しません。競合後に現在値を確認するための追加GetItemを減らせます。

新規作成はattribute_not_exists

PutItemは同じ主キーがあると既存アイテムを置き換えます。新規作成だけを許可したい場合は次の条件を使います。

attribute_not_exists(orderId)

作成時はattribute_not_exists、更新時はversion確認、と役割を分けると意図しない上書きを防ぎやすくなります。

Enhanced Clientなら自動化できる

AWS SDK for Java 2.xのDynamoDB Enhanced Clientでは、version属性に@DynamoDbVersionAttributeを付けるとVersionedRecordExtensionが楽観ロックを処理します。

@DynamoDbVersionAttribute
public Integer getVersion() {
    return version;
}

標準設定では、新規アイテムのversionは0から始まり、更新成功時に1ずつ増えます。

独自ExtensionをEnhanced Clientへ設定すると、デフォルトExtensionは自動読み込みされません。楽観ロックを残す場合はVersionedRecordExtensionも明示的に追加します。

Deleteは別扱い

AWS SDK for Java 2.xでは、@DynamoDbVersionAttributeを付けてもDelete時にはversion条件が自動追加されません。

削除でも競合を検出したい場合は、DeleteへConditionExpressionを明示的に設定します。

失敗した条件付き書き込みにもコストはかかる

条件がfalseになった場合でも、条件付き書き込みはWrite Capacityを消費します。

ConditionalCheckFailedExceptionは単なる通信失敗ではなく「期待していた状態と現在の状態が違う」という競合です。同じ古いversionのまま無条件に再試行せず、最新状態を確認してから再判定します。

一般的には、

  1. 最新データを確認する
  2. 業務上まだ更新してよいか判定する
  3. 新しいversionを使って再実行する

という流れにします。

Global Tablesでは注意

DynamoDB Global Tablesはリージョン間の競合を「last writer wins」で調整します。そのため、単一リージョンで使うversionベースの楽観ロックと同じ前提では動かない場合があります。

まとめ

DynamoDBの更新競合対策では、書き込み自体に条件を持たせるのが重要です。

  • 新規作成はattribute_not_exists
  • 更新はversionをConditionExpressionで確認
  • 競合はConditionalCheckFailedExceptionとして処理
  • ReturnValuesOnConditionCheckFailureで現在値を受け取れる
  • Enhanced Clientでは@DynamoDbVersionAttributeで自動化できる
  • DeleteとGlobal Tablesは挙動に注意する

参考