Article
🔒 DynamoDBのConditionExpressionで更新競合を防ぐ:Javaで楽観ロックを実装する
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のまま無条件に再試行せず、最新状態を確認してから再判定します。
一般的には、
- 最新データを確認する
- 業務上まだ更新してよいか判定する
- 新しいversionを使って再実行する
という流れにします。
Global Tablesでは注意
DynamoDB Global Tablesはリージョン間の競合を「last writer wins」で調整します。そのため、単一リージョンで使うversionベースの楽観ロックと同じ前提では動かない場合があります。
まとめ
DynamoDBの更新競合対策では、書き込み自体に条件を持たせるのが重要です。
- 新規作成は
attribute_not_exists - 更新はversionを
ConditionExpressionで確認 - 競合は
ConditionalCheckFailedExceptionとして処理 ReturnValuesOnConditionCheckFailureで現在値を受け取れる- Enhanced Clientでは
@DynamoDbVersionAttributeで自動化できる - DeleteとGlobal Tablesは挙動に注意する
参考
- Amazon DynamoDB Developer Guide: Condition expressions
- Amazon DynamoDB Developer Guide: Working with items and attributes
- AWS SDK for Java 2.x: Use expressions and conditions
- AWS SDK for Java 2.x: Enhanced Client extensions