若依分页原理
彻底吃透若依分页原理|分层职责、底层源码、手写分页、无参默认分页
作者:白鹿为溪 | 更新:2026-09-05
本文配套 6 张逻辑结构图,已全部以 base64 内嵌到本文中,上传到博客园时只需上传这一个 md 文件即可显示。
写在前面
我目前在南昌做业务内网开发,一天到晚泡在工单、流程、数据隔离这些需求里,项目排期一直比较满。但"忙"不会成为我停止更新的理由——我给自己定的节奏是:白天写业务,晚上啃源码。
现在写代码到处都在用 AI,我也用,而且用得不少。但有一条底线我从没放弃:工具可以代劳,底层必须自己懂。 你让 AI 帮你把 startPage() 调到能用,这只是第一步;真正决定你能不能处理线上疑难杂症的,是你有没有亲手翻过它背后的源码、亲手排过那段 SQL。
这期博客分享的就是我"下班后继续学习"的一部分成果。起因是一位读者在评论区的一句追问,一句话戳中了很多人没想明白的地方。我把它写成一篇完整的技术长文,希望能帮到同样在成长路上的你。
一句话回答"太长不看"
你知道
startPage()没传 request,却能读到分页参数吗?
因为ServletUtils.getRequest()内部委托给了 Spring 的RequestContextHolder,而RequestContextHolder是用一条 ThreadLocal 把"当前线程的请求"绑在身上的。startPage()顺着这条 ThreadLocal 摸到 request,再从request.getParameter("pageNum")里把分页参数读出来。全程没有显式传参,靠的是"同一线程内隐式传上下文"。
这个结论你先记着,下面我们一步步把它拆开看。
一、从一位读者的灵魂提问说起
前段时间我发了一篇讲若依分页的文章,评论区有位叫 Halloworlds 的读者问了这样一个问题:
我不明白的是
startPage()方法是如何从请求中获取到分页数据的,也没向startPage()方法传递 request 对象啊,从哪里读到请求参数呢?
这个问题问得特别好,因为它恰好卡在很多人"会用但没想明白"的那个点上:Controller 里明明只写了 startPage() 一个括号,它怎么知道前端传了第几页、每页几条?
答案就藏在调用链的尽头——RequestContextHolder 和它的 ThreadLocal。下面我们从头到尾把这条链走一遍。
二、若依分页的分层职责
先建立整体认知。若依(RuoYi)的分页不是某一个类在做,而是三层各干一件事:
| 层 | 负责 | 是谁 |
|---|---|---|
| ① 应用层 | 把"要分页"这个意图标记出来,并收集参数 | BaseController.startPage() + TableSupport |
| ② 插件层 | 把分页参数"埋"到当前线程,供下一次查询消费 | PageHelper.startPage()(ThreadLocal) |
| ③ 拦截器层 | 真正改写 SQL、执行 count 与 limit 查询 | PageInterceptor(MyBatis 插件) |
打个比方:startPage() 只是"按下分页开关"并"把参数放进抽屉";真正干活的,是 MyBatis 执行查询那一刻被插件拦截、然后偷偷改掉 SQL 的动作。

这张图是整篇文章的地图:①~④ 是参数采集,⑤~⑦ 是分页执行,⑧ 是结果封装。你可以边读边回来看它。
三、startPage() 的分层调用链逐层拆解
我们以若依经典的"查询用户列表"为例:
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage(); // ← 分页开关
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list); // ← 结果封装
}
注意,startPage() 是继承自 BaseController 的实例方法(不是静态方法),而且必须放在查询语句之前。下面逐层进入。
3.1 startPage() 本体
protected void startPage() {
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
它做了三件事:
- 从请求里取出分页参数,封成
PageDomain; - 把排序字段做防注入转义(
SqlUtil.escapeOrderBySql,避免orderByColumn被拼 SQL); - 调
PageHelper.startPage(...)开启分页,并设置reasonable(合理分页)。
3.2 TableSupport.buildPageRequest()
public static PageDomain buildPageRequest() {
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(ServletUtils.getParameterToInt(TableSupport.PAGE_NUM)); // pageNum
pageDomain.setPageSize(ServletUtils.getParameterToInt(TableSupport.PAGE_SIZE)); // pageSize
pageDomain.setOrderByColumn(ServletUtils.getParameter(TableSupport.ORDER_BY_COLUMN));
pageDomain.setIsAsc(ServletUtils.getParameter(TableSupport.IS_ASC));
return pageDomain;
}
PAGE_NUM = "pageNum",PAGE_SIZE = "pageSize"。到这里你会发现:所有参数都是通过 ServletUtils 拿的,压根没出现 request 这个词。这就是 Halloworlds 困惑的地方,我们马上揭晓。
3.3 ServletUtils:那个"看不见的 request"从哪来【回应核心提问】
public static int getParameterToInt(String name) {
return Convert.toInt(getRequest().getParameter(name), 0);
}
public static String getParameter(String name) {
return getRequest().getParameter(name);
}
public static HttpServletRequest getRequest() {
return getRequestAttributes().getRequest();
}
public static ServletRequestAttributes getRequestAttributes() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
}
看到没——getRequest() 最后落到了 RequestContextHolder.getRequestAttributes()。而 RequestContextHolder 是 Spring 提供的,它的底层就是两条 ThreadLocal:
public abstract class RequestContextHolder {
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
new NamedInheritableThreadLocal<>("Request context");
...
}
那么问题又来了:是谁把 request 塞进这条 ThreadLocal 的? 答案是 Spring MVC 的 FrameworkServlet。它在分发每个请求时(processRequest)会调用 initContextHolders(...),内部执行 RequestContextHolder.setRequestAttributes(requestAttributes),从而把"当前请求"绑定到当前线程的 ThreadLocal 上:
protected final void processRequest(HttpServletRequest request, HttpServletResponse response) {
...
initContextHolders(request, localeContext, requestAttributes); // 绑定到 ThreadLocal
try {
doService(request, response);
} finally {
resetContextHolders(request, previousLocaleContext, previousAttributes); // 请求结束清除
}
}
到这里,Halloworlds 的疑问就彻底解开了:
- request 是被 Spring 在分发请求时放进当前线程的 ThreadLocal 的;
startPage()通过ServletUtils → RequestContextHolder这条链,从同一个线程的 ThreadLocal 里把它取出来;- 取出来之后,再
request.getParameter("pageNum")读参数。
放与取,发生在同一个线程,所以不管中间隔了多少层,startPage() 都能徒手拿到 request,完全不需要穿参。

3.4 无参默认分页:当参数缺失时会发生什么?
"无参默认分页" 指的是:前端没传 pageNum / pageSize,或者传了但在某些路径下没生效时,系统怎么兜底。若依这里其实是双保险:
- 前端默认值:若依的 Vue 端在
queryParams里默认就带pageNum: 1, pageSize: 10,并经过getList()里的分页组件<pagination>绑定。所以绝大多数情况下,参数一进来就有值。 - 后端兜底:
getParameterToInt用的是Convert.toInt(x, 0),取不到时默认0;再配合PageHelper的reasonable合理化分页——当reasonable=true时,页码超范围会自动归到第一页或最后一页,而不是报错。 - 真正"无参":如果
pageNum/pageSize均为空或为 0,PageHelper.startPage(0,0)的pageSizeZero概念会让其返回全部(等价于不分页)。所以若依对"无参"的处理,本质是交给前端默认值 + 合理化分页把住边界。
一句话:不要指望"什么都不传还能自动分页得漂亮",分页的前提是"有明确页号与每页条数"。 若依的可贵之处,是它把"缺参"这一情况也处理得不至于炸出异常来。
四、PageHelper 底层:ThreadLocal 传递 + 拦截器改写 SQL
到这一步,参数已经准备好并交到 PageHelper 手里了。接下来才是分页真正的"魔法"。
4.1 核心思想:用 ThreadLocal 做"隐式传递"
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count,
Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page); // ★ 把 Page 塞进 ThreadLocal
return page;
}
// PageMethod 里:
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();
protected static void setLocalPage(Page page) { LOCAL_PAGE.set(page); }
注意这里 Page 是 ArrayList 的子类——它既是一条 List,又额外携带了 total(总数)、pageNum、pageSize 等分页元信息。这就解释了为什么分页查询返回的 List 其实是个 Page。
为什么用 ThreadLocal? 因为 startPage() 和真正的查询 selectUserList() 是两次独立调用,中间隔着 Service 层。如果不用 ThreadLocal,就得把分页参数从 Controller 一层层往下传到 Mapper,穿参穿到怀疑人生。ThreadLocal 让分页参数"挂在"当前线程上,下一次查询自然就能看到它。
4.2 PageInterceptor:在 MyBatis 执行时"半路拦截"
PageHelper 本质是一个 MyBatis 插件(Interceptor)。它通过注解声明拦截 Executor.query 方法:
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class,
CacheKey.class, BoundSql.class})
})
public class PageInterceptor implements Interceptor { ... }
当 selectUserList(user) 触发 MyBatis 走到 Executor.query(...) 时,intercept() 就会被调用,此时它从 LOCAL_PAGE(ThreadLocal)里取出分页参数,然后改写 SQL。
4.3 拦截器干了哪几件事?
- 判断是否需要分页:
dialect.skip(...)。不需要分页的查询直接按原 SQL 走。 - 查总数:把原 SQL 用 JSqlParser 解析成 AST,剔掉
ORDER BY/LIMIT/ 部分JOIN,生成select count(0) ...并执行,结果写进Page.total。 - 改写分页 SQL 并执行:根据数据库方言在末尾拼分页子句——
// MysqlParser(AbstractParser 的一个实现)
public String getPageSql(String sql) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
sqlBuilder.append(" limit ?,?");
return sqlBuilder.toString();
}
MySQL 拼 limit ?, ?,Oracle 用 ROWNUM,SQL Server 用 OFFSET ... FETCH。这正好呼应了「物理分页」——数据在数据库层就被截取了,只回传当前页。
4. finally 清理 ThreadLocal:无论成败,dialect.afterAll() 都会把分页参数从 ThreadLocal 里 remove 掉。这一步极其关键,后面讲 JVM 时我们会看到它为什么重要。

4.4 结果封装:PageInfo 与 TableDataInfo
查询完后,执行完的 List 实际类型是 Page。若依用 getDataTable 把它包装成给前端的标准返回结构:
protected TableDataInfo getDataTable(List<?> list) {
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal()); // 从 Page 里取出 total
return rspData;
}
new PageInfo(list) 会检测到传入的 list 是 Page,从而把 total、pages、pageNum 等补全。最终前端拿到的 JSON 长这样:
{ "code": 200, "msg": "查询成功",
"total": 100, "rows": [{...}, {...}, ...] }
五、物理分页 vs 逻辑分页
"分页"本质上只有两件事:总条数 + 当前页数据。而两者的核心区别,在于"当前页数据"是在数据库层截取,还是把全量拉进应用内存再切。
| 维度 | 物理分页(PageHelper 这种) | 逻辑分页(内存分页) |
|---|---|---|
| 截取位置 | 数据库层:LIMIT / ROWNUM / OFFSET |
应用层:List.subList() / Stream |
| 内存占用 | 堆里只有当前页,极小 | 全量数据进堆,极大 |
| 性能 | 好,数据量大也稳 | 差,数据量大直接 OOM |
| 依赖方言 | 依赖数据库(各库不同) | 不依赖,跨库代码一致 |
| 典型实现 | MyBatis-Plus 分页 / PageHelper | 早期 JDBC 内存分页、Pageable 某些实现 |

结论:现代 Web 项目,数据基本都能轻松过万,优先用物理分页(数据库截取)。逻辑分页只适合"数据量本就很小"或"需要先全量取回再本地加工"的少数场景。
六、结合 JVM 看分页:ThreadLocal 怎么存,数据在堆里怎么住
分页里那个"神秘感"最强的 ThreadLocal,以及"逻辑分页为什么容易撑爆内存",其实都能用 JVM 的底层机制解释清楚。
6.1 ThreadLocal 在 JVM 里到底长什么样
很多人以为 ThreadLocal 是"存在某个全局地方"。错。它住在每个 Thread 对象自己身上:
// java.lang.Thread 里的字段
ThreadLocal.ThreadLocalMap threadLocals = null;
也就是说:
- 每个线程都有属于自己的
ThreadLocalMap; ThreadLocal本身只是这个 Map 的 key;- 你
set进去的值(分页参数、request 等)是 value; - 而
ThreadLocalMap.Entry的 key 用的是WeakReference<ThreadLocal>弱引用,value 却是强引用。
这就是一个经典的内存泄漏点:当外部的 ThreadLocal 变量被回收后,Entry 的 key 变成 null,但 value 依然被强引用着。如果这个线程是线程池里的、会被反复复用,那么每次 startPage() 都往里塞值、又不 remove,这些"残留值"就会一直待在堆里,最终累积成泄漏。
这就是为什么 PageHelper 在 finally 里 dialect.afterAll() 清理 ThreadLocal —— 它防的不只是"串页",更是在防内存泄漏。
6.2 分页数据在堆里怎么住
再看分页数据的"住所":
- 物理分页:每次只把"当前页"这几条数据返回并放入堆,对象很快成为垃圾,被 Young GC(Minor GC) 轻松收回,堆压力极小;
- 逻辑分页:一次把全表 N 条全部装进堆,大对象直接晋升 老年代,数据一多就会触发 Full GC,甚至抛
OutOfMemoryError。
所以"逻辑分页数据量大就 OOM"不是玄学,而是堆内存放不下的必然结果。理解了这点,你自然就能想到应对之策:物理分页 + 分批次加载 + 必要时用游标分页。

七、真实项目里的分页:物联网 / MES / SaaS
分页从来不是"加个 limit"就万事大吉。数据规模与业务约束,会逼着你选不同的策略。 结合我在内网系统里踩过的坑,把三种典型业务单独说。
7.1 物联网 IoT:时序数据,用"游标分页"
设备上报数据是典型的时序 + 海量 + 只增不改。这种表动辄千万级,最怕的就是 LIMIT 900000, 20 这种深分页——数据库要扫描并丢弃前面 90 万行,越翻越慢,甚至拖垮库。
对策是 游标分页(Keyset Pagination):不去数 offset,而是拿"上一页最后一条记录"当锚点往下翻:
-- 第一页
SELECT * FROM device_telemetry ORDER BY id DESC LIMIT 20;
-- 第二页:把上一页最后一条的 id 带过来
SELECT * FROM device_telemetry WHERE id < #{lastId}
ORDER BY id DESC LIMIT 20;
- 优点:每次只取当前页,深翻页依旧高效,且天然走主键索引;
- 缺点:无法直接跳页(只能上/下翻),页码条那种 UI 不适合;时序库也常按时间窗口切片来做。
7.2 MES 制造执行:多条件组合,用"物理分页 + 稳定排序"
MES 里查工单、工序、报工记录,最典型的痛点不是性能,而是"翻页会重复或遗漏"。为什么?因为如果 ORDER BY create_time 有并列值,而数据库不保证并列时的稳定顺序,翻页时同一批数据就可能被分到两页。
对策是 排序一定要用唯一键兜底:
SELECT * FROM work_order
WHERE status = #{status} AND type = #{type}
ORDER BY create_time, id -- ★ id 兜底,保证顺序唯一稳定
LIMIT #{offset}, #{size};
再配合联合索引((status, type, create_time))命中组合条件。另外 MES 的报表场景 count 也可能很贵,大表可以改用近似统计或缓存,而不是每次都精确 count(0)。
7.3 SaaS 多租户:数据隔离,用"分页插件 + 租户拦截器"
SaaS 最大的特点是同一张表多个租户共存,所以任何列表查询都必须自动追加租户隔离条件,否则一家能查到另一家的数据。
实践上我会用 MyBatis-Plus 的多插件链,插件的顺序极其重要:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 租户隔离:自动拼 WHERE tenant_id = ?
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
public Expression getTenantId() {
return new LongValue(SecurityUtils.getTenantId());
}
public String getTenantIdColumn() { return "tenant_id"; }
}));
// 2. 分页:物理分页,改写 SQL、查 count
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 3. 乐观锁:更新时自动带 version 条件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
⚠️ 大坑提示:租户拦截器必须排在分页拦截器前面,否则分页插件生成的 SQL 可能漏掉租户条件,直接造成越权数据泄露。这在多租户系统里是安全级事故,不是小 bug。
另外深分页在多租户大表上同样要小心,可以先取主键再回表(WHERE id IN (SELECT id ... LIMIT ...))来降低回表压力。

八、手写一个分页:不依赖 PageHelper 能不能做?
标题里承诺了"手写分页",这里给一个原理级实现,帮你彻底明白分页插件到底在封装什么。
8.1 最朴素的手写:SQL 手动 limit
public PageResult<User> pageUsers(int pageNum, int pageSize, String keyword) {
int offset = (pageNum - 1) * pageSize;
// 1. 查总数
long total = userMapper.countByKeyword(keyword);
// 2. 查当前页
List<User> rows = userMapper.listByKeyword(keyword, offset, pageSize);
// 3. 组装结果
return new PageResult<>(total, rows);
}
<select id="listByKeyword" resultType="User">
SELECT * FROM sys_user
WHERE name LIKE CONCAT('%', #{keyword}, '%')
ORDER BY id
LIMIT #{offset}, #{pageSize}
</select>
这就是物理分页的裸核:offset + limit。任何分页插件,本质上都在帮你少写这几行 + 自动算 count + 自动处理方言。
8.2 仿 PageHelper:用 ThreadLocal + MyBatis 拦截器实现"隐式分页"
如果你理解了前面的原理,完全可以自己写一个迷你版:
// ① 分页参数放 ThreadLocal
public class MyPageHelper {
private static final ThreadLocal<PageParam> HOLDER = new ThreadLocal<>();
public static void startPage(int pageNum, int pageSize) {
HOLDER.set(new PageParam(pageNum, pageSize));
}
static PageParam get() { return HOLDER.get(); }
static void clear() { HOLDER.remove(); }
}
// ② MyBatis 拦截器:拦截查询,改写 SQL,finally 清理
@Intercepts(@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class MyPageInterceptor implements Interceptor {
public Object intercept(Invocation inv) throws Throwable {
PageParam p = MyPageHelper.get();
if (p == null) return inv.proceed(); // 没开分页,原样执行
try {
// 1) count
// 2) 给 BoundSql 的 SQL 拼 " limit ?, ?" 并重绑定参数
// 3) 执行后把结果包成 Page(继承 ArrayList,带上 total)
...
} finally {
MyPageHelper.clear(); // ★ 无论成否都清理,防泄漏
}
}
}
你发现没有,手写分页的三板斧就是:
offset/limit计算 + count 查询 + ThreadLocal 隐式传递。PageHelper 比我这里多做的,只是 JSqlParser 的 AST 解析、方言适配、以及一堆边界处理。
所以真正要学的不是背 API,而是这三板斧。 你一旦自己写出来一遍,再用 PageHelper 就再也不会"莫名其妙"了。
九、关于那张评论,最后把话说明白
回到 Halloworlds 的那个问题,我想把答案再浓缩成一段,你可以直接复制去回复读者:
startPage()并没有从"外部"拿 request。Spring MVC 在分发每个请求时,会通过FrameworkServlet.processRequest()调用initContextHolders(),把当前请求存进RequestContextHolder的 ThreadLocal(requestAttributesHolder)。若依的ServletUtils.getRequest()→getRequestAttributes()→RequestContextHolder.getRequestAttributes(),就是从当前线程的那条 ThreadLocal 里把 request 取出来,再request.getParameter("pageNum")读参数。
因为"放"和"取"发生在同一个线程,所以startPage()完全不传 request,也能摸到它。这就是 ThreadLocal 在框架里最经典的用途——在同一线程内隐式传递上下文,省去层层传参。
十、总结
- 若依分页 = 三层协作:
startPage()标记 + 收集参数 →PageHelper(ThreadLocal)埋参数 →PageInterceptor改写 SQL。 - 读参数的秘密:
RequestContextHolder背后的 ThreadLocal,让startPage()无需传 request 就能拿到当前请求。 - 物理分页 vs 逻辑分页:一个在数据库截取(性能好、吃内存少),一个在内存切片(简单但容易 OOM)。大表无脑选物理分页。
- JVM 视角:分页数据在堆里住;ThreadLocal 的弱引用 key 若不清除,配合线程池会造成内存泄漏——这也是
finally里必须清理的根因。 - 实际项目:IoT 用游标分页避开深分页;MES 用稳定排序 + 联合索引;SaaS 用租户拦截器(一定要排在分页前)。
最后一点想对你说的话:
这期博客从读者提问切入,一路讲到 JVM,可能有点长。但我始终觉得,能讲清楚"为什么"的文章,才值得被收藏。 在南昌做内网开发的日子,白天是业务,晚上是成长。愿我们都能在"用 AI"的同时,始终保留自己亲手摸一遍底层的底气。如果这篇文章帮到了你,欢迎评论、转发,也欢迎来评论区继续讨论。
(完)若依分页原理 · 分层职责 · 底层源码 · 手写分页 · 无参默认分页

浙公网安备 33010602011771号