中階閱讀時間 3 分鐘

DynamoDB 過濾策略

DynamoDB 裡的「過濾」是四件不同的事穿著同一個字。其中三件在資料被讀取並計費之前就把它縮小;一件——就是那個叫 Filter 的——在之後才縮小。知道哪個是哪個,就是這門技術的大半。

DynamoDB 裡的過濾是怎麼運作的?

DynamoDB 有四種過濾方式,而只有一種在你被計費之後才執行。分割鍵挑出一個分割,排序鍵縮小一段切片,而稀疏索引靠屬性的存在性過濾——這三種都在計量之前就砍掉你的讀取成本。FilterExpression 則在讀取之後才執行,所以它縮小回應,卻永遠不縮小帳單。

  • 分割鍵是最便宜的過濾:它挑出分割,所以你永遠不會碰到表的其餘部分。
  • 排序鍵begins_withbetween<> 在一個分割之內過濾——仍在計費之前,仍然便宜。
  • 稀疏索引靠缺席來過濾:一個項目只有在它具有那個被索引的屬性時才出現在索引裡,所以索引就是那個過濾後的集合。
  • FilterExpression 是陷阱:它在 DynamoDB 計量那次讀取之後才執行,所以它砍掉你的回應大小,卻永遠不砍你的帳單。

設定範例

一個產品目錄。一張表,分割鍵 PK,排序鍵 SK

PK = "DEPT#kitchen"   SK = "PROD#00194"

每個產品還帶著 priceinStock(一個布林值)和 clearanceAt(一個 unix 時間戳,只在被標記清倉的項目上才有)。一個部門裡的項目共用一個分割,依產品 id 排序。

我們想要四種存取模式。每一種都映射到一個不同的過濾策略——而其中任何一種選錯了,就是一個你會永遠付費的 Scan

依分割鍵過濾

「給我 kitchen 裡的每一個產品。」分割鍵直接回答這個:

Query  PK = "DEPT#kitchen"

DynamoDB 正好讀一個分割。表裡其他什麼都沒被碰到或計費。在最要緊的那個意義上,這是唯一免費的過濾——它就是 QueryScan 的差別。

從 SQL 過來,這感覺顛倒了:沒有一個 WHERE department = 'kitchen' 去掃描一個索引,你只是點名那個分割。如果你沒辦法點名它,那是一個建模問題,不是一個查詢問題。

依排序鍵過濾

「給我從 PROD#00100 往上的 kitchen 產品。」排序鍵在分割之內縮小,而且是在讀取被計量之前就做到:

Query  PK = "DEPT#kitchen"  AND  SK between "PROD#00100" AND "PROD#00200"

排序鍵條件是刻意受限的:=<<=>>=betweenbegins_with。沒有 OR,沒有任意述詞。

那個約束正是讓讀取保持精準的關鍵——DynamoDB 走一段連續切片,而不是整個分割。

這裡的槓桿是你如何編碼排序鍵。如果你的模式是「依價格區間」,一個 PROD#<id> 的排序鍵幫不上忙——你會把價格烤進鍵裡。

那是一個排序鍵策略的決定,在設計時做,不是在查詢時做。

依稀疏索引過濾

「給我目前所有在清倉的東西。」大多數產品不在清倉,所以你不想為了找出在清倉的那少數幾個而去讀整個目錄。

一個稀疏索引靠缺席解決這件事。一個全域次要索引只有在某個項目同時具有索引的兩個鍵屬性時,才會包含那個項目。

把 GSI 分割鍵設為 clearanceAt——只在清倉項目上才有——索引就什麼別的都不裝。

AWS 把這講得很清楚:一個 GSI「只包含具有被索引屬性的項目」,所以缺了鍵屬性的項目根本不會被傳播(AWS — 善用稀疏索引)。

沒有基礎表 所有產品 clearanceAt?複寫到 ClearanceIndex不在索引裡查詢索引 = 只有清倉項目

現在這個查詢只讀清倉項目,也只為它們計費:

Query  ON ClearanceIndex   GSI_PK = "CLEARANCE"   (sorted by clearanceAt)

過濾發生在你寫入資料的時候——靠著選擇要不要設 clearanceAt。索引就是那個過濾後的集合。哪一種索引類型合適,見 GSI 與 LSI

用 FilterExpression 過濾

「給我有庫存的 kitchen 產品。」inStock 不是一個鍵屬性,所以你伸手去抓一個 FilterExpression

Query  PK = "DEPT#kitchen"
Filter inStock = true

陷阱在這。DynamoDB 讀取 kitchen 分割裡的每一個項目,為它們全部計量容量,然後才丟掉缺貨的那些。

官方規則:一個過濾表達式是「在一次 Query 完成之後、但結果回傳之前套用的」,並且「不會消耗任何額外的讀取容量單位」——你已經為那次完整的讀取付過費了(AWS — Query 的過濾表達式)。

所以如果 kitchen 有 10,000 個產品而 12 個有庫存,你付費讀 10,000。回應很小;帳單不小。FilterExpression 縮小的是跨線傳的負載,永遠不是讀取。

還有第二道更尖銳的刀刃:分頁是在過濾之前計量的。一頁是 1 MB 的被讀取項目,不是 1 MB 的比對結果。

一個過濾可能回傳一個空頁卻設了 LastEvaluatedKey——DynamoDB 讀了一整個百萬位元組、什麼都沒比對到、交給你一個空陣列。你繼續翻頁,而你為每一個空頁都付了費。

DynamoDB Expression Builder 建好那個表達式——名稱、值與正確的保留字跳脫——讓 #inStock:val 佔位符第一次就正確。

比較這四者

何時過濾砍讀取成本?述詞能力設定成本
分割鍵讀取前是——一個分割只有相等免費(它就是鍵)
排序鍵讀取前是——一段切片範圍/begins_with排序鍵設計
稀疏索引讀取前是——僅索引某屬性的存在性額外的 GSI + 寫入成本
**FilterExpression讀取後否**幾乎任何條件

由上往下讀這張表:述詞能力往上走,成本控制往下走。FilterExpression 能精確表達任何東西,正是因為它在已讀取的項目上執行——那也是它沒辦法替你省錢的同一個原因。

在 DynoTable 裡看它

當你跑一個帶過濾的 Query 時,被讀取的項目和被回傳的項目之間的落差,就是整個故事。DynoTable 把耗用容量呈現在結果數旁邊——所以一個悄悄讀了整個分割的過濾是看得見的,不是藏在你的月帳單裡。

至於過濾回答不了的真正跨項目問題——「每個部門的平均價格」、「有庫存的產品連同它們的評論」——DynoTable 的 SQL Workbench 在一個有界的結果集上,於用戶端執行 GROUP BYJOIN 和聚合,而不是編譯成一個遍及全表的 Scan

陷阱與下一步

  • 別把 FilterExpression 當作你的主要存取路徑。 如果一個模式很常見,把它建模進一個鍵或一個稀疏索引裡。過濾是用來做最後那一點點縮小的,不是大宗。
  • 留意空頁。 一個帶過濾的查詢可能翻很久的頁卻什麼都不回傳。尊重 LastEvaluatedKey;別假設一個空頁就代表「結束了」。
  • 稀疏索引不是免費的。 它對每一個落進它的項目都耗費寫入容量和儲存——當屬性罕見時便宜,當它不罕見時就沒那麼便宜了。

容量計算機估算一次帶過濾的讀取實際會花多少,並試用 DynoTable,在你自己的表上看著耗用容量對比回傳列。

已更新