简历_使用 Redis 解决集群模式下的 Session 共享问题,使用拦截器实现用户的登录,校验和权限刷新以及对单位时间内请求频繁的用户IP地址进行限流。
系列博客目录
文章目录
1.使用 Redis 解决集群模式下的 Session 共享问题
集群的session共享问题
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构

如下图所示,以前是保存到session现在是Redis。

如下图所示,之前是请求并携带cookie,从session中获取用户。


@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (!RegexUtils.isCodeInvalid(phone)) {
// 2.如果不符合,返回错误信息。
return Result.fail("手机号格式错误");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
// session.setAttribute("code", code);
// 5.发送验证码,这里不调用第三方了,不是讲解的重点
log.debug("发送短信验证码成功,验证码{}", code);
// 6.返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.equals(code)){
// 3.不一致
return Result.fail("验证码错误");
}
// 4.一致
User user = query().eq("phone",phone).one();
// 5.判断用户是否存在
if(user == null){
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到redis中
// 7.1.随机生成Token
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
总结
Redis代替session需要考虑的问题:
- 选择合适的数据结构
- 选择合适的key
- 选择合适的存储粒度
2.使用拦截器实现用户的登录,校验和权限刷新
下图是之前的拦截器,由于需要刷新token有效期,因为用户在浏览非登录页面的时候,不应该突然掉登录。

下图是更新后的逻辑。

package com.hmdp.config;
import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
/**
* ClassName: MvcConfig
* Package: com.hmdp.config
* Description:
*
* @Author 醒了就刷牙
* @Create 2025/1/3 15:00
* @Version 1.0
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/upload/**",
"/shop-type/**",
"/user/code",
"/blog/hot",
"/user/login"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* ClassName: LoginInterceptor
* Package: com.hmdp.utils
* Description:
*
* @Author 醒了就刷牙
* @Create 2025/1/3 14:51
* @Version 1.0
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)){
return true;
}
// 2.基于token获取redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if ( userMap.isEmpty()){
return true;
}
// 5.将查询到的Hash数据转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* ClassName: LoginInterceptor
* Package: com.hmdp.utils
* Description:
*
* @Author 醒了就刷牙
* @Create 2025/1/3 14:51
* @Version 1.0
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal中是否又用户)
if(UserHolder.getUser() == null){
// 未登录时跳转到登录页面 ,将请求重定向到登录页面
//response.sendRedirect("/user/login");
response.setStatus(401);
return false;
}
return true;
}
}
3.对单位时间内请求频繁的用户IP地址进行限流。
在 Spring Boot 中实现基于 IP 地址的请求限流,通常使用 Redis 结合时间窗口(例如:一分钟)来进行限制。可以通过在每次请求时,记录用户 IP 地址的请求次数并设置过期时间,来实现请求频率控制。
实现思路
- 每次请求时,获取请求的 IP 地址。
- 使用 Redis 存储 IP 地址和对应的请求次数,并设置过期时间(时间窗口)。
- 每次请求时,判断该 IP 在当前时间窗口内的请求次数是否超过了预设的限制。
- 如果请求次数超过限制,拒绝请求或返回错误提示。
- 如果没有超过限制,允许请求继续执行。
步骤:
1. 添加 Redis 依赖
首先,确保在 pom.xml 中添加了 Redis 相关的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 配置 Redis
在 application.properties 或 application.yml 中配置 Redis 连接信息:
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=yourpassword
3. 创建限流服务类
接下来,创建一个 RateLimiterService 类,用于处理基于 Redis 的限流逻辑。
package com.example.demo.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RateLimiterService {
@Autowired
private StringRedisTemplate redisTemplate;
// 时间窗口,单位为秒
private static final long TIME_WINDOW = 60;
// 最大请求次数
private static final int MAX_REQUESTS = 100;
/**
* 判断用户 IP 是否超过限流
*
* @param ipAddress 请求的 IP 地址
* @return 是否超过限流
*/
public boolean isRateLimited(String ipAddress) {
String key = "rate_limit:" + ipAddress;
// 使用 Redis 的 INCR 命令递增请求次数
Long requestCount = redisTemplate.opsForValue().increment(key, 1);
if (requestCount == 1) {
// 如果是第一次请求,设置过期时间为时间窗口(单位:秒)
redisTemplate.expire(key, TIME_WINDOW, TimeUnit.SECONDS);
}
// 判断请求次数是否超过最大请求次数
return requestCount != null && requestCount > MAX_REQUESTS;
}
}
TIME_WINDOW:设定限流的时间窗口(比如60秒内)。MAX_REQUESTS:设定在这个时间窗口内允许的最大请求次数。StringRedisTemplate:用于操作 Redis 数据,increment命令用于递增请求次数,expire设置过期时间。
4. 创建拦截器
然后,我们可以在 Spring 的拦截器中使用 RateLimiterService,拦截请求并根据请求 IP 判断是否超过限流。
package com.example.demo.interceptor;
import com.example.demo.service.RateLimiterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class RateLimiterInterceptor implements HandlerInterceptor {
@Autowired
private RateLimiterService rateLimiterService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取客户端 IP 地址
String ipAddress = request.getRemoteAddr();
// 检查是否超过限流
if (rateLimiterService.isRateLimited(ipAddress)) {
response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS); // HTTP 429 状态码
response.getWriter().write("Too many requests. Please try again later.");
return false;
}
return true; // 继续处理请求
}
}
5. 配置拦截器
在 MvcConfig 中注册限流拦截器,并指定需要限流的请求路径。
package com.example.demo.config;
import com.example.demo.interceptor.RateLimiterInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Bean
public RateLimiterInterceptor rateLimiterInterceptor() {
return new RateLimiterInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 将 RateLimiterInterceptor 应用到所有请求路径
registry.addInterceptor(rateLimiterInterceptor())
.addPathPatterns("/**")
.order(1);
}
}
6. 测试
- 当用户 IP 地址在单位时间内超过最大请求次数(如100次)时,返回 HTTP 429 状态码,并提示
"Too many requests. Please try again later."。 - 如果请求次数未超过限制,允许请求继续执行。
额外优化:
- 滑动窗口:如果需要更加精细的控制,可以实现滑动时间窗口(Sliding Window)算法,而不是使用固定时间窗口。
- 令牌桶/漏斗算法:对于更复杂的限流需求,可以使用令牌桶(Token Bucket)或漏斗算法(Leaky Bucket)来实现更加动态的限流。
通过这种方式,你可以轻松实现对单位时间内频繁请求的 IP 地址进行限流,确保系统的稳定性和响应能力。
response 在这种情况下会直接返回给浏览器,而不会经过 Controller 层。这是因为你在拦截器中已经通过 response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS) 设置了 HTTP 响应状态,并通过 response.getWriter().write(...) 写入了响应内容。
具体来说,拦截器的 preHandle 方法执行时,如果判断出请求频繁(即超过了限流),你通过调用 response.setStatus 设置了 HTTP 状态码(HTTP 429:Too Many Requests),并且用 response.getWriter().write 写入了错误消息 "Too many requests. Please try again later."。
因为拦截器的 preHandle 返回 false 时,Spring MVC 会阻止请求继续传递到后续的处理器(即 Controller)。这样就可以拦截到请求,避免它进一步执行,直接返回给客户端响应错误信息。
具体执行流程:
- 客户端发起请求。
RateLimiterInterceptor拦截器的preHandle方法被调用。- 如果请求超过限流限制:
- 设置响应状态码为
429(表示过多请求)。 - 返回提示消息
"Too many requests. Please try again later."。
- 设置响应状态码为
preHandle返回false,请求被拦截,不会继续传递到 Controller 层。- 客户端收到 HTTP 429 响应,显示限流错误信息。
如果 preHandle 返回 true:
- 请求会继续传递到 Controller 层,最终由相应的 Controller 方法处理。
为什么不经过 Controller?
当你在拦截器中返回 false 时,Spring MVC 就会认为这个请求已经处理完毕(因为请求已经被拦截且不再传递给后续的处理器)。因此,这个请求不会到达 Controller 层。preHandle 方法的作用是拦截请求,决定是否允许它继续传递。
如果希望请求到达 Controller 层并且再做其他操作(如返回一个 JSON 格式的错误消息),你可以在 Controller 层进行处理,而不直接在拦截器中返回响应。
如果想要处理后续的 Controller 返回:
如果你希望请求被允许到达 Controller 层,但同时在 Controller 层进行限流处理,你可以在 preHandle 中返回 true,并让 Controller 层根据请求是否超限来返回适当的响应。
这可以通过如下方式实现:
- 拦截器允许请求继续。
- 在 Controller 层进行限流判断。
- 如果超限,返回适当的 HTTP 状态码和消息。
比如:
@GetMapping("/some-api")
public ResponseEntity<String> someApi() {
if (rateLimiterService.isRateLimited(ipAddress)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body("Too many requests. Please try again later.");
}
// 正常处理逻辑
return ResponseEntity.ok("Request successful!");
}
通过这种方式,你可以让 Controller 层控制返回的内容,同时在拦截器中不直接返回响应。
更多推荐
所有评论(0)