K8s部署的Spring Boot应用StandardManager会话堆积致堆内存升高排查
充电桩代理服务会话堆积问题排查
服务说明
该Spring Boot应用是基于WebSocket(搭载OCPP协议)的充电桩代理服务,采用多节点部署在负载均衡器后方,维持长连接,以便关联的RabbitMQ消息消费微服务可进行重新部署与维护。
堆转储分析
数万充电桩设备频繁连接断开,核心问题是org.apache.catalina.session.StandardManager中的sessions属性持续新增会话(7天内超70万)但未被清理。
对比其他会话存储:
org.apache.tomcat.websocket.server.WsServerContainer中的WebSocket会话数为523,符合实际活跃连接状态;- 自定义
WebsocketServerService中存储节点活跃会话的并发映射内会话数为530,也与实际一致。
预期所有会话属性的会话数应接近530,而非70万级别的堆积。
问题与怀疑点
导致StandardManager标准会话持续增长的原因可能是什么?推测WebSocket连接基于标准会话建立,若连接未被双方正常关闭,可能导致标准会话无法被GC回收,但不确定该推测是否准确。
目前怀疑原因是使用了常规的WebSecurityConfigurerAdapter配置(如下代码),而非Spring Security官方针对WebSocket的专属配置。当充电桩连接出现401等错误时,Socket未正常关闭,导致会话堆积无法被GC回收。
安全配置代码
@Configuration @EnableWebSecurity @AllArgsConstructor public class SecurityConfig extends WebSecurityConfigurerAdapter { private final WebsocketServerProperties websocketServerProperties; private final StationAuthenticationProvider stationAuthenticationProvider; private final AuthenticationEntryPoint authEntryPoint; @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(stationAuthenticationProvider); } @Override @SuppressWarnings("squid:S4502") protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .httpBasic() .authenticationEntryPoint(authEntryPoint) .and() .antMatcher(websocketServerProperties.getWebsocketPathVpn() + "/**").anonymous() .and() .antMatcher(websocketServerProperties.getWebsocketPathInternet() + "/**") .authorizeRequests() .anyRequest() .authenticated(); } }
WebSocket配置与处理器代码
@Configuration @EnableWebSocket @RequiredArgsConstructor @EnableConfigurationProperties(WebsocketServerProperties.class) public class WebsocketServerConfig implements WebSocketConfigurer { public static final String[] SUPPORTED_PROTOCOLS = new String[]{.....}; private final WebsocketServerProperties websocketServerProperties; private final WebsocketHandShakeInterceptor websocketHandShakeInterceptor; private final StationSocketHandler stationSocketHandler; public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(stationSocketHandler, websocketServerProperties.getWebsocketPathVpn() + "/*", websocketServerProperties.getWebsocketPathInternet() + "/*") .addInterceptors(websocketHandShakeInterceptor) .setHandshakeHandler(handshakeHandler()) .setAllowedOrigins("*"); } private HandshakeHandler handshakeHandler() { DefaultHandshakeHandler handler = new DefaultHandshakeHandler(); handler.setSupportedProtocols(SUPPORTED_PROTOCOLS); return handler; } @Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(websocketServerProperties.getMaxTextMessageBufferSize()); container.setMaxSessionIdleTimeout(websocketServerProperties.getMaxSessionIdleTimeout()); return container; } }
@Slf4j @Component @RequiredArgsConstructor(onConstructor = @__({@Autowired})) public class StationSocketHandler extends TextWebSocketHandler implements SubProtocolCapable { public static final String DEVICE_ID = "DEVICE_ID"; public static final String CONNECTION_ROUTE = "CONNECTION_ROUTE"; private final OcppMessageParser ocppMessageParser; private final WebsocketServerService websocketServerService; private final PingPongService pingPongService; @Override public void handleTextMessage(final WebSocketSession session, TextMessage textMessage) { OcppMessage message = ocppMessageParser.parsePayloadWithSessionId(session.getId(), textMessage.getPayload()); websocketServerService.processMessageFromStation(message); } @Override public void afterConnectionEstablished(WebSocketSession session) { Map<String, Object> attributes = session.getAttributes(); String deviceId = (String) attributes.get(DEVICE_ID); String connectionRoute = (String) attributes.get(CONNECTION_ROUTE); websocketServerService.connect(Route.toRoute(connectionRoute), deviceId, session); } @Override public void afterConnectionClosed(WebSocketSession session, @NonNull CloseStatus status) { log.info("WebSocketSession[{}][{}] closed with status {} and attributes {}", session.getId(), session.isOpen(), status, session.getAttributes()); websocketServerService.disconnectSessionId(session.getId()); } @Override protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception { pingPongService.handlePong(session, message); } @Override public @NonNull List<String> getSubProtocols() { return Arrays.asList(WebsocketServerConfig.SUPPORTED_PROTOCOLS); } }
排查思路与建议
原因分析方向
- 未正常关闭的HTTP会话与WebSocket关联问题:WebSocket握手基于HTTP请求完成,Tomcat会为每个握手请求创建HTTP会话(StandardSession)。如果握手失败(如401认证失败),但HTTP会话未被正确销毁,就会导致会话堆积。常规
WebSecurityConfigurerAdapter配置可能在处理认证失败时,未触发HTTP会话的销毁逻辑。 - Spring Security对WebSocket的适配缺失:WebSocket的安全配置需要专门处理,因为握手后会切换到TCP长连接,常规HTTP安全配置可能无法正确清理握手阶段产生的HTTP会话。例如认证失败时,Security异常处理仅返回错误响应,未主动 invalidate 对应HTTP会话。
- 会话超时配置未生效:检查Tomcat的
StandardManager会话超时配置是否正确设置。若超时时间过长或清理线程未正常运行,会导致大量过期会话堆积。可通过server.servlet.session.timeout配置确认超时时间,同时查看Tomcat日志中是否有会话清理记录。
排查步骤
- 堆转储分析会话详情:从堆转储中提取堆积的
StandardSession对象,查看创建时间、最后访问时间、关联请求信息,确认这些会话是否为握手失败请求产生,以及是否存在强引用导致无法回收。 - 模拟认证失败场景:手动模拟充电桩连接时的401情况,查看HTTP会话是否被正确销毁,检查日志中是否有会话销毁记录。
- 切换WebSocket专属安全配置:将安全配置改为Spring Security针对WebSocket的配置方式,使用
WebSocketSecurityConfigurer替代WebSecurityConfigurerAdapter处理WebSocket路径认证,观察会话堆积是否缓解。 - 检查会话清理机制:通过JMX查看
StandardManager的会话统计信息(活跃会话数、过期会话数等),验证Tomcat会话清理线程是否正常运行。 - 拦截器中添加会话清理逻辑:在
WebsocketHandShakeInterceptor的afterHandshake方法中,若握手失败,主动调用request.getSession().invalidate()销毁HTTP会话。
内容的提问来源于stack exchange,提问作者joe380
相关产品推荐
相关产品推荐

