DynamoDB GROUP BY:如何改用其他方式聚合
DynamoDB 里没有 GROUP BY。也没有 COUNT、SUM 或 AVG——原生 API 里没有,PartiQL 里也没有。DynamoDB 是一个键值 / 文档存储,不是一个分析引擎,所以聚合是 你 要搭的东西,不是查询规划器替你做的东西。
你能在 DynamoDB 里 GROUP BY 吗?
不能。DynamoDB 没有 GROUP BY、HAVING,也没有像 COUNT、SUM、AVG 这样的聚合函数——原生 API 里没有,PartiQL 里也没有,PartiQL 的 SELECT 只接受 WHERE 和 ORDER BY。你要通过在数据变化时预先计算合计(原子计数器或 + Lambda 汇总),或者在读取之后于应用端分组,来做聚合。
- DynamoDB 的 PartiQL
SELECT语法是SELECT … FROM … [WHERE …] [ORDER BY …]——而这就是全部清单。没有GROUP BY、没有HAVING、没有聚合函数、没有JOIN(AWS PartiQLSELECT参考)。 - 因为 DynamoDB“不原生支持跨项的像
SUM或COUNT这样的聚合操作”,AWS 自己的指导是随着数据变化 预先计算 聚合,并把结果作为普通的项存储(AWS:物化聚合)。 - 另一种选择——读取每一个项然后在你的应用里聚合——可行,但你每次查询都要为读取整张表付费。
- 对于一次性探索,DynoTable 的 SQL Workbench 直接对一张活的表运行
GROUP BY/COUNT/SUM/AVG——正是 DynamoDB 的 PartiQL 端点会拒绝的那些 SQL。
为什么在 DynamoDB 里聚合很难
DynamoDB 没有扫描时聚合引擎。Query 和 Scan 返回项;它们不折叠项。一次 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 做物化聚合查询):
- 应用写入原始项(一个订单、一次下载、一个事件)。没有聚合逻辑。
- 把这次写入捕获为一条流记录。
- 一个挂在流上的 Lambda 读取新项,推导出组(status、month、category…),并用一次原子
UpdateItem对匹配的聚合项ADD——当许多次调用触及同一个计数器时,它“避免了读-改-写竞态条件”。 - 你查询那个预先计算好的聚合——通常通过一个只索引汇总项的 ,这样“本月前 10”就是一次带
Limit 10的Query。
只有聚合项携带那个被索引的属性(例如 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 KB 行组成的 1 GB 表,在你的应用开始分组之前就已经大约是 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 BY | DynoTable Workbench(下文)或 Athena |
| 用 SQL 做周期性的 BI/报表 | Athena DynamoDB 连接器 |
在 DynoTable 的 SQL Workbench 里直接运行 GROUP BY
上面那些模式是你 在生产中 提供聚合的方式。但当你在探索一张表时——“现在,每个状态有多少订单?”——你不想去配一个 Lambda 或架起 Athena。你想把查询敲出来。
那正是 DynoTable 的 SQL Workbench 的用途。它运行真正的 SQL——GROUP BY、COUNT、SUM、AVG、HAVING,甚至 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 只支持 WHERE 和 ORDER BY——没有 GROUP BY、HAVING、聚合函数或 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)。
我怎么按组做 SUM 或 AVG?
SUM:为每个组保留一个累计合计,并在写入时对它 ADD。AVG:把和与计数都存下来,在读取时相除——没有原生的平均。对于一次性的探索型 AVG,在 DynoTable 的 SQL Workbench 里运行它,或者通过 Athena DynamoDB 连接器。
有没有一个 partiql group by 的变通办法?
没有 PartiQL 一侧的。要么预先计算聚合(计数器/Streams)并 SELECT 那个汇总项,要么在一个有 GROUP BY 的引擎里运行它——临时用 DynoTable 的 Workbench,周期性报表用 Athena。
想对你自己的表运行 GROUP BY 而不用写一个 Lambda?试试 DynoTable,把 SQL Workbench 指向一张活的表。