今天中午,正在美美吃饭,突然收到销售部经理发来的消息,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<?> pagearg0, param1没有 @Param,默认用位置名
String userIduserId, 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处理思路

  1. Mapper 不用 IPage 参数 — 避免 MyBatis-Plus 分页插件拦截

  2. 不用 PageHelper.startPage() — 避免 ThreadLocal 污染和 JSqlParser 冲突

  3. 纯手动分页 — 自己算 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>
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐