分页这件事,前后端各踩一半的坑
分页这件事,前后端各踩一半的坑
分页是那种"看起来已经做完了"的功能。Page 对象一传,records 一取,表格一渲染,页面上出现了页码,需求就算交付了。真正出问题的时候,症状往往离分页很远:用户说翻到后面几页特别慢,运营说导出的数据和页面看到的对不上,测试说删掉一条数据之后列表空白了。
这篇把分页从 SQL 层到 Vue 层完整走一遍,重点讲那些代码不报错、但行为是错的的地方。
一、先看 count 语句,它才是最慢的那条
大部分人优化分页的第一反应是给 select 加索引。但实际上,列表接口的耗时大头经常是那条 count。
InnoDB 没有 MyISAM 那种行数计数器,count(*) 必须真的去扫一个可用的索引来统计。一张 800 万行的表,即使走了二级索引,count(*) 也要几百毫秒到秒级。而你在 explain 里看 select 语句,可能只扫了 20 行。
MyBatis-Plus 的 PaginationInnerInterceptor 提供了 optimizeCountSql(默认 true),它会把 select 的字段列表替换成 count(*)。这个优化本身是有效的,但它有个容易被忽略的副作用:它会尝试去掉不参与查询条件的 left join。
去掉 left join 是对的,因为左连接不改变主表行数;但条件是——你 where 里的字段必须能明确归属到某张表。如果你写的是:
select o.id, o.no, c.name as customer_name
from orders o
left join customer c on c.id = o.customer_id
where status = 1
status 没有表别名。生成 count 语句时,优化器一旦把 customer 去掉,status 就变成有歧义或者干脆报错。官方文档明确要求这种情况下必须给列加别名:
select o.id, o.no, c.name as customer_name
from orders o
left join customer c on c.id = o.customer_id
where o.status = 1
这不是风格问题,是能不能跑的问题。别名这件事,团队里只要有一个人不写,分页就会在某个特定查询条件下崩掉,而且往往是上线之后才发现。
顺带说一下 optimizeJoinOfCountSql,它在 3.4 之后才有,作用是进一步优化 join 的 count。默认开启,但如果你发现 count 结果不对(比如某些一对多关联被错误裁剪),第一件事是把它关掉做对照,确认问题是不是出在这儿。
如果 count 确实慢到无法接受,还有一个更彻底的办法:searchCount = false。
Page<OrderVO> page = new Page<>(current, size);
page.setSearchCount(false);
关掉之后 total 恒为 0,前端拿不到总页数。所以它只适合两种场景:无限滚动(本来就不需要总数)、或者你用一个缓存的总数近似值。如果你的列表页有页码,这个开关基本不能用——这一点要在设计阶段就想清楚,不要等到页面做完了才发现总页数没法显示。
二、maxLimit 不设,等于把数据库交给了前端
默认情况下,PaginationInnerInterceptor 对 size 没有任何上限。也就是说前端传 size=100000,它真的会去查十万条。
这不是理论风险。浏览器的地址栏是可以手改的,接口是可以被抓包重放的,更常见的情况是某个前端同事写了个"导出全部"的功能,直接把 size 传成了 999999,然后在测试环境把服务打挂了。
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L);
pagination.setOverflow(false);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
三个配置项值得说清楚:
maxLimit:size的上限,默认null即不限。设成 500 之后,前端传 100000 会被压到 500,但total和页码计算仍然按原始的size来,所以如果前端依赖size回显,会出现"我传了 1000,返回只有 500 条"的困惑。要么前端自己限制,要么统一在拦截器里把size改回去。overflow:默认false。含义是当current超过最大页数时怎么办。false时返回空列表(这是我更推荐的行为,因为空列表是真实状态);设成true时会自动回到第一页,看起来"友好",但会让前端的分页逻辑陷入混乱——用户点第 99 页,回来的是第 1 页的数据,而页码还显示 99。DbType.MYSQL:不指定的话会去猜,猜错的时候生成的limit语法可能不对。显式指定最稳。
还有一个版本坑:从 3.5.9 开始,PaginationInnerInterceptor 依赖的 JSqlParser 解析能力被拆到了独立的 mybatis-plus-jsqlparser 依赖里。升级到这个版本之后如果没有补依赖,启动时会直接报 ClassNotFoundException。这个变化在升级日志里不太显眼,但撞上的人不少。
另外值得单独提一句 size 的非法值。size=0 在 MyBatis-Plus 里的行为不是"返回空",而是不拼 limit,也就是退化成全表查询;size=-1 同理。这比 maxLimit 缺失还危险,因为传 0 的人往往只是想"先不查数据",结果把整张表拉了出来。所以在接收参数的地方做一次兜底是最省事的:
long size = (req.getSize() == null || req.getSize() <= 0) ? 20L : req.getSize();
long current = (req.getCurrent() == null || req.getCurrent() <= 0) ? 1L : req.getCurrent();
current=0 也要挡。MyBatis-Plus 内部会算 offset = (current - 1) * size,current=0 会得到负数 offset,MySQL 直接报语法错误——但这个错误发生在 SQL 执行阶段,而不是参数绑定阶段,堆栈信息指向的位置会误导你去查 mapper。
另外,如果你配了多个内部拦截器(比如同时有租户插件、乐观锁插件),分页插件必须最后加。官方文档写得很直白:"如果配置多个插件,切记分页最后添加"。原因是分页依赖最终生成的 SQL,加在前面会被后续插件改写掉。
三、order by 缺省,同一页会重复出现同一条数据
这是最隐蔽的一个。MySQL 的 limit 在没有 order by 时,返回顺序是不保证稳定的。同一个查询连续执行两次,理论上可能返回不同的顺序。
后果是:用户在第 3 页看到"订单 A",翻到第 4 页又看到"订单 A",而另一条数据从来没出现过。用户不会说"你的 SQL 没排序",他会说"这个列表数据乱了"。
// 错误:只有一个可能重复的排序字段
page.addOrder(OrderItem.desc("create_time"));
// 正确:加上唯一键兜底
page.addOrder(OrderItem.desc("create_time"));
page.addOrder(OrderItem.desc("id"));
create_time 精度到秒,同一秒创建的多条记录顺序就是随机的。加 id 作为第二排序字段,排序就变成全序,分页才稳定。
这条规则不只适用于 MyBatis-Plus,任何手写 limit 的分页都一样。审查 SQL 的时候,看到 limit 而前面没有 order by,就该当成缺陷处理。
四、前端传来的排序字段,是一个注入点
为了做点击表头排序,前端通常会把字段名传给后端:
{ "page": 1, "size": 20, "sortField": "createTime", "sortOrder": "desc" }
后端如果直接这么用:
page.addOrder("asc".equals(sortOrder)
? OrderItem.asc(sortField)
: OrderItem.desc(sortField));
那么 sortField 就变成了一个可以拼接进 SQL 的字符串。OrderItem 拼的是 order by 后面的内容,MyBatis-Plus 不会对它做参数化——order by 位置本来也不支持占位符。
攻击者传 sortField = "id; drop table x" 或者更隐蔽的 "(case when (select ...) then id else create_time end)",就是实打实的注入。
正确的做法是白名单映射,前端传逻辑字段名,后端查表换成物理列名:
private static final Map<String, String> SORT_WHITELIST = Map.of(
"createTime", "o.create_time",
"amount", "o.amount",
"customerName", "c.name"
);
String column = SORT_WHITELIST.get(sortField);
if (column == null) {
column = "o.create_time"; // 兜底,不要抛异常给前端
}
page.addOrder("asc".equalsIgnoreCase(sortOrder)
? OrderItem.asc(column)
: OrderItem.desc(column));
这里还有个细节:前端传的是驼峰 createTime,数据库列是下划线 create_time。这个转换不能靠 StringUtils.camelToUnderline() 自动做,因为一旦自动化,白名单就失去意义了——自动转换等于允许任何字段名进来。必须手写映射表,虽然啰嗦,但这是唯一安全的做法。
五、深分页:offset 越大越慢,这不是索引能解决的
limit 1000000, 20 的真实执行过程是:按顺序取出 1000020 行,丢掉前 1000000 行,返回最后 20 行。前面那一百万行的回表成本,无论你怎么加索引都省不掉。
这是 B+ 树结构决定的,不是配置问题。常见的三种应对:
第一种,延迟关联。 先用覆盖索引把主键捞出来,再用主键回表:
select o.* from orders o
inner join (
select id from orders
where status = 1
order by create_time desc, id desc
limit 1000000, 20
) t on t.id = o.id;
子查询只查 id,可以完全走索引,不用回表。代价是 SQL 变复杂,且要求 order by 的字段在索引里。
第二种,游标分页。 不传页码,传上一页最后一条记录的排序值:
select * from orders
where (create_time, id) < ('2026-03-01 10:00:00', 12345)
order by create_time desc, id desc
limit 20;
这种方式每页耗时恒定,与页码无关。缺点是不能跳页,只能上一页/下一页。所以它适合"加载更多"和信息流,不适合带页码的后台列表。
第三种,业务上限制可翻页深度。 很多系统实际上没人会翻到第 5000 页。把页码上限卡在 200 页,超过之后提示"请缩小筛选范围",这是最省事也最诚实的方案。搜索引擎大多也是这么做的。
选哪种取决于产品形态,不是技术洁癖。但有一点必须明确:如果列表页要做无限滚动,从一开始就走游标分页,不要先做 offset 分页然后再改,改的时候前端整个列表状态管理都要重写。
六、一对多 join:一页 20 条,可能只展示了 8 条
这个坑比前面几个都难查,因为 SQL 完全正确,total 也完全正确。
订单列表要显示商品明细,于是 left join order_item。一个订单有 3 个商品,join 之后一个订单变成 3 行。limit 0, 20 是按行截断的,所以第一页拿到的是 20 行,但它们可能只属于 8 个订单。
而 total 那边,count(*) 在同样 join 的情况下数出来的是 60(20 个订单 × 3 个商品),于是页面显示"共 60 条",每页 20 条,共 3 页。用户翻完 3 页,看到的订单数量加起来可能只有 25 个,剩下的重复出现或者干脆缺失。
这里最要命的是 count 的优化恰好会把这个错误"藏起来":optimizeCountSql 会尝试去掉不参与条件的 left join,去掉之后 count 数的是订单数 20,看起来对了。但它和 select 的 limit 语义已经对不上了——select 仍然在按 join 后的行数截断。
处理方式只有三条路,没有第四条:
方案一,主表分页,明细单独查。 select 只查订单表,.in("id", ids) 把当前页 20 个订单的商品明细一次性捞出来,在内存里按 orderId 分组贴回去。这是最稳的做法,20 个订单的明细一次 in 查询,代价可控。
// 第一步:主表分页,拿到 20 个订单
Page<Order> page = orderMapper.selectPage(new Page<>(current, size), wrapper);
List<Long> ids = page.getRecords().stream().map(Order::getId).toList();
// 第二步:一次 in 查询把明细全捞出来
Map<Long, List<OrderItem>> itemMap = ids.isEmpty()
? Map.of()
: orderItemMapper.selectList(Wrappers.<OrderItem>lambdaQuery().in(OrderItem::getOrderId, ids))
.stream()
.collect(Collectors.groupingBy(OrderItem::getOrderId));
注意 ids.isEmpty() 的判断。空集合拼进 in () 是语法错误,而 current 越界时 records 就是空的,这个分支一定会被走到。
方案二,先说清楚"分页的单位是什么"。 如果产品要的是"按商品行分页"(一个订单的 3 个商品可以跨页显示),那就接受重复订单号,前端自己做视觉合并。这没有错,但必须让产品明确知道,而不是技术单方面决定。
方案三,用 group_concat 把明细压成一行。 适合只展示"商品名称摘要"的场景,不适合需要结构化操作明细的界面。
绝大多数后台系统的正确选择是方案一。它的本质是把"分页"和"关联查询"解耦——分页只对一张表负责,关联是分页之后的事。这条原则一旦确立,后面很多类似的坑会自然消失。
顺便说一下自定义分页方法。如果不用 selectPage 而自己写 XML,方法签名的第一个参数必须是 IPage,否则分页插件不会介入:
// 正确:第一个参数是 IPage,返回 List
List<OrderVO> selectOrderPage(IPage<OrderVO> page, @Param("q") OrderQuery q);
很多人在这里的误解是"返回类型得是 IPage 才会有 total"。实际上插件是把 total 写回传进去的那个 page 对象的,返回 List 完全没问题,调用方从入参对象上取 page.getTotal() 就行。反过来,如果第一个参数不是 IPage,插件找不到可以写总数的载体,SQL 也不会被改写,limit 根本不会加上——这时候接口会把全表返回给你。
七、Vue 这边,total 和页码的边界情况最多
后端把 total 算对了,前端仍然能把它用错。下面几个是高频的。
页码越界。 用户在最后一页删掉了唯一一条数据,然后刷新列表。此时 total 从 21 变成 20,current 还是 2,但只有 1 页了。接口返回空数组,页面显示"暂无数据",而页码栏还停在 2。用户以为数据全丢了。
async function loadList() {
const res = await api.list({ page: current.value, size: size.value });
// 页码越界时自动回退
if (res.records.length === 0 && res.total > 0 && current.value > 1) {
current.value = Math.ceil(res.total / size.value);
return loadList();
}
list.value = res.records;
total.value = res.total;
}
这段逻辑有个前提:res.total > 0。如果 total 是 0,说明确实没数据,不该回退,否则会陷入死循环(回退到第 1 页还是空,再回退……被 current > 1 挡住了,但会白白多请求一次)。加这个判断能省掉很多无意义的请求。
前端自己算 total。 见过不少代码写 total.value = list.value.length。这在后端分页下完全是错的——list 只是当前页的 20 条。这种 bug 的特点是:数据少的时候一切正常(只有一页,长度就等于总数),数据一多就露馅。
请求竞态。 用户快速点了第 2 页、第 3 页、第 4 页。三个请求并发发出,网络快慢不定,第 3 页的响应可能最后才回来,于是列表显示的是第 3 页数据,而页码高亮在第 4 页。
let requestSeq = 0;
async function loadList() {
const seq = ++requestSeq;
const res = await api.list({ page: current.value, size: size.value });
if (seq !== requestSeq) return; // 有更新的请求了,丢弃本次结果
list.value = res.records;
}
用 AbortController 取消旧请求也可以,但要注意 axios 取消请求会抛 CanceledError,得在 catch 里放行,否则一个正常的用户操作会弹出一堆错误提示。用序号丢弃反而更简单,因为响应体已经拿到了,只是不用它。
size 的单位混淆。 后端用 size,前端组件库(比如 Element Plus 的 el-pagination)用 page-size。有人写成 :page-size="size" 又在 @change 里 size = val,看起来没问题。但如果同时存在"每页条数"下拉框,切换时通常要把 current 重置为 1,否则会出现"第 5 页 + 每页 50 条"这种页码溢出。这个重置很多人会漏。
undefined 被序列化成字符串。 筛选条件里某个字段为空时,{ keyword: undefined } 经过某些序列化方式会变成 keyword=undefined 发给后端。后端拿到的是字符串 "undefined",于是 where keyword like '%undefined%',查出来零条。表现就是"什么都没输,结果却是空的"。要素是:在发请求前统一剔除空值,而不是在 URL 上补救。axios 的 params 也会保留 undefined 的键,这一点跟 fetch 的 URLSearchParams 不同,值得留意。
八、让分页成为一个真正被复用的东西
上面这些问题,单个看都不难,难的是它们散落在几十个列表页里,靠 review 抓不干净。实际项目里比较有效的做法是收敛成一个组合式函数:
// composables/usePagination.js
import { ref, watch } from 'vue';
export function usePagination(fetcher, options = {}) {
const { defaultSize = 20 } = options;
const list = ref([]);
const total = ref(0);
const current = ref(1);
const size = ref(defaultSize);
const loading = ref(false);
const error = ref(null);
let seq = 0;
async function load(extra = {}) {
const mySeq = ++seq;
loading.value = true;
error.value = null;
try {
const res = await fetcher({ page: current.value, size: size.value, ...extra });
if (mySeq !== seq) return;
if (res.records.length === 0 && res.total > 0 && current.value > 1) {
current.value = Math.max(1, Math.ceil(res.total / size.value));
return load(extra);
}
list.value = res.records;
total.value = res.total;
} catch (e) {
if (mySeq !== seq) return;
error.value = e;
} finally {
if (mySeq === seq) loading.value = false;
}
}
function changeSize(val) {
size.value = val;
current.value = 1; // 关键:切每页条数必须回第一页
load();
}
return { list, total, current, size, loading, error, load, changeSize };
}
然后配合 watch(current, load) 就能支持翻页。这么做的价值不在于少写代码,而在于边界逻辑只写一遍。页码越界、竞态、切页大小重置页码——这些规则写在一个地方,改一次全站生效。
如果列表还配了筛选条件,要注意筛选条件变化时必须把 current 重置为 1。否则用户在第 8 页筛了个只有 2 条结果的条件,直接空页面。这个重置不要写在筛选组件的 @change 里(容易漏),而是写在 load 的调用入口统一处理,或者干脆让 extra 参数变化时自动重置。
watch(() => JSON.stringify(filters), () => {
current.value = 1;
load();
});
九、一个经常被忽略的问题:分页和缓存的组合
列表页通常会用 keep-alive 缓存,用户从详情页返回时要恢复原来的页码和滚动位置。这个需求本身合理,但和"数据可能已变更"是矛盾的。
常见的错误实现是只缓存了 list 没缓存 current,返回时页码是 1 但显示的是原来第 5 页的数据。或者是缓存了 current,但返回时重新请求了第 1 页,current 还显示 5。
onActivated 是处理这个的标准位置:
import { onActivated } from 'vue';
onActivated(() => {
// 只恢复状态,不重新请求,避免闪烁
// 如果需要保证数据新鲜,可以在这里调 load(),
// 但要接受页码被保留、数据被刷新的组合
if (!initialized.value) {
initialized.value = true;
load();
}
});
这里没有标准答案,取决于业务对数据新鲜度的要求。但有一点值得坚持:缓存的是"用户视角的状态"(页码、筛选条件、滚动位置),而不是"上次的响应数据"。数据可以重新拉,状态必须恢复。反过来做,用户就会看到过期数据,或者看到一个自相矛盾的界面。
十、最后,怎么验证分页是对的
分页的 bug 很难靠肉眼测试发现,因为正常路径永远是对的。列出几个必须覆盖的场景,可以直接当验收清单用:
- 数据量刚好等于页大小(比如 20 条、每页 20)。这时候总有 1 页还是 2 页?
total = 20, size = 20,正确页数是 1。但很多前端写Math.ceil(total / size)是对的,写total / size + 1就错了。而total = 0时页数应该是 0 还是 1,也要明确。 - 删掉最后一页的最后一条,看是否自动回退。
- 在同一秒内插入多条数据,翻页看是否有重复或遗漏——这一条专门验证排序是否稳定。
- URL 手动改
size=100000,看是否被maxLimit拦住,以及返回的size和前端预期是否一致。 - 快速连点页码,看最终列表和页码是否一致。
count和records的一致性:total说 137 条,逐页翻完是否真的是 137 条。不一致通常是count的 join 优化出了问题。
第六项特别值得单独测。因为它需要人工翻很多页,平时没人做,而它一旦不一致,用户看到的就是"数据在跳"。
分页这套东西,技术上没有深度,但它是前后端契约里字段最多、状态最复杂的一环。current、size、total、records、pages、orders 六个值,任何一个的定义在前端和后端之间偏了一点,表现出来的就是"数据不对"。把定义写在文档里,比事后排查便宜得多。