1.线程dump初步问题排查
某日,前线反馈登录app耗时很久,且有可能登不上去。 排查发现cpu被打满,内存也打到了80%以上,领导让运维导了个dump文件给我,结果是线程dump,然后就 重启服务了。 先保证运行
没有发现Deadlock字样 ,但是发现不少线程卡在 e.printstacktrace()。 而触发堆栈打印的错误是在我之前写的一个 "拟登录" 方法调用处。 这里改造的时候有些随意,因为基本不会抛出异常,但为了保证不影响程序的运行,加了个catch,然后加了e.printstacktrace()。
该服务有一定年头,基于spring security做的登录校验。后续的服务使用认证中心颁发token+网关统一鉴权,
拟登录就是flutter端使用认证中心登录后,调用此服务,此服务在检测到存在有效的token时,基于token提取用户信息,在过滤器内将用户信息注入到
SecurityContextHolder.getContext().getAuthentication()线程变量中
初步怀疑是有其他服务批量调用该服务,且触发了拟登录。然后调用传递的参数错误,导致拟登录失败,批量e.print 导致线程堆积
e.print
//都在等待 System.err 这把锁
synchronized (s.lock()) {
...
}
修改打印,加上点其他的日志,看什么情况下触发了批量拟登录。初步排查结束,因为导完线程dump服务就重启了
2.jmap 及 内存dump分析
过了一周多,收到一封邮件,堆内存使用率告警,堆内存使用超95% 。 应该是上次的问题没有得到解决。 查看监控平台,发现fullgc 的频率 在加快,已经到了几分钟一次了。fullgc后的内存也没有看到下降,无疑是内存泄漏了。 尝试使用
jmap -dump:live,format=b,file=heap.hprof [pid]
导出堆dump ,发现导出的文件太大, 运维根本拿不下来。 使用
jmap -histo[:live] [pid] | sort -nr
发现了某个自定义的对象居然有几十万之多,该对象是描述用户手机菜单的。 检查代码,查看到底这个对象被什么持有了, 没有发现其他static 之类的root根引用。 查看与之相关的对象, 用户信息对象。 用户信息会持有 多个[手机菜单] 对象。
jmap -histo [pid] | grep [用户信息对象类名]
果然,用户信息对象也有十几万多,为什么会产生这么多用户信息对象,突然,我好像想到了什么。
拟登录的改造是1年前,是为了迎合前端flutter改造。 大概几月前,前端跟我说 flutter端不使用cookie,也不保存jsessionid,当初没有在意。 因为服务不依赖session的情况时,是不会产生jsessionid的。但是在一个月前,flutter端迁移入不少以前的功能,也就是会有不少请求进入到我们本文这个使用 spring security框架的服务[app后端服务]。那么在没有使用jsessionid的情况下,一个请求会触发一次拟登录操作。就会创建一个用户信息,然后维护到Tomcat的 session列表内,也就是说用户信息会不断的产生 不断的产生session。
但是session肯定会有自己的过期策略的,除非它是惰性删除,只要访问到才进行判断。debug看下清除策略
org.apache.catalina.core.ContainerBase#threadStart
protected void threadStart() {
...
thread = new Thread(new ContainerBackgroundProcessor(), threadName);
thread.setDaemon(true);
thread.start();
}
org.apache.catalina.core.ContainerBase.ContainerBackgroundProcessor
try {
while (!threadDone) {
try {
//10s一次执行一次
Thread.sleep(backgroundProcessorDelay * 1000L);
} catch (InterruptedException e) {
// Ignore
}
if (!threadDone) {
//内部逻辑
processChildren(ContainerBase.this);
}
}
}...
org.apache.catalina.session.ManagerBase#backgroundProcess
public void backgroundProcess() {
count = (count + 1) % processExpiresFrequency;
if (count == 0)
//10s 加1 也就是 60s执行一次
processExpires();
}
...
for (Session session : sessions) {
//遍历判断 清除过期的session
if (session != null && !session.isValid()) {
expireHere++;
}
}
最终发现,tomcat的失效策略是1分钟扫描一次,清理30分钟内没有访问的session,也就是说即使1分钟内进入1万个请求,创建了1万个用户信息,也会在半小时后清除,但是为什么没有清除掉呢,那就是用户信息除了session的引用,还有其他引用了。还得继续查。
逐步debug排查 ,发现 前人 重写了 org.springframework.security.web.authentication.AuthenticationSuccessHandler#onAuthenticationSuccess 回调方法,然后进行了一个骚操作
CacheManager.putContent(session.getId(), auth, 2 * 60 * 1000);
本质上把它放到一个static hashmap了,传入了过期时间,其实是做一个 惰性删除的操作 ,每次get的时候判断时间戳,但是这个操作hashmap的类除了putContent方法,就没有任何地方在调用了。。。 加上没有维护线程进行定期清除,这波属实是负向优化了。 定位到问题,测试环境再检验一下,将xmx改小,不传递jsessionid的情况下进行压测,内存很快就被打满了。 导出堆dump,用jprofiler打开
gc根就是它 (等半小时后 tomcat自行删除session引用)
3.解决问题
既然问题发现了,最快的解决方式就是直接将上述方法 CacheManager.putContent方法移除,但是只这么做是不够的。假定后续用户数增多,flutter端调用[app后端服务]请求数增多,也是有可能半小时内将服务打死的。并且频繁创建session而不使用,等待过期删除也是平白增大gc压力。 可以想到的解决方案有
- 修改jsessionid创建方式,使其等同于用户的accountName,这样n个请求也只有一个session
因为同时存在两个客户端,直接修改jsessionid创建方式,波及面较大,测试回归难度较大
- 自行维护引用,即使创建n个session,也共同维护一个用户对象,这样即使有n个session,内存占用也不会很大
用户对象信息只能维护在内存中,何时刷新这个对象将成为一个问题 ?因为修改用户信息的操作并不会通知到此服务
- 拟登录时不创建session(不存入ManagerBase#sessions中)
既然flutter端交互都不依赖jsessionid了,维护到sessions中毫无意义
(原app端仍在使用jsessionid,原app端的逻辑不用动,只需要在拟登录的操作中进行修改)
权衡之下,决定从第三点 拟登录不创建session入手。
org.apache.catalina.session.ManagerBase#add
public void add(Session session) {
sessions.put(session.getIdInternal(), session);
int size = getActiveSessions();
if( size > maxActive ) {
synchronized(maxActiveUpdateLock) {
if( size > maxActive ) {
maxActive = size;
}
}
}
}
断点打在此处,看session是何时维护到 sessions中的。 发现在org.springframework.security.web.session.SessionManagementFilter#doFilter
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (!securityContextRepository.containsContext(request)) {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
if (authentication != null && !trustResolver.isAnonymous(authentication)) {
authentication不为空的情况下会保存session
....
securityContextRepository.saveContext(SecurityContextHolder.getContext(),
request, response);
}
else {
if (request.getRequestedSessionId() != null
//jsessionid前端不传 不直接调用request.getSession的情况下,一般不会创建session,所以这个分支忽略
...
}
}
}
chain.doFilter(request, response);
}
该过滤器的作用是 判断缓存中是否有同一session id的请求,如果不存在则从上下文中获取身份认证信息,并使用SessionAuthenticationStrategy对身份认证信息执行必要的操作,比如重新生成session id来防止session-fixation攻击 ,既然拟登录的操作只在无session交互的场景下进行,那这个过滤器就没有执行的必要了。
在拟登录的操作时进行 request.setAttribute(FILTER_APPLIED, Boolean.TRUE); 操作,该过滤器就不会作用了。 加上代码重新执行,发现在 org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper#onResponseCommitted
protected void saveContext(SecurityContext context) {
final Authentication authentication = context.getAuthentication();
HttpSession httpSession = request.getSession(false);
// See SEC-776
if (authentication == null || trustResolver.isAnonymous(authentication)) {
//authentication 为空时 return 后续不执行
if (httpSession != null && authBeforeExecution != null) {
httpSession.removeAttribute(springSecurityContextKey);
}
return;
}
if (httpSession == null) {
//创建session
httpSession = createNewSessionIfAllowed(context);
}
...
}
仍然会创建session 。 该方法会在 目标方法反射执行后调用(也就是web层方法结束后),起初,我以为这个方法同样是以过滤器的形式执行。那么只需要在目标方法执行完毕,过滤器会调前 将 SecurityContextHolder.getContext() 中authentication置为空,该方法就不会执行了。 加入拦截器后发现并没有其作用,细看之下,原来作用时机是在 org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod#invokeAndHandle 也就是handlerAdapter调用handlerMethod,并处理返回值阶段执行
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
//反射调用目标方法
Object returnValue = this.invokeForRequest(webRequest, mavContainer, providedArgs);
this.setResponseStatus(webRequest);
....
mavContainer.setRequestHandled(false);
try {
//处理返回值
this.returnValueHandlers.handleReturnValue(returnValue, this.getReturnValueType(returnValue), mavContainer, webRequest);
} catch (Exception var6) {
...
throw var6;
}
}
既然是在处理返回值阶段,意味着目标方法已经执行完毕(切面增强也执行完了),那么只要想办法在目标方法结束,统一将 SecurityContextHolder.getContext() 中authentication置为空即可, 这不就是切面吗 。 刚好这个历史工程有个切面粗暴的切了所有的RequestMapping注解,那么在around或者after后,判断请求是否进行了拟登录操作,如果有,则将 SecurityContextHolder.getContext() 中authentication置空。
于advice方法处加上这段逻辑,终于发现不在创建session了,flutter端 ,每次的用户信息于过滤器中从redis中获取,放入threadlocal中,请求正常执行,结束不再创建session,session反复创建的问题的得到解决。 最后为了安全起见,配置中心加入开关,发现问题修改开关让本次的所有操作(除了删除那个毫无意义的map)都不执行,结束。
方案可优化项:
每次用户信息从redis中获取,因为没有较好的事件通知机制,修改用户信息后无法及时刷新,所以redis中的缓存只有1分钟。n个请求不会创建session,但是仍然会创建n个用户对象,只是会离开方法栈时脱离引用,等待gc回收。 更好的方案应当是结合方案2 ,将用户信息置于本地内存中,基于事件及时刷新用户信息,这样就不用反复的创建用户对象了。