列表里要带子表统计值时,为什么需要 QM 聚合型 JOIN

上一篇讲了 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),会在聚合前进入右侧子查询;
  • 暴露字段是稳定指标,比如 salesAmountuniqueCustomers
  • DSL 只能引用 QM 允许的字段,而不是自己写子查询;
  • 排序、导出、审计可以看到这些字段来自哪个聚合口径。

Java 引擎的测试会断言右侧被渲染成 LEFT JOIN (SELECT ... GROUP BY ...) 这种受控聚合子查询。运行期 filter 会从 ModelResultContext.extData 读取参数,在聚合前变成右侧 WHERE 条件;缺少参数或传入非法字符时会失败关闭,函数源码也不会泄漏到 SQL 里。salesAmount 会按 TM 里的默认聚合方式渲染成 sum(agg_src.sales_amount) salesAmountuniqueCustomers 会按 COUNT_DISTINCT 口径渲染成 count(distinct agg_src.customer_key) uniqueCustomers

如果某个场景需要在 QM 里显式指定聚合函数和输出别名,也可以走 leftJoinAggregate 这类构建方式。比如指定 groupBysumcount,再通过 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 只是结果。

更重要的是,统计口径终于有了归属。

相关推荐
用户925807911481 小时前
redission原理
java·后端
小旭95271 小时前
Spring Cloud 集成分布式日志 ELK+Swagger 接口文档实战
java·分布式·后端·elk·spring cloud
属鼠哥1 小时前
HDFS 短路读取:mmap 与 Unix Domain Socket 铸就的零拷贝艺术
后端
好好风格1 小时前
Scrapling:现代 Web 抓取,正在从“写选择器”走向“自适应”
linux·后端
屋外雨大,惊蛰出没1 小时前
spring boot+mybatis开发基础复习
java·spring boot·后端
叫我少年1 小时前
C# 文件级 using(global using)
后端
郝学胜_神的一滴1 小时前
系统设计 014:缓存深度实战:如何用 Cache 优雅优化数据库读写?
前端·后端·面试
ai程序羊沸沸1 小时前
Spring Cloud 微服务入门:从组件清单到问题驱动的学习路径
后端·微服务
铁皮饭盒1 小时前
sharp.js安装不上, Bun.Image说: 我不用安装
前端·后端