分页查询与总数统计是列表接口中最常见的两个需求,但把它们放在同一次请求里往往带来双重性能压力。MySQL 的 LIMIT 语法在浅分页时足够高效,一旦 OFFSET 变大,引擎不得不扫描并丢弃大量行;而 COUNT(*) 在没有合适索引时也会触发全表扫描。本文围绕如何在一个查询或最小代价内同时优化这两部分展开。

一、深分页与总数统计的成本来源
先看最常规的分页写法:SELECT * FROM orders WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20。MySQL 执行这条语句时,并不是直接从第 100000 行开始读取,而是先按排序顺序扫描出前 100020 行,然后把前面的 100000 行全部丢弃,最后返回剩余 20 行。偏移量越大,被丢弃的行越多,这些行虽然不返回给客户端,但引擎仍然需要逐行读取、比较排序键并可能产生回表,消耗大量 CPU 和 I/O。当 status = 1 无法被索引覆盖时,还会出现大量随机回表,深分页的延迟会呈线性甚至更陡峭地增长。
COUNT(*) 的成本则来自另一个层面。InnoDB 存储引擎不会在表结构里保存精确的总行数,因为 MVCC 机制下不同事务看到的行版本并不相同。执行 SELECT COUNT(*) FROM orders WHERE status = 1 时,优化器通常选择一个最小的二级索引进行扫描,如果没有合适的二级索引,就会对聚簇索引做全表扫描。对于千万级或亿级数据表,一次 COUNT 可能扫描几百万到上千万行,耗时从几百毫秒到数秒不等。分页查询和总数统计同时执行,还可能导致缓冲池中的热数据被大量扫描页挤出,进一步拖慢其他查询。
要降低联合开销,核心思路有两条:一是让分页查询尽可能在索引内部完成排序和筛选,减少回表;二是让总数统计也走覆盖索引,或者干脆用缓存和近似值替代精确 COUNT。下面分别介绍可行的优化方案。
二、联合优化方案:覆盖索引与延迟关联
覆盖索引是指查询需要的列全部包含在同一个索引中,不需要再回表读取聚簇索引。对于分页列表,通常筛选条件、排序字段和主键可以组成联合索引。例如针对 WHERE status = 1 ORDER BY id DESC 的场景,可以创建 KEY idx_status_id (status, id)。这样排序和筛选都在索引内完成,扫描时只需要顺序读取索引页,避免随机回表。
但如果列表需要返回订单号、金额、创建时间等其他字段,仅靠 (status, id) 联合索引无法覆盖全部结果列,仍然要回表。此时可以使用延迟关联:先在索引中取出当前页的主键集合,再根据主键关联原表获取完整行。SQL 示例如下:
SELECT o.id, o.order_no, o.amount, o.create_time
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE status = 1
ORDER BY id DESC
LIMIT 100000, 20
) t ON o.id = t.id;
子查询中只访问 (status, id) 索引,LIMIT 深分页阶段不需要回表,丢弃前 100000 行的代价大大降低。外层通过主键关联回表,只处理最终 20 行数据,回表次数从 100020 次降到 20 次。执行计划中可以看到子查询的 type 为 range 或 index,外层为 eq_ref,整体响应通常能从秒级降到百毫秒级。
总数统计同样需要走覆盖索引。对 status = 1 的精确计数,MySQL 可以扫描 idx_status_id 索引完成,不需要回表。查询写法为:
SELECT COUNT(*) FROM orders WHERE status = 1;
如果表中还有其他更小的索引,优化器可能自动选择占用空间更小的索引来扫描。对于联合优化场景,建议分开执行 COUNT 和分页查询,而不是使用 SQL_CALC_FOUND_ROWS。虽然 SQL_CALC_FOUND_ROWS 看起来能在一条语句中同时返回结果集和总数,但它仍然需要扫描所有满足条件的行来计算总数,无法避免深分页的偏移量扫描,而且在 MySQL 8.0 中已被标记为弃用。分开执行的好处是两个查询可以分别使用最优索引,COUNT 走覆盖索引扫描,分页走延迟关联,互不拖累。
三、进阶实践:keyset 分页、总数缓存与近似统计
如果业务允许“上一页/下一页”这种顺序翻页,而不需要任意跳页,keyset 分页(也叫 seek method 或游标分页)是比 OFFSET 更高效的方案。其思路是记录上一页最后一条数据的排序键,下一页直接从该键之后开始读取。例如上一页最后一行的 id 为 812345,则下一页查询写为:
SELECT id, order_no, amount, create_time FROM orders WHERE status = 1 AND id < 812345 ORDER BY id DESC LIMIT 20;
配合 (status, id) 联合索引,这条查询可以精确定位到索引中的起始位置,只读取需要的 20 行,不会因为翻到第几页而产生额外的丢弃成本。keyset 分页的局限是无法直接跳到第 N 页,但对于移动端无限滚动、后台订单流的逐页浏览等场景非常合适。总数统计也可以与 keyset 分页联合优化:首次进入列表时执行一次 COUNT 并返回给客户端缓存,后续翻页请求不再重复 COUNT,只有刷新或条件变化时才重新统计。
对于总数精确度要求不高的场景,可以使用近似统计替代 COUNT(*)。MySQL 提供了 information_schema.tables.table_rows 字段,它记录的是存储引擎估算的行数,通常用于快速获取表的近似总行数。查询语句如下:
SELECT table_rows FROM information_schema.tables WHERE table_schema = 'shop' AND table_name = 'orders';
这个值在 InnoDB 中可能偏差 10% 到 50%,适合后台仪表盘、运营概览等允许一定误差的页面。也可以用 EXPLAIN SELECT COUNT(*) FROM orders WHERE status = 1 观察执行计划中的 rows 估算值,但它的偏差可能更大,不建议在需要准确性的接口中直接使用。更稳妥的方式是业务端做总数缓存:设置 30 秒到 5 分钟的过期时间,数据写入时通过消息队列或触发器主动失效缓存,这样既能保证大部分请求在几十毫秒内返回,又能把 COUNT 的触发频率降到极低。
综合来看,MySQL 分页查询与总数统计的联合优化没有一刀切的完美方案,需要根据数据规模、翻页方式和精度要求组合使用。最通用的组合是建立覆盖索引 + 延迟关联分页 + 分开执行 COUNT + 业务缓存总数;对于深翻页明显变慢的场景,优先推动产品改为 keyset 分页;如果总数允许近似值,则用 table_rows 或缓存进一步降低数据库压力。最终目标始终一致:让列表接口在一次请求或最小代价内同时拿到数据页与总记录数,同时避免大偏移量扫描和重复全表统计。