若依分页原理

彻底吃透若依分页原理|分层职责、底层源码、手写分页、无参默认分页

作者:白鹿为溪 | 更新: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);
}

它做了三件事:

  1. 从请求里取出分页参数,封成 PageDomain
  2. 把排序字段做防注入转义SqlUtil.escapeOrderBySql,避免 orderByColumn 被拼 SQL);
  3. 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,完全不需要穿参。

startPage 参数从哪里来

3.4 无参默认分页:当参数缺失时会发生什么?

"无参默认分页" 指的是:前端没传 pageNum / pageSize,或者传了但在某些路径下没生效时,系统怎么兜底。若依这里其实是双保险

  1. 前端默认值:若依的 Vue 端在 queryParams 里默认就带 pageNum: 1, pageSize: 10,并经过 getList() 里的分页组件 <pagination> 绑定。所以绝大多数情况下,参数一进来就有值。
  2. 后端兜底getParameterToInt 用的是 Convert.toInt(x, 0),取不到时默认 0;再配合 PageHelperreasonable 合理化分页——当 reasonable=true 时,页码超范围会自动归到第一页或最后一页,而不是报错。
  3. 真正"无参":如果 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); }

注意这里 PageArrayList 的子类——它既是一条 List,又额外携带了 total(总数)、pageNumpageSize 等分页元信息。这就解释了为什么分页查询返回的 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 拦截器干了哪几件事?

  1. 判断是否需要分页dialect.skip(...)。不需要分页的查询直接按原 SQL 走。
  2. 查总数:把原 SQL 用 JSqlParser 解析成 AST,剔掉 ORDER BY / LIMIT / 部分 JOIN,生成 select count(0) ... 并执行,结果写进 Page.total
  3. 改写分页 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 时我们会看到它为什么重要。

PageHelper 分页核心流程

4.4 结果封装:PageInfoTableDataInfo

查询完后,执行完的 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) 会检测到传入的 listPage,从而把 totalpagespageNum 等补全。最终前端拿到的 JSON 长这样:

{ "code": 200, "msg": "查询成功",
  "total": 100, "rows": [{...}, {...}, ...] }

五、物理分页 vs 逻辑分页

"分页"本质上只有两件事:总条数 + 当前页数据。而两者的核心区别,在于"当前页数据"是在数据库层截取,还是把全量拉进应用内存再切

维度 物理分页(PageHelper 这种) 逻辑分页(内存分页)
截取位置 数据库层:LIMIT / ROWNUM / OFFSET 应用层:List.subList() / Stream
内存占用 堆里只有当前页,极小 全量数据进堆,极大
性能 好,数据量大也稳 差,数据量大直接 OOM
依赖方言 依赖数据库(各库不同) 不依赖,跨库代码一致
典型实现 MyBatis-Plus 分页 / PageHelper 早期 JDBC 内存分页、Pageable 某些实现

物理分页 vs 逻辑分页

结论:现代 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 在 finallydialect.afterAll() 清理 ThreadLocal —— 它防的不只是"串页",更是在防内存泄漏

6.2 分页数据在堆里怎么住

再看分页数据的"住所":

  • 物理分页:每次只把"当前页"这几条数据返回并放入堆,对象很快成为垃圾,被 Young GC(Minor GC) 轻松收回,堆压力极小;
  • 逻辑分页:一次把全表 N 条全部装进堆,大对象直接晋升 老年代,数据一多就会触发 Full GC,甚至抛 OutOfMemoryError

所以"逻辑分页数据量大就 OOM"不是玄学,而是堆内存放不下的必然结果。理解了这点,你自然就能想到应对之策:物理分页 + 分批次加载 + 必要时用游标分页。

结合 JVM 看分页


七、真实项目里的分页:物联网 / 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(),把当前请求存进 RequestContextHolderThreadLocalrequestAttributesHolder)。若依的 ServletUtils.getRequest()getRequestAttributes()RequestContextHolder.getRequestAttributes(),就是从当前线程的那条 ThreadLocal 里把 request 取出来,再 request.getParameter("pageNum") 读参数。
因为"放"和"取"发生在同一个线程,所以 startPage() 完全不传 request,也能摸到它。这就是 ThreadLocal 在框架里最经典的用途——在同一线程内隐式传递上下文,省去层层传参


十、总结

  1. 若依分页 = 三层协作startPage() 标记 + 收集参数 → PageHelper(ThreadLocal)埋参数 → PageInterceptor 改写 SQL。
  2. 读参数的秘密RequestContextHolder 背后的 ThreadLocal,让 startPage() 无需传 request 就能拿到当前请求。
  3. 物理分页 vs 逻辑分页:一个在数据库截取(性能好、吃内存少),一个在内存切片(简单但容易 OOM)。大表无脑选物理分页。
  4. JVM 视角:分页数据在堆里住;ThreadLocal 的弱引用 key 若不清除,配合线程池会造成内存泄漏——这也是 finally 里必须清理的根因。
  5. 实际项目:IoT 用游标分页避开深分页;MES 用稳定排序 + 联合索引;SaaS 用租户拦截器(一定要排在分页前)。

最后一点想对你说的话:

这期博客从读者提问切入,一路讲到 JVM,可能有点长。但我始终觉得,能讲清楚"为什么"的文章,才值得被收藏。 在南昌做内网开发的日子,白天是业务,晚上是成长。愿我们都能在"用 AI"的同时,始终保留自己亲手摸一遍底层的底气。如果这篇文章帮到了你,欢迎评论、转发,也欢迎来评论区继续讨论。


(完)若依分页原理 · 分层职责 · 底层源码 · 手写分页 · 无参默认分页

posted @ 2026-09-05 09:08  白鹿为溪  阅读(17)  评论(0)    收藏  举报