说起session还要从http协议说起,http协议是无状态协议。所谓无状态协议,就是http请求之间是相互独立的。比如,你用百度搜索“2020年NBA总冠军是谁”,百度服务器会给你一个响应,这一次的http请求就算完事儿了。当你再去搜索“谁会赢得2020年美国总统大选”,这个时候百度服务器并不会感知到你上一次搜索了啥,你的两次搜索是完全独立的。可以用下面的图简略表示下。
对于百度搜索这样的场景,这样的无状态协议问题不大。但是对于大多数场景,这种无状态协议是远远不够的。比如,你要查看自己淘宝的购物车,淘宝会返回一个登陆页面,这个时候你输入了自己的用户名和密码,发起了一次登陆的http请求,假设用户名和密码都正确,这个时候系统会自动跳转到登陆成功的页面;但是当你点击付款的时候,发起了一次付款的http请求,这个时候系统会校验当前的用户信息,以及用户是否登陆,由于http是无状态协议,服务器并不知道你已经通过上次请求了登陆,会再次返回登陆页面。这样每操作一次都需要登陆一次,用户体验极差。
为了解决类似的问题,session应运而生。以淘宝登陆为例,它的主要工作流程如下:
1. 用户在登陆页面输入用户名和密码,点击登陆,发送登录请求至淘宝服务器。
2. 服务器接收到请求后,校验请求头部中未携带sessionId,于是创建session对象,并将sessionId放入response的cookie对象中。
3. 服务器校验用户和密码成功后,将用户信息存放至session中。请求返回,浏览器将response中cookie缓存至本地。
4. 用户点击付款,发送付款请求,由于请求的是相同的域名,浏览器会自动在请求头部中添加sessionId。
5. 服务器接收到请求后,识别到请求头部中sessionId,直接根据sessionId去查找上一次创建的session对象,并将该对象放入request对象中。
6. 服务器从request的session对象中获取到用户信息,校验通过,付款成功。
session的出现很大程度上解决了http请求无状态的问题。但是,随着业务量增加和对服务高可用的诉求,分布式应用和微服务得到了越来越广泛的应用,这样使用传统的session就会存在问题。接下来我们就来复现一下这个问题。
1.使用springboot实现一个简单的登陆功能
访问http://localhost:8080/login,如果用户已经登陆,则直接返回主页面;如果用户未登陆,跳转至登陆页面。在登陆页面输入用户名和密码,点击登陆,跳转至登陆成功页面(此处不对用户名和密码进行校验)。
package com.example.mysession.collector;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Controller
public class LoginContoller {
/**
* 返回登陆页面
*/
@GetMapping("/login")
public String login() {
HttpServletRequest request = getRequest();
HttpSession session = request.getSession();
// session中包含用户信息,直接返回主页
String usernameInSession = (String) session.getAttribute("username");
if (!StringUtils.isEmpty(usernameInSession)) {
return "main";
}
// 重定向至登陆页面
return "loginpage";
}
/**
* 处理登陆请求
*/
@PostMapping("/doLogin")
public String doLogin(@RequestParam String username, @RequestParam String password) {
HttpServletRequest request = getRequest();
HttpSession session = request.getSession();
// 将用户名放入session,并通知用户登陆成功(这里不对用户名和密码进行校验)
session.setAttribute("username", username);
return "main";
}
private HttpServletRequest getRequest() {
ServletRequestAttributes servletRequestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return servletRequestAttributes.getRequest();
}
}
2.我们来分析一下这个简单的登陆流程
(1)springboot启动后,访问http://localhost:8084/login。回想1.1中的流程,由于我们是首次访问,请求头中没有带sessionId,因此tomcat会为我们创建一个session对象,并将sessionId返回,浏览器将sessionId缓存至本地。
请求前:
请求后:
(2)输入用户名和密码,点击登陆。这时请求会带上SessionId,tomcat会根据SessionId找到之前创建session,并将其放入request对象中。doLogin()方法在处理时会将用户信息存入session。
(3)再访问http://localhost:8084/login。此次请求浏览器依然会将SessionId放入请求头部,login()方法根据SessionId找到session,进而在该session中找到用户信息,判断当前用户已经登陆,直接返回登陆成功页面。
3.使用nginx反向代理,实现简单的集群
(1)上述是单机环境,session完美解决了http协议无状态的问题,使得我们不需要频繁登陆。接下来,我们启动两个相同的服务(1中简单登陆服务),他们分别监听8084和8085端口。
(2)测试两个服务是否都正常可用
8084:
8085:
(3)使用ngnix进行反向代理
nginx.conf:
#user nobody;
worker_processes 1;
events {
# 并发连接数量
worker_connections 1024;
}
http {
# tomcat集群
upstream tomcat_servers{
server 127.0.0.1:8084;
server 127.0.0.1:8085;
}
server {
# 监听80端口
listen 80;
server_name localhost;
# 将请求交给tomcat集群处理
location / {
proxy_set_header Host $host:$server_port;
proxy_pass http://tomcat_servers;
}
}
}
4.出现问题
(1)访问http://localhost/login。
(2)输入用户名和密码,点击登陆。
(3) 再次访问http://localhost/login。按照之前单节点的处理逻辑,应该返回登陆成功的页面才对,但是现在好像有点不太对?
上述的现象比较明显,就是服务器好像并没有记住我们已经登陆过了。要解释这个问题我们首先要看下session的本质,session本质是tomcat为我们创建的一个对象,它使用ConcurrentHashMap保存属性值。tomcat本质也是一个Java程序,一个tomcat容器(8084)创建的session另外一个tomcat(8085)自然是获取不到的。
好了,有了上面的知识,我们再来看看这个问题是怎么产生的。我们启动了两个tomcat服务器(分别监听8084和8085端口),使用ngnix进行反向代理,为了达到负载均衡的效果,我们在ngnix上置的策略是轮流访问8084和8085,也就是请求第一次访问8084节点,第二次访问8085节点,再是8084...以此类推。
当我们第一次访问http://localhost/login时,ngnix会为我们路由到8084节点,由于是第一次访问8084会为我们创建一个session,sessionId为32AC7EEFE4CD0486F88B23B84594A768,并返回至浏览器。
当我们输入用户名和密码,点击登陆,第二次访问时,ngnix会为我们路由到8085节点。这一次我们请求头部带了SessionId:32AC7EEFE4CD0486F88B23B84594A768,但是这个session是8084节点创建的,8085节点获取不到这个session,所以8085又会创建一个新的session,709F94B639FFD8DF34110E1F1760D84D,返回至浏览器。
至此我们已经登陆成功了。我们再次访问http://localhost/login,ngnix会为我们路由到8084节点,请求头部带上上一次请求返回的sessionId:709F94B639FFD8DF34110E1F1760D84D,而这个是8085节点创建的,8084节点获取不到这个session,所以认为我们没有登陆过。
...
如此循环,我们会发现每一次请求,tomcat都会为我们创建一个session对象,而一个tomcat容器创建的session对象,另一个tomcat容器获取不到,这就是问题的根源。可能有的人会说,这不简单,在ngnix上配置策略,让每次来自同一个IP的用户访问同一个tomcat容器,这样不就好了?但是这样主要有两个问题,第一,如果一个节点出问题挂掉了,那么之前一直访问这个节点的用户登陆信息就丢失了,需要重新登陆,这显然不合理;第二,移动端应用越来越多,而移动端可能换一个基站IP就变了。
知道了上述问题的原因,我们来看看怎么解决这个问题。首先来回顾下tomcat处理请求的流程
从上面的分析以及工作流程我们可以知道,解决该问题的关键就是如何让多个tomcat共享同一个session。我们自然而然就会想到,把session存储在一个公共的地方,这样每个tomcat就都会获取到了,这个公共的地方就是数据库(本文以Redis为例)。spring session的实现原理,就是在tomcat中加入了一个优先级很高的filter,来一个偷天换日,将request中的session置换为spring session,而这个spring session就存储在数据库中(Redis),这样一来,不同的tomca就可以共享同一个session啦。具体的细节我们在源码解析中再详细介绍,先看看spring session如何使用。
可以参考spring官网https://docs.spring.io/spring-session/docs/1.3.0.RELEASE/reference/html5/guides/httpsession.html
1.添加依赖
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.3.5.RELEASE
com.example
my-session
0.0.1-SNAPSHOT
my-session
Demo project for Spring Boot
1.8
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.session
spring-session-data-redis
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.boot
spring-boot-maven-plugin
2.添加一个配置文件
package com.example.mysession.config;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@EnableRedisHttpSession
public class SpringSessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory();
}
}
3.向spring中注入一个Initializer
package com.example.mysession.initializer;
import jdk.nashorn.internal.runtime.regexp.joni.Config;
import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer;
import org.springframework.stereotype.Component;
@Component
public class Initializer extends AbstractHttpSessionApplicationInitializer {
public Initializer() {
super(Config.class);
}
}
4.测试
(1)访问http://localhost/login。
(2)输入用户名和密码,点击登陆。
(3)再次请求http://localhost/login。直接跳转至登陆成功页面。
这里我们可以看到,第二次请求和第三次请求携带的都是第一次请求返回的sessionId。
spring的官网写的非常清晰,我们使用@EnableRedisHttpSession注解创建了一个springSessionRepositoryFilter。而这个filter就是用来将tomcat创建的session对象替换为spring session。
实际处理的Filter时SessionRepositoryFilter,而SessionRepositoryFilter又继承OncePerRequestFilter,所以dofilter()方法在OncePerRequestFilter类中实现。
OncePerRequestFilter中的dofilter()方法
/**
* This {@code doFilter} implementation stores a request attribute for "already
* filtered", proceeding without filtering again if the attribute is already there.
* @param request the request
* @param response the response
* @param filterChain the filter chain
* @throws ServletException if request is not HTTP request
* @throws IOException in case of I/O operation exception
*/
@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
throw new ServletException("OncePerRequestFilter just supports HTTP requests");
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String alreadyFilteredAttributeName = this.alreadyFilteredAttributeName;
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
// 已经过滤过了
if (hasAlreadyFilteredAttribute) {
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
// 还未过滤
else {
// Do invoke this filter...
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
doFilterInternal(httpRequest, httpResponse, filterChain);
}
finally {
// Remove the "already filtered" request attribute for this request.
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
我们来看这个doFilterInternal方法。(SessionRepositoryFilter类中的方法)这里采用了装饰器模式,将HttpServletRequest和HttpServletResponse封装成SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper。在这两个Wrapper中对tomcat的session进行了替换。
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest,
response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
}
finally {
wrappedRequest.commitSession();
}
}