MyBatis分页插件ThreadLocal污染问题解析
1. 问题现象神秘的幽灵数据最近在排查一个线上问题时遇到了件怪事一个查询全量数据的接口大部分情况下返回完整数据集但偶尔会莫名其妙只返回1条记录。测试同学戏称这是幽灵数据现象——数据时有时无完全不符合预期逻辑。这个findAll()接口代码极其简单public ListSdSubscription findAll() { return sdSubscriptionMapper.selectAll(); }通过添加DEBUG日志我们发现了更诡异的现象——MyBatis实际执行的SQL被自动添加了LIMIT 1SELECT * FROM sd_subscription LIMIT 12. PageHelper工作原理深度解析2.1 分页插件的核心机制PageHelper作为MyBatis最流行的分页插件其核心原理是通过拦截器动态修改SQL语句。当调用PageHelper.startPage()时分页参数会被存入ThreadLocal执行SQL前拦截器会检查ThreadLocal是否存在分页参数存在则根据数据库方言自动添加分页语句如MySQL的LIMIT最终在finally块中清理ThreadLocal2.2 线程污染的产生条件问题通常出现在以下场景某次请求调用了startPage()但未执行查询比如中间抛出异常Tomcat线程池中的线程被复用新的请求恰好分配到被污染的线程该线程的ThreadLocal仍保留着之前的分页参数3. 问题复现与验证方案3.1 最小化复现Demo// 线程池模拟Tomcat环境 ExecutorService pool Executors.newFixedThreadPool(1); // 第一次请求设置分页但未完成 pool.submit(() - { PageHelper.startPage(1, 10); throw new RuntimeException(模拟业务异常); }); // 第二次请求复用污染线程 pool.submit(() - { ListUser users userMapper.selectAll(); System.out.println(users.size()); // 输出10而非全部 });3.2 诊断工具推荐使用Arthas监控ThreadLocalwatch com.github.pagehelper.PageHelper getLocalPage开启MyBatis完整日志logging.level.org.mybatisTRACE4. 解决方案与最佳实践4.1 临时解决方案手动清理线程PageHelper.clearPage();添加AOP切面AfterReturning(execution(* com..mapper.*.*(..))) public void clearPage() { PageHelper.clearPage(); }4.2 根治方案设计建议采用装饰器模式封装PageHelperpublic class SafePageHelper { public static E PageE startPage(PageParam param) { try { return PageHelper.startPage(param.getPageNum(), param.getPageSize()); } catch (Exception e) { clearPage(); throw e; } } }5. 生产环境防护策略5.1 线程池隔离方案对于关键业务线建议使用Hystrix线程池隔离配置Tomcat独立线程池server.tomcat.max-threads200 server.tomcat.min-spare-threads205.2 监控体系建设添加ThreadLocal监控指标Metrics.gauge(pageHelper.threadLocal.count, () - PageHelper.getLocalPage() ! null ? 1 : 0);配置告警规则rules: - alert: PageHelperThreadLeak expr: sum(pageHelper_threadLocal_count) 0 for: 5m6. 深度优化建议6.1 插件改造方案可以继承PageInterceptor重写Override public Object plugin(Object target) { if (target instanceof Executor) { return new SafePageInterceptor(executor); } return target; }6.2 新一代分页方案考虑使用JPA的分页抽象public interface UserRepository extends JpaRepositoryUser, Long { PageUser findAll(Pageable pageable); }7. 经验总结在实际项目中我们发现使用PageHelper.startPage()后如果后续有异步操作必须手动clearPage()批量任务中混用分页查询时建议使用独立的ExecutorServiceMyBatis-Plus的分页方案采用参数传递而非ThreadLocal可避免此类问题关键教训任何使用ThreadLocal的组件都需要考虑线程池复用场景这是Java Web开发中的高频陷阱。建议在代码评审时特别检查ThreadLocal清理逻辑。