简介:分页功能实现容易、测出问题难。本文复盘一类典型生产故障:基于 ThreadLocal 传参的分页插件在参数未清理时,被线程池复用的线程会让后续请求“继承”残留分页参数,造成唯一性校验失效、列表数据截断、更新语句报错等概率性异常,并给出从调用规范、拦截器兜底到监控告警的完整治理方案。
项目开发阶段顺风顺水,功能自测手到擒来,偏偏到了集成测试,问题接二连三冒出来,而且个个透着诡异。
故障一:账号可以重复注册。 用户名唯一性校验的链路并不复杂:
if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))|| "匿名用户".equals(username)) {msg = "注册用户'" + username + "'失败";return;}
对应的数据访问层 SQL 只是一条简单的计数查询:
<select id="checkUserNameUnique" parameterType="String" resultType="int">select count(1) from sys_user where user_name = #{userName} limit 1</select>
按理说这条链路几乎不可能出错,但实测结果令人咋舌:已经注册成功的账号,换个会话再次提交注册,居然还能成功。
故障二:下拉列表“缺斤短两”。 分类管理的下拉列表接口,数据库中明明有十几条数据,接口却稳定只返回 5 条。翻遍调用方代码,没有任何地方传递过分页参数。
故障三:重置密码报 SQL 异常。 管理员在后台重置用户密码时,系统直接抛出数据库异常,报错信息非常直白:update 语句不认识 “LIMIT 5”。
把三个故障放在一起,共性浮出水面:都与 SQL 有关;都是概率性出现,触发几率高但并非每次必现;单独审查每段代码都逻辑自洽。而第三个故障的报错信息,实际上已经把真凶供了出来——某段不该分页的 SQL,被凭空拼接了 LIMIT 子句。
要理解来龙去脉,得先弄清这类分页组件的通用实现原理。目前主流分页方案大多基于持久层框架的拦截器机制实现“物理分页”,核心流程是:
startPage(pageNum, pageSize) 的方法声明分页意图;简化后的示意代码如下:
public class PageContext {private static final ThreadLocal<PageParam> LOCAL = new ThreadLocal<>();public static void startPage(int pageNum, int pageSize) {LOCAL.set(new PageParam(pageNum, pageSize));}public static PageParam get() {return LOCAL.get();}// 隐患所在:缺少与 set 严格配对、且时机可控的清理动作}
这套设计的初衷是好的:业务代码无需为每条 SQL 显式携带分页参数,拦截器“自动”完成改写,体验顺滑。但顺滑背后藏着一个被忽视的前提——ThreadLocal 中的参数,必须在 SQL 消费后立即清理。
Web 容器普遍采用线程池模型:请求结束后,工作线程不销毁,而是归还线程池等待复用。而 ThreadLocal 的数据绑定在线程本身,生命周期与线程一致,不会随某次 HTTP 请求结束而自动消亡。
把两者叠加,故障时序便一目了然:
startPage(1, 5) 把分页参数写入线程 1 的 ThreadLocal。查询执行完毕,但某个环节没有触发清理(例如 startPage 与目标查询之间插入了其他 SQL、方法中途抛异常跳过了清理逻辑等)。线程 1 归还池中,ThreadLocal 里仍残留着 pageSize=5。至此,一切灵异现象都有了合理解释。这也解释了为什么开发自测时难以复现:单人顺序操作时线程占用规律、间隔短,残留参数往往刚好被紧随其后的分页查询“消化”掉;而测试环境的并发请求把线程复用路径打散,问题便高概率暴露。
残留分页参数对不同类型 SQL 的影响可归为两类。
第一类:显性报错。 insert、update、delete 等语句本身不支持 LIMIT 子句,被拼接后数据库直接抛语法异常,日志线索明确,定位成本低。重置密码报错即属此类。
第二类:隐性逻辑错误,危害更大。
隐性问题的共同点是:代码层面毫无提示,测试用例若不专门构造线程复用场景,几乎必然漏测。这类“沉默的故障”恰恰最值得在架构层面投入防御。
第一条纪律:startPage 之后必须紧跟目标查询,中间不得插入任何其他 SQL。分页参数本质上是一次性的“下次生效”标记,一旦与预期 SQL 错位,就会污染无辜语句。
// 正确:声明与消费紧邻PageContext.startPage(pageNum, pageSize);List<Category> list = categoryMapper.selectAll();// 危险:中间插入的 SQL 会“抢走”分页参数PageContext.startPage(pageNum, pageSize);int count = userMapper.checkUserNameUnique(username); // 被误分页List<Category> list = categoryMapper.selectAll(); // 实际未分页
第二条纪律:查询结果应在同一逻辑单元内消费完毕,避免把分页声明与查询拆散到多层方法中,降低中途插入 SQL 的概率。
更彻底的做法是把清理责任收归框架层:拦截器在 SQL 执行结束后,无论成败,都在 finally 中清理 ThreadLocal。
// 以持久层拦截器为例(示意代码)public class PageInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {PageParam param = PageContext.get();try {if (param != null) {// 改写 SQL:拼接 LIMIT、执行 count 等}return invocation.proceed();} finally {PageContext.clear(); // 无论 SQL 成败,一律清理}}}
注意两点细节:其一,清理必须放在 finally 中,否则 SQL 抛异常时依然残留;其二,分页元信息应在执行阶段就提取到结果对象里,而不是让业务侧事后再去读 ThreadLocal。
即便拦截器已兜底,仍建议在请求入口增加清理过滤器,作为最后防线,覆盖一切可能的路径遗漏:
public class PageContextCleanupFilter implements Filter {@Overridepublic void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)throws IOException, ServletException {try {chain.doFilter(req, resp);} finally {PageContext.clear();}}}
分页插件只是 ThreadLocal 误用的一个缩影。用户上下文、链路追踪 ID、日期格式化器、事务标记等场景同样依赖 ThreadLocal 传递隐式状态,也同样会在线程池环境下翻车。总结三条通用纪律:
落到团队工程实践上,建议把“ThreadLocal 清理”写入代码评审检查清单,并在公共组件层面提供统一的上下文管理工具,避免每个人手写一套清理逻辑、漏一处是一处。
回头看这次教训:功能实现本身不难,难的是理解“看起来没问题”与“并发下真的没问题”之间的鸿沟。ThreadLocal 未清理引发的连锁反应,表面上是某个分页插件的坑,本质上是“隐式传参 + 线程复用”这一组合的结构性风险。把清理动作做成框架兜底而非人工自觉,把关键链路纳入监控而非依赖用户反馈,才算真正把这个坑填平。