上一篇讲了 group by 为什么要进入语义层。
它解决的是另一类常见需求:业务不只想看明细列表,还想按客户、仓库、状态、月份做汇总。
但在真实系统里,还有一种需求出现得更频繁,也更容易把后端代码写乱:
列表本身还是列表,但每一行旁边要带一点子表统计值。
比如:
- 订单列表里显示明细行数量;
- 入库单列表里显示已入库件数;
- 客户列表里显示最近一次下单时间;
- 调拨单列表里显示异常明细数量;
- 项目列表里显示任务总数和已完成数量。
这些需求看起来都不大。它们不是完整报表,也不是复杂 BI,只是列表上多几个字段。
但后端真正实现时,经常会开始分叉。
只查主表不够
普通列表查询通常围绕主表展开。
比如订单列表,主表里有订单号、客户、状态、创建时间、负责人。
这时候 DSL 或 QM 已经可以很好地表达:
json
{
"model": "OrderQueryModel",
"columns": ["orderNo", "customer$caption", "state", "createdAt"],
"slice": [
{ "field": "createdAt", "op": "[)", "value": ["2026-06-01", "2026-07-01"] }
],
"orderBy": [{ "field": "createdAt", "dir": "desc" }]
}
问题是,业务很快会补一句:
这个列表能不能顺便显示一下每个订单有多少行明细?已出库数量是多少?
这些字段通常不在订单主表里,而在订单明细表、出库明细表、任务表或日志表里。
如果只是偶尔一个页面要,后端手写一段查询也能完成。但这种需求一多,维护成本就会上来。
为每个统计字段建汇总表不一定划算
一种做法是建汇总表。
订单明细数量、已出库数量、异常数量、最近处理时间,都提前算好,写到一张订单汇总表里。列表查询时直接 JOIN 这张表,或者干脆把这些字段同步回主表。
在数据量很大、查询非常频繁、实时性要求明确的场景里,这么做有价值。
但很多中小系统没到这个阶段。
数据量不大,业务口径还在变,今天要明细数量,明天要已完成数量,后天又想看最近一次操作时间。为了这些字段马上引入汇总表、定时任务、同步逻辑、补偿机制和一致性处理,有时候比直接查询还重。
所以早期更常见的做法,是在接口里临时补。
比如先查订单主表,再查一遍明细表,按订单 ID 分组,最后在 Java 里把统计值塞回列表。
或者在 Repository 里写一段类似这样的 SQL:
sql
select
o.id,
o.order_no,
o.state,
line_stat.line_count,
line_stat.shipped_qty
from biz_order o
left join (
select
order_id,
count(*) as line_count,
sum(shipped_qty) as shipped_qty
from biz_order_line
group by order_id
) line_stat on line_stat.order_id = o.id
where o.created_at >= ? and o.created_at < ?
这段 SQL 本身没什么问题。
真正的问题是:如果每个列表都这么写一段,统计口径又开始散回业务代码。
散在接口里的聚合 JOIN 会带来什么问题
这类写法最开始很快。
但几个页面下来,问题会变得明显:
- 订单列表统计了所有明细,导出接口只统计了有效明细;
- 一个接口排除了删除状态,另一个接口忘了排除;
- 一个接口用
count(*),另一个接口用count(line.id); - 一个接口按订单 ID 聚合,另一个接口因为 JOIN 了额外表导致行数被放大;
- 前端想排序
lineCount,后端才发现这个字段不是模型字段,只是某段 SQL 的别名; - 后续要加权限、导出、审计时,很难说明这个统计值从哪里来。
这些问题不是 SQL 水平不够造成的。
相反,很多后端都能把这段 SQL 写出来。难点在于:它不应该在每个接口里各写一遍。
因为这里已经不只是"怎么 JOIN",而是一个业务查询口径:
主模型是什么,明细模型是什么,按哪个键关联,明细侧先按什么粒度聚合,聚合后暴露哪些字段,这些字段能不能排序、能不能筛选、能不能导出。
这些都应该进入 QM。
QM 聚合型 JOIN 解决的是口径归属
所谓 QM 聚合型 JOIN,不是开放一套让调用方自由拼 JOIN 的语法。
它更像是把一类稳定需求声明到查询模型里:
js
const fo = loadTableModel('FactOrderModel');
const sales = loadTableModel('FactSalesModel');
const fs = sales
.filterEq(sales.orderStatus, 'COMPLETED')
.filterEq(sales.orderId, (ctx) => ctx.extData.orderId)
.groupBy(sales.orderId)
.as('fsByRuntimeOrder');
export const queryModel = {
name: 'OrderSalesAggregateRelationQueryModel',
loader: 'v2',
model: fo,
joins: [
fo.leftJoin(fs).on(fo.orderId, fs.orderId)
],
columnGroups: [
{
caption: '订单信息',
items: [
{ ref: fo.orderId },
{ ref: fo.amount }
]
},
{
caption: '销售明细聚合',
items: [
{ ref: fs.salesAmount },
{ ref: fs.uniqueCustomers }
]
}
]
};
这段是 Java 引擎 ecommerce 测试模型的简化版。它保留了两类过滤:orderStatus 这种固定值过滤,以及 ctx.extData.orderId 这种运行期参数过滤。
重点是这几个约束:
- 关联键由
fo.leftJoin(fs).on(fo.orderId, fs.orderId)声明,不由调用方随便传; - 明细表先聚合,再回到主模型,避免一对多 JOIN 把主表行数放大;
- 右侧
groupBy(sales.orderId)必须覆盖 join key; - 明细侧筛选,比如
filterEq(sales.orderStatus, 'COMPLETED')和filterEq(sales.orderId, (ctx) => ctx.extData.orderId),会在聚合前进入右侧子查询; - 暴露字段是稳定指标,比如
salesAmount、uniqueCustomers; - DSL 只能引用 QM 允许的字段,而不是自己写子查询;
- 排序、导出、审计可以看到这些字段来自哪个聚合口径。
Java 引擎的测试会断言右侧被渲染成 LEFT JOIN (SELECT ... GROUP BY ...) 这种受控聚合子查询。运行期 filter 会从 ModelResultContext.extData 读取参数,在聚合前变成右侧 WHERE 条件;缺少参数或传入非法字符时会失败关闭,函数源码也不会泄漏到 SQL 里。salesAmount 会按 TM 里的默认聚合方式渲染成 sum(agg_src.sales_amount) salesAmount,uniqueCustomers 会按 COUNT_DISTINCT 口径渲染成 count(distinct agg_src.customer_key) uniqueCustomers。
如果某个场景需要在 QM 里显式指定聚合函数和输出别名,也可以走 leftJoinAggregate 这类构建方式。比如指定 groupBy、sum、count,再通过 on 关联回主模型。但这一篇先用 aggregate relation 的例子,因为它更接近日常模型配置:右侧度量优先沿用 TM 中已经定义好的默认聚合口径。
这样,调用方看到的仍然是一份普通列表查询:
json
{
"model": "OrderSalesAggregateRelationQueryModel",
"columns": [
"orderId",
"amount",
"salesAmount",
"uniqueCustomers"
],
"orderBy": [{ "field": "salesAmount", "dir": "desc" }]
}
后端维护的不是某个 Controller 里的 SQL 片段,而是 QM 里的一个受控聚合关系。
这和前面几篇的思路是一致的:不是让查询更自由,而是把反复出现的查询能力收进模型。
它不是预聚合的替代品
这里也要说清楚:QM 聚合型 JOIN 不是用来替代汇总表、缓存、预聚合或数仓的。
如果数据量很大,或者这些统计字段被高频访问,最终可能还是要做预聚合、缓存、物化视图,甚至单独的数据分析链路。
但在系统演化早期,很多需求没必要一上来就做那么重。
先通过 QM 把口径收住,让列表可以稳定带出子表统计值。等以后真的遇到性能压力,再把同一个口径迁移到预聚合或缓存层。
这样比一开始就在每个接口里写不同 SQL 更容易演化。
因为模型已经告诉我们:
- 这个统计值叫什么;
- 它来自哪里;
- 按什么粒度聚合;
- 怎么关联回主模型;
- 哪些业务入口允许使用它。
执行方式可以变,业务口径不用跟着散。
小结
group by 让 DSL 能表达汇总查询。
QM 聚合型 JOIN 则解决另一类问题:列表还是列表,但每行需要带出子表聚合值。
这类能力在小型系统里尤其常见。因为业务确实需要它,但又不一定值得立刻为每个统计字段建汇总表。
把它放进 QM,核心不是炫技,也不是鼓励自由 JOIN,而是把"主表列表 + 子表统计值"这种反复出现的模式固定下来。
后端少写几段临时 SQL 只是结果。
更重要的是,统计口径终于有了归属。