PageHelper 防坑指南:从兜底方案到根治方案

PageHelper 防坑指南:从兜底方案到根治方案

前几天线上报了个奇怪的错,SQL 里出现了两个 LIMIT。当时看到日志我整个人都懵了,心想这什么鬼,谁能写出这种 SQL?

后来一顿排查,发现是 PageHelper 和 MyBatis-Plus 的分页"打架"了。更坑的是,这问题藏了好几个月才暴露出来。

今天就来聊聊这个坑,以及我们后来是怎么防范的。

先看看当时的报错

那天下午,告警群突然弹了条消息:

sql 复制代码
2024-11-18 14:32:17.892 ERROR [http-nio-8080-exec-23] c.x.common.exception.GlobalExceptionHandler - SQL执行异常
org.springframework.jdbc.BadSqlGrammarException: 
### Error querying database. Cause: java.sql.SQLSyntaxErrorException: 
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'LIMIT 10 LIMIT 10' at line 1
### SQL: SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE status = ? LIMIT 10 LIMIT 10

LIMIT 10 LIMIT 10,两个 LIMIT 挨在一起,SQL 直接炸了。

我第一反应是,谁这么虎,手写 SQL 写了俩 LIMIT?但转念一想不对,我们的 SQL 都是 MyBatis 生成的,不可能有这种低级错误。

顺着代码扒下去

根据堆栈信息找到了对应的 Mapper,代码长这样:

java 复制代码
// OrderServiceImpl.java
public IPage<Order> queryOrderList(OrderQueryDTO dto) {
    PageHelper.startPage(dto.getPageNum(), dto.getPageSize());
    
    Page<Order> page = new Page<>(dto.getPageNum(), dto.getPageSize());
    return orderMapper.selectPage(page, buildQueryWrapper(dto));
}

看到这我就明白了。

这段代码里同时用了两种分页方案:上面是 PageHelper 的 startPage(),下面是 MyBatis-Plus 的 IPage。两边都会往 SQL 里拼 LIMIT,所以出现了两个。

但问题是,这代码是谁写的?什么时候写的?

翻了一下 git 记录,真相浮出水面了。

历史的锅

原来我们项目之前全部用的 PageHelper,后来技术栈统一,要求迁移到 MyBatis-Plus。这个接口是三个月前改的,开发同学把分页逻辑改成了 IPage,但是忘了把上面那行 PageHelper.startPage() 删掉。

最骚的是,这 bug 藏了三个月才爆出来。为什么能藏这么久?这就要说说 ThreadLocal 的多层防护机制了。

为什么这个 bug 这么难暴露

先看看正常情况下,ThreadLocal 里的分页信息是怎么被清理的:

flowchart TD subgraph 第一层防护["第一层防护:PageHelper 自身"] A[PageHelper.startPage] --> B[设置 ThreadLocal] B --> C[执行 SQL 查询] C --> D[PageInterceptor.intercept] D --> E["finally { clearPage() }"] E --> F[清理 ThreadLocal] end style 第一层防护 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px style F fill:#c8e6c9,stroke:#388e3c

PageHelper 自己在 finally 里做了清理,正常执行查询的话,ThreadLocal 肯定会被清掉。

flowchart TD subgraph 第二层防护["第二层防护:Tomcat 线程池"] G[请求结束] --> H[线程归还线程池] H --> I{下次被复用} I --> J[大概率是新的业务场景] J --> K[不一定会触发分页查询] end style 第二层防护 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style K fill:#bbdefb,stroke:#1976d2

就算第一层没清理干净,线程池复用的时候,下一个请求也不一定会执行分页查询。就算执行了,也不一定是 MyBatis-Plus 的 selectPage。

所以这个 bug 要暴露,需要满足一连串苛刻的条件:

flowchart LR subgraph 触发条件["Bug 触发条件链"] A["startPage 被调用"] --> B["没执行查询就结束"] B --> C["ThreadLocal 残留"] C --> D["线程被复用"] D --> E["新请求用了 MBP 分页"] E --> F["两个 LIMIT 撞一起"] end style 触发条件 fill:#fff3e0,stroke:#ff9800,stroke-width:2px style F fill:#ffcc80,stroke:#f57c00

这概率,难怪三个月才碰上。平时可能也触发了,但如果是普通查询被加了 LIMIT,可能就是少返回了几条数据,根本没人注意到。这次是两个 LIMIT 撞一起直接报语法错误,才被发现。

PageHelper 的工作原理

这里简单过一下 PageHelper 的原理,不然后面的分析看不懂。

flowchart TB subgraph 用户代码["用户代码"] A["PageHelper.startPage(1, 10)"] end subgraph ThreadLocal存储["ThreadLocal 存储"] B["LOCAL_PAGE.set(page)"] end subgraph MyBatis执行["MyBatis 执行"] C["执行 Mapper 方法"] D["PageInterceptor.intercept()"] end subgraph 分页处理["分页处理"] E{"skip() 判断"} F["需要分页"] G["不需要分页"] H["生成 COUNT SQL"] I["执行 COUNT 查询"] J["getPageSql() 拼接 LIMIT"] K["执行分页查询"] end subgraph 清理["清理"] L["finally: clearPage()"] M["LOCAL_PAGE.remove()"] end A --> B B --> C C --> D D --> E E -->|"ThreadLocal 有值"| F E -->|"ThreadLocal 无值"| G F --> H H --> I I --> J J --> K K --> L G --> L L --> M style 用户代码 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px style ThreadLocal存储 fill:#fff3e0,stroke:#ff9800,stroke-width:2px style MyBatis执行 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style 分页处理 fill:#fce4ec,stroke:#e91e63,stroke-width:2px style 清理 fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px

关键点在于:startPage() 把分页参数存到 ThreadLocal,然后拦截器在执行 SQL 的时候读出来拼接 LIMIT。

正常情况下,finally 里会清理 ThreadLocal,没问题。

但如果 startPage() 之后、查询之前就出问题了(异常、提前 return、或者像我们这样根本没走 PageHelper 的查询),那 ThreadLocal 就残留下来了。

网上看到的一个方案

搜问题的时候看到一篇文章,作者遇到的场景是把查询改成 ES 了,但忘删 startPage()。他们的解决方案是在 Filter 或 Dubbo SPI 里统一调用 PageHelper.clearPage()

java 复制代码
// Controller 层切面
@Aspect
@Component
public class PageHelperAspect {
    @Before("execution(* com.xxx.controller..*.*(..))")
    public void clearPageBefore() {
        PageHelper.clearPage();
    }
}

这个方案能解决问题,在请求入口处先清理一下,确保每个请求有个干净的起点。

不过我们后来选择了另一个方向。

我们的选择:主动检测,快速失败

我们的想法是,与其到处兜底清理,不如让问题早暴露、早发现。毕竟兜底清理解决的是症状,代码里的问题还在那儿。

做法也简单,写个 MyBatis 拦截器,用 JSqlParser 解析 SQL,发现已经有 LIMIT 了,同时 ThreadLocal 里还有 PageHelper 的分页信息,直接抛异常。

flowchart TB subgraph 拦截器检测["DuplicateLimitInterceptor"] A["intercept 被调用"] --> B{"检查 ThreadLocal"} B -->|"getLocalPage == null"| C["直接放行"] B -->|"存在分页信息"| D["解析 SQL"] D --> E{"SQL 是否已有 LIMIT"} E -->|"没有"| C E -->|"有"| F["抛出异常"] F --> G["输出详细错误信息"] end style 拦截器检测 fill:#ffebee,stroke:#f44336,stroke-width:2px style F fill:#ef9a9a,stroke:#c62828 style G fill:#ef9a9a,stroke:#c62828 style C fill:#c8e6c9,stroke:#388e3c

具体代码:

java 复制代码
@Intercepts({
    @Signature(type = Executor.class, method = "query", 
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DuplicateLimitInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 先检查 ThreadLocal 里有没有 PageHelper 的分页信息
        Page<?> localPage = PageHelper.getLocalPage();
        if (localPage == null) {
            return invocation.proceed();
        }
        
        // 有的话,解析 SQL 看看是不是已经有 LIMIT 了
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql();
        
        if (hasLimitClause(sql)) {
            // SQL 本身有 LIMIT,又设置了 PageHelper,直接报错
            String errorMsg = String.format(
                "检测到重复分页!SQL已包含LIMIT子句,但ThreadLocal中存在PageHelper分页信息。\n" +
                "请检查是否存在PageHelper与MyBatis-Plus混用的情况。\n" +
                "Mapper: %s\n" +
                "SQL: %s\n" +
                "ThreadLocal Page: pageNum=%d, pageSize=%d",
                ms.getId(), sql, localPage.getPageNum(), localPage.getPageSize()
            );
            throw new IllegalStateException(errorMsg);
        }
        
        return invocation.proceed();
    }
    
    private boolean hasLimitClause(String sql) {
        try {
            Statement stmt = CCJSqlParserUtil.parse(sql);
            if (stmt instanceof Select) {
                Select select = (Select) stmt;
                SelectBody selectBody = select.getSelectBody();
                if (selectBody instanceof PlainSelect) {
                    return ((PlainSelect) selectBody).getLimit() != null;
                }
            }
        } catch (Exception e) {
            // 解析失败就算了,不影响正常流程
        }
        return false;
    }
    
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
    
    @Override
    public void setProperties(Properties properties) {}
}

这样一来,开发阶段就能发现问题:

sql 复制代码
2024-11-19 09:15:33.421 ERROR [http-nio-8080-exec-07] c.x.interceptor.DuplicateLimitInterceptor - 检测到重复分页!
SQL已包含LIMIT子句,但ThreadLocal中仍存在PageHelper分页信息。
请检查是否存在PageHelper与MyBatis-Plus混用的情况。
Mapper: com.xxx.mapper.OrderMapper.selectPage
SQL: SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE status = ?
ThreadLocal Page: pageNum=1, pageSize=10

比线上炸了再排查强多了。

顺便聊聊两种分页方案的区别

既然踩了这个坑,就顺便说说 PageHelper 和 MyBatis-Plus 分页的区别。

PageHelper 的方式:

java 复制代码
PageHelper.startPage(1, 10);
List<Order> list = orderMapper.selectList(wrapper);
PageInfo<Order> pageInfo = new PageInfo<>(list);
flowchart LR subgraph PageHelper["PageHelper 方式"] A["startPage()"] -.->|"ThreadLocal 隐式传递"| B["Interceptor"] B --> C["拼接 LIMIT"] end style PageHelper fill:#fff3e0,stroke:#ff9800,stroke-width:2px

分页参数通过 ThreadLocal 传递,不用改方法签名,侵入性低。但问题就是前面说的,ThreadLocal 用不好容易泄漏。

MyBatis-Plus 的方式:

java 复制代码
Page<Order> page = new Page<>(1, 10);
IPage<Order> result = orderMapper.selectPage(page, wrapper);
flowchart LR subgraph MyBatisPlus["MyBatis-Plus 方式"] A["Page 对象"] -->|"方法参数显式传递"| B["selectPage()"] B --> C["拼接 LIMIT"] end style MyBatisPlus fill:#e8f5e9,stroke:#4caf50,stroke-width:2px

分页参数直接作为方法参数传进去,跟着方法走,不存在泄漏的问题。

从安全性来说,MyBatis-Plus 的方式更稳妥。但如果项目里已经大量使用 PageHelper 了,迁移的时候一定要小心,别像我们一样漏删代码。

几个要注意的地方

踩完这个坑,总结几点:

startPage() 必须紧跟查询,中间不要有任何可能 return 或抛异常的逻辑。这种写法就是埋雷:

java 复制代码
PageHelper.startPage(1, 10);
if (someCondition) {
    return Collections.emptyList();  // 雷
}
doSomething();  // 这里抛异常也是雷
return mapper.selectList();

迁移的时候要彻底 ,别留尾巴。可以全局搜一下 PageHelper.startPage,确保都处理干净了。

可以加个检测拦截器,尤其是迁移期间。等稳定了再下掉,当个保险。

最后

这个问题说大不大,说小也不小。线上如果悄悄少返回了数据,不报错的话可能很长时间都发现不了。幸好我们这次是两个 LIMIT 撞一起直接报错了,不然还得查更久。

写这篇文章的时候参考了一篇掘金上的文章,作者遇到的场景是改 ES 查询后忘删 startPage(),排查思路写得挺详细的,感兴趣可以看看:坑爹啊,注释无用代码竟会导致bug!又被PageHelper坑了


你们项目里用的什么分页方案?PageHelper 还是 MyBatis-Plus?迁移过程中有没有踩过类似的坑?

对于这种问题,你们倾向于兜底清理还是主动检测?欢迎在评论区聊聊你的看法。

相关推荐
ziwu2 小时前
昆虫识别系统【最新版】Python+TensorFlow+Vue3+Django+人工智能+深度学习+卷积神经网络算法
后端·图像识别
三翼鸟数字化技术团队2 小时前
基于redis的多资源分布式公平锁的设计与实践
redis·后端
今天没有盐2 小时前
Scala Map集合完全指南:从入门到实战应用
后端·scala·编程语言
LSTM972 小时前
如何使用 C# 将 RTF 转换为 PDF
后端
Jing_Rainbow2 小时前
【AI-7 全栈-2 /Lesson16(2025-11-01)】构建一个基于 AIGC 的 Logo 生成 Bot:从前端到后端的完整技术指南 🎨
前端·人工智能·后端
7***53342 小时前
Rust错误处理模式
开发语言·后端·rust
通往曙光的路上2 小时前
焚决糟糕篇
java·spring boot·tomcat
6***v4172 小时前
spring boot 项目打印sql日志和结果,使用logback或配置文件
spring boot·sql·logback
16_one2 小时前
autoDL安装Open-WebUi+Rag本地知识库问答+Function Calling
人工智能·后端·算法