![]() |
| Amazon DynamoDB |
はじめに
DynamoDB を業務システムにどこまで適用できるか検証しています。
前回までは、DynamoDB のパーティションキー設計がスロットリングに与える影響を検証しました。
キー設計の重要性は体感できました。次のステップとして、実際の業務画面に必要な「検索」をDynamoDBでどう実現するのか? について考えてみます。
RDB であれば WHERE 句に条件を並べるだけで済む検索機能。DynamoDB で同じことをしようとすると、何ができて何ができないのか。架空のコーヒー焙煎メーカー「Kiro Roasters」の在庫管理画面を題材に、業務ユーザーから来そうな検索要望を1つずつ試してみました。
まずは結論から
- DynamoDB は「検索エンジン」ではない(改めて)。 RDB のように設計すると詰む
- GSI の PK/SK 設計に落とし込めるかが分水嶺。 落とし込めればページネーションも安定
- GSI は万能ではない。 増やすほど書き込みコストとホットパーティションのリスクが上がる
想定される読者
- RDB の経験はあるが、DynamoDB でのデータ設計はこれからの方
- 業務システムの検索画面を DynamoDB で作ろうとしている方
- GSI や FilterExpression の使いどころを整理したい方
今回の題材
架空のコーヒー焙煎メーカー「Kiro Roasters」の在庫管理業務を検証対象とします。
Kiro Roasters は AWS Summit Japan でおなじみの serverlesspresso へコーヒー豆を卸す焙煎メーカーをイメージしています。製造メーカーの基幹業務システムを完全サーバーレスでクラウドシフトしていくことが可能なのか、を検証するための題材として設定してみました。
Kiro Roasters の詳細設定
読み飛ばしても大丈夫です(クリックで展開)
会社の成り立ち
Kiro Roasters は 2020年に東京で創業したスペシャルティコーヒーの焙煎メーカーです。創業者はもともと大手食品メーカーの生産管理部門出身で、「少量多品種の焙煎豆を、鮮度を保ったまま全国に届ける」をミッションに立ち上げました。
社名の「Kiro」は、コーヒー豆を重さ(キロ)で売買する文化と、焙煎の「火路(きろ)」── 火が豆に伝わる道筋 ── をかけた造語です。実際のところはいつもお世話になっている Kiro ちゃんから取ってます。
ビジネスモデル
B2B(カフェチェーン・レストラン向け卸)と D2C(EC サブスク・ギフト)のハイブリッドモデルを採用しています。売上構成はカフェチェーン向けが60%、EC が25%、業務用卸が15%。
カフェチェーンとの取引では「毎朝決まった時間に届く」が絶対条件のため、朝6〜9時の出荷オペレーションに遅延は許されません。この時間帯に東京倉庫から数十店舗分の焙煎豆が一斉出庫される構造が、前回までの検証でホットパーティションを狙うポイントになっていました。
倉庫と物流
| 拠点 | 倉庫ID | 出荷比率 | 役割 |
|---|---|---|---|
| 東京本社 | WH-TOKYO | 70% | メイン倉庫。焙煎工場併設。関東圏のカフェチェーン向け出荷の中心 |
| 大阪 | WH-OSAKA | 20% | 関西エリアのカフェ向け。EC出荷の一部も担当 |
| 福岡 | WH-FUKUOKA | 10% | 九州エリア + アジア向け輸出(台湾・シンガポール) |
東京に極端に偏った出荷構造は、急成長期に東京の取引先が増えた結果です。本来なら地方倉庫に分散させるべきですが、焙煎工場が東京にしかないため「焙煎 → 即出荷」の鮮度優先で東京一極集中が続いています。この「業務上の理由で偏りが生まれる」構造は、DynamoDB のキー設計問題と相似形です。
取扱商品(約5,000 SKU)
スペシャルティコーヒーの世界は品種・産地・グレード・焙煎度・容量の組み合わせで SKU が爆発的に増えます。
- 生豆: エチオピア、ブラジル、コロンビア、グアテマラ、ケニア、インドネシア、コスタリカ、パナマの8産地。各産地に2〜3品種 × 2グレード = 約30 SKU
- 焙煎済み豆: 30生豆 × 6焙煎度(ライト〜イタリアン) × 5容量(100g/200g/500g/1kg/5kg業務用)= 約900 SKU
- ブレンド: 自社ブレンド約50種 × 6焙煎度 × 5容量 = 約1,500 SKU
- ドリップバッグ・ギフトセット等: 約500 SKU
- 資材(包装袋、段ボール、ラベル、バルブ等): 約2,000 SKU
- 合計: 約5,000 SKU
人気商品トップ3は「エチオピア イルガチェフェ G1 ミディアムロースト 200g」「Kiro ブレンド シティロースト 500g」「グアテマラ アンティグア SHB フルシティ 1kg(業務用)」。特にエチオピア イルガチェフェ G1 は看板商品で、朝の出荷ラッシュでも出庫件数が最も多くなります。
時間帯別の業務フロー
朝6時。焙煎工場が前日夜に焙煎した豆が倉庫に並ぶ。そこからカフェチェーン数10店舗分の出荷ピッキングが一斉に始まります。
| 時間帯 | イベント | 書き込み負荷 | 読み取り負荷 |
|---|---|---|---|
| 5:00〜6:00 | 焙煎完了品の入庫 | ★★ | ─ |
| 6:00〜9:00 | カフェチェーン向け出荷ラッシュ | ★★★ ピーク | ★★(欠品チェック) |
| 9:00〜12:00 | 仕入れ担当の在庫照会・発注判断 | ★ | ★★★ |
| 14:00〜16:00 | EC出荷(個人向け) | ★★ | ★ |
| 16:00〜18:00 | 在庫補充・倉庫間移動 | ★★ | ★ |
| 月末1日 | 棚卸し(全件スキャン) | ─ | ★★★★ バースト |
今回テーマにする「検索」が使われるのは主に 9:00〜12:00 の在庫照会と、月末の棚卸しです。商品名や産地で絞ったり、棚のエリアごとに確認したり、単価帯で絞ったりといった操作が発生します。
なぜコーヒーショップなのか
AWS はワークショップ等でコーヒーショップを事例に使うことが多いです(注文 → 作成 → 受け渡しのイベント駆動パターン等)。Kiro Roasters はそれを「製造メーカー側」に寄せた設定で、仕入れ・焙煎・在庫・出荷という製造業の典型的な業務フローをカバーしつつ、コーヒーという親しみやすい題材で記事の敷居を下げる狙いがあります。
今後の検証シリーズでは、在庫管理だけでなく製造指図(焙煎バッチ管理)、購買(生豆発注の自動化)、会計(原価計算)など、業務システムの各領域に展開していければと思ってます。
命名規則
| 対象 | 形式 | 例 |
|---|---|---|
| 倉庫ID | WH-{CITY} | WH-TOKYO |
| 商品ID | ITEM#{産地}-{品種}-{グレード}-{焙煎度}-{容量} | ITEM#ETH-YIRG-G1-MEDIUM-200G |
| ロットID | LOT#{日付}-{連番} | LOT#2026-05-26-001 |
| 入出庫ID | MOV#{タイムスタンプ}-{連番} | MOV#2026-05-26-08-00-001 |
| 焙煎バッチID | BATCH#{日付}-{AM/PM}-{連番} | BATCH#2026-05-26-AM-01 |
アーキテクチャ
前回までの検証で使ったサーバーレス構成をそのまま流用します。今回は負荷テスト用の処理を外し、在庫管理画面(オンライン処理)だけに絞った形です。
| レイヤー | 構成要素 | 役割 |
|---|---|---|
| フロントエンド | Amplify Hosting(Next.js SSR) | 在庫管理 UI の配信 |
| 認証 | Cognito | 画面のログイン |
| API | API Gateway(REST API) |
GET /inventory , POST /inventory/ship
|
| ロジック | Lambda(Inventory-query) | 在庫の検索・一覧取得 ← 今回の主役 |
| ロジック | Lambda(Inventory-ship) | 出庫処理(在庫数の更新) |
| データストア | DynamoDB | 在庫データ(kiro-roasters-inventory-good) |
| 可観測性 | CloudWatch | メトリクス / ログ |
今回いじるのは Inventory-query の Lambda と DynamoDB のインデックス設計だけです。画面から受け取った検索条件を DynamoDB のクエリにどう翻訳するかが本題になります。
検証環境のため、在庫 API には API Gateway のオーソライザーを設定していません。Cognito は画面のログインにのみ使っています。本番用途では API 側の認可も必須です。
在庫管理画面
在庫管理画面として、個別の在庫照会と出庫処理および在庫一覧の表示ができる画面を想定しています。在庫一覧では、倉庫を選んで一覧を表示し、そこから条件を絞り込んでいく形です。

一覧の表示件数は 20件ずつのページ送りを前提とします。1つの倉庫に約5,000件の在庫がある中で、全件を一度に返す画面は業務では使い物になりません。ここは動かせない制約として先に置いておきます。
そして「次へ」を押したら確実に次の20件が表示されること。これを今回検証の必須要件として設定します。ページ番号を指定して5ページ目に飛ぶような UI は、今回は要件から外しておきます。
今回のテーブル設計
在庫管理業務において中心となるのは商品の在庫テーブルです。取り扱う各商品について、倉庫別に数量などを管理する必要があります。
前回検証も踏まえた良いテーブル設計(高カーディナリティなパーティションキー設計)は以下のようになります。
ベーステーブル: kiro-roasters-inventory-good
PK(パーティションキー) = itemId (約5,000種類 — 商品ごとに分散)
SK(ソートキー) = warehouseId ("WH-TOKYO" / "WH-OSAKA" / "WH-FUKUOKA")
実際のデータはこんな形で入っています。1商品 × 1倉庫で1レコード、5,000 SKU × 3倉庫で計15,000件です。
| itemId (PK) | warehouseId (SK) | itemName | quantity | location | unitPrice |
|---|---|---|---|---|---|
| ITEM#ETH-YIRG-G1-MEDIUM-200G | WH-TOKYO | エチオピア イルガチェフェ G1 ミディアム 200g | 340 | A-01-02 | 1640 |
| ITEM#ETH-YIRG-G1-MEDIUM-200G | WH-OSAKA | エチオピア イルガチェフェ G1 ミディアム 200g | 85 | B-01-05 | 1640 |
| ITEM#ETH-YIRG-G1-MEDIUM-200G | WH-FUKUOKA | エチオピア イルガチェフェ G1 ミディアム 200g | 12 | C-02-01 | 1640 |
| ITEM#BRA-SANTOS-CITY-500G | WH-TOKYO | ブラジル サントス シティ 500g | 8 | A-05-01 | 2980 |
| ITEM#BRA-SANTOS-CITY-500G | WH-OSAKA | ブラジル サントス シティ 500g | 156 | B-02-03 | 2980 |
この設計で効率よく取れるのは、PK(+SK)を指定したアクセスです。
// 「東京倉庫のイルガチェフェ200gの在庫は?」→ GetItem で1件取得
const result = await ddb.send(new GetItemCommand({
TableName: 'kiro-roasters-inventory-good',
Key: {
itemId: { S: 'ITEM#ETH-YIRG-G1-MEDIUM-200G' },
warehouseId: { S: 'WH-TOKYO' },
},
}));
// 「イルガチェフェ200gは全倉庫にいくつある?」→ PK 指定の Query で3件取得
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
KeyConditionExpression: 'itemId = :item',
ExpressionAttributeValues: { ':item': { S: 'ITEM#ETH-YIRG-G1-MEDIUM-200G' } },
}));
出庫処理のように「どの商品をどの倉庫から引くか」が決まっている操作には、この形がぴったりです。前回の検証でも 4,000 req/s の書き込みをスロットルなしで処理できていました。
問題は、業務ユーザーの検索要望がこの形に収まらないことです。
具体的な要望
このような業務画面を作ったら実際にありそうな要望をいくつか挙げてみます。
共通事項として、各ユーザーは所属する地域によって利用する倉庫がある程度決まっている想定です。
| # | 要望内容 | RDBでの実現方法 |
|---|---|---|
| ① | 倉庫別に在庫を一覧したい | WHERE warehouse_id = 'tokyo' |
| ② | 商品名の部分一致で絞り込みたい | WHERE name LIKE '%ブラジル%' |
| ③ | 棚番号のエリアで絞り込みたい | WHERE location LIKE 'A-01%' |
| ④ | 単価の帯で絞り込みたい | WHERE unit_price BETWEEN 3000 AND 5000 |
| ⑤ | 棚番号と単価を組み合わせて絞りたい | WHERE location LIKE 'A-01%' AND unit_price BETWEEN 3000 AND 5000 |
RDB ならどれも SQL の1行で済みます。
DynamoDB におけるデータ取得方法を整理
実際の検証に入る前に、DynamoDB を使った場合のデータ検索についておさらいしておきます。
3種類のデータ取得方法
DynamoDB でデータを取得する際の方法としては、GetItem / Query / Scan があります。
それぞれ整理しておくと以下のようになります。
| 操作 | 条件 | 読み取る範囲 | 用途 |
|---|---|---|---|
| GetItem | PK(+SK)を完全指定 | 1件 | 特定の1件を狙って取る |
| Query | PK の等値指定(=)が必須 | 指定した PK 内のみ | PK 内のデータを絞って取る |
| Scan | 条件不要 | テーブル全件 | 全件を読む |
RDB の感覚だと「Scan で全件取ってから絞り込めばいいのでは?」と考えたくなります。実際それで動きます。ただし Scan はテーブルサイズに比例して RCU を消費するため、データが増えるほどコストとレイテンシが悪化します。月次の棚卸しのようなバッチ処理なら許容できますが、オンライン画面の検索で毎回 Scan を投げるのは現実的ではありません。
// これは動くが、テーブル全件を読むのでオンライン画面には不向き
const result = await ddb.send(new ScanCommand({
TableName: 'kiro-roasters-inventory-good',
FilterExpression: 'warehouseId = :wh',
ExpressionAttributeValues: { ':wh': { S: 'WH-TOKYO' } },
}));
つまり業務画面の検索を作るときの方針はこうなります。
Scan を避け、Query で取れる形にキー設計する
そして Query は「PK の等値指定(=)が必須」という制約があるため、検索の起点にしたい属性を PK に持つ入れ物が必要になります。それを用意する手段が、次に説明する LSI と GSI です。
DynamoDB のインデックス
というわけで、Query の範囲を広げる手段である2つのインデックスについて整理しておきます。
LSI(Local Secondary Index)
テーブルと同じパーティションキーを共有し、別のソートキーを持つインデックスです。
| 特徴 | 内容 |
|---|---|
| PK | テーブルと同じ(変更不可) |
| SK | 任意の属性を指定できる |
| 追加コスト | WCU はテーブルと共有(追加なし) |
| 整合性 | 強い整合性(Consistent Read)も可能 |
| 制約 | テーブル作成時にしか追加できない / 最大5個 |
コストが安く整合性も強い。ただし今回のテーブル設計では、itemId を PK にしているため、業務画面の検索起点である warehouseId とテーブルの PK が一致しないため、LSI では対応できません。
LSI が活きるのは「テーブルの PK がそのまま検索の起点になる設計」の場合です。今回は該当しないため、以降は GSI で対応していきます。
GSI(Global Secondary Index)
テーブルとは別のパーティションキーを自由に設定できるインデックスです。
| 特徴 | 内容 |
|---|---|
| PK | 任意の属性を指定できる |
| SK | 任意の属性を指定できる |
| 追加コスト | GSI 固有の WCU/RCU が別途消費される |
| 整合性 | 結果整合性のみ(Strong Consistent Read 不可) |
| 制約 | テーブルあたり最大20個 / いつでも追加可能 |
検索起点を変えられるのが最大の強み。ただし GSI を増やすたびに書き込みコストが上がり、大量データを取り扱うケースでは前回記事で確認した通り GSI のキー設計を間違えるとホットパーティションが発生する リスクもあります。
3つ目のインデックス「ベクトルインデックス」
長らく DynamoDB のインデックスは LSI と GSI の2種類でしたが、2026年8月にベクトルインデックス(Vector Index) が追加されました。プレビューを経ずに GA でリリースされ、全商用リージョンで利用できます。
| LSI / GSI | ベクトルインデックス | |
|---|---|---|
| 検索方式 | 完全一致・範囲検索 | 類似度検索(近似最近傍探索・ANN) |
| API |
Query / Scan
|
SearchVectors |
| 用途 | 業務システムの条件検索 | セマンティック検索、RAG、推薦エンジン |
ベクトル埋め込みを属性に格納しておき、「意味が近いもの」を探す仕組みです。距離関数はコサイン / ユークリッド / 内積の3種類、最大4096次元まで対応しています。既存テーブルにも UpdateTable で後から追加できます。
今回扱うのは「商品名に特定の文字列を含む」といった条件検索なので、ベクトルインデックスの出番はありません。ただ「曖昧な意図で探したい」という要望にはハマる可能性があるため、別途検証してみたいところです。
では、5つの要望を1つずつ見ていきます。
要望①「倉庫別に在庫を一覧したい」
RDB なら:
SELECT * FROM inventory WHERE warehouse_id = 'tokyo'
東京倉庫の出荷担当は、毎朝6時からのピッキングの前に自分の倉庫の在庫をざっと確認します。大阪の在庫がいくらあっても関係ありません。見たいのは常に自分が扱う倉庫の分だけです。
これは要望というより画面の土台です。倉庫を選んで一覧が出ないなら在庫管理画面として成立しません。
対応方法
そのままでは Query できない
ベーステーブルの PK は itemId です。「東京倉庫の在庫を全部」という検索は、PK を指定しないので Query が使えません。全件 Scan するしかない状態です。
// ❌ warehouseId は PK ではないので KeyCondition に書けない
KeyConditionExpression: 'warehouseId = :wh'
// → ValidationException: Query condition missed key schema element
GSI を1本追加する
そこで warehouseId を PK に持つ GSI を追加することで、検索の起点を変えます。
GSI byWarehouse:
PK = warehouseId ("WH-TOKYO" / "WH-OSAKA" / "WH-FUKUOKA")
SK = itemId
GSI 側のデータはこう並びます。倉庫ごとにまとまり、その中が商品ID順にソートされた状態です。
| warehouseId (PK) | itemId (SK) | itemName | quantity |
|---|---|---|---|
| WH-TOKYO | ITEM#ETH-YIRG-G1-MEDIUM-200G | エチオピア イルガチェフェ G1 ミディアム 200g | 340 |
| WH-TOKYO | ITEM#BRA-SANTOS-CITY-500G | ブラジル サントス シティ 500g | 8 |
| WH-TOKYO | ... | (東京倉庫の残り約5,000件) | ... |
| WH-OSAKA | ITEM#ETH-YIRG-G1-MEDIUM-200G | エチオピア イルガチェフェ G1 ミディアム 200g | 85 |
これで Query できます。
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byWarehouse',
KeyConditionExpression: 'warehouseId = :wh',
ExpressionAttributeValues: { ':wh': { S: 'WH-TOKYO' } },
Limit: 20,
}));
warehouseId が GSI のパーティションキーになったので KeyConditionExpression で指定できます。Limit: 20 も設定することで 1 ページ分 20 件の結果が安定して返ります。
この GSI の PK は warehouseId(カーディナリティ3)です。前回記事で「GSI の PK を低カーディナリティにするとホットパーティションが発生する」と確認した構成そのものです。
読み取り専用の照会用途であれば RCU の話なので影響は限定的ですが、書き込みが多いテーブルで低カーディナリティな GSI を持つのはリスクという点は覚えておく必要があります。
検証結果
判定: GSI 1本追加で要望どおり実現
「倉庫を選んで20件ずつ一覧」は、GSI を1本追加するだけで満たせました。
要望②「商品名の一部で絞り込みたい」
RDB なら:
SELECT * FROM inventory WHERE product_name LIKE '%ブラジル%'
朝9時。仕入れ担当が発注書を作る前に在庫を確認します。「ブラジル産って今どれくらい残ってる?」という粒度で見たいわけですが、東京倉庫の一覧は約5,000件。20件ずつページを送って探すのは現実的ではありません。商品を名前で探せることが要望事項です。
対応方法
要望①で追加した GSI を用いて、以下の実装をすれば動きます。
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byWarehouse',
KeyConditionExpression: 'warehouseId = :wh',
FilterExpression: 'contains(itemName, :keyword)',
ExpressionAttributeValues: {
':wh': { S: 'WH-TOKYO' },
':keyword': { S: 'ブラジル' },
},
}));
ただし、FilterExpression でフィルタリングする流れとなり、検索効率は決して良くありません。
FilterExpression の仕組み
FilterExpression は Query/Scan で読み取った後に適用されます。
① DynamoDB が KeyCondition に合うレコードを読み取る(ここで RCU 消費)
② 読み取った結果に FilterExpression を適用して絞り込む
③ 絞り込まれた結果をクライアントに返す
つまり warehouseId = 'WH-TOKYO' に該当する5,000件を全部読み取ってから、その中で商品名に「ブラジル」を含むものだけを返しています。RCU は5,000件分が消費されます。
本画面での制約事項
今回対象の画面では、20件ずつのページ送りを前提としています。
問題なのはこの画面の前提を満たせないことです。20件ずつのページ送りにするため Limit と ExclusiveStartKey を足すとこうなります。
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byWarehouse',
KeyConditionExpression: 'warehouseId = :wh',
FilterExpression: 'contains(itemName, :keyword)',
ExpressionAttributeValues: {
':wh': { S: 'WH-TOKYO' },
':keyword': { S: 'ブラジル' },
},
Limit: 20, // ← 20件ずつ
ExclusiveStartKey: lastEvaluatedKey, // ← 前ページの続きから
}));
ここで Limit: 20 の意味は「20件返す」ではなく「20件読む」です。読んだ20件に後からフィルタを掛けるので、残る件数はその20件の中身次第になります。
| ページ | 読む範囲 | 読んだ件数 | フィルタ後 | 画面に出る件数 |
|---|---|---|---|---|
| 1ページ目 | 商品ID順の先頭20件 | 20 | 3 | 3件 |
| 2ページ目 | 次の20件 | 20 | 11 | 11件 |
| 3ページ目 | 次の20件(エチオピア産の範囲) | 20 | 0 | 0件 |
表示件数がページごとにバラつくだけでなく、0件のページが発生するのに「次へ」は続くという状態になります。ユーザーには「該当なし」に見えるのに、押すとまた出てくる画面です。
RDB の LIMIT 20 は WHERE で絞った後に適用されるので、条件に合うものが20件あれば必ず20件返ります。ここが根本的に違います。
「常に20件返す」ようにするなら、アプリ側で20件集まるまでループして読み続ける実装が必要です。ただし何回読めば足りるかは事前に分からないため、レイテンシも RCU 消費も読めなくなります。要件ごとに独自の実装も必要となり、望ましくありません。
検証結果
判定: 実現不可。妥協して商品コードの前方一致に変更
今回は妥協案として、検索方式の変更をユーザー側に申し入れすることにしました(というテイ)。
商品コードの前方一致です。
// GSI byWarehouse の SK は itemId なので begins_with が使える
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byWarehouse',
KeyConditionExpression: 'warehouseId = :wh AND begins_with(itemId, :prefix)',
ExpressionAttributeValues: {
':wh': { S: 'WH-TOKYO' },
':prefix': { S: 'ITEM#BRA-' }, // ブラジル産
},
Limit: 20,
}));
ITEM#BRA- で始まる商品を出す形なら、GSI の SK に対する begins_with として KeyConditionExpression で表現できます。読み取るのは条件に合うレコードだけ、Limit: 20 も安定します。
前方一致で判断可能な産地で絞り込みができれば業務上は困らないということで、画面側では産地のプルダウンを用意して、選択した産地に応じてコードのプレフィックスに変換する形です。
検索プルダウンはこのようなイメージです。
前方一致で絞り込みができて、20件ずつのページングも問題なし。
ユーザー要望を完全に実現することは難しかったので、妥協案で合意した という形になります。
DynamoDB 単体での部分一致検索は難しく、本格的に実現するには、OpenSearch Service との連携が定番のパターンのようです。「DynamoDB Streams → Lambda → OpenSearch」でデータを同期し、検索は OpenSearch に任せる構成です。最新世代の OpenSearch Serverless ではゼロスケールにも対応し、コスト面のハードルも下がりつつあります。
要望③「棚番号のエリアで絞り込みたい」
RDB なら:
SELECT * FROM inventory WHERE warehouse_id = 'tokyo' AND location LIKE 'A-01%'
倉庫の現場からの要望です。ピッキングや棚卸しは棚を順番に回っていく作業なので、「今日は A エリアの1列目を見る」という単位で在庫を確認したい。倉庫全体の5,000件が商品コード順に並んでいても、作業の役には立ちません。
棚番号は A-01-02 のように「エリア - 列 - 段」の構造になっています。A-01 まで指定して、その配下の棚をまとめて確認します。
対応方法
GSI を1本追加する
location を SK に持つ GSI を追加します。
GSI byLocation:
PK = warehouseId
SK = location ("A-01-02" / "B-01-05" など)
GSI 側は倉庫ごとに棚番号順で並びます。
| warehouseId (PK) | location (SK) | itemId | itemName | quantity |
|---|---|---|---|---|
| WH-TOKYO | A-01-02 | ITEM#ETH-YIRG-G1-MEDIUM-200G | エチオピア イルガチェフェ G1 ミディアム 200g | 340 |
| WH-TOKYO | A-01-05 | ITEM#BRA-SANTOS-CITY-1KG | ブラジル サントス シティ 1kg | 220 |
| WH-TOKYO | A-05-01 | ITEM#BRA-SANTOS-CITY-500G | ブラジル サントス シティ 500g | 8 |
| WH-TOKYO | B-01-05 | ... | ... | ... |
SK が文字列として順序を持っているので、前方一致が使えます。
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byLocation',
KeyConditionExpression: 'warehouseId = :wh AND begins_with(location, :loc)',
ExpressionAttributeValues: {
':wh': { S: 'WH-TOKYO' },
':loc': { S: 'A-01' },
},
Limit: 20,
}));
begins_with は SK に対して KeyConditionExpression で使える関数です。要望②で contains(部分一致)が使えなかったのと対照的で、前方一致なら KeyCondition に書けるというのが DynamoDB の特徴です。
検証結果
判定: GSI 1本追加で要望どおり実現
読み取るのは条件に合うレコードだけ、Limit: 20 も安定します。棚番号を A だけにすればAエリア全体、A-01-02 まで指定すればその棚だけ、と階層のどの深さでも同じクエリで対応できました。
要望④「単価の帯で絞り込みたい」
RDB なら:
SELECT * FROM inventory WHERE warehouse_id = 'tokyo' AND unit_price BETWEEN 3000 AND 5000
棚卸しのときに出てきた要望です。単価によって扱いを変えているためで、ゲイシャ種のような高額品は二重チェックの対象、逆に低単価の資材は目視確認で済ませています。「3,000〜5,000円の帯」のように価格帯を指定してリストアップしたい、という話です。
境界の金額は作業内容によって変わるので、単価の範囲を指定できることが要望事項です。
対応方法
GSI の SK に単価を置く
unitPrice を SK に持つ GSI を追加すれば、KeyConditionExpression で範囲を指定できます。
GSI byUnitPrice:
PK = warehouseId
SK = unitPrice (数値。1640 / 2980 / 8500 など)
const result = await ddb.send(new QueryCommand({
TableName: 'kiro-roasters-inventory-good',
IndexName: 'byUnitPrice',
KeyConditionExpression: 'warehouseId = :wh AND unitPrice BETWEEN :minPrice AND :maxPrice',
ExpressionAttributeValues: {
':wh': { S: 'WH-TOKYO' },
':minPrice': { N: '3000' },
':maxPrice': { N: '5000' },
},
Limit: 20,
}));
FilterExpression を使っていないため、Limit: 20 は「条件に合うレコードを20件返す」として正確に機能します。
SK に対しては BETWEEN のほかに >= や <= も使えるので、上限なしの「3,000円以上」という指定も可能です。
並び替えも同じ GSI でできる
DynamoDB には RDB の ORDER BY に相当する機能がありません。並び替えとして使えるのはソートキーの昇順/降順だけです。
逆に言えば、SK に unitPrice を持つ GSI を作った時点で「単価順に並べる」も手に入っています。
ScanIndexForward: false, // 降順(単価の高い順)
「高額品だけ見たい」というフィルタの要望に応えるために作った GSI が、「単価の高い順に並べたい」というソートの要望も同時に満たしている形です。GSI の SK に何を置くかで、範囲検索と並び替えの両方が決まる。ここが GSI 設計の肝です。
逆に言うと、itemName の五十音順や updatedAt の新しい順で並べたいなら、それぞれ SK に持つ GSI が必要になります。「一覧のカラムをクリックしたら任意の項目でソート」という RDB では当たり前の UI は、DynamoDB では GSI の本数と直結します。
検証結果
判定: GSI 1本追加で要望どおり実現
単価の帯で絞り込みつつ、降順(単価の高い順)で取得した画面がこちらです。
これで GSI は3本になりました。①の一覧用、③の棚番号用、④の単価用です。
要望⑤「棚番号と単価を組み合わせて絞りたい」
RDB なら:
SELECT * FROM inventory WHERE warehouse_id = 'tokyo' AND location LIKE 'A-01%' AND unit_price >= 3000
③と④を実装したところで、当然のように出てきた要望です。「A-01 エリアの高額品だけリストアップしたい」。棚卸しの現場では、エリアを回りながら高額品を優先的にチェックするので、この2つは同時に使いたい条件です。
さらに聞いていくと、組み合わせたい条件はこれだけではありませんでした。産地(商品コード)と棚番号で絞りたい日もあるし、産地と単価帯で見たい場面もある。要望をまとめると「条件を自由に組み合わせたい」ということです。
対応方法
GSI が2本あっても同時には使えない
ここで DynamoDB の根本的な制約に当たります。KeyConditionExpression に書けるのは PK の等値条件 + SK の条件1つだけです。
// ❌ SK に2つの条件は書けない
KeyConditionExpression: 'warehouseId = :wh AND begins_with(location, :loc) AND unitPrice >= :min'
byLocation(SK=location)と byUnitPrice(SK=unitPrice)の2本を持っていても、1回の Query で使えるのはどちらか一方です。インデックスはそれぞれ独立したデータ構造なので、両方の順序を同時に使うことはできません。
回避策を考えてみると、どれも行き詰まります。
| 方式 | 結果 |
|---|---|
| 片方を FilterExpression に逃がす |
Limit が不安定になる → 画面の前提に反する
|
| 2本を別々に Query してアプリ側で突き合わせ | 両方を全件取得してから積集合を取ることになり、20件ずつのページ送りが成立しない |
| 組み合わせパターンごとに GSI を作る | 条件が4つあれば組み合わせは十数通り。GSI の上限20本をすぐ使い切る |
検証結果
判定: 実現できず
「条件を自由に組み合わせる」という要望は、DynamoDB では実現できませんでした。 画面としては「絞り込み条件はどれか1つだけ選択」という制限を付けることになります。
検索条件はプルダウンで排他選択です。「ロケーション前方一致」を選んだら単価範囲は使えません。
RDB から移ってくると一番戸惑うのがここだと思います。WHERE 句に条件を1つ足す行為に、DynamoDB では設計上のコストが発生します。そしてそのコストは、最終的に画面でユーザーができることの制限として現れます。
整理: 検索要件が来たときの判断フロー
5つの要望を通して見えてきた判断基準をまとめます。
5つの要望の対応まとめ
| # | 要望 | 判定 | GSI |
|---|---|---|---|
| ① | 倉庫別に在庫を一覧したい | 要望どおり実現 | +1本(byWarehouse) |
| ② | 商品名の部分一致で絞り込みたい | 妥協。商品コードの前方一致に変更 | 追加なし(①の GSI を流用) |
| ③ | 棚番号のエリア(A-01)で絞り込みたい |
要望どおり実現 | +1本(byLocation) |
| ④ | 単価の帯(3,000〜5,000円)で絞り込みたい | 要望どおり実現(並び替えも兼用) | +1本(byUnitPrice) |
| ⑤ | 棚番号と単価を組み合わせて絞りたい | 実現できず。条件はどれか1つに制限 | 追加なし |
5つの要望に対して、追加した GSI は3本。②は方式を変える妥協で、⑤は要望そのものを取り下げてもらう形になりました。
補足
段階的に絞り込む検索なら1本の GSI で足りる
要望⑤で「条件の自由な組み合わせは実現できない」という結論になりました。ただし組み合わせる順序を固定できるなら、1本の GSI で複数条件を扱う方法があります。
なお以下は今回の検証で実際に試したものではなく、ドキュメントを読んで整理した内容です。
2025年11月のアップデートで、GSI の PK・SK にそれぞれ最大4属性(合計8属性) を指定できるようになりました。従来は # で文字列連結した合成キーを自作する必要がありましたが、今は既存の属性をそのまま並べるだけです。
GSI byWarehouseLocationPrice:
PK = [warehouseId]
SK = [location, unitPrice] ← 2属性を並べて指定できる
左から順に絞り込める
SK に並べた属性は、左から順に条件を足していけます。
// ① 倉庫だけ指定
KeyConditionExpression: 'warehouseId = :wh'
// ② 棚番号を追加(前方一致もOK)
KeyConditionExpression: 'warehouseId = :wh AND begins_with(location, :locPrefix)'
// ③ 棚番号を確定させて単価で範囲を絞る
KeyConditionExpression: 'warehouseId = :wh AND location = :loc AND unitPrice BETWEEN :min AND :max'
すべて KeyConditionExpression だけで表現できているので、RCU も無駄にせず、Limit も安定します。GSI 1本でこれだけの絞り込みパターンをカバーできるのが利点です。
ただし途中を飛ばせない
制約は2つあります。
| # | 制約 |
|---|---|
| 1 | SK 属性は定義順に左から指定する。途中を飛ばせない |
| 2 | 不等号(> < BETWEEN begins_with)は最後の条件にしか使えない |
// ❌ location を飛ばして unitPrice だけで絞ることはできない
KeyConditionExpression: 'warehouseId = :wh AND unitPrice BETWEEN :min AND :max'
// ❌ 不等号が2つ入るのも不可
KeyConditionExpression: 'warehouseId = :wh AND begins_with(location, :p) AND unitPrice > :min'
今回のケースに適用できるか
今回の要望には向きません。 「棚番号で絞る」「単価で絞る」は、それぞれ独立して単独で使いたい軸です。SK に [location, unitPrice] と並べてしまうと、単価だけで絞りたいときに必ず棚番号を先に指定しなければなりません。
「今日は A エリアの高額品」という使い方だけなら1本で足りますが、「単価順に全体を並べたい」も要望として出ています。そのため今回は byLocation と byUnitPrice を分けて持つ形が適切でした。
業務フローが階層的なら有効
逆に、業務側の操作が「上位から順に絞り込む」形に設計できるならこの方式がハマります。
棚卸しの作業を例にすると、担当者は倉庫の棚を順番に回っていきます。「A-01 の棚を数えて、そのうち高額品(単価3,000円以上)は二重チェックする」という手順であれば、絞り込む順序は常に棚番号 → 単価で固定されます。
GSI: PK = [warehouseId], SK = [location, unitPrice]
倉庫を選ぶ → warehouseId = 'WH-TOKYO'
→ 棚エリアを選ぶ → AND begins_with(location, 'A-01')
→ 単価帯で絞る → AND location = 'A-01-02' AND unitPrice >= 3000
画面側を「倉庫 → 棚エリア → 単価帯」のドリルダウン UI にしておけば、ユーザーは常に左から順に条件を足していくことになり、制約が制約になりません。時系列データ(年 → 月 → 日)も同じパターンです。
つまり判断はこうなります。
| 業務要件 | 適した設計 |
|---|---|
| 各条件を独立して単独で使いたい | 検索軸ごとに GSI を分ける |
| 上位から順に絞り込む操作にできる | マルチ属性キーの GSI 1本にまとめる |
「検索条件を自由に組み合わせたい」という要望をそのまま受けると GSI が増え続けますが、業務フローを階層的に整理できれば GSI は1本で済むわけです。DynamoDB のキー設計が「業務の設計」と地続きになるのはこういうところです。
マルチ属性キーが使えるのは GSI のみです。ベーステーブルのキースキーマと LSI は従来どおり PK 1属性 + SK 1属性です。
なお PK 側を複数属性で構成してカーディナリティを上げれば、前回記事で扱ったホットパーティション対策としても使えます。
まとめ
DynamoDB で業務画面の検索を実現しようとしたとき、RDB との一番の違いは「後から自由に検索条件を追加できない」ことです。
RDB は WHERE 句に条件を足すだけ。インデックスがなくても動く(遅いだけ)。DynamoDB は検索要件をテーブル設計の段階で織り込まないと、効率的に取得できません。GSI の追加はコストにも直結するので、ホイホイと増やすことも難しいです。
一方で、今回の検証では部分一致検索(要望②)だけは GSI では解決できないということも明らかになりました。FilterExpression で「動かす」ことはできますが、データ量が増えるほど RCU コストとページネーションの不安定さが問題になります。
次回は、この「DynamoDB 単体では対応しきれない検索」を OpenSearch に任せる構成を実際に組んでみようと思います。








