DynamoDB の複合ソートキー
とは、パーティションキーとソートキーの組み合わせです。
これを強力にする鍵は、ソートキーの 中身 に何を入れるかにあります。階層構造を1本の
区切り付き文字列としてエンコードすれば、単一の Query がソート順でサブツリー全体を
読み取れます — 結合も、再帰も、2 回目の往復もありません。
DynamoDB の複合ソートキーはどう機能するのか?
複合ソートキーは、階層構造を1本の区切り付き文字列 — root/photos/2026/ — に詰め込み、DynamoDB はそれを UTF-8 のバイト順で保存します。レイアウトがすでにツリーと一致しているため、begins_with(SK, "root/photos/") を使った単一の Query がサブツリー全体をパス順で読み取ります。結合も、再帰も、2 回目の往復もなく、連続した へのプレフィックススキャンだけです。
- ソートキーは単なる ID ではなく、ソート可能な文字列。 パスを詰め込めば —
root/photos/2026/— DynamoDB はそのパーティションのアイテムを自動的に UTF-8 の バイト順で保存します。 - 区切り文字がプレフィックス一致をサブツリー読み取りに変える。
begins_with(SK, "root/photos/")は、そのフォルダのすべての子孫を 1 回のクエリで返します。 - ソートキーが対応するのは範囲条件であって、任意のフィルタではない。 使えるのは
begins_with、between、>、<— 必要な読み取りがScanではなくプレフィックスや 範囲になるようにキーを設計しましょう。 - 区切り文字は要となる存在。 パスセグメント内に現れ得ないものを選ばないと、 無関係な 2 つのブランチが衝突します。
なぜソートキーがすべての勝負どころなのか
SQL から来ると、フォルダツリーは parent_id の自己結合でモデル化し、それを再帰的に
たどるでしょう — 階層ごとに 1 クエリです。DynamoDB では、それは結合を持たない
キーバリューストアに対する N+1 の地雷になります。
DynamoDB はすべてのアイテムをパーティションキーの下に、そのソートキー順で、文字列なら UTF-8 のバイト順で保存します(AWS: Query key conditions)。 つまりソートキーが パスそのもの であれば、物理レイアウトはすでにツリーと一致します。 読み取りはグラフ走査ではなく、連続したスライスへのプレフィックススキャンになるのです。
これがその転換点です。ソートキーは完全一致で照合する識別子ではありません。ソート可能な アドレスなのです。それを設計すれば、クエリはおのずと決まります。
ファイルシステムのツリーをモデル化する
アカウントごとのファイルツリーを保存するとしましょう。アカウントごとに 1 つのドライブが 自然なパーティションで、その中のパスがソートキーです。
| PK | SK | node_type | bytes |
|---|---|---|---|
| DRIVE#a91 | root/ | folder | - |
| DRIVE#a91 | root/docs/ | folder | - |
| DRIVE#a91 | root/docs/taxes.pdf | file | 88210 |
| DRIVE#a91 | root/photos/ | folder | - |
| DRIVE#a91 | root/photos/2026/ | folder | - |
| DRIVE#a91 | root/photos/2026/beach.jpg | file | 284910 |
| DRIVE#a91 | root/photos/2026/sunset.jpg | file | 512004 |
ここで働いている、オリジナルの取り決めが 2 つあります。
PK = DRIVE#<account>は、1 つのアカウントのツリー全体を単一の に収めるため、どんなサブツリー読み取りも単一パーティションのQueryになります。SKは完全なパスで、フォルダには末尾に/を付けます。この末尾スラッシュは 意図的なものです — フォルダを自身の子より 前 にソートさせ、root/photos/をroot/photosという名前の兄弟ファイルと区別できるようにします。
サブツリーを 1 クエリで読み取る
root/photos/ 配下のすべて — フォルダ、サブフォルダ、ファイルを再帰的に — 一覧します。
Query
KeyConditionExpression = PK = :drive AND begins_with(SK, :prefix)
:drive = "DRIVE#a91"
:prefix = "root/photos/"
これは root/photos/、root/photos/2026/、beach.jpg、sunset.jpg を — パス順で、
1 回の課金読み取りで返します。ドライブ全体ではなく、そのスライス内のアイテム分だけを
支払います。
DynoTable では、まさにこの begins_with クエリをパスのソートキーに対して実行すると、
フォルダとその子孫がパス順で返ってきます — 手書きすべきプレースホルダー構文はありません。
自分のコード用に生の KeyConditionExpression(名前、値、begins_with)が必要ですか?
DynamoDB 式ビルダー で組み立ててコピーできます。

サブツリー全体ではなく 1 階層だけを一覧する
begins_with は 再帰的 な読み取りを与えます。非再帰的なディレクトリ一覧 —
root/photos/ の直下の子だけで、それより深いものは含めない — が欲しい場合は、
depth(深さ)属性を保存してソートキーの範囲とフィルタを加えるか、パスを
parent GSI に分割します。最もシンプルな形は、parent 属性(root/photos/)を
保持し、それをキーにした GSI を用意することです。
要点はこうです。ソートキーは プレフィックス と 範囲 の問いに安く答えます。
「直下の子だけ」は別の問いです — FilterExpression が効率化してくれると期待するのでは
なく、明示的にモデル化しましょう。フィルタは読み取りの 後 に走り、捨てられるアイテムの
分も支払うことになります。
区切り文字を慎重に選ぶ
区切り文字はデータ契約の一部です。ルールは 2 つ。
- パスセグメント内に決して現れてはいけない。 ファイル名に
/が含まれ得るなら、/は誤った区切り文字です —a/bという名前のファイルは、bを保持するaという フォルダと区別できません。予約バイト(#や制御文字を使うチームもあります)を選び、 セグメント内では禁止しましょう。 - 境界でのソート順に注意する。
/(0x2F)は数字や文字より前にソートされ、 これは通常ツリー順として望ましいものです。区切り文字を変えれば順序も変わります — 実データに対して検証しましょう。
複合ソートキー vs. 独立したソート属性
複合ソートキー(root/photos/2026/x) | プレーンな ID ソートキー + parent 属性 | |
|---|---|---|
| サブツリー読み取り | 1 回の begins_with クエリ | 再帰クエリ(N+1)または GSI 走査 |
| 順序 | パス順、無料 | 明示的なソート属性を追加する必要あり |
| 移動 / リネーム | すべての子孫を書き換え | parent ポインタを 1 つ更新 |
| 直下の子の一覧 | depth 属性か GSI が必要 | 自然(parent = x) |
複合キーが勝つのは、読み取りが サブツリー型で順序が重要 な場合です。フラットな ID モデルが勝つのは、ツリーが絶えず変化する場合です。読み取り中心の階層のほとんど — ファイルツリー、カテゴリツリー、組織図 — は複合キー寄りです。
落とし穴と次のステップ
- キーに詰め込みすぎない。 エンコードするものはすべて不変で、プレフィックスでのみ インデックスされます。等価で照合したい属性は、それ専用のフィールドか GSI に置くべきで、 ソートキーに押し込むものではありません。
- ソートキーは任意の
WHEREを扱えない。 使えるのはbegins_with、between、 比較だけです。FilterExpressionに手を伸ばしていることに気づいたら、おそらくキーの モデル化を誤っています — Query と Scan を参照。 - キー設計をさらに深掘りする なら シングルテーブル設計 にあります。サブツリー読み取りにベーステーブルではなくインデックスが必要な場合は GSI と LSI を参照してください。
式ビルダー で begins_with キー条件を組み立て、
DynoTable をダウンロード して、これらのプレフィックスクエリを自分の
テーブルに対して実行し、サブツリーがパス順で返ってくる様子を見てみましょう。
(そして SQL に置いてきた自己 JOIN は? 必要になれば
DynoTable の SQL Workbench が今でも実行してくれます。)


