Article

🧭 DynamoDBで『更新したのに古い値が返る』を理解する:結果整合性と強い整合性の使い分け

AWS, DynamoDB, Java, Backend, Database

DynamoDBを使っていると、次のような現象に遭遇することがあります。

UpdateItem は成功したのに、直後に読み直したら古い値が返ってきた。

これは必ずしも不具合ではありません。

DynamoDBの読み取りは、デフォルトでは 結果整合性(Eventually Consistent Read) です。

この記事では、結果整合性と 強い整合性(Strongly Consistent Read) の違い、Javaからの指定方法、GSIでの注意点、実務でどう使い分けるかを整理します。

まず結論

最初に要点だけまとめると、次のようになります。

読み取り先 デフォルト 強い整合性を指定できるか
テーブル 結果整合性 できる
LSI 結果整合性 できる
GSI 結果整合性 できない
DynamoDB Streams 結果整合性 できない

また、読み取りコストは一般的に次の関係です。

  • 4 KBまでの強い整合性読み取り: 1 RCU
  • 4 KBまでの結果整合性読み取り: 0.5 RCU

つまり、強い整合性読み取りは結果整合性読み取りに比べて、同じデータ量なら読み取り容量を約2倍消費します。

そのため、すべての読み取りを強い整合性にするのではなく、本当に最新値が必要なアクセスだけ指定するのが基本です。

結果整合性とは

結果整合性とは、更新直後の読み取りで、少し前のデータが返る可能性がある読み取り方式です。

たとえば、次のような処理を考えます。

1. status = PENDING のデータを更新
2. status = COMPLETED に変更
3. UpdateItem が成功
4. 直後に GetItem
5. まれに status = PENDING が返る

DynamoDBでは、書き込みが成功するとデータは永続化されています。ただし、結果整合性読み取りでは、読み取り先に最新状態が反映されるまでの短い時間差によって、古い値が返る可能性があります。

AWS公式ドキュメントでも、結果整合性読み取りは最近完了した書き込みを反映していない場合があり、少し時間を置いて再度読み取ると最新値が返ると説明されています。

重要なのは、書き込みが失敗しているわけではないという点です。

強い整合性とは

強い整合性読み取りを使うと、その読み取りより前に正常終了した書き込みを反映した最新データを取得できます。

DynamoDBの GetItemQueryScan では、リクエストに ConsistentRead=true を指定できます。

たとえば、JavaのAWS SDK for Java 2.xでは次のように書けます。

import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;

import java.util.Map;

public class UserRepository {

    private final DynamoDbClient dynamoDbClient;
    private final String tableName;

    public UserRepository(DynamoDbClient dynamoDbClient, String tableName) {
        this.dynamoDbClient = dynamoDbClient;
        this.tableName = tableName;
    }

    public GetItemResponse findLatest(String userId) {
        GetItemRequest request = GetItemRequest.builder()
                .tableName(tableName)
                .key(Map.of(
                        "userId",
                        AttributeValue.builder().s(userId).build()
                ))
                .consistentRead(true)
                .build();

        return dynamoDbClient.getItem(request);
    }
}

ポイントはここです。

.consistentRead(true)

これを指定しなければ、GetItem はデフォルトで結果整合性読み取りになります。

Queryでも指定できる

Query でも同様です。

QueryRequest request = QueryRequest.builder()
        .tableName("Orders")
        .keyConditionExpression("userId = :userId")
        .expressionAttributeValues(Map.of(
                ":userId",
                AttributeValue.builder().s("user-123").build()
        ))
        .consistentRead(true)
        .build();

QueryResponse response = dynamoDbClient.query(request);

テーブル、またはLSIを対象にした Query なら、ConsistentRead=true を使えます。

LSIとは

LSIは Local Secondary Index(ローカルセカンダリインデックス) の略です。

同じパーティションキーを使いつつ、別のソートキーで検索したいときに利用するインデックスです。

LSIは元テーブルと同じパーティションに保存されるため、強い整合性読み取りが利用できます。

GSIでは強い整合性を使えない

ここが特にハマりやすいポイントです。

GSI、つまり Global Secondary Index(グローバルセカンダリインデックス) では、強い整合性読み取りを指定できません。

たとえば次のコードはNGです。

QueryRequest request = QueryRequest.builder()
        .tableName("Users")
        .indexName("email-index")
        .keyConditionExpression("email = :email")
        .expressionAttributeValues(Map.of(
                ":email",
                AttributeValue.builder()
                        .s("[email protected]")
                        .build()
        ))
        .consistentRead(true)
        .build();

このようにGSIに対して ConsistentRead=true を指定すると、AWS SDKの仕様上 ValidationException になります。

AWSのJava SDKリファレンスにも、GSIは結果整合性読み取りのみをサポートしており、GSIに ConsistentRead=true を指定すると ValidationException が返ると明記されています。

なぜGSIは更新直後に古く見えやすいのか

GSIはベーステーブルとは別のインデックスとして管理されます。

たとえば次のデータがあるとします。

Users table

PK: userId
email: [email protected]
status: ACTIVE

そして email をキーにしたGSIがあるとします。

email-index

PK: email

ベーステーブルを更新したあと、その変更内容がGSIへ反映されるまでには時間差が発生する可能性があります。

そのため、

UpdateItem
↓
GSIをQuery

という処理では、更新直後に以前の状態が見えることがあります。

ここで ConsistentRead=true を付ければ解決できそうに見えますが、GSIでは指定できません。

この制約は設計時点で意識しておく必要があります。

GSIで最新値が必要なときはどうするか

よく使う方法は、GSIは検索用、最新データの確定はベーステーブルで行うという設計です。

たとえばメールアドレスからユーザーを検索する場合です。

1. email-index を Query
2. userId を取得
3. ベーステーブルを GetItem
4. GetItemだけ ConsistentRead=true

Javaでイメージを書くと次のようになります。

// 1. GSIでユーザーIDを探す
QueryRequest queryRequest = QueryRequest.builder()
        .tableName("Users")
        .indexName("email-index")
        .keyConditionExpression("email = :email")
        .expressionAttributeValues(Map.of(
                ":email",
                AttributeValue.builder().s(email).build()
        ))
        .build();

QueryResponse queryResponse = dynamoDbClient.query(queryRequest);

String userId = queryResponse.items().get(0)
        .get("userId")
        .s();

// 2. 最新値が必要ならベーステーブルを強整合性で読む
GetItemRequest getItemRequest = GetItemRequest.builder()
        .tableName("Users")
        .key(Map.of(
                "userId",
                AttributeValue.builder().s(userId).build()
        ))
        .consistentRead(true)
        .build();

GetItemResponse latestUser = dynamoDbClient.getItem(getItemRequest);

ただし注意点があります。

GSI自体への反映がまだ終わっておらず、検索結果そのものに対象データが出てこないケースでは、この方法だけでは解決できません。

「書き込み直後に、そのGSIを使って必ず検索できなければならない」という要件なら、アクセスパターン自体を見直した方が安全です。

実務ではどこで強い整合性を使うべきか

まず次の観点で考えると整理しやすいです。

強い整合性が向いているケース

  • 更新直後の確認画面
  • 二重処理を避けるために最新ステータスを確認したい処理
  • 最新の設定値が必須な処理
  • ベーステーブルの状態を基準に次の処理を分岐する場合

たとえば、次のような処理です。

決済処理
↓
注文ステータス更新
↓
次の処理を実行してよいか最新ステータスを確認

こうした処理では、古いステータスを読んでしまうと、業務ロジックそのものに影響する可能性があります。

結果整合性で十分なケース

  • 一覧画面
  • ダッシュボード
  • 数秒の反映遅延が許容できる検索
  • ログや履歴の表示
  • 最新状態であることが厳密には要求されない参照処理

一覧表示のたびに強い整合性読み取りを使うと、読み取り容量の消費が増えます。

最新性が必要かどうかを要件から判断することが重要です。

「全部ConsistentRead=true」にしない方がよい理由

強い整合性は便利ですが、常に使う必要はありません。

AWS公式ドキュメントでは、4 KBまでのデータの場合、

強い整合性読み取り     : 1 RCU
結果整合性読み取り     : 0.5 RCU

となります。

つまり同じ読み取り量なら、強い整合性の方が読み取り容量を多く消費します。

オンデマンドモードでも考え方は同様で、4 KBまでの強い整合性読み取り1回は1 Read Request Unit、結果整合性読み取りはその半分です。

大量アクセスがあるAPIで何となく ConsistentRead=true を指定すると、コストへ直接影響します。

FilterExpressionを使っても読み取りコストは減らない

読み取り整合性と合わせて覚えておきたいのが FilterExpression です。

DynamoDBの Query では、FilterExpression で返却するデータを絞れます。

ただし、RCUは最終的にアプリへ返されたデータ量ではなく、Queryが読み取ったアイテムのサイズを基準に計算されます。

そのため、

.filterExpression("#status = :status")

を書いてレスポンス件数が減っても、必ずしもRCUが減るわけではありません。

コストを下げたい場合は、FilterExpressionで後から捨てるのではなく、必要なデータだけを取得できるキー設計を考える方が重要です。

ReturnConsumedCapacityで実際の消費量を確認する

DynamoDBの読み取りコストを確認するときは ReturnConsumedCapacity が便利です。

GetItemRequest request = GetItemRequest.builder()
        .tableName("Users")
        .key(Map.of(
                "userId",
                AttributeValue.builder().s("user-123").build()
        ))
        .consistentRead(true)
        .returnConsumedCapacity(ReturnConsumedCapacity.TOTAL)
        .build();

GetItemResponse response = dynamoDbClient.getItem(request);

System.out.println(response.consumedCapacity().capacityUnits());

通常はレスポンスに消費容量情報は含まれません。

調査時に ReturnConsumedCapacity.TOTAL や、Queryなら INDEXES を指定すると、実際にどの程度容量を使っているか確認できます。

性能改善やコスト調査をするときに役立ちます。

使い分けの考え方

実務では次の順番で考えると分かりやすいです。

この読み取りは最新値でなければ困る?
        │
        ├─ No → 結果整合性
        │
        └─ Yes
             │
             ├─ テーブル / LSI → ConsistentRead=trueを検討
             │
             └─ GSI → 強い整合性は使えない
                         ↓
                   アクセスパターンを再検討

DynamoDBでは「どのデータを保存するか」だけでなく、どのキーから、どの整合性で読むかまで含めて設計する必要があります。

特にGSIを使う場合は、更新直後の最新性が必要かどうかを事前に確認しておくと、後からハマりにくくなります。

まとめ

DynamoDBで更新直後に古い値が返ってきた場合、まず読み取り整合性を確認してみてください。

  • DynamoDBはデフォルトで結果整合性読み取り
  • テーブルとLSIでは ConsistentRead=true が使える
  • GSIでは強い整合性読み取りは使えない
  • 強い整合性読み取りは結果整合性より読み取り容量を多く消費する
  • GSIは検索用、最新値の確定はベーステーブルで行う設計も有効
  • FilterExpression で返却件数を減らしても、読み取ったデータ量に基づくRCUは減らない
  • ReturnConsumedCapacity を使うと実際の消費量を確認できる

「更新は成功しているのに値が戻った」という現象を見たとき、アプリケーションコードだけを疑うのではなく、DynamoDBの整合性モデルも確認すると原因を切り分けやすくなります。

参考資料