如果你的 ORM 生成的 SQL在一个看似简单的分组查询上突然长出三四层子查询不要急着怪数据库。我最近从一堆慢查询日志里翻出一个特别典型的问题业务方要统计近 30 天内有活跃订单的省份的订单总额就这么一个需求组里最开始的实现慢到接口超时。排查之后的问题并不在数据库而在 ORM 生成 SQL 的形态——我把它换成了 easy-query 的隐式分组写法在子查询里做聚合同一套数据同一台机器查询时间直接降了一个数量级。这篇文章就围绕这个场景展开讲讲“隐式分组”到底是什么、怎么用、以及它凭什么是性能天花板。先说一个很多人踩过的坑多数 ORM 在处理“子查询 分组 再聚合”这种需求时默认策略是先在内层查出分组结果再作为一个派生表让外层去连接或过滤。听起来没问题但对数据库优化器来说派生表往往意味着物化物化就会引入临时表、排序、额外的扫描成本。真正跑起来之后你的查询从“两表分组”变成了“内层分组 外层再套一层”执行计划的形状完全不同。EF Core 如此其他基于实体映射的 ORM 也大同小异。那 easy-query 凭什么能不一样核心就在它对子查询里的分组语义做了专门设计让分组不需要你在业务代码里绕来绕去SQL 形状也尽可能拉平。下面我把完整的前因后果、代码、生成的 SQL 和执行计划对比都摊开来说。1. 先还原场景为什么 ORM 一碰到子查询分组就翻车1.1 子查询 分组是 ORM 的重灾区我见过太多类似案例了单表统计没问题两表 JOIN 也没问题但一旦把“分组结果”再塞进另一个查询当条件ORM 生成的 SQL 就开始失控。原因其实不复杂——ORM 的核心能力是“实体映射”而不是“查询优化”。当你写了一个带子查询分组的需求ORM 首先要保证的是“能把结果正确映射回对象”它并不关心最终 SQL 是否以最优雅的方式执行。以我之前遇到的需求为例先按省份统计活跃用户数筛出活跃用户达到 100 人的省份再统计这些省份的订单总金额。这个需求在业务上非常正常但在 ORM 里写起来却有两个天然矛盾。第一个矛盾子查询需要 GROUP BY 省份同时 COUNT 用户第二个矛盾外层还要再按省份 GROUP BY并 SUM 金额。两个分组叠在一起ORM 很难在不产生冗余派生表的情况下表达清楚。大多数 ORM 会把内层分组结果当作一张临时表外层再去连接。这在大数据量下尤其致命因为内层物化的结果集如果比较大数据库不仅要为它分配内存还可能触底到磁盘临时文件。我曾经在某个数据量并不夸张的环境下看到一条本应毫秒级的查询被拖到了三秒以上原因就是优化器选择了一个极端糟糕的物化顺序。1.2 问题本质不是聚合难写而是 SQL 形状失控如果你把问题归结为“GROUP BY 太难写”那就错怪分组聚合了。数据库本身对分组聚合非常熟练它是利用索引和哈希结构高效完成这件事的。真正让数据库难受的是 ORM 把本来可以一次扫描完成的分组逻辑硬生生拆成了“内层分组 外层查询”的两段式结构。举个例子一条理想的 SQL 大概是这样数据库沿着订单表的索引一路扫过去边扫边累加金额同时在内存里维护一个省份维度的小哈希表扫完就拿到结果。这是数据库最舒服的执行方式。但如果你在 SQL 里塞了一个派生表数据库就必须先把内层查出来的那些中间行物化成结果集分配内存甚至排序然后外层再从头扫一遍这个中间结果。一次扫描变成两次扫描内存分配多了排序可能也多了执行计划的稳定性自然就差了。我并不是说所有 ORM 生成的 SQL 都是垃圾而是说当子查询和分组叠在一起时多数 ORM 根本没有能力去判断“这个派生表是否可以消除”也没有意愿去优化。它只知道按你的对象导航关系一层层嵌套。SQL 形状一旦失控后面调索引、调统计信息都很难救回来。1.3 为什么单拎 easy-query 出来对比选择 easy-query 来对比不是因为它名气大而是因为它在查询 API 的设计上确实少走了很多弯路。它在子查询里做分组聚合时会尽量让分组键、过滤条件、聚合函数保持在一个语义平面上不额外生成无意义的包裹层。也就是说开发者的写法意图能更直接地翻译成 SQL。当然我不打算把话说满。easy-query 不是魔法它也要遵守数据库优化器的基本规则。真正让我在多个项目里持续选择它的理由是它能让我在“复杂查询”和“可读性”之间不用做太多妥协。同一个业务需求我用 EF Core 写要反复调整 LINQ 结构用 easy-query 写往往一遍就过而且生成的 SQL 一眼能看懂。这一点在长期维护的项目里价值比所谓“省几行代码”大得多。2. 隐式分组的设计逻辑与核心原理2.1 “隐式”到底隐藏了什么很多第一次听到“隐式分组”的人会问分组不就是 GROUP BY 嘛有什么显式隐式之分这个问题的关键在于“分组这件事在查询里到底由谁来决定、写在哪个位置”。传统写法里你要明确写一个 groupBy把分组列塞进去再把聚合函数写出来两边意见必须一致否则要么 SQL 报错要么统计结果跟你想的不一样。代码一多尤其是子查询里还要继续聚合时这个“一致性维护”就成了负担。你经常得为了满足语法要求把不在分组里的字段也带上或者用 DISTINCT、子查询去绕。隐式分组的思路是把“分组键”和“聚合逻辑”绑定在一起。当你通过 select 指明哪些字段用于分组、哪些字段用于聚合时ORM 自动把它们翻译成一条以分组为骨架的查询省掉了在多个地方重复声明分组意图的过程。写法上更简单更重要的是它降低了开发者在子查询里“写错分组”的概率因为你不需要手工维护 GROUP BY 和 SELECT 之间的一致性。2.2 子查询里的隐式分组到底让 SQL 省了什么我拿前面那个省份统计需求来拆开看。如果完全手写 SQL我的第一版会是这样SELECT u.province, SUM(o.amount) AS total_amount FROM t_order o JOIN t_user u ON o.user_id u.id WHERE o.status 1 AND u.province IN ( SELECT u2.province FROM t_user u2 JOIN t_order o2 ON u2.id o2.user_id WHERE o2.status 1 GROUP BY u2.province HAVING COUNT(DISTINCT u2.id) 100 ) GROUP BY u.province;这条 SQL 本身已经算简洁了但它在语义上是“先查省份再查金额”数据库在 IN 子查询的处理上往往不如 JOIN 来得稳定。如果换成 easy-query我可以把一个分组聚合后的子查询当作普通查询对象直接参与外层 JOIN而且不需要为了凑语法写出额外的嵌套。省掉的是那一层为了“让子查询能作为表使用”而不得不加的包裹逻辑。你别说这层包裹无所谓。在 ORM 世界多包一层就意味着你多给了优化器一次犯错的机会它可能把外层过滤条件推不进内层可能选错 JOIN 顺序可能对上千万行的中间结果做一次不必要的排序。这些都是在 SQL 里一眼看不见、但执行计划里写得清清楚楚的成本。2.3 从执行计划看为什么这套设计配得上“性能天花板”性能天花板这个说法很容易被人当成夸张但它背后其实有个硬道理数据库在分组聚合上的理论性能上限是由“扫描的数据量 分组运算成本”决定的不是由查询写法决定的。同一个需求谁能用最接近理论下限的方式执行谁就站在性能天花板上。执行计划里有三个指标值得盯扫描行数、临时表面积、排序次数。理想情况下一条分组查询应该走覆盖索引数据库只把分组键和聚合列取出来边扫边聚合完全不产生临时表也不产生文件排序。我见过不少高性能查询执行计划里清清爽爽一个 INDEX SCAN一个 HASH GROUP BY连 SORT 都没有。但如果查询被包了一层派生表执行计划会多出“物化派生表”的节点。这个节点一旦出现扫描行数通常会放大临时表一出现内存和磁盘的边界就开始紧张。easy-query 的隐式分组之所以让我觉得接近“天花板”是它大部分情况下能生成让优化器一眼看穿意图的扁平 SQL。优化器不需要做很多猜测就能选择那条接近理论下限的执行路径。这一点比什么“自动索引优化”都实在。3. 实战拆解在子查询里把隐式分组用到极致3.1 业务场景与表结构设计为了让你能直接照着复现我这边用一个模拟项目来描述不涉及任何真实业务数据。场景是某个电商系统的订单统计模块两张表一张用户表一张订单表。表结构简化后如下CREATE TABLE t_user ( id BIGINT PRIMARY KEY, province VARCHAR(32) NOT NULL, status INT NOT NULL ); CREATE TABLE t_order ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, status INT NOT NULL, created_at DATETIME NOT NULL ); CREATE INDEX idx_order_user_status ON t_order(user_id, status); CREATE INDEX idx_order_amount ON t_order(amount);业务需求是三层的第一层找出近 30 天有有效订单status 1的用户第二层把这些用户按省份分组筛出活跃用户数大于等于 100 的省份第三层统计这些省份所有有效订单的金额总和。注意这里“订单金额总和”需要作用在订单表上但筛选条件依赖的是“活跃省份”这个分组结果。3.2 easy-query 写法一段代码把分组和聚合同时表达在 easy-query 里我的习惯是先构造内层的隐式分组查询得到一个 SubQuery再把它当成普通表去参与外层的 JOIN。下面这段代码是我在实际项目中反复使用的模式// 第 1 步构造活跃省份子查询隐式分组 SubQueryProvinceAggDTO activeProvinceSub easyQuery .query(User.class, u2) .innerJoin(Order.class, o2, on - on .eq(t - t.column(u2.id), t - t.column(o2.user_id)) .eq(t - t.column(o2.status), 1)) .filter(t - t.ge(o2.created_at, LocalDateTime.now().minusDays(30))) // 隐式分组select 里出现的普通字段即分组键 // 聚合函数自动作用在分组键内无需单独再写一行 groupBy .select(ProvinceAggDTO.class, s - s .column(u2.province) .countDistinct(u2.id).as(activeUsers)) .having(t - t.countDistinct(u2.id).ge(100L)) .toSubQuery(); // 第 2 步主查询直接 JOIN 这个分组子查询 ListProvinceAmountDTO result easyQuery .query(Order.class, o) .innerJoin(User.class, u, on - on .eq(t - t.column(o.user_id), t - t.column(u.id)) .eq(t - t.column(o.status), 1)) .innerJoin(activeProvinceSub, ap, on - on .eq(t - t.column(u.province), t - t.column(ap.province))) .filter(t - t.ge(o.created_at, LocalDateTime.now().minusDays(30))) .select(ProvinceAmountDTO.class, s - s .column(u.province).as(province) .sum(o.amount).as(totalAmount)) .toList();这段代码有两点值得你注意。第一第 1 步里我没有单独写 groupBy而是通过 select 里的字段组合让 ORM 自己推断出“按 province 分组”。这就是“隐式分组”在实际写法上的意义。第二这个 SubQuery 不是一条拼好的 SQL 字符串它是一个有类型的查询对象可以继续在 WHERE、JOIN、子查询里复用维护性比字符串拼接好太多。3.3 EF Core 作为对照组的写法再来看看 EF Core 的写法。.NET 生态里用 LINQ 表达同样的需求并不难但在结构上你需要非常小心地维护分组键和聚合之间的关系。我先写一个相对完整的版本var innerQuery context.Users .Where(u u.Orders.Any(o o.Status 1 o.CreatedAt DateTime.Now.AddDays(-30))) .GroupBy(u u.Province) .Select(g new ProvinceAggDTO { Province g.Key, ActiveUsers g.Select(u u.Id).Distinct().Count() }) .Where(x x.ActiveUsers 100) .Select(x x.Province); var result await context.Orders .Where(o o.Status 1 o.CreatedAt DateTime.Now.AddDays(-30)) .Select(o new { o.User.Province, o.Amount }) .Where(x innerQuery.Contains(x.Province)) .GroupBy(x x.Province) .Select(g new ProvinceAmountDTO { Province g.Key, TotalAmount g.Sum(x x.Amount) }) .ToListAsync();这段 LINQ 能不能跑能。但问题在于它写起来已经有点“绕”了内层查询是为了筛出省份外层查询为了统计金额但两个查询之间的关联条件被放进了 Contains 而不是 JOIN。对 EF Core 的翻译器来说Contains 往往会把子查询转化为 IN 条件有时候还好有时候就会产生不必要的嵌套。你为了让它正确执行还得去猜翻译器的脾气。在实际项目里我把这份代码交给另一位同事时他第一反应是“这段 LINQ 我得看五分钟才敢改”。这种可读性代价在需求复杂到三四个子查询嵌套时会被放大到离谱。这也是为什么我后来坚持在关键统计接口上换用能直接控制 SQL 形态的框架。3.4 生成 SQL 与执行计划逐段对比easy-query 在刚才那套写法下生成的 SQL 基本就是我希望手写的样子SELECT u.province, SUM(o.amount) AS totalAmount FROM t_order o INNER JOIN t_user u ON o.user_id u.id AND o.status 1 INNER JOIN ( SELECT u2.province, COUNT(DISTINCT u2.id) AS activeUsers FROM t_user u2 INNER JOIN t_order o2 ON u2.id o2.user_id WHERE o2.status 1 AND o2.created_at ? GROUP BY u2.province HAVING COUNT(DISTINCT u2.id) 100 ) ap ON u.province ap.province WHERE o.created_at ? GROUP BY u.province;EF Core 那条 LINQ 在同等条件下不同的版本翻译结果会有差异但整体形状大概率是SELECT u.province, SUM(o.amount) AS totalAmount FROM t_order o INNER JOIN t_user u ON o.user_id u.id WHERE o.status 1 AND o.created_at ? AND u.province IN ( SELECT sub.province FROM ( SELECT u2.province AS province FROM t_user u2 INNER JOIN t_order o2 ON u2.id o2.user_id WHERE o2.status 1 AND o2.created_at ? GROUP BY u2.province HAVING COUNT(DISTINCT u2.id) 100 ) sub ) GROUP BY u.province;你能直观看出差别前者把子查询作为 JOIN 对象后者把子查询结果当 IN 条件。JOIN 方式和 IN 方式在数据量小的时候差距不明显但一旦内层分组结果集变大、外层表数据量也变大数据库优化器对 JOIN 的路径优化空间会大很多。它可以做 hash join可以调整连接顺序甚至可以复用外层已经扫出来的分组结果。IN 子查询则经常被翻译成重复执行的半连接万一没有合适的索引那就是肉眼可见的慢。4. 性能实测与“天花板”是怎么来的4.1 测试环境与数据规模我是在一台普通的开发服务器上做的对比配置不算高但足够说明问题。数据库选的是 MySQL 8.0InnoDB 引擎运行在 8 核 16G 的容器里。数据量按真实业务常见规模来t_user 表 10 万行t_order 表 50 万行分布到 31 个省份近 30 天有效订单大约 18 万行活跃用户超过 100 人的省份有 24 个。为了让测试公平两张表的相关字段都建了索引尤其是 t_order 的 user_id、status、created_at 这三个条件的组合索引我专门设计过ALTER TABLE t_order ADD INDEX idx_user_status_time(user_id, status, created_at);这个组合索引能同时覆盖内层查询里的关联条件和外层查询里的过滤条件属于最理想情况。你要是连索引都不建那再好的 SQL 也是白搭这一点后面会单独说。4.2 对比结果与解读同一份数据清空缓存后连续跑三次取平均值结果大概是下面这样实现方式平均耗时扫描行数估算执行计划关键节点easy-query 隐式分组约 0.4 秒28 万索引扫描 HASH GROUP BY无物化子查询EF Core LINQ 对照写法约 2.1 秒45 万多一层半连接内层分组物化为派生表手写优化 SQL约 0.35 秒27 万索引扫描 HASH GROUP BY第一次看到这个数字我也怀疑是不是 EF Core 的翻译器在某些版本上吃亏。但后来我把 EF Core 生成的 SQL 原封不动拿到数据库客户端里手工执行发现耗时依然是两秒左右说明问题不在翻译器而在 SQL 形状本身。easy-query 生成的那条 SQL和我手写优化后的版本几乎一致所以跑出来的数据才这么接近。结论不是“EF Core 不行”而是“同一个业务需求SQL 形状不同执行效率就是能差五倍”。ORM 界的很多性能争议其实就是 SQL 形状争议。4.3 决定性因素索引、派生表与优化器为什么 0.4 秒和 2.1 秒会差这么多我拆开执行计划看了很久发现真正的分水岭是“派生表”和“半连接”的处理方式。easy-query 的 SQL 里内层分组结果作为一个 JOIN 派生表出现MySQL 优化器可以对它做物化也可以选择不物化直接下推条件。由于外层 JOIN 条件刚好落在省份字段上优化器能提前过滤掉大量无效数据。而 EF Core 常用方案里的 IN 子查询本质上会被转成带有依赖性的半连接。它的执行逻辑有点像“外层每扫到一行订单就去判断这个省份满不满足活跃条件”。听起来是同一件事但数据库实现起来的开销完全不同。半连接在驱动表较小的时候还好一旦驱动表数据量大它就会反复维护内部状态的哈希结构扫描行数自然就上去了。所以所谓“性能天花板”在我眼里就是两句话第一能一次扫描完成的分组绝不要拆成多次扫描第二能在 JOIN 阶段完成的条件过滤绝不要拖到 IN 子查询里去逐行判断。easy-query 的隐式分组恰好把这两点都照顾到了。5. 常见坑位与排查实录5.1 分组键上没索引再好的写法也白搭很多人兴致勃勃地换上新 ORM结果查询还是慢第一反应是“这个框架也不行”。但我观察过太多次真正的问题往往是分组键上根本没有合适的索引。分组聚合的快慢很大程度上取决于数据库能不能“按索引顺序扫描分组键”。如果索引顺序和分组键不一致数据库就只能先把所有行取出来再内存排序或者建哈希表这个成本会随着数据量直线上升。以我这里的场景为例如果你在 t_user.province 上没有任何索引那么内层按省份分组时数据库不得不全表扫 10 万用户再分组。10 万行不算多但订单表一旦到千万级这个全表扫描的代价就会变得非常难看。我的经验是凡是频繁用于 GROUP BY 和 JOIN 的字段都应该评估是否建组合索引尽量让分组键出现在索引的最左侧。这听起来是数据库常识但实际项目里很多人会用 ORM 写完查询就直接上线根本没有打开执行计划看过一眼。遇到慢查询时才想起来看索引往往已经积累了一堆线上问题。5.2 统计结果的空值陷阱隐式分组在写起来非常顺滑的同时也有一个容易被忽视的细节空值。分组键为 NULL 时数据库会有自己的一套默认行为但 ORM 不一定如实翻译给你。比如某个用户表里 province 字段是空的分组聚合时这个 NULL 会被单独归成一组count 和 sum 的结果都跟普通省份不一样。我在一个报表项目里就踩过一次某个省份的数据总是对不上查了半天发现自己用的是 LEFT JOIN一部分订单对应的用户没有 province 值这些订单被并进了 NULL 分组而不是被过滤掉。后来我在分组查询里显式加了过滤条件 province IS NOT NULL数据才对上。这个坑跟框架关系不大但正因为 easy-query 把“隐式分组”写得太顺你反而容易忽略“分组的边界条件是否完整”这个问题。建议做统计类需求时先明确业务上允不允许分组键为空不允许的话一定要在查询入口把它们排除掉不要迷信默认行为。5.3 逻辑复杂度与 SQL 可读性平衡隐式分组也不是银弹。如果某个查询里同时有多个子查询并且每个子查询都需要独立做分组我还是建议你先把逻辑拆成几个清晰的查询对象再在最后一步组合而不是试图在一条表达式里塞下所有东西。代码是给人读的数据库执行计划是给机器读的这两者的优化方向并不总是一致。有一次我需要统计“每个品类下近 30 天有交易用户中各地区用户的平均客单价”。这个需求拆开至少三个维度品类、地区、用户。如果我全写进一个隐式分组的表达式里生成的 SQL 虽然能跑但执行计划里出现了我没有预料到的排序操作。后来我把地区维度的统计单独抽成一个子查询再 JOIN 回主查询逻辑清晰了执行计划也干净了很多。所以我的建议是隐式分组适合用在一个子查询内部的“单层分组”场景它解决的是“分组意图如何简洁表达”的问题一旦涉及多个分组维度叠加该拆就拆不要为了秀技术而把所有东西揉成一团。6. 使用边界与个人经验总结6.1 我会在哪些场景主动用隐式分组用了一段时间后我给自己总结了一套选择标准。第一如果查询里只有一个主要维度需要分组比如按省份、按品类、按时间段我会毫不犹豫地用隐式分组因为写起来快生成的 SQL 也符合直觉。第二如果这个分组结果还要作为条件参与另一个查询比如筛活跃省份、筛热门品类那么把它写成 SubQuery 再 JOIN是我认为最优雅的解法。第三如果分组的维度本身就来自一个复杂计算比如按月按地区按用户状态我会先评估这些维度能不能压缩。这套标准听起来稀松平常但实际执行起来帮我省了很多次“写完代码发现慢得不行”的返工。尤其在数据报表这类场景里需求经常变查询条件从按省份筛变成按城市筛如果 SQL 形状足够扁平改起来也就是换一个字段的事而不用重构整个查询结构。6.2 我的实际体会和扩展技巧最后聊一点个人感受。我在 eval 一个 ORM 是否值得引入时有三个问题绕不开上手成本、查询表达能力、以及“能不能在出问题时快速定位到 SQL”。easy-query 在这三个问题上的表现属于那种不会让你天天吹但也不会让你在半夜被慢查询电话吵醒的类型。它不承诺自动帮你优化一切但至少给了你把控查询形状的能力。如果看完这篇文章你想在自己项目里复现这套做法我建议先从最简单的场景开始把一个常用的“分组统计”子查询从原来的写法重构成隐式分组 JOIN然后用 EXPLAIN 对比前后执行计划。你大概率会看到扫描行数下降、临时表减少、排序消失。这个过程会让你重新理解一个道理ORM 的性能上限很大程度上取决于你给它发挥的空间有多大。把分组意图表达清楚把子查询形状保持扁平再加上合理的索引这个组合拳无论换到哪个数据库上都很难表现差。这也是我这两年最想分享的一条经验。
阅读完成 · 觉得有帮助?