DynamoDB 中的補零排序索引鍵
一個 DynamoDB 字串排序索引鍵按字典序排序 —— 從左到右,一個字元一個字元 —— 而非按數值。所以 "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 “直接就好用” —— 引擎知道那一列是數值的。對一個不是 Number 型別的排序索引鍵,DynamoDB 沒有這種奢侈。
DynamoDB 按 UTF-8 位元組順序比較字串(S)排序索引鍵,依據 AWS 排序索引鍵文件。是位元組,不是大小。@@P19@@(0x39)壓過 "10",因為它的首位元組勝過 "1"(0x31)。長度無關緊要 —— 只有第一個不同的位元組說了算。
那就是暗坑:一個數字一住進字串排序索引鍵,每一次遍歷範圍的 Query 都會以一個看起來亂套的順序返回行。
構建一個排行榜排序索引鍵
拿一個賽季制街機排行榜。每個賽季一個項集合,容納每位玩家的成績,而你想要最高分在前。
用一個複合鍵在單一項集合裡給它建模:
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 分的成績埋在中間。對排行榜毫無用處。
補到一個定寬
挑一個寬到足以容納你將來會記錄的最大分數的寬度,然後用零左補。假設分數封頂在一千萬 —— 那是八位,所以用十位留出餘量:
| 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。數學終於和位元組相符了。
寬度是一道單向門。如果你補到十位、而某個分數後來超過了那個,那麼一個 11 位的值會排在一個 10 位的 之前 —— 把一切重新弄壞 —— 而修它意味著重寫每一個現存的 rankKey。把寬度超額預留;代價是寥寥幾個位元組。
降序排序:存差值
排行榜想要最高分在前。DynamoDB 能用 ScanIndexForward: false 正向或反向讀取一個排序索引鍵,所以降序通常是一個讀取時的標誌 —— 先夠這個。
但當一個項集合必須服務混合的排序方向時,或者你想讓最高分物理上在前、無論讀取標誌如何,就把數字本身翻轉。存 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 論文的精神 —— 鍵是不透明的位元組,所以你把意圖編碼 進 那些位元組裡。
加一個決勝局
兩個玩家可能打平。一個光禿禿的補零分數會在排序索引鍵上相撞,而第二次寫入會覆蓋第一次(相同的 PK + SK)。追加一個唯一字尾,讓每次成績都是一個不同的項,且平局可以確定地裁決:
rankKey = "<paddedScore>#<paddedTimestamp>#<playerId>"例如 "0000001500#0000001719100800#p_8842"。同樣的分數,更早的時間戳贏得更高的位次 —— 把時間戳也補零,否則它會重新引入你剛剛修掉的那個一模一樣的 bug。
在 DynoTable 中,你可以瀏覽按補零的 rankKey 排序的賽季排行榜,親眼看到補零後的值將各行正確對齊——這是在釋出之前確認寬度無誤的有力證明。
手工組裝那個複合鍵時,很容易把一個寬度敲錯。在運算式構建器裡為一次”賽季頂端”的 Query 生成 KeyConditionExpression,能在你拿寬度做實驗時讓 begins_with / between 語法保持誠實。

要避開的陷阱
- 補得太窄。 整套方案在某個值第一次溢位寬度時就垮掉。按最壞情況來定大小,然後再加幾位。
- 忘了讀取標誌。 如果你只會降序讀取,
ScanIndexForward: false也許就是你所需的全部 —— 當一個標誌就能搞定時別去夠翻轉鍵。 - 一個集合裡混用寬度。 共享一個排序範圍的每一個鍵都必須用同樣的寬度。一次只給新行補零、不給舊行補零的遷移,會把它們錯誤地交錯起來。
- 補錯了片段。 在一個複合鍵裡,給參與排序的 每一個 數字片段補零 —— 分數和時間戳都要,而不只是分數。
後續步驟
補零是更廣的排序索引鍵設計工具箱裡的一件工具;當你過載一個鍵去服務好幾種模式時,把它與項集合配對,並在排序對了之後,倚靠一次精確的 Query 而非一次 Scan。
試用 DynoTable,去瀏覽一張真實的表,在你上線模式之前看你的補零排序索引鍵落入數值順序。


