DynamoDBのScanオペレーション完全解説 - 2025年版ベストプラクティスと最新の回避策
Scanオペレーションの本質的な問題を理解する
DynamoDBの「Scan」オペレーションは、一見すると便利な全件取得機能に見えますが、その内部動作を正確に理解していないと、予期せぬパフォーマンス劣化や想定外のコスト増大を招きます。AWS公式APIリファレンスによると、Scanは常にテーブルまたはセカンダリインデックスの全アイテムを順次読み込む仕様となっており、この基本的な動作原理は2025年現在も変わっていません。
多くのエンジニアが誤解しているのが「FilterExpression」の役割です。SQLのWHERE句のように事前フィルタリングすることで読み込み量を削減できると期待しがちですが、実際には全データを読み込んだ後にフィルタリングが適用されるため、「RCU(Read Capacity Units)」の消費削減には一切寄与しません。この仕様は、DynamoDBがキー・バリューストアとして設計されているという本質に起因しており、RDBMSのような柔軟なクエリ最適化とは根本的に異なるアプローチを取っているためです。
FilterExpressionとProjectionExpressionの誤解を解く
DynamoDBのScanオペレーションにおける二つの重要なパラメータについて、正確な理解が必要です。
まず「FilterExpression」は、データベースから読み込まれた後のアイテムに対して適用される条件式です。公式ドキュメントで明記されているように、この処理はRCUの消費が完了した後に実行されるため、スループット削減の効果は全くありません。例えば、100万件のアイテムから10件を抽出する場合でも、100万件分のRCUが消費されます。
次に「ProjectionExpression」は、クライアントに返却する属性を限定する機能ですが、これもまたキャパシティユニットの消費には影響しません。RCUの計算は返却データ量ではなく、読み込んだアイテムのサイズに基づいて行われるため、巨大なアイテムの一部属性のみを取得する場合でも、アイテム全体のサイズ分のRCUが消費されます。
整合性モデルの限界と実装上の注意点
Scanオペレーションの整合性モデルには、重要な制約があります。ConsistentRead: true
を指定することで強整合性読み込みは可能ですが、これは個々のアイテム読み込みに対する整合性保証であり、スキャン全体での「スナップショット一貫性」は保証されません。つまり、長時間にわたるスキャン処理中に他のトランザクションによる更新が反映される可能性があり、集計処理や分析用途には本質的に不適切です。
さらに重要な制約として、「GSI(Global Secondary Index)」に対するScanでは強整合性の指定自体が不可能です。GSIは非同期レプリケーションによって維持されるため、本質的に結果整合性のみのサポートとなっています。この仕様は、GSIを活用した検索機能の設計において重要な考慮事項となります。
2025年最新のリスク管理とコスト制御戦略
オンデマンドモードにおける暴走リスクと新たな防御機構
オンデマンドモードは瞬時のスケーリングを実現する優れた機能ですが、不適切なScanオペレーションによる「暴走スケール」のリスクを内包しています。新規作成されたテーブルは公式ドキュメントによると、デフォルトで最大12,000読み込みリクエスト/秒まで即座にスケールする能力を持っています。これは、誤って実装されたScanオペレーションが短時間で膨大なコストを発生させる可能性を意味します。
この問題に対する画期的な対策として、2024年5月に発表された「オンデマンド最大スループット上限」機能が利用可能になりました。この機能により、テーブルごとに読み書きスループットの上限を設定でき、予期せぬコスト爆発を防ぐことができます。CloudWatchメトリクスのOnDemandMaxReadRequestUnits
を監視することで、設定した上限への接近を検知し、プロアクティブな対応が可能になります。
実際の導入事例として、あるフィンテック企業では、開発環境のテーブルに対して読み込み上限を1,000リクエスト/秒に設定することで、開発者の誤ったクエリによる月額数十万円規模のコスト事故を未然に防いでいます。このような「ガードレール」の設定は、特に複数のチームが共同開発するマイクロサービス環境において必須の運用プラクティスとなりつつあります。
PartiQLという新たな落とし穴
PartiQLによるSQL互換クエリのサポートは、RDBMSに慣れたエンジニアにとって魅力的な機能ですが、新たな落とし穴も生んでいます。WHERE句にパーティションキー条件を含まないSELECT文は、自動的に完全テーブルスキャンに変換されます。SQLライクな構文により、この挙動が直感的に理解しづらく、本番環境での重大なインシデントにつながるケースが報告されています。
以下の表は、PartiQLクエリがどのような内部オペレーションに変換されるかを示したものです。
表 PartiQLクエリと内部オペレーションの対応関係
PartiQLクエリパターン | 内部オペレーション | RCU消費 | 推奨度 |
---|---|---|---|
WHERE句にパーティションキー(=またはIN) | Query | 最小限 | 推奨 |
WHERE句にパーティションキー+ソートキー | Query | 最小限 | 推奨 |
WHERE句に非キー属性のみ | Scan + Filter | 全件分 | 非推奨 |
WHERE句なし | Scan | 全件分 | 禁止 |
PartiQLを採用する場合は、必ずクエリプランの確認と、CloudWatchメトリクスによる継続的な監視を行う必要があります。特にScannedCount
とCount
の比率を監視し、10倍以上の乖離が見られる場合は即座にクエリの見直しを行うべきです。
パフォーマンス監視の実践的アプローチ
重要メトリクスの理解と活用
効果的なScan回避戦略を実装するためには、適切なメトリクス監視が不可欠です。CloudWatchによるDynamoDB監視では、以下のメトリクスが特に重要となります。
最も注目すべきメトリクスはScannedCount
とCount
の比率です。この比率が大きいほど、無駄な読み込みが発生していることを意味します。例えば、ScannedCount
が10,000でCount
が10の場合、99.9%のデータが無駄に読み込まれていることになり、即座の改善が必要です。
ConsumedReadCapacityUnits
メトリクスは、実際のRCU消費量を示し、コスト計算の基礎となります。プロビジョンドモードでは設定値との比較により、スロットリングリスクを評価できます。ThrottledRequests
が発生している場合は、キャパシティ不足またはホットパーティションの存在を示唆しており、アクセスパターンの見直しが必要です。
アラート設計のベストプラクティス
実践的なアラート設計として、以下の3段階のしきい値設定を推奨します。
表 推奨アラートしきい値設定
アラートレベル | ScannedCount/Count比 | ConsumedRCU(対プロビジョン) | 対応アクション |
---|---|---|---|
Warning | 5以上 | 70%以上 | クエリパターンレビュー |
Critical | 20以上 | 85%以上 | 即時調査・改善 |
Emergency | 100以上 | 95%以上 | サービス停止検討 |
これらのアラートは、開発環境では緩めに、本番環境では厳しく設定し、段階的に最適化していくアプローチが効果的です。
実装パターンとベストプラクティス
AWS SDK v3への移行と最新実装パターン
AWS SDK for JavaScript v2はメンテナンスモードに移行しており、新規開発ではv3の採用が必須となっています。v3では、モジュラーアーキテクチャにより必要な機能のみをインポートできるため、バンドルサイズの削減とコールドスタートの改善が期待できます。
以下は、やむを得ずScanを使用する場合の最小限かつ安全な実装例です。
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { RateLimiter } from "limiter";
// クライアントの初期化(リージョンは環境変数から取得を推奨)
const client = new DynamoDBClient({
region: process.env.AWS_REGION || "ap-northeast-1",
maxAttempts: 3,
retryMode: "adaptive"
});
const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: {
removeUndefinedValues: true,
convertEmptyValues: false
}
});
// レート制限の実装(秒間10リクエストまで)
const limiter = new RateLimiter({ tokensPerInterval: 10, interval: "second" });
async function safeScanWithRateLimit(tableName: string): Promise<any[]> {
const results: any[] = [];
let lastEvaluatedKey: Record<string, any> | undefined;
let totalScanned = 0;
let totalReturned = 0;
try {
do {
// レート制限の適用
await limiter.removeTokens(1);
const response = await ddb.send(new ScanCommand({
TableName: tableName,
Limit: 100, // ページサイズを小さく保つ
ExclusiveStartKey: lastEvaluatedKey,
ReturnConsumedCapacity: "TOTAL" // 消費キャパシティの監視
}));
if (response.Items) {
results.push(...response.Items);
}
// メトリクス収集
totalScanned += response.ScannedCount || 0;
totalReturned += response.Count || 0;
// 非効率性の検出
if (totalScanned > totalReturned * 10) {
console.warn(`非効率なScan検出: ScannedCount=${totalScanned}, Count=${totalReturned}`);
}
lastEvaluatedKey = response.LastEvaluatedKey;
// ConsumedCapacityのログ出力(監視用)
if (response.ConsumedCapacity) {
console.log(`Consumed RCU: ${response.ConsumedCapacity.ReadCapacityUnits}`);
}
} while (lastEvaluatedKey);
} catch (error) {
console.error("Scan operation failed:", error);
throw error;
}
return results;
}
Query + GSIによる効率的な実装
Scanの代替として最も推奨される実装パターンは、適切に設計されたGSI(Global Secondary Index)を活用したQueryオペレーションです。特に「スパースインデックス」の概念を理解し活用することで、必要最小限のデータのみをインデックス化し、コストとパフォーマンスの最適化が可能になります。
import { QueryCommand } from "@aws-sdk/lib-dynamodb";
// スパースGSIを活用した効率的なクエリ実装
interface UserOrderQueryParams {
userId: string;
orderStatus?: "PENDING" | "COMPLETED" | "CANCELLED";
limit?: number;
}
async function queryUserOrdersEfficiently(params: UserOrderQueryParams): Promise<any[]> {
const { userId, orderStatus, limit = 100 } = params;
// GSIの設計: GSI1PK = "USER#{userId}", GSI1SK = "ORDER#{status}#{timestamp}"
// statusが特定の値を持つアイテムのみGSI属性が設定される(スパース設計)
let keyConditionExpression = "#pk = :userId";
const expressionAttributeNames: Record<string, string> = {
"#pk": "GSI1PK"
};
const expressionAttributeValues: Record<string, any> = {
":userId": `USER#${userId}`
};
if (orderStatus) {
keyConditionExpression += " AND begins_with(#sk, :statusPrefix)";
expressionAttributeNames["#sk"] = "GSI1SK";
expressionAttributeValues[":statusPrefix"] = `ORDER#${orderStatus}#`;
}
const response = await ddb.send(new QueryCommand({
TableName: process.env.DYNAMODB_TABLE_NAME!,
IndexName: "GSI1",
KeyConditionExpression: keyConditionExpression,
ExpressionAttributeNames: expressionAttributeNames,
ExpressionAttributeValues: expressionAttributeValues,
Limit: limit,
ScanIndexForward: false, // 最新順に取得
ReturnConsumedCapacity: "INDEXES"
}));
// 消費キャパシティの監視(GSI単位)
if (response.ConsumedCapacity?.GlobalSecondaryIndexes?.GSI1) {
const gsiConsumed = response.ConsumedCapacity.GlobalSecondaryIndexes.GSI1;
console.log(`GSI1 Consumed RCU: ${gsiConsumed.ReadCapacityUnits}`);
}
return response.Items || [];
}
分析・バッチ処理の代替アーキテクチャ
S3エクスポートによるオフライン処理への移行
大規模なデータ分析やバッチ処理において、Scanオペレーションは本質的に不適切です。DynamoDBのS3エクスポート機能は、RCUを一切消費せずにテーブルデータをS3に出力できる画期的な機能として、2025年現在の標準的なアプローチとなっています。
S3エクスポートには「フルエクスポート」と「増分エクスポート」の2種類があり、用途に応じて使い分けが可能です。フルエクスポートは日次・週次のスナップショット作成に適しており、増分エクスポートはCDC(Change Data Capture)パターンでのリアルタイム分析基盤構築に活用できます。
エクスポートされたデータはParquet形式で保存され、AWS Glue、Amazon Athena、Amazon EMRなどのサービスで効率的に処理できます。特にAthenaを使用したSQLクエリは、DynamoDBのPartiQLよりもはるかに柔軟で高速な分析を可能にします。
実践的なデータパイプライン設計
以下は、S3エクスポートを活用したデータパイプラインの実装例です。
import { DynamoDBClient, ExportTableToPointInTimeCommand } from "@aws-sdk/client-dynamodb";
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { EventBridgeClient, PutRuleCommand, PutTargetsCommand } from "@aws-sdk/client-eventbridge";
class DynamoDBExportPipeline {
private dynamoClient: DynamoDBClient;
private s3Client: S3Client;
private eventBridgeClient: EventBridgeClient;
constructor(region: string = "ap-northeast-1") {
this.dynamoClient = new DynamoDBClient({ region });
this.s3Client = new S3Client({ region });
this.eventBridgeClient = new EventBridgeClient({ region });
}
// 日次エクスポートのスケジューリング設定
async setupDailyExportSchedule(tableName: string, s3Bucket: string): Promise<void> {
const ruleName = `${tableName}-daily-export`;
// EventBridgeルールの作成(毎日午前2時JST)
await this.eventBridgeClient.send(new PutRuleCommand({
Name: ruleName,
ScheduleExpression: "cron(0 17 * * ? *)", // UTC 17:00 = JST 2:00
State: "ENABLED",
Description: `Daily export of ${tableName} to S3`
}));
// Lambda関数をターゲットとして設定
await this.eventBridgeClient.send(new PutTargetsCommand({
Rule: ruleName,
Targets: [{
Arn: process.env.EXPORT_LAMBDA_ARN!,
Id: "1",
Input: JSON.stringify({
tableName,
s3Bucket,
exportType: "FULL"
})
}]
}));
}
// エクスポート実行と監視
async executeExport(tableName: string, s3Bucket: string): Promise<string> {
const exportTime = new Date().toISOString();
const s3Prefix = `dynamodb-exports/${tableName}/${exportTime}/`;
try {
const response = await this.dynamoClient.send(new ExportTableToPointInTimeCommand({
TableArn: `arn:aws:dynamodb:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:table/${tableName}`,
S3Bucket: s3Bucket,
S3Prefix: s3Prefix,
ExportFormat: "DYNAMODB_JSON", // または "ION" for binary format
ExportType: "FULL_EXPORT"
}));
console.log(`Export initiated: ${response.ExportDescription?.ExportArn}`);
// エクスポート状態の監視ロジック
await this.monitorExportStatus(response.ExportDescription?.ExportArn!);
return s3Prefix;
} catch (error) {
console.error(`Export failed for ${tableName}:`, error);
throw error;
}
}
private async monitorExportStatus(exportArn: string): Promise<void> {
// 実装省略: DescribeExportコマンドでステータスをポーリング
// 完了後、CloudWatchメトリクスやSNS通知を送信
}
}
設計段階からScanを回避するアーキテクチャ
アクセスパターン駆動設計の重要性
DynamoDBの設計において最も重要なのは、実装前にアクセスパターンを徹底的に洗い出すことです。AWSのベストプラクティスガイドでは、以下のステップでの設計を推奨しています。
まず、すべてのユースケースとアクセスパターンを列挙します。次に、各パターンに対してパーティションキーとソートキーの組み合わせを設計します。この際、単一テーブル設計パターンを採用することで、JOINの必要性を排除し、効率的なデータアクセスを実現できます。
以下の表は、ECサイトを例とした典型的なアクセスパターンとその実装方法を示しています。
表 ECサイトのアクセスパターンと実装設計
アクセスパターン | パーティションキー | ソートキー | GSI | 実装方法 |
---|---|---|---|---|
ユーザー情報取得 | USER#{userId} | PROFILE | - | Query |
ユーザーの注文履歴 | USER#{userId} | ORDER#{timestamp} | - | Query |
商品情報取得 | PRODUCT#{productId} | META | - | Query |
カテゴリ別商品一覧 | CATEGORY#{categoryId} | PRODUCT#{productId} | - | Query |
注文ステータス別検索 | - | - | GSI1PK: STATUS#{status} | Query on GSI |
日付範囲での売上集計 | - | - | S3エクスポート | Athena SQL |
この設計により、Scanが必要なケースを事実上ゼロに削減できます。
コンポジットキーとオーバーロードパターン
高度な設計パターンとして、「コンポジットキー」と「属性オーバーロード」の組み合わせが効果的です。パーティションキーとソートキーに複数の情報を埋め込むことで、単一のテーブルで多様なアクセスパターンをサポートできます。
// コンポジットキーを活用した柔軟な設計例
interface CompositeKeyDesign {
PK: string; // 例: "TENANT#123#USER#456"
SK: string; // 例: "ORDER#2024-01-15T10:30:00Z#ORD-789"
// GSIでの逆引き用
GSI1PK?: string; // 例: "ORDER#ORD-789"
GSI1SK?: string; // 例: "TENANT#123"
}
class CompositeKeyBuilder {
static buildUserOrderKey(tenantId: string, userId: string): string {
return `TENANT#${tenantId}#USER#${userId}`;
}
static buildOrderSortKey(timestamp: Date, orderId: string): string {
return `ORDER#${timestamp.toISOString()}#${orderId}`;
}
static parseCompositeKey(key: string): Record<string, string> {
const parts = key.split('#');
const result: Record<string, string> = {};
for (let i = 0; i < parts.length; i += 2) {
if (parts[i] && parts[i + 1]) {
result[parts[i]] = parts[i + 1];
}
}
return result;
}
}
運用フェーズでの継続的最適化
コスト最適化のための定期レビュープロセス
DynamoDBの運用において、定期的なアクセスパターンレビューは不可欠です。ビジネス要件の変化により、当初想定していなかったアクセスパターンが発生し、結果としてScanに頼らざるを得ない状況が生まれることがあります。
四半期ごとのレビューでは、CloudWatch InsightsやContributor Insightsを活用し、以下の観点で分析を行います。まず、最もコストを消費しているオペレーションを特定します。次に、ScannedCountが異常に高いクエリパターンを抽出します。そして、新たに追加されたアクセスパターンがGSIの追加で解決できないか検討します。
段階的マイグレーション戦略
既存システムでScanに依存している場合、一度にすべてを置き換えるのではなく、段階的なマイグレーションが現実的です。
第一段階として、読み込み専用のGSIを追加し、新規機能から順次Queryベースに移行します。第二段階では、既存のScanオペレーションにレート制限を適用し、システムへの影響を最小限に抑えながら移行を進めます。第三段階として、バッチ処理をS3エクスポート + Athenaに置き換えます。最終段階で、残存するScanオペレーションを完全に排除します。
この段階的アプローチにより、サービスの継続性を保ちながら、着実にパフォーマンスとコスト効率を改善できます。
まとめと今後の展望
DynamoDBのScanオペレーションは、その利便性の裏に潜む深刻なリスクを理解し、適切に回避することが重要です。2025年現在、オンデマンド最大スループット上限やS3エクスポートなど、新たなガードレールと代替手段が充実してきており、より安全で効率的な運用が可能になっています。
重要なのは、DynamoDBをRDBMSの代替として捉えるのではなく、NoSQLデータベースとしての特性を理解し、その強みを最大限に活かす設計を行うことです。アクセスパターン駆動設計、適切なGSIの活用、そして継続的な監視と最適化により、Scanに頼らない高性能なシステムを構築できます。
今後、AWSはDynamoDBの機能拡張を続けており、より柔軟なクエリ機能や自動最適化機能の追加が期待されます。しかし、基本的な設計原則は変わらないため、本記事で解説したベストプラクティスを実践することで、将来にわたって持続可能なDynamoDBアーキテクチャを構築できるはずです。