生产问题排查①

123 阅读8分钟

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打开

image.png

image.png 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 ,将用户信息置于本地内存中,基于事件及时刷新用户信息,这样就不用反复的创建用户对象了。