😎

DynamoDB GSIマルチキー機能の本質:複合キー設計の民主化とその制約(アップデート内容概要編)

に公開

DynamoDB GSIマルチキー機能にdive deepしてみた:複合キー設計の民主化とコスト削減効果

AWSの発表

2025年11月、Amazon DynamoDB のグローバルセカンダリインデックス(GSI)におけるマルチキーサポートが発表されました。

一見「新機能」に見えますが、本質を整理すると 「昔からあった複合キー設計が、簡単に、いつでもできるようになった」 という理解に落ち着きました。(いや機能として出てきたので新機能なんですけどね)
以下、整理していきます。

何が変わったのか?

GSI作成時に指定できるキーの数が大幅に拡張されました:

  • パーティションキー(以降、PKEY):最大4つ
  • ソートキー(以降、SKEY):最大4つ

合計8つの属性を組み合わせて、柔軟なクエリパターンを実現できるようになりました。

キーの種類 指定可能な数 クエリ時のルール
パーティションキー (PKEY) 最大4つ 定義したすべての属性に対して等価条件(=)が必須。 一部でも欠けるとエラーになります。
ソートキー (SKEY) 最大4つ 左から順に部分的な指定が可能(例:3つ定義して最初の1つだけ、あるいは2つだけ指定して検索)。

サンプルシナリオ:マルチテナント型IoTプラットフォーム

具体例があったほうが説明しやすいので、IoTのような時系列データを管理するテーブルを想定します。

データ構造

{
  "device_id": "DEV-001",
  "tenant_id": "TENANT-A",
  "group": "FACTORY-1",
  "place": "BUILDING-B",
  "timestamp": "2025-01-28T10:00:00Z",
  "sensor_1": 85,
  "sensor_2": 42.5,
  "status": "ERROR"
}

ベーステーブル設計

PKEY: device_id
SKEY: timestamp

ビジネス要件

マルチテナント型サービスでは、階層的なフィルタリングが求められます:

  1. 契約単位tenant_idで特定顧客のデータを取得
  2. 部署単位tenant_id + groupで特定部署のデータを取得
  3. 場所単位tenant_id + group + placeで特定設置場所を取得
  4. 状態単位:上記に加えてstatusで絞り込み
  5. 時間範囲:さらにtimestampで期間指定

従来の複合キー設計(Before)

設計方法

複数の要素を#で連結した複合キーを事前に作成する必要がありました。
(例としてわかりやすい言葉を選んでいるので一部DDBの予約語と衝突している可能性があります。衝突している場合はプレースホルダを使う必要があります。)

// アプリケーション側で複合キーを生成
const item = {
  device_id: "DEV-001",
  tenant_id: "TENANT-A",
  group: "FACTORY-1",
  place: "BUILDING-B",
  timestamp: "2025-01-28T10:00:00Z",
  sensor_1: 85,
  
  // GSI用の複合キー(冗長データ)
  GSI_PK: "TENANT-A#FACTORY-1#BUILDING-B",
  GSI_SK: "ERROR#2025-01-28T10:00:00Z"
};

// GSI定義
// PKEY: GSI_PK
// SKEY: GSI_SK

クエリ例

// 契約単位で取得
KeyConditionExpression: "GSI_PK BEGINS_WITH :tenant",
ExpressionAttributeValues: {
  ":tenant": "TENANT-A"
}

// 部署まで絞り込み
KeyConditionExpression: "GSI_PK BEGINS_WITH :tenant_group",
ExpressionAttributeValues: {
  ":tenant_group": "TENANT-A#FACTORY-1"
}

// 場所まで完全指定+時間範囲
KeyConditionExpression: "GSI_PK = :full AND GSI_SK BETWEEN :start AND :end",
ExpressionAttributeValues: {
  ":full": "TENANT-A#FACTORY-1#BUILDING-B",
  ":start": "ERROR#2025-01-01",
  ":end": "ERROR#2025-01-31"
}

課題と制約

1. アプリケーション依存性が高い

  • 複合キーの生成ロジックをアプリケーション側で実装する必要がある
  • #の区切り文字の管理、属性の順序管理が必須
  • データ内に#が含まれる場合のエスケープ処理が必要
// 複雑な複合キー生成ロジック
function generateGSIPK(tenant_id, group, place) {
  // エスケープ処理
  const sanitize = (str) => str.replace(/#/g, '\\#');
  
  return `${sanitize(tenant_id)}#${sanitize(group)}#${sanitize(place)}`;
}

2. 数値の扱いが困難

文字列として連結するため、数値の範囲検索で問題が発生します。

// 文字列比較では正しくソートされない
"100" < "80"  // true(文字列として比較されるため)

// ゼロパディングが必須
GSI_SK: "ERROR#2025-01#00085"  // 温度85度
GSI_SK: "ERROR#2025-01#00100"  // 温度100度

// アプリ側でパディング処理
temperature.toString().padStart(5, '0')

3. 後付けが困難(バックフィル必須)

最初の設計時に複合キーを考慮していない場合、途中で追加するのが非常に困難です。

// 新しい要件:status も検索条件に追加したい
// 既存:GSI_SK = "2025-01-28T10:00:00Z"
// 新規:GSI_SK = "ERROR#2025-01-28T10:00:00Z"

// → 全データのバックフィルが必要!
// 数百万〜数億レコードの更新が必要になる可能性

バックフィルのコストとリスク:

  • 時間:数時間〜数日かかる可能性
  • コスト:全レコードの書き込み = WCU × レコード数
  • リスク:本番データの大規模更新、ダウンタイムの可能性

4. ストレージの無駄

検索専用の冗長属性を持つ必要があります。

// 100万レコード × 50 bytes の冗長データ
// = 50MB × $0.25/GB = 約$0.0125/月

// 10億レコードでは:50GB × $0.25 = $12.5/月の無駄

5. NoSQL設計の高い学習コスト

  • 複合キー設計のパターンを事前に深く理解する必要がある
  • 最初から完璧な設計が求められる(後からの変更が困難)
  • チーム全体での設計パターンの共有が必須

6. KEYに指定した項目に欠落があるとINDEXテーブルに登録されない

これは、こちらのブログに明記されています. グローバルセカンダリインデックスにおける拡張複合キーの紹介 セクション に明記されております。


今回のアップデート(After)

新しいGSI設計

複合キーカラムを作成せず、既存の各カラムを個別に指定するだけです。

// GSI定義(コンソールまたはIaC)
GSI名: TenantHierarchyIndex
PKEY: 
  - tenant_id
  - group
SKEY:
  - place
  - status
  - timestamp
ProjectionType: INCLUDE
NonKeyAttributes: ['sensor_1', 'sensor_2']

クエリ例

// 1. 契約単位で取得
KeyConditionExpression: "tenant_id = :tenant",
ExpressionAttributeValues: {
  ":tenant": "TENANT-A"
}

// 2. 部署まで絞り込み
KeyConditionExpression: 
  "tenant_id = :tenant AND #group = :grp",
ExpressionAttributeNames: {
  "#group": "group"  // 予約語のため
},
ExpressionAttributeValues: {
  ":tenant": "TENANT-A",
  ":grp": "FACTORY-1"
}

// 3. 場所まで指定
KeyConditionExpression: 
  "tenant_id = :tenant AND #group = :grp AND place = :place",
ExpressionAttributeValues: {
  ":tenant": "TENANT-A",
  ":grp": "FACTORY-1",
  ":place": "BUILDING-B"
}

// 4. 状態まで指定
KeyConditionExpression: 
  "tenant_id = :tenant AND #group = :grp AND place = :place AND #status = :st",
ExpressionAttributeValues: {
  ":tenant": "TENANT-A",
  ":grp": "FACTORY-1",
  ":place": "BUILDING-B",
  ":st": "ERROR"
}

// 5. 時間範囲で絞り込み(最後のキーで範囲検索可能)
KeyConditionExpression: 
  "tenant_id = :tenant AND #group = :grp AND place = :place AND #status = :st AND #ts >= :start",
ExpressionAttributeValues: {
  ":tenant": "TENANT-A",
  ":grp": "FACTORY-1",
  ":place": "BUILDING-B",
  ":st": "ERROR",
  ":start": "2025-01-01T00:00:00Z"
}

改善点

1. 複合キー生成ロジックが不要

// Before: 複雑な文字列操作
GSI_PK: `${tenant_id}#${group}#${place}`
GSI_SK: `${status}#${timestamp}#${temperature.padStart(5,'0')}`

// After: そのまま保存
// 何もしない!既存の属性をそのまま使える

2. 数値型を維持できる

// Before: 文字列に変換+パディング
temperature: 85"00085"

// After: 数値のまま
temperature: 85  // Number型として正確な範囲検索が可能

// クエリ例
KeyConditionExpression: "... AND temperature >= :temp",
ExpressionAttributeValues: {
  ":temp": 80  // 数値のまま指定
}

3. バックフィル不要で後付け可能

これが最大のメリットです。
ただし、GSIのキーに指定した属性が既存データに最初からすべて存在している場合に限ります。もし「新しい検索パターンのために新しく追加した属性」をキーにするなら、その属性を埋めるための更新(バックフィル)は必要です。

条件 バックフィル 理由
GSIキーに指定する属性がすべて既存データに存在 不要 DynamoDBが自動的にインデックス構築
GSIキーに指定する属性の一部が既存データに欠けている 必要 欠けている属性を追加する更新が必要
新規追加した属性をGSIキーに使用 必要 その属性を埋めるための全件更新が必要

実例:要件変更への対応

// 初期設計
GSI1:
  PKEY: device_id
  SKEY: timestamp

// 3ヶ月後、新しい要件が発生
// 「tenant_id でも検索したい」

// Before(従来):
// 1. 全レコードを更新してGSI_PK="tenant_id#timestamp"を追加
// 2. バックフィル処理(数時間〜数日)
// 3. アプリケーションコードの修正

// After(マルチキー):
// 1. 新しいGSIを作成するだけ
GSI2:
  PKEY: tenant_id, device_id
  SKEY: timestamp
// → DynamoDBが自動的にインデックス構築(既存データはそのまま)
// → アプリケーション側の変更は最小限(クエリ追加のみ)

4. 学習コストの低減

Before: 複合キー設計パターンの深い理解が必須

  • いつ#を使うか?
  • どの順序で連結するか?
  • 数値のパディングルール
  • エスケープ処理
  • BEGINS_WITH の活用方法

After: SQLライクな直感的なクエリ

  • 各属性を個別に指定
  • 等価条件と範囲条件の組み合わせ
  • 既存のRDB知識が活かせる

重要な制約事項

マルチキー機能には重要な制約があります。これを理解しないと期待通りに動作しません。

1. キーをスキップできない(階層構造)

// GSI定義
PKEY: tenant_id, group
SKEY: place, status, timestamp

//  OK: 左から順番に指定
tenant_id = "TENANT-A"
tenant_id = "TENANT-A" AND group = "FACTORY-1"
tenant_id = "TENANT-A" AND group = "FACTORY-1" AND place = "BUILDING-B"

//  NG: 途中をスキップできない
tenant_id = "TENANT-A" AND place = "BUILDING-B"  // group をスキップ
tenant_id = "TENANT-A" AND group = "FACTORY-1" AND status = "ERROR"  // place をスキップ

内部的には複合キーと同じ構造

マルチキー機能は、内部的には以下のような複合キーとして扱われます:

// 内部表現(イメージ)
PKEY: "TENANT-A#FACTORY-1"
SKEY: "BUILDING-B#ERROR#2025-01-28T10:00:00Z"

// そのため、途中をスキップすると一致しない
// "TENANT-A#???#BUILDING-B" のようなクエリはできない

2. 範囲条件は最後のキーのみ

これは最も重要な制約です。

// GSI定義
SKEY: status, year_month, temperature

// OK: 最後の属性で範囲検索
status = "ERROR" AND year_month = "2025-01" AND temperature >= 80

// NG: 途中の属性で範囲検索はできない
status = "ERROR" AND year_month >= "2025-01"  // year_month で範囲検索
status BEGINS_WITH "ERR" AND year_month = "2025-01"  // status で前方一致

// OK: 前の属性まで等価条件、最後だけ範囲
status = "ERROR" AND year_month BETWEEN "2025-01" AND "2025-03"

範囲検索可能な演算子(最後のキーのみ):

  • < , <= , > , >=
  • BETWEEN ... AND ...
  • BEGINS_WITH(文字列の前方一致)

等価条件のみ可能な演算子(前のキー):

  • = のみ

3. キーの順序設計が重要

前述の制約から、キーの順序を慎重に設計する必要があります。

基本原則

1. 完全一致で検索される属性を前に
2. 範囲検索したい属性を最後に
3. カーディナリティ(値の種類)を考慮

良い設計例

// ケース1:ステータス絞り込み+時間範囲
SKEY: status, timestamp
// status="ERROR" で絞り込み、timestamp で範囲検索

// ケース2:階層+数値範囲
SKEY: region, building, floor_number
// region="EAST", building="A", floor >= 3

// ケース3:カテゴリ+価格範囲
SKEY: category, sub_category, price
// category="Electronics", sub_category="Sensors", price >= 100

悪い設計例

// 範囲検索したい属性が途中にある
SKEY: timestamp, status, temperature
// timestamp で範囲検索したいが、最初に配置されている
// → status と temperature で絞り込めない

// カーディナリティの低い属性が最後
SKEY: region, city, is_active
// is_active (true/false) が最後では範囲検索の意味がない

4. その他3つの属性の使い方

「範囲検索は最後だけ」という制約があるため、前の3つの属性は階層的な絞り込みに使います。

パターン1:地理的階層

SKEY: region → prefecture → city → postal_code

// クエリ例
region = "関東" AND prefecture = "東京都" AND city = "渋谷区" AND postal_code >= "150-0000"

パターン2:組織階層

SKEY: company → department → team → employee_id

// クエリ例
company = "ACME" AND department = "Engineering" AND team = "Backend" AND employee_id >= "E1000"

パターン3:製品カテゴリ階層

SKEY: main_category → sub_category → brand → price

// クエリ例
main_category = "Electronics" AND sub_category = "Smartphones" AND brand = "Apple" AND price >= 50000

パターン4:時間粒度の階層

SKEY: year → month → day → hour

// クエリ例
year = "2025" AND month = "01" AND day = "28" AND hour >= 10

ポイント:

  • 完全一致で絞り込む属性を前に配置
  • 値のバリエーションが多い属性ほど前に
  • 最も頻繁に範囲検索する属性を最後に

GSIの「増殖」を抑える:多次元キー設計による書き込み・ストレージの最適化

DDBのベストプラクティスには、可能な限り少ないテーブルを使用することを推奨しています。原則としてこれに従うように設計できるといいのではないでしょうか?

GSIの作成可能数と設計の自由度

  • デフォルト上限:20 GSI/テーブル
  • LSI(ローカルセカンダリインデックス):5個/テーブル
  • マルチキー機能により、1つのGSIで複数のアクセスパターンをカバー可能
  • 結果:GSI数を大幅に削減(例:5つ→2つ)

実例:アクセスパターンのカバー範囲

// 従来:5つのGSIが必要
GSI1: device_id → timestamp
GSI2: device_id#tenant_id → timestamp
GSI3: tenant_id → timestamp
GSI4: tenant_id#status → timestamp
GSI5: tenant_id#status#year_month → timestamp

// マルチキー:1つのGSIで対応可能
GSI1:
  PKEY: device_id, tenant_id
  SKEY: status, year_month, timestamp

// カバーできるアクセスパターン:
// 1. device_id + tenant_id
// 2. device_id + tenant_id + status
// 3. device_id + tenant_id + status + year_month
// 4. device_id + tenant_id + status + year_month + timestamp範囲

Before(従来の複合キー設計)

// GSI設計
GSI1: device_id → timestamp
GSI2: device_id → status#timestamp  
GSI3: device_id#tenant_id → status#timestamp

// 冗長属性を含む
アイテムサイズ: 250 bytes
データ量: 131GB

// ストレージコスト
ベーステーブル: 131GB × $0.25 = $32.75/
GSI1ALL): 131GB × $0.25 = $32.75/
GSI2ALL): 131GB × $0.25 = $32.75/
GSI3ALL): 131GB × $0.25 = $32.75/
合計: $131/

// 書き込みコスト(オンデマンド、新価格)
525.6M リクエスト/
ベーステーブル: 525.6 × $1.25 = $657/
GSI1: $657/
GSI2: $657/月(キー属性変更時は2倍)
GSI3: $657/
合計: 約$2,600/

年間合計: ($131 + $2,600) × 12 = $32,772/

After(マルチキー設計)

// GSI設計(1つで対応)
GSI1:
  PKEY: device_id, tenant_id
  SKEY: status, year_month, temperature
  ProjectionType: INCLUDE
  NonKeyAttributes: ['timestamp', 'device_name']

// 冗長属性なし
アイテムサイズ: 200 bytes
データ量: 105GB

// ストレージコスト
ベーステーブル: 105GB × $0.25 = $26.25/
GSI1INCLUDE 40%): 42GB × $0.25 = $10.5/
合計: $36.75/
削減額: $94.25/月(72%削減)

// 書き込みコスト(オンデマンド、新価格)
525.6M リクエスト/
ベーステーブル: $657/
GSI1: $657/
合計: $1,314/
削減額: $1,286/月(49%削減)

年間合計: ($36.75 + $1,314) × 12 = $16,209/
年間削減額: $16,56351%削減)

まとめ

マルチキー機能がもたらす3つの価値

1. 設計の民主化

  • 複合キー設計の深い知識が不要に
  • 直感的なクエリ設計が可能
  • チーム全体での理解が容易に

2. 運用の柔軟性

  • バックフィル不要で後付け可能
  • 要件変更への迅速な対応
  • ダウンタイムなしでGSI追加

3. コストの最適化

  • この設計を活かしてGSI数の削減できる可能性が高い
  • 冗長属性の削除によるストレージ削減
  • プライマリキーレベルの絞り込みでRCU削減
  • 年間コスト削減もあると想定されます

制約事項の再確認

必ず覚えておくべきルール:

  1. キーをスキップできない(階層構造)
  2. 範囲検索は最後のキーのみ
  3. 前のキーはすべて等価条件
  4. 複合キーのいずれかのコンポーネントが欠落している場合、単一キーの GSI と同様にアイテムはインデックス化されません

設計のポイント

 完全一致で検索される属性を前に
 範囲検索したい属性を最後に
 Projection Type は INCLUDE を基本に
 更新頻度の低い属性をGSIに含める

参考リンク


マルチキー機能は、DynamoDBのアクセシビリティを大幅に向上させる機能です。従来は限られたエキスパートしか使いこなせなかった複合キー設計が、誰でも簡単に実装できるようになりました。

特に「バックフィル不要」という点は、本番運用中のシステムにとって非常に大きな価値があります。要件変更に柔軟に対応でき、コストも大幅に削減できるこの機能を、ぜひ活用してください。

株式会社システムゼウス

Discussion