1. 问题现象:诡异的空Cookie异常
最近在生产环境遇到一个极其诡异的Bug:用户明明在浏览器中携带了有效的Cookie访问系统,但在我们的SSO鉴权Filter中,request.getCookies()却始终返回null。这个现象违背了HTTP协议的基本常识,让我们团队陷入了长达三天的深度排查。
典型症状表现为:
- 用户访问需要鉴权的接口时被强制登出
- 浏览器开发者工具显示Cookie确实已随请求发送
- 问题仅在高并发时段偶发出现,难以稳定复现
- 同一线程处理的前后两个请求中,Request对象竟然是同一个实例
通过添加诊断代码,我们捕获到了关键证据:
java复制// 诊断代码片段
HttpServletRequest httpReq = (HttpServletRequest) request;
Cookie[] cookies = httpReq.getCookies();
int hashCode = System.identityHashCode(httpReq);
HttpServletRequest tlReq = SysContent.getRequest(); // 从ThreadLocal获取
int tlHashCode = tlReq != null ? System.identityHashCode(tlReq) : -1;
if(hashCode == tlHashCode && cookies == null) {
log.error("检测到严重Bug:请求对象被复用但Cookie解析失败");
throw new ServletException("Request初始化异常");
}
2. 深度排查:ThreadLocal与Tomcat对象复用的爱恨情仇
2.1 Tomcat的请求对象复用机制
Tomcat作为高性能Web服务器,其核心优化手段之一就是对象池技术。对于HttpServletRequest这样的高频创建对象,Tomcat采用"创建-使用-回收-复用"的循环机制:
- 对象创建:当新请求到达时,从对象池获取或新建Request对象
- 请求处理:填充请求参数、解析Header和Cookie等
- 对象回收:请求处理完成后调用
recycle()方法重置对象状态 - 对象复用:回收后的对象放回池中供后续请求使用
关键点在于:recycle()方法需要将对象重置到初始状态,包括清空所有请求参数、重置内部解析器等。
2.2 ThreadLocal的强引用陷阱
在我们的系统中,使用了一个常见的ThreadLocal工具类来保存当前请求:
java复制public class SysContent {
private static final ThreadLocal<HttpServletRequest> requestHolder = new ThreadLocal<>();
public static void setRequest(HttpServletRequest request) {
requestHolder.set(request);
}
public static HttpServletRequest getRequest() {
return requestHolder.get();
}
public static void clear() {
requestHolder.remove();
}
}
问题就出在这个看似无害的设计上:当Tomcat线程处理完一个请求后,如果ThreadLocal没有及时清理,它会保持对Request对象的强引用,导致:
- Tomcat的
recycle()无法完全重置对象状态(某些内部字段仍被占用) - 当该Request对象被复用于下一个请求时,处于"半初始化"状态
- Cookie解析器可能因为缓冲区被占用而跳过解析步骤
3. 问题根因:对象生命周期管理的多米诺效应
通过分析Tomcat源码(org.apache.catalina.connector.Request),我们还原了完整的异常链:
- 第一张骨牌:某个请求处理过程中发生异常,导致Filter链提前终止,ThreadLocal未执行清理
- 第二张骨牌:Tomcat回收Request对象时,由于强引用存在,部分字段未能正确重置
- 第三张骨牌:复用的Request对象在解析Cookie时,因内部解析器状态异常而静默失败
- 最终表现:
getCookies()返回null,而其他方法(如getRequestURI())却正常工作
特别值得注意的是:URI解析通常发生在Cookie解析之前,这解释了为什么我们能看到部分请求参数正常而Cookie异常的现象。
4. 解决方案:三层防御体系构建
4.1 第一层防御:确保ThreadLocal及时清理
修改Filter实现,确保在任何情况下都能清理ThreadLocal:
java复制public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
// 业务处理
chain.doFilter(request, response);
} finally {
// 确保清理
SysContent.clear();
}
}
4.2 第二层防御:调整Filter执行顺序
通过@Order注解确保我们的Filter最先执行:
java复制@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CleanupFilter implements Filter {
// 实现同上
}
4.3 第三层防御:增强对象状态校验
在关键业务逻辑前添加状态检查:
java复制private void validateRequest(HttpServletRequest request) {
if(request.getCookies() == null && request.getHeader("Cookie") != null) {
throw new IllegalStateException("请求状态异常:Cookie未解析");
}
}
5. 经验总结与最佳实践
5.1 ThreadLocal使用黄金法则
- 必须清理:在
finally块中执行remove() - 尽早清理:在对象生命周期结束前清理(对Tomcat来说就是在Filter链结束前)
- 防御性清理:即使你认为不会发生异常,也要做好清理
5.2 Tomcat调优建议
- 监控对象池状态:通过JMX检查
org.apache.tomcat.util.threads.ThreadPoolExecutor指标 - 合理设置对象池大小:根据并发量调整
maxThreads和acceptCount - 启用详细日志:在排查类似问题时,开启
org.apache.catalina.connector的DEBUG日志
5.3 通用编程启示
- 对象池场景:特别注意外部引用对对象状态的影响
- 线程池场景:任何线程级变量都必须考虑清理问题
- 防御性编程:对关键对象进行状态校验
这个案例给我们的最大教训是:在高性能框架中,任何微小的资源管理疏忽都可能被放大成严重的生产问题。特别是在使用对象池+线程池的组合场景时,必须严格把控对象的生命周期。