PageHelper 的 ThreadLocal 污染和 两个分页插件互相干扰
今天中午,正在美美吃饭,突然收到销售部经理发来的消息,APP有一个页面突然报了一堆报错信息!
what??!
who can tell me this why?
本来运行畅通无阻的接口,突然罢工了,这究竟是人性的扭曲,还是道德的沦丧!
咳咳,扯远了,先看报错信息。

MyBatis 参数绑定错误。offset 没找到,但可用参数是 [arg0, userId, param1, param2]。
原因
Mapper 方法参数没加
@Param注解,MyBatis 默认用arg0,arg1或param1,param2,而 XML 里写的是#{offset}。
奥,原来是这个问题,简简单单很好解决,把参数一一绑定上,Mapper 加 @Param完美解决。
But !,问题来了,我的 @Select 里并没有配置offset参数啊,我的分页是交给 IPage<?> 作为参数,MyBatis-Plus 分页插件会自动处理分页,按理来说不会出现这个问题。
@Select("<script>" +
"SELECT * FROM ( " +
" SELECT " +
" user_id, " +
" resource_sum as total_resource, " +
" 0 as total_created, " +
" resource_sum as remaining, " +
" create_time as latest_time, " +
" remark, " +
" 'RECHARGE' as record_type " +
" FROM ai_image_package_record " +
" WHERE user_id = #{userId} " +
" UNION ALL " +
" SELECT " +
" user_id, " +
" 0 as total_resource, " +
" create_sum as total_created, " +
" (-create_sum) as remaining, " +
" create_time as latest_time, " +
" remark, " +
" 'CREATE' as record_type " +
" FROM ai_create_image_record " +
" WHERE user_id = #{userId} " +
") t " +
"ORDER BY t.latest_time DESC" +
"</script>")
IPage<UserImageStatsVO> selectUserAllRecordsPage(IPage<?> page, @Param("userId") String userId);
public IPage<UserImageStatsVO> getUserAllRecordsPage(String userId, long current, long size) {
if (StringUtils.isBlank(userId)) {
return new Page<>();
}
Page<UserImageStatsVO> page = new Page<>(current, size);
return aiImageStatsMapper.selectUserAllRecordsPage(page, userId);
}
先不想这个问题,先解决问题,毕竟项目不等人,没有offset我就添加上去。
"ORDER BY t.latest_time DESC " +
"LIMIT #{page.offset}, #{page.size}" + // ← 手动分页
这下没问题了吧,启动、测试、收工。然而。。。。。。事与愿违
静下来思考一下,先改回去仔细看一下报错日志。
咦,怎么个事,又正常了?!
再重启一下,正常,再重启一下,正常,再重启下,失败!
为什么一会正常一会不正常,仔细查看一下代码,看一下其它的分页会不会也这样。没问题,没报错,不对,这个分页用的是PageHelper 。就是你,找到问题了!
根本原因:
PageHelper.startPage()的 ThreadLocal 污染PageHelper 使用
ThreadLocal存储分页参数。如果某个线程执行了startPage()但没执行对应的查询,或者查询抛异常导致PageHelper.clearPage()没执行,这个线程的 ThreadLocal 里就会残留分页参数。当这个线程被 Tomcat 线程池复用去处理下一个请求时:
如果下一个请求走的是有手动 LIMIT 的 SQL:PageHelper 发现 SQL 里已经有
LIMIT,可能跳过(或冲突),导致报错如果下一个请求走的是没有手动 LIMIT 的 SQL:PageHelper 自动追加
LIMIT,看起来"正常"如果 ThreadLocal 里没有残留:你的手动
LIMIT就会执行,但#{page.offset}参数绑定失败,报错
PageHelper (
PageInterceptor) — 用 ThreadLocal 存分页参数MyBatis-Plus 分页 (
PaginationInnerInterceptor) — 通过IPage参数传递当某个线程先执行了带 PageHelper 的接口,ThreadLocal 里残留了分页参数,再执行你的接口时:
如果 ThreadLocal 有残留:PageHelper 拦截你的 SQL,但你的 SQL 里没有
LIMIT,它自动追加 → 看起来正常 ✅如果 ThreadLocal 干净:你的 SQL 没有
LIMIT,MyBatis-Plus 分页插件也不生效(因为你没配置或冲突)→ 返回全量数据,不会报错 ✅如果 ThreadLocal 有残留且你之前写过手动 LIMIT:双重 LIMIT → 语法错误 ❌
某些边缘情况:参数绑定顺序变化 →
offset not found❌
一、PageHelper 的 ThreadLocal 污染
1.1 PageHelper 的工作原理
┌─────────────────────────────────────────┐
│ ThreadLocal<Page> LOCAL_PAGE │
│ (每个线程独立存储分页参数) │
└─────────────────────────────────────────┘
│
PageHelper.startPage(1, 10) ───────┤
│ ▼
│ ┌─────────────┐
│ │ Page对象 │
│ │ pageNum=1 │
│ │ pageSize=10 │
│ └─────────────┘
│ │
SQL执行 ────────┼────────────────────┘
│
▼
PageInterceptor.intercept()
│
▼
从ThreadLocal取Page ──→ 修改SQL加LIMIT
│
▼
执行分页SQL
│
▼
需要清理ThreadLocal? ──→ PageHelper.clearPage()
1.2 正常流程 vs 污染流程
正常流程:
请求A → startPage() → 执行SQL → clearPage() → 线程归还线程池
[设置] [使用] [清理] [干净]
污染流程(异常导致未清理):
请求A → startPage() → 执行SQL → 异常抛出 → 未clearPage() → 线程归还线程池
[设置] [使用] [未清理!!!] [残留Page对象]
请求B(复用同一线程)→ 没有startPage() → 执行你的allRecords
[无设置] [PageInterceptor发现ThreadLocal有残留]
[自动给SQL加LIMIT]
[冲突或参数绑定失败]
1.3 为什么"有时报错,有时不报错"
Tomcat线程池(假设3个线程):
线程1: 请求A(有startPage) → 异常 → 残留Page → 请求C(你的接口) → 被拦截 → 可能正常或报错
│
线程2: 请求B(无startPage) → 正常 → 干净 → 请求D(你的接口) → 干净 → 正常
│
线程3: 请求E(有startPage) → 正常 → clearPage → 干净 → 请求F(你的接口) → 正常
关键:线程复用是随机的,所以表现为"有时可以,有时报错"。
二、两个分页插件互相干扰
2.1 插件拦截链
MyBatis 的插件是责任链模式,多个拦截器按顺序执行:
SQL执行
│
▼
┌─────────────────┐
│ PageInterceptor │ ← PageHelper(先注册)
│ (PageHelper) │
└─────────────────┘
│
▼ 修改后的SQL
┌─────────────────────────┐
│ PaginationInnerInterceptor │ ← MyBatis-Plus(后注册)
│ (MyBatis-Plus) │
└─────────────────────────┘
│
▼ 再次修改后的SQL
执行SQL
2.2 干扰场景分析
场景1:Mapper 有 IPage<?> 参数 + 没写 LIMIT
IPage<UserImageStatsVO> selectUserAllRecordsPage(IPage<?> page, @Param("userId") String userId);
执行流程:
1. MyBatis-Plus 看到 IPage 参数 → 认为是分页查询 → 准备拦截
2. PageHelper 看到 ThreadLocal 有残留(或检测SQL)→ 也准备拦截
3. 两个插件都试图修改SQL → 冲突
场景2: Mapper 写了手动 LIMIT
"LIMIT #{page.offset}, #{page.size}"
执行流程:
1. PageHelper 解析SQL时发现已有LIMIT → 可能跳过或冲突
2. MyBatis-Plus 看到 IPage 参数 → 尝试再分页 → 双重LIMIT
3. 参数绑定阶段:#{page.offset} 找不到 → BindingException
场景3:ThreadLocal 有残留 + SQL 没有 LIMIT
// Mapper 没有 LIMIT
"ORDER BY t.latest_time DESC"
执行流程:
1. PageHelper 发现 ThreadLocal 有 Page 对象
2. 自动给 SQL 追加 LIMIT
3. 看起来"正常"执行了分页
4. 但这不是你预期的行为(可能数据不对)
三、接口报错的具体路径
结合堆栈:
1. 请求进入 allRecords()
2. Service 调用 aiImageStatsMapper.selectUserAllRecordsPage(page, userId)
3. MyBatis 执行查询
4. 进入拦截器链:
PageInterceptor.intercept() ← 先执行
│
├── 检查 ThreadLocal:发现残留 Page 对象(或检测到 IPage 参数)
│
├── 尝试解析/修改 SQL
│ └── 需要生成缓存 key:BaseExecutor.createCacheKey()
│ └── 解析参数:#{page.offset}
│ └── 参数绑定失败!
│ "Parameter 'offset' not found. Available parameters are [arg0, userId, param1, param2]"
│
└── 抛出 BindingException
关键点:
page参数在 MyBatis 中默认不展开属性PageHelper 试图读取
page.offset做缓存 key 或分页计算但可用参数只有
[arg0, userId, param1, param2],page不在其中
四、参数绑定细节
4.1 MyBatis 参数命名规则
// 方法签名
IPage<UserImageStatsVO> selectUserAllRecordsPage(IPage<?> page, @Param("userId") String userId);
| 参数 | 可用名称 | 说明 |
|---|---|---|
IPage<?> page | arg0, param1 | 没有 @Param,默认用位置名 |
String userId | userId, arg1, param2 | 有 @Param,可用自定义名 |
所以
#{page.offset}找不到,因为page不是有效的参数名。
4.2 为什么 #{param1.offset} 可以
#{param1.offset} → param1 = IPage 对象 → 调用 getOffset()
但 param1 是内部命名,不稳定。
五、解决方案原理对比
| 方案 | 原理 | 效果 |
|---|---|---|
PageHelper.clearPage() | 手动清理 ThreadLocal | 解决污染,但不解决双插件冲突 |
去掉 IPage 参数 | 不让 MyBatis-Plus 识别为分页 | 避免双插件同时拦截 |
手写 LIMIT + 基本类型参数 | 完全自己控制分页 | 两个插件都不触发 |
| 去掉 PageHelper 配置 | 只剩一个分页插件 | 彻底消除冲突 |
| 统一 JSqlParser 版本 | 解决解析器兼容 | 让 PageHelper 正常工作 |
六、推荐根治方案
如果项目能改造,统一用 MyBatis-Plus 分页:
┌─────────────────────────────────────────┐
│ 去掉 PageHelper 依赖和配置 │
│ 保留 MyBatis-Plus PaginationInnerInterceptor │
│ 所有 Mapper 用 IPage<?> 参数 │
│ 所有 Service 传 new Page<>(current, size) │
└─────────────────────────────────────────┘
│
▼
无 ThreadLocal,无插件冲突
分页参数通过方法参数传递
线程安全,无残留问题
七、应急方案
应急方案只适合紧急解决当前问题,让服务继续跑起来,不适合长期的问题处理,后期还是建议优化项目架构,统一分页插件。
7.1处理思路
Mapper 不用
IPage参数 — 避免 MyBatis-Plus 分页插件拦截不用
PageHelper.startPage()— 避免 ThreadLocal 污染和 JSqlParser 冲突纯手动分页 — 自己算 offset,手写 LIMIT
7.2修改Mapper
@Select("<script>" +
"SELECT * FROM ( " +
" SELECT " +
" user_id, " +
" resource_sum as total_resource, " +
" 0 as total_created, " +
" resource_sum as remaining, " +
" create_time as latest_time, " +
" remark, " +
" 'RECHARGE' as record_type " +
" FROM ai_image_package_record " +
" WHERE user_id = #{userId} " +
" UNION ALL " +
" SELECT " +
" user_id, " +
" 0 as total_resource, " +
" create_sum as total_created, " +
" (-create_sum) as remaining, " +
" create_time as latest_time, " +
" remark, " +
" 'CREATE' as record_type " +
" FROM ai_create_image_record " +
" WHERE user_id = #{userId} " +
") t " +
"ORDER BY t.latest_time DESC " +
"LIMIT #{offset}, #{size}" +
"</script>")
List<UserImageStatsVO> selectUserAllRecordsPage(@Param("userId") String userId, @Param("offset") long offset, @Param("size") long size);
添加 "LIMIT #{offset}, #{size}" 同时添加 @Param("offset") long offset, @Param("size") long size
7.3修改Service
public IPage<UserImageStatsVO> getUserAllRecordsPage(String userId, long current, long size) {
if (StringUtils.isBlank(userId)) {
return new Page<>();
}
long offset = (current - 1) * size;
// 查询数据
List<UserImageStatsVO> records = aiImageStatsMapper.selectUserAllRecordsPage(userId, offset, size);
// 查询总数
long total = aiImageStatsMapper.countUserAllRecords(userId);
// 组装分页结果
Page<UserImageStatsVO> page = new Page<>(current, size, total);
page.setRecords(records);
return page;
}
7.4处理HandlerInterceptor
处理 HandlerInterceptor,每个HandlerInterceptor 请求结束后强制清理,防止ThreadLocal污染。为分页查询添加一道保险。
@Component
public class PageHelperCleanupInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 每个请求结束后强制清理
PageHelper.clearPage();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private PageHelperCleanupInterceptor cleanupInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(cleanupInterceptor)
.addPathPatterns("/**");
}
}
八、其他方案
方案一:不用 page 参数名,直接传 offset/size(最直接)
Mapper:
@Select("<script>" +
"SELECT * FROM ( " +
" ... " +
") t " +
"ORDER BY t.latest_time DESC " +
"LIMIT #{offset}, #{size}" +
"</script>")
List<UserImageStatsVO> selectUserAllRecordsPage(
@Param("userId") String userId,
@Param("offset") long offset,
@Param("size") long size);
Service:
public IPage<UserImageStatsVO> getUserAllRecordsPage(String userId, long current, long size) {
if (StringUtils.isBlank(userId)) {
return new Page<>();
}
long offset = (current - 1) * size;
List<UserImageStatsVO> list = aiImageStatsMapper.selectUserAllRecordsPage(userId, offset, size);
// 手动组装 IPage
Page<UserImageStatsVO> page = new Page<>(current, size);
page.setRecords(list);
// 需要额外查总数,或者先写死
return page;
}
方案二:用 param1 代替 page(临时绕过)
如果你坚持保留现在的 Mapper 签名,把 #{page.offset} 改成 #{param1.offset}:
"LIMIT #{param1.offset}, #{param1.size}"
但 param1 是 MyBatis 内部命名,不稳定,不推荐生产用。
方案三:加 @Param("page") 注解(如果必须用 IPage)
IPage<UserImageStatsVO> selectUserAllRecordsPage(
@Param("page") IPage<?> page,
@Param("userId") String userId);
然后 SQL 里用 #{page.offset} 就能绑定到。但注意:IPage 的 offset 属性名可能叫 offset 或需要通过方法计算,要看具体版本。
方案四:完全避开这个问题 —— 用 XML + <bind>(最稳)
如果一定要手写 LIMIT,用 XML Mapper:
<select id="selectUserAllRecordsPage" resultType="com.redBean.system.domain.vo.UserImageStatsVO">
<bind name="offset" value="(page.current - 1) * page.size"/>
SELECT * FROM (
SELECT user_id, resource_sum as total_resource, ...
FROM ai_image_package_record WHERE user_id = #{userId}
UNION ALL
SELECT user_id, 0 as total_resource, ...
FROM ai_create_image_record WHERE user_id = #{userId}
) t
ORDER BY t.latest_time DESC
LIMIT #{offset}, #{page.size}
</select>
<bind>可以计算表达式,避免参数绑定问题。
推荐
| 场景 | 选哪个 |
|---|---|
| 快速解决,不想改太多 | 方案一,去掉 IPage 参数,手动分页 |
| 项目统一用 MyBatis-Plus 分页 | 方案三 + 分页插件,但要去掉手动 LIMIT |
| 一定要手写 LIMIT 且用 IPage | 方案四,XML + <bind> |
更多推荐


所有评论(0)