概要
DynamoDBの公式ドキュメントではconditional writeにより、他のセッションから同時に更新があった場合に意図しない結果となることを防ぐテクニックが紹介されています。
https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.ConditionalUpdate
上記のドキュメントでは複数セッションからの同時更新のみが考慮されていますが、SDKのリトライの振る舞いを考えるとシングルセッションからの更新でも極力conditional writeを活用すべきです。
AWS SDKのリトライについて振る舞いについて
DynamoDBのクライアントを含むAWS SDKでは対象の処理系/プラットフォームに関わらず、一律リトライの仕組みが提供されています。
リトライの仕組みについては以下のドキュメントに詳細な説明があります。
https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html
エラーが発生したとしてもクライアント側での作り込みなしにリトライが自動的に実行されることから、非常に有用な仕組みです。
しかしながら、問題はこのリトライの条件に以下のものも含まれていることです。
Connection errors, defined as any error received by the SDK in which an HTTP response from the service is not received.
すなわち、サービス側から応答がない、あるいはコネクションエラーなどのケースでも自動的にリトライが実行されます。
以下の文字列はAWS SDK for Java 2.xxのRetryPolicyのデフォルトの設定をtoStringで文字列として出力したものですが、50xのエラーコードに加えて、IOException,ApiCallAttemptTimeoutException, UncheckedIOException, clockのskew, throttling等も対象となることがわかります。
"RetryPolicy(additionalRetryConditionsAllowed=false,
aggregateRetryCondition=AndRetryCondition(conditions=[AndRetryCondition(conditions=[MaxNumberOfRetriesCondition(maxNumberOfRetries=8),
OrRetryCondition(conditions=[OrRetryCondition(conditions=[RetryOnStatusCodeCondition(statusCodesToRetryOn=[500,
502,
503,
504]),
RetryOnExceptionsCondition(exceptionsToRetryOn=[class java.io.IOException,
class software.amazon.awssdk.core.exception.ApiCallAttemptTimeoutException,
class java.io.UncheckedIOException,
class software.amazon.awssdk.core.exception.RetryableException]),
RetryOnClockSkewCondition(),
RetryOnThrottlingCondition()]),
software.amazon.awssdk.awscore.retry.conditions.RetryOnErrorCodeCondition@33a053d])]),
TokenBucketRetryCondition(capacity=500/500,
exceptionCostFunction=TokenBucketExceptionCostCalculator(throttlingExceptionCost=0,
defaultExceptionCost=5))]),
backoffStrategy=FullJitterBackoffStrategy(baseDelay=PT0.025S,
maxBackoffTime=PT20S),
throttlingBackoffStrategy=EqualJitterBackoffStrategy(baseDelay=PT0.5S,
maxBackoffTime=PT20S))"
具体的にどんな問題が起きえるのか
例えば以下のAWS SDK for Java 2.xxを用いたサンプルプログラムでは正常時には100回インクリメントされることが期待されます。
しかしながら、タイムアウト値を意図的に私の環境ではタイムアウトが発生しリトライが起きる値に調整することで、プログラムが正常終了しているにもかかわらず値が100よりも大きくなることを確認しています。
final var standardClient = DynamoDbClient.builder()
.overrideConfiguration((b) -> {
b.apiCallAttemptTimeout(Duration.ofMillis(150));
})
.httpClient(ApacheHttpClient.builder().build())
.build();
final var key = new HashMap<String, AttributeValue>();
key.put("PK", AttributeValue.fromN("1"));
final var value = new HashMap<String, AttributeValue>();
value.put(":inc", AttributeValue.fromN("1"));
final var updateItemRequest = UpdateItemRequest.builder()
.key(key)
.expressionAttributeValues(value)
.updateExpression("SET updated_counter = updated_counter + :inc")
.tableName("Test")
.build();
for (int i = 0 ; i < 100 ; i ++ ) {
System.out.println("current count=" + i);
standardClient.updateItem(updateItemRequest);
}
また、DEBUGログからも自動でのリトライが複数回実行されていることを確認しています。
2023-05-04 15:59:06.141 [main] DEBUG software.amazon.awssdk.request:96 - Retryable error detected. Will retry in 13ms. Request attempt number 2
software.amazon.awssdk.core.exception.ApiCallAttemptTimeoutException: HTTP request execution did not complete before the specified timeout configuration: 150 millis
at software.amazon.awssdk.core.exception.ApiCallAttemptTimeoutException$BuilderImpl.build(ApiCallAttemptTimeoutException.java:97) ~[sdk-core-2.20.57.jar:?]
at software.amazon.awssdk.core.exception.ApiCallAttemptTimeoutException.create(ApiCallAttemptTimeoutException.java:38) ~[sdk-core-2.20.57.jar:?]
このサンプルは単なるカウンターなので、意図せず一回のリクエストで複数回インクリメントされたとしても実害はありません。
しかしながら、例えば前述の商品の価格の更新処理では、リトライで複数回実行されてしまうと意図しない結果となってしまうと考えられます。
加えてDynamoDBではサービス側では監査ログの有効化、あるいはDynamoDB Streamsでキャプシャした更新内容を保存しておく等の対策を取らないと誰がどのタイミングで更新したか特定できないことから、意図しない値となった時の調査が困難なことが予想されます。
まとめ
- シングルセッションからの更新であったとしても、SDKの自動リトライにより複数回更新が実行される可能性を考慮して、なるべくconditional writeを利用すべき。
- SDKのタイムアウトやリトライの振る舞いは事前に把握しておくべき。