背景
在我们现有的系统中,用于登录的 Redis 服务器 CPU 占用率长期处于 90% 的高位,这一状况带来了极大的风险隐患。因此,领导委派给我一项艰巨的任务,旨在解决这一紧迫问题。
分析
总体分析
经过深入分析,我发现 Redis 的 hgetall 操作极为频繁,而这些操作主要是由 Spring Security 框架执行,主要是hgetall 获取spring:session:expirations:{timestamp}中的所有会话、一是hgetall获取的数据量过大,二是执行hgetall过于频繁。
简单介绍下springsecuriy用到的rediskey
Redis Key | 说明 |
spring:session:sessions:{sessionId} | Spring Session 的默认存储 Key,用于存储每个会话的信息,其中 {sessionId} 是具体的会话 ID。 |
spring:session:sessions:expires:{sessionId} | 存储会话的过期时间戳,与具体的会话 ID 相关联。 |
spring:session:expirations:{timestamp} | 存储每分钟需要过期的会话 ID 集合,{timestamp} 是每分钟的时间戳。 |
spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME: | 索引 Key,用于存储用户名与会话 ID 的映射关系,方便根据用户名快速查找对应的会话 ID 集合。 |
抽丝剥茧,命中难点
进一步调查发现,我们的系统目前存在两套鉴权体系,一套基于 token,另一套基于 cookie。虽然 token 鉴权体系本身并未直接使用 Redis,但由于集成了 Spring Security 框架,token 类型同样会生成 cookie,并由此对 Redis 进行读写操作。
在实际使用中,采用 cookie 鉴权方式的主要是管理系统,其使用人员相对较少;而 token 鉴权方式的使用量却极为庞大,是导致 Redis CPU 占用率居高不下的关键因素。此外,我们所有应用都默认开启了 "Spring 定时清理过期会话" 的任务,这一任务也导致了 QPS(每秒查询率)过高,进一步加剧了 Redis 的负载。
springsecurity定时清理会话任务,如果不配置,默认是每分钟执行一次
编辑
方案
分析出结果后,解决方案也呼之欲出了
定时任务处理
仅在少数特定的应用中保留 session 过期处理机制。
如果是springboot2及以上,则可以通过配置关掉。而我们用的springboot1,配置无法关掉,只能另辟蹊径,把非登录相关应用的清理时间都改为一天一次或一月一次。修改方式如下
yml中配置
yaml
spring:
session:
cleanup:
cron:
# spring session 定期清理redis关闭,这个定时任务用户中心开启即可
# expression: '-' springboot2.0才支持
expression: '0 02 02 ? * *'
cookie过多治理
总体方案
对于采用 token 鉴权方式的系统,不再生成 cookie,避免对 Redis 进行读写操作。
因为springsecurity默认使用spring-session中的 SessionRepositoryFilter来进行session的操作,所以我们需要生成一个自定义session处理拦截器,来覆盖springsecurity自身的处理,如果header中存在token,就不再执行SessionRepositoryFilter拦截器。就不会对redis进行任何操作。
我们需要添加拦截器,需要他在SessionManagementFilter拦截器执行前执行,然后重新打sso包,让各应用更新使用(SessionRepositoryFilter
并非 Spring Security 默认过滤器链的一部分,它通常是通过 spring-session
项目引入的,以便在分布式应用中共享会话信息。在实际使用中,SessionRepositoryFilter
通常在 SessionManagementFilter
之前执行,以便能够正确地管理和存储会话信息)
这里简单介绍下springsecurity的拦截器加载顺序
序号 | 过滤器名称 | 说明 |
1 | SecurityContextPersistenceFilter |
恢复安全上下文,从会话或请求中加载安全信息,为后续过滤器的执行奠定基础。 |
2 | HeaderWriterFilter |
添加安全相关的 HTTP 响应头,如内容安全策略(CSP)、HTTP 严格传输安全(HSTS)等,增强应用的安全性。 |
3 | CorsFilter |
处理跨域资源共享(CORS)请求,验证跨域请求的合法性。 |
4 | CsrfFilter |
验证 CSRF 令牌,防止跨站请求伪造攻击,保护应用免受恶意请求的侵害。 |
5 | LogoutFilter |
处理注销请求,使用户能够安全地退出应用,清除相关的安全上下文。 |
6 | UsernamePasswordAuthenticationFilter |
处理基于用户名和密码的登录请求,验证用户的身份,并在验证成功后创建认证对象。 |
7 | DefaultLoginPageGeneratingFilter |
如果未配置自定义登录页,生成默认的登录页面,简化了登录流程的实现。 |
8 | BasicAuthenticationFilter |
处理 HTTP Basic 认证,验证请求中的认证信息,并在验证成功后创建认证对象。 |
9 | BearerTokenAuthenticationFilter |
提取 JWT 等令牌进行认证,适用于基于令牌的认证机制。 |
10 | RequestCacheAwareFilter |
恢复缓存的请求,以便在用户登录后能够正确地重定向到原始请求的页面。 |
11 | SecurityContextHolderFilter |
使安全上下文在请求中可用,为后续的安全操作提供上下文支持。 |
12 | AnonymousAuthenticationFilter |
如果未找到认证信息,则分配一个匿名用户认证对象,使匿名用户也能够访问应用中的某些资源。 |
13 | SessionManagementFilter |
处理会话固定防护和并发控制,管理会话相关的安全策略,如防止会话劫持等。 |
14 | ExceptionTranslationFilter |
捕获安全异常并将其转换为相应的 HTTP 响应,如未认证或未授权的错误响应,使应用能够以合适的方式处理安全问题。 |
15 | FilterSecurityInterceptor |
强制执行授权规则,检查用户是否有权限访问请求的资源,是 Spring Security 授权过程的核心。 |
以下是为实现该解决方案所编写的代码:
生成自定义session处理拦截器
创建一个MySessionRepositoryFilter继承SessionRepositoryFilter
java
package com.onlylowg.config.filter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Primary;
import org.springframework.core.annotation.Order;
import org.springframework.session.SessionRepository;
import org.springframework.session.web.http.SessionRepositoryFilter;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* token filter
*/
@Order(SessionRepositoryFilter.DEFAULT_ORDER)
@Component
public class MySessionRepositoryFilter extends SessionRepositoryFilter {
private String tokenHeader = "Authorization";
private String tokenHead = "bearer";
private String accessToken = "access_token";
/**
* Creates a new instance.
*
* @param sessionRepository the <code>SessionRepository</code> to use. Cannot be null.
*/
public MySessionRepositoryFilter(SessionRepository sessionRepository) {
super(sessionRepository);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!StringUtils.isNotBlank(request.getHeader(tokenHeader))) {
super.doFilterInternal(request, response, filterChain);
return;
}
filterChain.doFilter(request, response);
}
}
配置拦截器
配置MySessionRepositoryFilter在SessionManagementFilter拦截器执行前执行
kotlin
package com.onlylowg.config;
import com.onlylowg.config.filter.MySessionRepositoryFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.session.*;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import javax.annotation.Resource;
/**
* spring security配置类
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// jwt:
// header: Authorization
// tokenHead: bearer
// accessToken: access_token
private String tokenHeader = "Authorization";
private MySessionRepositoryFilter mySessionRepositoryFilter;
private FindByIndexNameSessionRepository sessionRepository;
* @date 2017-08-29
* 这里这样注入,避免被扫包时加入到fitlers中,然后手动加入拦截器,设置在UsernamePasswordAuthenticationFilter之前执行
* 注意JwtAuthenticationTokenFilter 不能加注解,加的话,jwt会工作两次
*/
@Bean("jwtAuthenticationTokenFilter")
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() throws Exception {
return new JwtAuthenticationTokenFilter();
}
/**
* 配置资源服务器请求相关
*
* @param http 请求
* @throws Exception 异常
* @author WangHQ
* @date 2017-07-13
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
init();
http
.csrf().disable()
//.requestMatcher(new OAuthRequestedMatcher())
.authorizeRequests()
.antMatchers("/**").permitAll()//允许所有访问,权限控制交由filter
.anyRequest().authenticated()
;
http.addFilterBefore(mySessionRepositoryFilter, SessionManagementFilter.class);
}
public void init() {
mySessionRepositoryFilter = new MySessionRepositoryFilter(sessionRepository);
}
}