DynamoDB におけるソートキーのゼロ埋め
DynamoDB の文字列ソートキーは、数値順ではなく辞書順で — 1文字ずつ、左から右へ — ソートされます。だから "10" は "2" より前に着地します。"1" が "2" より前に来るからです。固定幅へのゼロ埋めは、文字列順を数値順に一致させる方法です。
DynamoDB のソートキーで "10" が "2" より前にソートされるのはなぜですか?
DynamoDB の文字列ソートキーは、数値順ではなく UTF-8 バイト順で辞書的に比較されるからです。"1" のバイト値は "2" より小さいため、"10" は "2" より前に位置します。先頭ゼロで固定幅に埋めると — "2" は "0000000002" になります — 文字列順が数値順と完全に一致します。
- 罠: 文字列として保存された数値は単語のようにソートされます。
"100"、"11"、"2"が DynamoDB が返す順序です — あなたが意図したものではありません。 - 修正: すべての数値を固定幅まで先頭ゼロで埋め、
"2"を"0000000002"にします。これで辞書順と数値順が一致します。 - 幅は一度だけ選ぶ: 保存し得る最大値に合わせてサイズを決め、さらに数桁加えます。後で幅を変えると、すべてのキーを書き換えることになります。
- 降順は無料: 高い順(リーダーボードのケース)にソートするには、これもゼロ埋めして
maxValue - valueを保存します — DynamoDB には属性ごとのソート方向がありません。
なぜ文字列ソートキーが裏切るのか
SQL の出身者なら、整数列に対する ORDER BY score DESC は「ただ動く」 — エンジンが列を数値だと知っているからです。DynamoDB には、Number 型でないソートキーに対するそのような贅沢はありません。
DynamoDB は文字列(S)ソートキーを UTF-8 バイト順で比較します(AWS のソートキードキュメント)。大きさではなく、バイトです。"9"(0x39)は "10" を上回ります。最初のバイトが "1"(0x31)に勝るからです。長さは無関係 — 最初に異なるバイトだけが決め手です。
それが罠です。数値が文字列ソートキーの中に入った瞬間、範囲を辿るすべての Query が、ぐちゃぐちゃに見える順序で行を返します。
リーダーボードのソートキーを構築する
シーズン制のアーケードリーダーボードを取り上げます。シーズンごとに1つのアイテムコレクションがすべてのプレイヤーのプレイを保持し、上位スコアを先頭にしたいとします。
単一のアイテムコレクション内の複合キーでモデル化します:
leaderboardId(パーティションキー) — 例:SEASON#2026-SPRING。rankKey(ソートキー) — ゼロ埋めしたスコアとタイブレーカー。
素朴な最初の試みは、生のスコアを文字列として保存します:
| leaderboardId | rankKey | playerHandle |
|---|---|---|
| SEASON#2026-SPRING | "9" | quickdraw |
| SEASON#2026-SPRING | "10" | ace_pilot |
| SEASON#2026-SPRING | "1500" | nightowl |
| SEASON#2026-SPRING | "240" | bytecrash |
SEASON#2026-SPRING に対する Query は、それらをこのバイト順で返します: "10"、"1500"、"240"、"9"。9点のプレイが最下位に居座り、1500点のプレイが真ん中に埋もれます。リーダーボードには使い物になりません。
固定幅まで埋める
記録し得る最大スコアに十分な幅を選び、それからゼロで左詰めします。スコアの上限が1千万だとしましょう — それは8桁なので、余裕を持たせて10桁を使います:
| leaderboardId | rankKey | playerHandle |
|---|---|---|
| SEASON#2026-SPRING | "0000000009" | quickdraw |
| SEASON#2026-SPRING | "0000000010" | ace_pilot |
| SEASON#2026-SPRING | "0000000240" | bytecrash |
| SEASON#2026-SPRING | "0000001500" | nightowl |
これですべてのキーが同じ長さになり、バイト単位の比較と数値の比較が同一の順序を生みます。昇順の Query は 9, 10, 240, 1500 を返します。ついに計算がバイトと一致します。
幅は一方通行の扉です。10桁まで埋めて後でスコアがそれを超えると、11桁の値が10桁のものより前にソートされ、すべてを再び壊します — そしてそれを直すには既存のすべての rankKey を書き換えることになります。幅は過剰にプロビジョニングしましょう。コストはわずか数バイトです。
降順にソートする: 差分を保存する
リーダーボードは最高スコアを先頭に望みます。DynamoDB は ScanIndexForward: false でソートキーを前方にも後方にも読めるので、降順は通常は読み取り時のフラグです — まずそれに手を伸ばしましょう。
しかし、1つのアイテムコレクションが混在したソート方向に奉仕しなければならない場合や、読み取りフラグに関係なくトップスコアを物理的に先頭にしたい場合は、数値そのものを反転させます。同じ幅にゼロ埋めして maxValue - score を保存します:
score inverted (9999999999 - score) rankKey
1500 9999998499 "9999998499"
240 9999999759 "9999999759"
10 9999999989 "9999999989"
9 9999999990 "9999999990"反転した値に対する昇順のバイト順は、元のスコアを高い順に返します: 1500, 240, 10, 9。このコツは 2007年の Amazon Dynamo 論文の精神そのものです — キーは不透明なバイトなので、意図をバイトの中にエンコードするのです。
タイブレーカーを加える
2人のプレイヤーが同点になり得ます。むき出しの埋めたスコアはソートキーで衝突し、2回目の書き込みが最初のものを上書きします(同じ PK + SK)。各プレイが別個のアイテムになり、同点が決定的に解決されるよう、一意のサフィックスを追加します:
rankKey = "<paddedScore>#<paddedTimestamp>#<playerId>"たとえば "0000001500#0000001719100800#p_8842"。同じスコアなら、より早いタイムスタンプが上位スロットを勝ち取ります — タイムスタンプも埋めましょう。さもなければ、今直したばかりのバグを再導入します。
DynoTable では、ゼロ埋めした rankKey でソートしたシーズンのリーダーボードを閲覧し、埋められた値が行を正しく並べているのを確認できます — 本番にリリースする前に幅が正しいという証明です。
複合キーを手で組み立てると、幅をうっかり打ち間違えやすいです。「シーズンのトップ」の Query 用の KeyConditionExpression を Expression Builder で生成すれば、幅を試している間も begins_with / between の構文を正確に保てます。

避けるべき落とし穴
- 埋める幅が狭すぎる。 ある値が幅をオーバーフローした最初の瞬間に、スキーム全体が崩壊します。最悪のケースに合わせてサイズを決め、それから数桁加えましょう。
- 読み取りフラグを忘れる。 常に降順でしか読まないなら、
ScanIndexForward: falseだけで十分かもしれません — フラグで済むのに反転キーに手を伸ばさないこと。 - 1つのコレクション内で幅が混在。 ソート範囲を共有するすべてのキーは、同じ幅を使わなければなりません。新しい行は埋めても古い行は埋めない移行は、それらを誤って交互配置します。
- 間違ったセグメントを埋める。 複合キーでは、順序付けに関与するすべての数値セグメントを埋めましょう — スコアだけでなく、スコアとタイムスタンプの両方です。
次のステップ
ゼロ埋めは、より広いソートキー設計ツールキットの中の1つの道具です。キーをオーバーロードして複数のパターンに奉仕するときはアイテムコレクションと組み合わせ、順序付けが正しくなったら Scan の代わりに正確な Query に頼りましょう。
DynoTable を試して、実際のテーブルを閲覧し、スキーマをリリースする前に、ゼロ埋めしたソートキーが数値順に並ぶ様子を観察してください。


