引言
通常情况下,生产环境的web应用为了解决单点故障,都会多台部署,这样就会存在一个问题,当我们登录后,会将登录信息存储到session中,如果依赖原生的web容器中的session,当负载均衡到不同服务器会出现不同登录状态的情况,如下图:
当用户首次访问服务时,假设被Nginx代理到Server A,用户填写账户信息进行登录,此时,会在Server A上创建一个session保存登录信息,当用户继续访问服务时,可能会被Nginx代理到Server B,但Server B上没有找到这个session,此时又会引导用户去登录,显然这种处理是不能满足需求的。通常情况下,我们不会直接使用web容器的session,会将登录信息存储到第三方存储中(如redis),这时架构会演变成下面这样:
这种架构在实际开发中也是比较常用的,今天我们来介绍另外一种比较常用的实现——spring session + redis。
一. 环境搭建
1. 在spring boot web环境基础上,添加以下配置
pom.xml
org.springframework.session
spring-session-data-redis
org.springframework.boot
spring-boot-starter-data-redis
org.apache.commons
commons-pool2
application.yml
server:
port: 8080
servlet:
session:
# Session timeout. 默认单位秒,不起作用,用@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 300000000)配置
# timeout: 86400 # 24 * 60 * 60
cookie:
name: sts # 配置cookie名称
# cookie timeout,seconds
max-age: 3600
spring:
######## redis configuration #######
redis:
database: 5
host: 192.168.0.121
# host: 127.0.0.1
port: 6379
timeout: 5000
password:
lettuce:
pool:
max-idle: 9
min-idle: 1
max-active: 9
max-wait: 5000
######## spring session configuration 这里用@EnableRedisHttpSession注解配置#######
# session:
# Session store type
# store-type: redis
# redis:
# Session flush model
# flush-mode: ON_SAVE
# Namespace for keys used to store sessions.
# namespace: spring:session
Application.java
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 30, redisNamespace = "CAS_SSO_SESSION")
@SpringBootApplication
public class SessionApplication {
public static void main(String[] args) {
SpringApplication.run(SessionApplication.class, args);
}
}
LocalHttpSessionListener.java
/**
* session生命周期监听
*
*/
@Configuration
public class LocalHttpSessionListener implements HttpSessionListener {
@Override
public void sessionCreated(HttpSessionEvent se) {
HttpSession session = se.getSession();
System.out.println(String.format("session[id=%s]被创建", session.getId()));
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
HttpSession session = se.getSession();
System.out.println(String.format("session[id=%s]已过期", session.getId()));
}
}
SessionController.java
@RestController
public class SessionController {
@GetMapping("/sessionId")
public String getSessionId(HttpServletRequest request, HttpSession session) throws ServletException {
String uid = null;
if (request.getSession().getAttribute("uid") != null) {
uid = request.getSession().getAttribute("uid").toString();
}else {
uid = UUID.randomUUID().toString();
}
request.getSession().setAttribute("uid", uid);
return request.getSession().getId();
}
}
2. 测试
a. 启动服务
b. 访问http://localhost:8080/sessionId 看redis中是否有数据存储
c. 重启服务,再次访问http://localhost:8080/sessionId看uid是否依然存在
二. 主要配置类加载
1. 集成入口
RedisHttpSessionConfiguration:由EnableRedisHttpSession注解引入,主要初始化了以下重要类:
a. SessionRepositoryFilter: 过滤器,将web 容器的HttpServletRequest和HttpServletResponse分别代理为 SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper
b. RedisIndexedSessionRepository: redis session key 操作类
c. RedisMessageListenerContainer: redis key生命周期监听器
SessionAutoConfiguration: 由spring.factories引入,主要初始化了以下重要类:
a. DefaultCookieSerializer:cookie相关操作,如向response写入cookie
2. 时序图如下:
3. 相关session key
如上图所示,一个session会存储为3个key。图中有一个id为b6c9ccd7-5670-4cdf-b509-ece45f3ca943的session,3个key分别为:
a. CAS_SSO_SESSION:sessions:b6c9ccd7-5670-4cdf-b509-ece45f3ca943
该key类型为Hashes,过期时间为maxInactiveIntervalSeconds + 300s (5min), 存储了session主要数据,如下图,保存了session的创建时间,最大有效时间(EnableRedisHttpSession注解中maxInactiveIntervalInSeconds属性值),最新访问时间,业务属性值(request.getSession().setAttribute("uid", uid))。
b. CAS_SSO_SESSION:sessions:expires:b6c9ccd7-5670-4cdf-b509-ece45f3ca943
该key类型为Strings,过期时间为maxInactiveIntervalSeconds(过期时间是以这个key为准),该key的value为空,和下面c中的key配合使用,我们可以监听这个key过期事件,来实现自己的业务(比如单点登录中的认证中心,session过期后,要清除所有的客户端本地session,就是通过这个功能实现的)。
c. CAS_SSO_SESSION:expirations:1891606440000
该key类型为Sets,过期时间为maxInactiveIntervalSeconds + 300s (5min),保存值为 expires:[sessionId],1891606440000是由lastAccessedTimeInMillis和maxInactiveInSeconds的下一个整分钟换算而来,源码如下:
static long expiresInMillis(Session session) {
int maxInactiveInSeconds = (int) session.getMaxInactiveInterval().getSeconds();
long lastAccessedTimeInMillis = session.getLastAccessedTime().toEpochMilli();
return lastAccessedTimeInMillis + TimeUnit.SECONDS.toMillis(maxInactiveInSeconds);
}
// 参数timeInMs由上面expiresInMillis方法获得
static long roundUpToNextMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
date.add(Calendar.MINUTE, 1);
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}
该key主要为了弥补Redis键空间通知不能保证过期键的通知的及时性,由定时任务执行,触发key的过期事件,源码如下:
// 定时任务执行, 为了弥补Redis键空间通知不能保证过期键的通知的及时性。
void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);
if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}
String expirationKey = getExpirationKey(prevMin);
Set
调度会定时获取CAS_SSO_SESSION:expirations:1891606440000中的值(expires:b6c9ccd7-5670-4cdf-b509-ece45f3ca943),并查询key是否存在,如果该key已经过期了(由于某些原因并没有及时清理),会触发该key的过期事件
源码地址