接手过一个统计报表的查询接口,数据量一上来,列表页响应从几百毫秒直接飙到四秒多。当时最扎眼的就是两条SQL,一条是 select count(*) from ...,另一条是同条件下带 order by 的分页查询。做过慢SQL优化的同学都懂,这俩难兄难弟一旦碰上复杂查询条件,那就是连锁反应。我当时的处理思路就是把 count 和分页合并成一条SQL,配合窗口函数解决。今天就把这条优化路径从头到尾掰开揉碎讲清楚。
1. 慢在哪:Count 和分页为什么容易变成性能短板
1.1 两条SQL的最坏情况
大多数业务场景里,列表页的接口逻辑非常相似:前端传入 pageNum、pageSize,后端先执行一条 count 查询拿总数,再执行一条 limit 查询拿当前页的数据。分开写的好处是代码直观、逻辑清晰,但性能上的问题也很典型。
第一条 count 的慢,在于它要扫描全表或者扫描满足条件的所有行。比如你在查询条件里加了五六张表的 join,count 会把所有关联结果先算出来再逐行计数。MySQL 里如果 where 条件没法命中索引,或者 group by 的字段没有索引,count 往往是整条链路上最慢的SQL。
第二条分页查询的慢,则多半吃在排序和深分页上。order by create_time desc limit 100000, 20 这种写法,MySQL 需要先找出排序后的前100020行,再丢掉前100000行。数据量一大,这个排序的临时文件会写得人心里发慌。
1.2 为什么说 Count 和分页有天然的联系
从业务层面看,count 的查询条件和分页的查询条件几乎一模一样,只是最后的结果一个是总数、一个是数据行。这俩SQL往往要解析两遍、执行两遍、回表两遍。优化的首要思路,是让数据库只做一次条件过滤,把“符合条件的总行数”和“符合条件的当前页数据”一起拿出来。
这里需要理解一个 SQL 标准里的概念:窗口函数。像 count(*) over() 这种写法,可以在不改变原有查询行数的情况下,把整个结果集的行数附加到每一行上。也就是说,原来你 limit 拿回20行数据,现在这20行每一行后面都跟着一个字段,值就是满足条件的总行数。
这个方案就是标题里说的“合并 count 和分页”,实现思路不是把两条SQL物理上拼成一条字符串,而是利用数据库的窗口计算能力,把聚合结果和明细结果融合在同一个执行计划里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 合并 Count 与分页的实现方案
2.1 MySQL 8.0 窗口函数写法
先看一个实际场景。假设我们有一张订单表 t_order,字段包括 id、order_no、user_id、amount、status、create_time。业务要求是查询某段时间内状态为“已支付”的订单,按创建时间倒序分页。
优化前是这样两条SQL:
sql复制-- count
SELECT COUNT(*)
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01';
-- 分页
SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
ORDER BY create_time DESC, id DESC
LIMIT 20 OFFSET 0;
合并后的一条SQL:
sql复制SELECT id, order_no, user_id, amount, status, create_time,
COUNT(*) OVER() AS total_count
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
ORDER BY create_time DESC, id DESC
LIMIT 20 OFFSET 0;
执行完这条SQL,返回的每一行里都会带一个 total_count 字段。你在代码里拿 result.get(0).getTotalCount() 就能拿到总数,完全不用再执行一次 count。
这里需要特别强调一个坑:over() 括号里必须是空的,不能写 partition by。如果你写了 count(*) over(partition by user_id),那统计的就是每个用户自己的订单数,不是总行数。写空窗口,表示“基于整个结果集做聚合”,这才是我们需要的总行数。
2.2 Oracle 和 PostgreSQL 的对应写法
Oracle 对窗口函数的支持比 MySQL 早得多,11g、12c 甚至更早版本都能直接跑。写法不变,还是 count(*) over()。
sql复制SELECT id, order_no, user_id, amount, status, create_time,
COUNT(*) OVER() AS total_count
FROM t_order
WHERE status = 1
AND create_time >= TO_DATE('2025-01-01', 'yyyy-mm-dd')
AND create_time < TO_DATE('2025-02-01', 'yyyy-mm-dd')
ORDER BY create_time DESC, id DESC
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
PostgreSQL 同样也支持,语法基本一致。如果你用的是 SQL Server,它的写法是 COUNT(*) OVER() 也能跑。这套方案对主流关系型数据库都适用,核心前提就是数据库版本支持窗口函数。
2.3 MySQL 5.7 及更早版本的替代思路
生产环境里还有不少 MySQL 5.7 的项目,窗口函数用不了,这时候我一般用子查询实现同样的效果:
sql复制SELECT t.id, t.order_no, t.user_id, t.amount, t.status, t.create_time,
cnt.total_count
FROM (
SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
ORDER BY create_time DESC, id DESC
LIMIT 20 OFFSET 0
) t
CROSS JOIN (
SELECT COUNT(*) AS total_count
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
) cnt;
原理很简单:先查当前页的20行数据,再查一个总数,然后做笛卡尔积。因为子查询结果只有一行,20行乘以1行,结果还是20行,总数字段被附加到每一行上。
这个方案的性价比其实见仁见智。好处是能少一次网络往返、少一次业务代码拼接;坏处是执行计划本质上还是要跑两次条件查询。真正快不快要看你原来的两条SQL是不是真的慢,如果慢点不在执行次数上,而在于 order by 的排序,那这个写法提升不大。我更建议5.7环境里把它作为代码可读性优化,而不是绝对意义上的性能优化。
3. 核心原理解读:窗口函数和 total_count 的计算逻辑
3.1 窗口函数的执行顺序
要彻底弄明白 count(*) over() 为什么能附带总数,需要理解 SQL 各个子句的执行顺序。很多人以为 SQL 是照书写顺序执行的,其实不是。数据库执行一条SQL的顺序大致是:from -> where -> group by -> having -> 窗口函数 -> select -> order by -> limit。
窗口函数是在 where 之后、order by 之前执行。也就是说,窗口函数面对的数据集,是“已经过滤完条件”的结果集,还没有做分页的截断。在这个时机做 count(*),统计到的行数自然就是满足 where 条件的所有行数。
这也是整个优化方案成立的理论基础。如果窗口函数在 limit 之后执行,那统计数量就变成20了,完全不是我们想要的结果。所以你在验证方案的时候,可以先执行一条去掉 limit 的查询,看总数是不是正确,再带上 limit 看返回行数是否还是20行而总数不变。
3.2 执行计划里的真实情况
我实际看过的执行计划里,MySQL 8.0 在处理 count(*) over() 时,会在 Table scan 之后增加一个 WindowAgg 节点。从工作方式看,WindowAgg 需要缓存完整的结果集来做排序和聚合计算。如果满足条件的数据行有100万行,这个节点就会把100万行先放到内存或者临时表里,然后计算总数。
所以重点在于:count(*) over() 并没有减少数据库需要处理的数据量,它只是把原本要执行两次的查询合并成一次,省的是 SQL 解析、网络往返和条件过滤的重复开销。你原来条件过滤100万行,现在还是要过滤100万行。这个优化本身不是银弹,它的价值在于降低重复扫描和重复传输。
用生活化类比来说:原来你要统计一个班级有多少人,还要点出前20个人的名字。你选择先跑一遍教室数人头,再跑一遍教室点名。合并之后的方案是:你在点名前20个人名的同时,顺便让班长在名单上写了个“总共45人”。省的不是数人头这个动作,而是来回跑教室的腿。
3.3 为什么能直接取第一行数据的总数字段
有同学会问:total_count 每一行都一样,我取第一行就行。这确实没问题,因为窗口函数的结果是“复制”到每一行上的,不具备行间差异性。实际操作时需要注意:如果当前页没有任何数据,那结果集为空,get(0) 就会越界。建议先判断 List 是否为空。
java复制if (!list.isEmpty()) {
Long totalCount = list.get(0).getTotalCount();
result.setTotal(totalCount);
} else {
result.setTotal(0L);
}
千万别在空列表时强制取第一行的 total_count,这个报错藏得深,一旦用户翻到没有数据的页就会出现 IndexOutOfBoundsException。
4. 业务代码怎么改:从 MyBatis 到 MyBatis-Plus
4.1 原生的 MyBatis 写法
用原生 MyBatis 的时候,我把 XML 里的查询语句改成上面那种带 count(*) over() 的形式即可。实体类里加一个字段:
java复制private Long totalCount;
应用层获取总数时,不再调用 selectCount 方法,而是从分页结果的第一行取值。
java复制public PageResult<OrderVO> pageOrders(OrderQuery query) {
List<OrderVO> records = orderMapper.selectOrderPage(query);
Long total = records.isEmpty() ? 0L : records.get(0).getTotalCount();
return new PageResult<>(total, records);
}
这个改法对现有接口几乎是无痛的,SQL 里的字段列表增加了 count(*) over() AS total_count,业务侧不用动查询参数的封装。
4.2 MyBatis-Plus 的分页插件场景
现在很多项目用的是 MyBatis-Plus,配了 PaginationInnerInterceptor。它的工作原理是在你写的 SQL 后面自动拼接 LIMIT,并且自动生成一条 count 查询。框架自动生成的 count 会把 select 字段列表替换成 count(*),这在大多数时候是好用的,但碰上多表 join、select 里有子查询或者 distinct 时,自动生成的 count SQL 偶尔会跑得慢,甚至报错。
如果你的项目里已经用了 MyBatis-Plus 分页插件,又不打算去掉自动 count,那可以用 page.setOptCount(false) 关掉自动 count,然后在业务代码里手动用一条合并SQL查总数和当前页数据。
操作路径是:
java复制Page<Order> page = new Page<>(query.getPageNum(), query.getPageSize());
page.setOptCount(false); // 关闭自动 count
IPage<Order> result = orderMapper.selectOrderPage(page, query);
Long total = result.getRecords().isEmpty() ? 0L : result.getRecords().get(0).getTotalCount();
实测下来,这样改能避免分页插件自动生成的 count 和你的业务 SQL 条件不一致的问题。尤其是当你用了 for update 或者 group by 的场景,自动 count 往往不是最优执行计划。
4.3 若依框架里的分页
若依这类后台管理框架,封装的 startPage() 和 getDataTable() 会强制走 PageHelper 或者 MyBatis-Plus 的自动 count 逻辑。你要是想完全改成合并 SQL,需要跳过 startPage() 的拦截,直接自己写分页逻辑。
若依项目里 PageUtils.startPage() 是在执行 SQL 前向线程变量里塞了 PageDomain,然后由拦截器统一处理。你可以在 service 层不调 startPage(),而是自己把 pageNum 和 pageSize 计算成 LIMIT offset, size 传进 mapper。这样做虽然绕开了框架封装,但换来了对 SQL 的完全掌控,后面排查慢SQL也更容易。
我的建议是:能用框架默认分页就别折腾,只有当数据量确实大、自动 count 的 SQL 已经明显拖慢接口时,才值得手工合并。为了优化而优化,会增加未来接手同学的阅读成本。
5. 千万不能踩的坑:count 字段与 group by、distinct 的混用
5.1 带 group by 时窗口函数的总数不对
这个坑我踩过,而且是线上踩的。看这条SQL:
sql复制SELECT user_id, COUNT(*) OVER() AS total_count
FROM t_order
WHERE status = 1
GROUP BY user_id
LIMIT 10;
猜猜 total_count 返回的是什么?它是“group by 之后的分组数”,不是订单总数。因为执行顺序是 where -> group by -> 窗口函数,窗口函数在分组之后计算,看到的是分组后的结果集。这一点和我们的预期差得很大。
如果你需要对分组后的结果分页,同时又要分组总数,比较稳妥的方案是先子查询:
sql复制SELECT t.user_id, t.order_count, COUNT(*) OVER() AS total_group_count
FROM (
SELECT user_id, COUNT(*) AS order_count
FROM t_order
WHERE status = 1
GROUP BY user_id
) t
ORDER BY t.order_count DESC
LIMIT 10;
这里的 total_group_count 才是分组数,也就是页面上“共多少组”的组件需求。
5.2 count(distinct) 和窗口函数的组合拳
还有一类场景是 select count(distinct user_id),统计去重后的用户数。这个需求如果直接套 count(distinct user_id) over(),MySQL 是允许执行的,但性能可能比较难看,因为它需要对 user_id 做一次全量去重。这时候我通常换成:
sql复制SELECT t.user_id, t.total_unique_user_count
FROM (
SELECT user_id,
COUNT(DISTINCT user_id) OVER() AS total_unique_user_count
FROM t_order
WHERE status = 1
) t
GROUP BY t.user_id, t.total_unique_user_count
ORDER BY t.user_id
LIMIT 10;
说实话,这种查询如果数据量过百万,再便宜的去重也会吃掉不少内存。更好的办法是把去重总数算好之后放缓存里,或者通过近似算法(比如 Redis 的 HyperLogLog)做。SQL 合并不是万能的,别硬凑。
5.3 避免慢 SQL 分析的“伪优化”
我在排查慢SQL时见过不少开发同学把两条SQL改成一条带 count(*) over() 的SQL,然后拿“执行时间变短了”来证明优化成功。这里有个陷阱:优化后单条SQL的执行时间确实可能比原来两条慢SQL的时间短,但如果窗口函数导致临时文件落盘,在高并发下整体数据库负载反而更高。
判断优化是否有效,建议看三个方面:优化前后的 QPS 变化、临时表落盘次数、扫描行数。扫描行数没有明显下降、临时文件明显增加的情况下,合并SQL可能只是把时间从应用层挪到了数据库层。真正有效的优化是让查询条件命中索引,让排序走索引避免 filesort,所以合并SQL之后,我还顺手做了下面几条:
6. 让合并查询真正变快的关键:索引和排序
6.1 联合索引的设计
分页查询的 where 条件是 status 和 create_time,order by 是 create_time desc, id desc。这种情况下,一个联合索引 (status, create_time, id) 几乎是最优解。
sql复制ALTER TABLE t_order ADD INDEX idx_status_create_id (status, create_time, id);
为什么要把 id 放进索引?因为 order by create_time desc, id desc 里包含两个排序字段,索引可以天然按这个顺序排列数据,避免额外的 filesort。如果只建 (status, create_time),最终排序时大概率还会多一步临时排序。
6.2 深分页的场景处理
如果你的用户会翻到100页以后,比如 limit 2000, 20,即使有索引,数据扫描量还是会越来越大。合并SQL解决不了深分页问题,这时候常见方案是改成“基于上一页最后一条记录的游标分页”,或者用延迟关联:
sql复制SELECT t1.id, t1.order_no, t1.user_id, t1.amount, t1.status, t1.create_time,
t2.total_count
FROM (
SELECT id
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
ORDER BY create_time DESC, id DESC
LIMIT 2000, 20
) t
INNER JOIN t_order t1 ON t1.id = t.id
CROSS JOIN (
SELECT COUNT(*) AS total_count
FROM t_order
WHERE status = 1
AND create_time >= '2025-01-01'
AND create_time < '2025-02-01'
) t2;
延迟关联的核心是先只查主键,再回表拿完整数据。因为主键索引体积小,排序和扫描的开销会低很多。这个方案和前面的窗口函数方案不冲突,可以组合使用。
6.3 缓存 total_count 的取舍
如果列表接口的查询条件非常固定,比如后台管理系统首页的固定几条数据,可以把 total_count 放到 Redis 缓存里,设置短过期时间(例如 60 秒),减少数据库压力。但要注意:带有用户维度的查询条件不适合缓存,缓存键的爆炸会让内存加速消耗。
我之前给某个后台接口做过一版:SQL里仍然保留 count(*) over() 做兜底,但在 service 层先查 Redis 缓存,缓存命中就直接返回。结果发现命中率不高,因为这些查询条件组合太散。后来我干脆把缓存下线,只保留了索引优化和合并SQL,效果反而更好。所以缓存不是必选项,先测量,再决定。
7. 常见问题与排查技巧实录
7.1 返回的 total_count 是字符串
MySQL 的驱动对 count(*) 返回类型映射有可能是 Long 或 BigDecimal,取决于你写的 jdbcType 和 MyBatis 的配置。我遇到过 total_count 被映射成 BigDecimal 的情况,前端拿到后序列化变成了科学计数法,导致页面上数字显示成 1.2345E10。
解决办法是在 SQL 里做一次类型转换:
sql复制CAST(COUNT(*) OVER() AS SIGNED) AS total_count
7.2 使用 over() 后排序失效
有时候你会在 SQL 里写 ORDER BY total_count,但 over() 生成的字段是计算列,排序规则可能和你预期的不一致。请记住:total_count 每行都一样,按它排序没有任何意义。真正要排序的是业务字段,比如订单金额、创建时间。不要在没用的事情上浪费精力。
7.3 分页组件与 total_count 的联动
前端用的往往是 ElementUI 的 el-pagination 组件,它需要 total 这个属性来渲染总页数。你只需要在后端返回的 DTO 里把 totalCount 字段单独set好,组件就能正常工作。要注意的是:点击搜索之后,页码没有重置为1,可能导致用户停留在第5页,搜索后看不到第1页数据。这个问题和SQL无关,但排查起来会让人误以为分页合并SQL出了问题。
规范的交互是:搜索按钮点击后,前端先把 currentPage 重置为1,再发起请求。前后端配合好,这个体验问题就不会出现。
7.4 慢 SQL 的定位方法
如果你怀疑合并后的SQL还是慢,先打开慢查询日志:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
然后用 EXPLAIN 看执行计划,关注 type 字段是否为 ref 或 range,Extra 字段里是否有 Using filesort 和 Using temporary。这两项出现越多,说明优化空间越大。
排查过的案例里,最常见的问题是:where 条件里的 create_time 写成 create_time >= '2025-01-01 00:00:00',在 create_time 上有索引的前提下,优化器可能因为字符集或隐式转换放弃索引。用函数包一层 DATE(create_time) >= '2025-01-01' 更是会让索引失效。写法上一定要保持字段干净,让索引能正常工作。
7.5 什么时候别用窗口函数
最后说说反向场景。如果总数达到千万级,窗口函数需要缓存全量结果集,内存和临时表开销都不小。相比传统的 count(*) + 分页两条SQL,窗口函数不一定更快。我建议先小范围验证,用真实数据量压测,对比两条SQL方案和一条SQL方案的耗时,再做决定。
另外,如果业务里根本不需要显示总数,只需要“加载更多”这种交互,就别查总数了。select * from t_order where ... limit 20 就够了,count 纯属多余。这种场景下合并SQL反而拖累性能,因为 count(*) over() 一定会扫描所有满足条件的行。
8. 实操总结:一份可以直接参考的落地清单
把上面的经验收拢成清单,照着走基本不会翻车:
- 确认数据库版本支持窗口函数(MySQL 8.0 / Oracle / PostgreSQL 都可以)。
- 用
count(*) over()替换原来的limit查询,去掉独立的 count SQL。 - 如果版本不支持,用子查询 +
CROSS JOIN实现同样效果。 - 实体类增加
totalCount字段,Mapper XML 里用AS total_count映射。 - 代码里记得判空再取
totalCount,避免空页报错。 - 检查 where 条件和 order by 字段,建设联合索引。
- 深分页场景用延迟关联或游标分页,不硬扛
limit 大偏移量。 - 用
EXPLAIN验证执行计划,确认没有Using filesort。 - 压测两种方案,用数据说话,不盲目推广窗口函数。
- 不需要分页条数的场景,不查 count。
合并 count 和分页这个优化看起来只是SQL语句的改写,但它背后牵出了很多值得想清楚的问题:窗口函数的执行顺序、分页的深翻页陷阱、联合索引的落地方案、框架自动 count 的开关。我自己的体会是,优化慢SQL不能只靠一招,而是要把“少查一次、扫得少、排得快”这几件事一起做,效果才会稳定。
最后分享一个小技巧:如果你在排查一条分页SQL时不确定到底慢在哪,先只查询主键列,看时间,再加上业务字段,看时间。两步一对比,很快就能判断是不是回表查询导致的。绝大多数分页慢的问题,最后都能定位到排序、回表和深分页这三件事上,对症下药即可。
