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?迁移过程中有没有踩过类似的坑?

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

相关推荐
大学生资源网15 分钟前
基于springboot的万亩助农网站的设计与实现源代码(源码+文档)
java·spring boot·后端·mysql·毕业设计·源码
苏三的开发日记25 分钟前
linux端进行kafka集群服务的搭建
后端
q_191328469538 分钟前
基于SpringBoot2+Vue2的诗词文化传播平台
vue.js·spring boot·mysql·程序员·计算机毕业设计
苏三的开发日记43 分钟前
windows系统搭建kafka环境
后端
爬山算法1 小时前
Netty(19)Netty的性能优化手段有哪些?
java·后端
Tony Bai1 小时前
Cloudflare 2025 年度报告发布——Go 语言再次“屠榜”API 领域,AI 流量激增!
开发语言·人工智能·后端·golang
想用offer打牌1 小时前
虚拟内存与寻址方式解析(面试版)
java·后端·面试·系统架构
無量1 小时前
AQS抽象队列同步器原理与应用
后端
五阿哥永琪1 小时前
RedisTemplate、StringRedisTemplate、RedisIndexedSessionRepository之间的区别?
spring boot
9号达人2 小时前
支付成功订单却没了?MyBatis连接池的坑我踩了
java·后端·面试