中階閱讀時間 3 分鐘

DynamoDB GROUP BY:如何改用其他方式聚合

DynamoDB 裡沒有 GROUP BY。也沒有 COUNTSUMAVG——原生 API 裡沒有,PartiQL 裡也沒有。DynamoDB 是一個鍵值 / 文件儲存,不是一個分析引擎,所以聚合是 要搭的東西,不是查詢規劃器替你做的東西。

你能在 DynamoDB 裡 GROUP BY 嗎?

不能。DynamoDB 沒有 GROUP BYHAVING,也沒有像 COUNTSUMAVG 這樣的聚合函式——原生 API 裡沒有,PartiQL 裡也沒有,PartiQL 的 SELECT 只接受 WHEREORDER BY。你要透過在資料變化時預先計算合計(原子計數器或 + Lambda 彙總),或者在讀取之後於應用端分組,來做聚合。

  • DynamoDB 的 PartiQL SELECT 語法是 SELECT … FROM … [WHERE …] [ORDER BY …]——而這就是全部清單。沒有 GROUP BY、沒有 HAVING、沒有聚合函式、沒有 JOINAWS PartiQL SELECT 參考)。
  • 因為 DynamoDB“不原生支援跨項的像 SUMCOUNT 這樣的聚合操作”,AWS 自己的指導是隨著資料變化 預先計算 聚合,並把結果作為普通的項儲存(AWS:物化聚合)。
  • 另一種選擇——讀取每一個項然後在你的應用裡聚合——可行,但你每次查詢都要為讀取整張表付費。
  • 對於一次性探索,DynoTable 的 SQL Workbench 直接對一張活的表執行 GROUP BY / COUNT / SUM / AVG——正是 DynamoDB 的 PartiQL 端點會拒絕的那些 SQL。

為什麼在 DynamoDB 裡聚合很難

DynamoDB 沒有掃描時聚合引擎。QueryScan 返回項;它們不折疊項。一次 Scan 每次 1 MB 地讀取整張表,它消耗的容量基於它讀取的項,而不是你保留的行——一個 FilterExpression 是在掃描 之後 但在結果返回 之前 應用的,所以它 收窄結果集卻不降低賬單AWS Scan API 參考:一個篩選“不消耗任何額外的讀容量單位”;容量基於被掃描的項大小,而非被返回的)。壓根就沒有一個 GROUP-BY 鉤子可供你掛上一個求和或計數。

PartiQL 並不改變這一點。PartiQL 是同一個引擎之上的一種 SQL 相容 方言,所以它繼承了同樣的限制——它是一個語法表層,不是一個新的執行模型。有文件記載的 SELECT 語法 乾脆就沒有一個 GROUP BY 記號。關於 PartiQL 與真正 SQL 之間的完整差距,參見 PartiQL 對比 SQL

所以問題不是“我怎麼寫一個 GROUP BY”——而是“我的聚合住在哪裡,它是什麼時候被計算的?”有三個答案。

模式 1:寫入時聚合(原子計數器)

如果你提前就知道那些組——按狀態計數、按客戶合計、按月下載數——就保留一個計數器項,並在每次寫入時更新它。

用一個 ADD ,讓遞增是原子且並行安全的。ADD 對數字和集合起作用,它避開了讀-改-寫競態,所以兩個都在遞增同一個計數器的寫入方永遠不會互相覆蓋(AWS 指出原子的 ADD“避免了讀-改-寫競態條件”):

UpdateItem
Key                         { pk: "STATS#orders", sk: "status#shipped" }
UpdateExpression            "ADD orderCount :one"
ExpressionAttributeValues   { ":one": 1 }

這就是你的 SELECT COUNT(*) … GROUP BY status——只不過那個計數已經作為一個項坐在那裡,能用一次個位數毫秒的 GetItem 讀到。權衡:你必須在寫入時就知道分組鍵,而且你把計數器更新耦合到了寫入路徑上。如果應用在寫入 之後 但在計數器更新 之前 崩潰,兩者就漂移得不同步了——而這恰恰是下一個模式所解耦的那個失敗模式。

模式 2:DynamoDB Streams + Lambda 彙總

當你不想在寫入路徑上有聚合邏輯時——或者寫入是一個你沒法輕易包裹的普通 PutItem——就把它移到下游。這是 AWS 自己推薦的模式,物化聚合AWS:用 GSI 做物化聚合查詢):

  1. 應用寫入原始項(一個訂單、一次下載、一個事件)。沒有聚合邏輯。
  2. 把這次寫入捕獲為一條流記錄。
  3. 一個掛在流上的 Lambda 讀取新項,推匯出組(status、month、category…),並用一次原子 UpdateItem 對匹配的聚合項 ADD——當許多次呼叫觸及同一個計數器時,它“避免了讀-改-寫競態條件”。
  4. 你查詢那個預先計算好的聚合——通常透過一個只索引彙總項的 ,這樣“本月前 10”就是一次帶 Limit 10Query

稀疏 GSI 的訣竅:只有聚合項攜帶那個被索引的屬性(例如 Month),所以原始事件行被自動排除在索引之外——“佔表中全部項的一小部分”,這讓索引保持便宜、讀取保持快。

這把聚合從寫入路徑上解耦,讓寫入保持簡單,代價是 最終一致——AWS 指出“在一次下載被記錄與聚合被更新之間有幾秒鐘的延遲。”對儀表盤、排行榜和趨勢計數器來說這沒問題。

同樣的重試告誡適用:一次被重試的 Lambda 呼叫會重新執行那個 ADD,所以“一次重試會把計數遞增不止一次”,留下一個 近似 值。要精確的計數,就加上冪等(例如一個以源項的 id 為鍵的條件運算式);否則那點小誤差對分析和排行榜來說沒問題。

模式 3:Scan/Query 之後在應用端分組

蠻力選項:讀取那些項,在你的程式碼裡給它們分組。

groups = {}
resp = table.scan()                        # or query() for one partition
while True:
    for item in resp["Items"]:
        key = item["status"]
        groups[key] = groups.get(key, 0) + 1
    if "LastEvaluatedKey" not in resp:
        break
    resp = table.scan(ExclusiveStartKey=resp["LastEvaluatedKey"])

這是正確的,有時也是對的做法——但對成本要誠實。一次 Scan 讀取 表中的每一個項,而無論你篩不篩選,讀容量都一樣。所以在一次完整 Scan 上做應用端分組,意味著你每次聚合都要為讀取整張表付費,而延遲隨表增長。AWS 把“讀取時掃描並計數”列為“只適合延遲不是問題的極小資料集”(AWS:為什麼要預先計算聚合)。

透過 Query 收窄到單個分割區(例如計數一個客戶的訂單)時,應用端分組是完全合理的——你只在讀取一個項集合。關於兩者之間的完整成本差距,參見 Query 對比 Scan。在 us-east-1 的按需模式下,一次全表 Scan 會為每一個被檢查的項按最終一致每 4 KB 計 0.5 個 RCU——一張 1 GB、行大小 1 KB 的表,在你的應用開始分組之前就已經約 250,000 個 RCU。用項大小計算器量一個有代表性的項,再到定價計算器裡給這次掃描估個價。

對於真正臨時的、對一張 DynamoDB 表的分析型 SQL——那種你只跑一次的、用完即棄的“GROUP BY status,把它們數一數”——AWS 的答案是給它指一個單獨的引擎:Amazon Athena DynamoDB 聯結器 讓你透過一個 Lambda 聯結器用真正的 SQL(GROUP BY、聚合,甚至到其他源的 JOIN)查詢這張表(AWS:Amazon Athena DynamoDB 聯結器)。它在幕後掃描這張表,所以它是一個報表/BI 工具,不是一條熱路徑。

我該用哪個模式?

你需要…
在一條熱讀取路徑上的一個已知組合計模式 1——原子計數器(ADD
不碰寫入路徑的聚合模式 2——Streams + Lambda 彙總
收窄到單個分割區的一個計數模式 3——Query 後在應用裡分組
精確合計、無漂移模式 1/2 加上 冪等守衛
探索時的一次性 GROUP BYDynoTable Workbench(下文)或 Athena
用 SQL 做週期性的 BI/報表Athena DynamoDB 聯結器

在 DynoTable 的 SQL Workbench 裡直接執行 GROUP BY

上面那些模式是你 在生產中 提供聚合的方式。但當你在探索一張表時——“現在,每個狀態有多少訂單?”——你不想去配一個 Lambda 或架起 Athena。你想把查詢敲出來。

那正是 DynoTable 的 SQL Workbench 的用途。它執行真正的 SQL——GROUP BYCOUNTSUMAVGHAVING,甚至 JOIN——直接對著你活的 DynamoDB 表,在它讀取的行上於用戶端執行聚合。這就是 DynamoDB 的 PartiQL 端點會拒絕的那些 SQL:

SELECT status, COUNT(*) AS orders, SUM(total) AS revenue
FROM "Orders"
GROUP BY status
HAVING SUM(total) > 1000
ORDER BY revenue DESC

誠實的說法:在底層,DynoTable 以 API 允許的方式讀取項(能 Query 的地方就 Query,必須 Scan 的地方就 Scan),把它們物化出來,然後在 Workbench 裡做分組——與模式 3 相同的“先讀後聚合”機制,只是不用那個迴圈,而且 在 DynamoDB 的訪問模式規則之內。它是為 探索和臨時分析 而造的,不是為了替換一條熱讀取路徑上的生產彙總。為那個用途,就預先計算(模式 1 / 2)。

關於同一個楔子的 JOIN 一側——DynoTable 也執行 PartiQL 做不了的跨表連線——參見 DynamoDB JOIN。想恰好就這項能力比較各家 GUI 用戶端?參見 DynamoDB GUI 對比

常見問題

DynamoDB PartiQL 支援 GROUP BY 嗎? 不支援。DynamoDB 的 PartiQL SELECT 只支援 WHEREORDER BY——沒有 GROUP BYHAVING、聚合函式或 JOIN。該語法有 文件記載SELECT … FROM … [WHERE …] [ORDER BY …]

我能對一整張 DynamoDB 表做 COUNT(*) 嗎? 不能作為一個聚合函式——PartiQL 一個都沒有。這個 API 在 Scan/Query 上給你 Select=COUNT,它返回 匹配項 的一個計數,但仍然讀取(並計費)掃描觸及的每一個項(AWS Scan API 參考:容量基於被檢查的項,而非被返回的)。對於一個被頻繁讀取的合計,保留一個計數器項(模式 1)。

我能 GROUP BY 分割區索引鍵嗎? 在 DynamoDB 或 PartiQL 裡都不能。如果“按分割區索引鍵”是一個已知的訪問模式,就用一次原子 ADD 為每個鍵維護一個聚合項(模式 1),或者用 Streams + Lambda 把它彙總起來(模式 2)。

我怎麼按組做 SUMAVG SUM:為每個組保留一個累計合計,並在寫入時對它 ADDAVG:把和與計數都存下來,在讀取時相除——沒有原生的平均。對於一次性的探索型 AVG,在 DynoTable 的 SQL Workbench 裡執行它,或者透過 Athena DynamoDB 聯結器。

有沒有一個 partiql group by 的變通辦法? 沒有 PartiQL 一側的。要麼預先計算聚合(計數器/Streams)並 SELECT 那個彙總項,要麼在一個有 GROUP BY 的引擎裡執行它——臨時用 DynoTable 的 Workbench,週期性報表用 Athena。


想對你自己的表執行 GROUP BY 而不用寫一個 Lambda?試試 DynoTable,把 SQL Workbench 指向一張活的表。

已更新