中階閱讀時間 1 分鐘

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(排序索引鍵)—— 補零的分數加一個決勝局。

一個樸素的初次嘗試把原始分數存成字串:

leaderboardIdrankKeyplayerHandle
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 分的成績埋在中間。對排行榜毫無用處。

補到一個定寬

挑一個寬到足以容納你將來會記錄的最大分數的寬度,然後用零左補。假設分數封頂在一千萬 —— 那是八位,所以用位留出餘量:

leaderboardIdrankKeyplayerHandle
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 語法保持誠實。

在 DynoTable 中瀏覽賽季排行榜,按補零的 rankKey 排序。
在 DynoTable 中瀏覽賽季排行榜,按補零的 rankKey 排序。

要避開的陷阱

  • 補得太窄。 整套方案在某個值第一次溢位寬度時就垮掉。按最壞情況來定大小,然後再加幾位。
  • 忘了讀取標誌。 如果你只會降序讀取,ScanIndexForward: false 也許就是你所需的全部 —— 當一個標誌就能搞定時別去夠翻轉鍵。
  • 一個集合裡混用寬度。 共享一個排序範圍的每一個鍵都必須用同樣的寬度。一次只給新行補零、不給舊行補零的遷移,會把它們錯誤地交錯起來。
  • 補錯了片段。 在一個複合鍵裡,給參與排序的 每一個 數字片段補零 —— 分數和時間戳都要,而不只是分數。

後續步驟

補零是更廣的排序索引鍵設計工具箱裡的一件工具;當你過載一個鍵去服務好幾種模式時,把它與項集合配對,並在排序對了之後,倚靠一次精確的 Query 而非一次 Scan

試用 DynoTable,去瀏覽一張真實的表,在你上線模式之前看你的補零排序索引鍵落入數值順序。

已更新