系列博客目录



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 地址的请求次数并设置过期时间,来实现请求频率控制。

实现思路

  1. 每次请求时,获取请求的 IP 地址。
  2. 使用 Redis 存储 IP 地址和对应的请求次数,并设置过期时间(时间窗口)。
  3. 每次请求时,判断该 IP 在当前时间窗口内的请求次数是否超过了预设的限制。
  4. 如果请求次数超过限制,拒绝请求或返回错误提示。
  5. 如果没有超过限制,允许请求继续执行。

步骤:

1. 添加 Redis 依赖

首先,确保在 pom.xml 中添加了 Redis 相关的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 配置 Redis

application.propertiesapplication.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."
  • 如果请求次数未超过限制,允许请求继续执行。

额外优化:

  1. 滑动窗口:如果需要更加精细的控制,可以实现滑动时间窗口(Sliding Window)算法,而不是使用固定时间窗口。
  2. 令牌桶/漏斗算法:对于更复杂的限流需求,可以使用令牌桶(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)。这样就可以拦截到请求,避免它进一步执行,直接返回给客户端响应错误信息。

具体执行流程:

  1. 客户端发起请求。
  2. RateLimiterInterceptor 拦截器的 preHandle 方法被调用。
  3. 如果请求超过限流限制:
    • 设置响应状态码为 429(表示过多请求)。
    • 返回提示消息 "Too many requests. Please try again later."
  4. preHandle 返回 false,请求被拦截,不会继续传递到 Controller 层
  5. 客户端收到 HTTP 429 响应,显示限流错误信息。

如果 preHandle 返回 true

  • 请求会继续传递到 Controller 层,最终由相应的 Controller 方法处理。

为什么不经过 Controller?
当你在拦截器中返回 false 时,Spring MVC 就会认为这个请求已经处理完毕(因为请求已经被拦截且不再传递给后续的处理器)。因此,这个请求不会到达 Controller 层。preHandle 方法的作用是拦截请求,决定是否允许它继续传递。

如果希望请求到达 Controller 层并且再做其他操作(如返回一个 JSON 格式的错误消息),你可以在 Controller 层进行处理,而不直接在拦截器中返回响应。

如果想要处理后续的 Controller 返回:
如果你希望请求被允许到达 Controller 层,但同时在 Controller 层进行限流处理,你可以在 preHandle 中返回 true,并让 Controller 层根据请求是否超限来返回适当的响应。

这可以通过如下方式实现:

  1. 拦截器允许请求继续。
  2. 在 Controller 层进行限流判断。
  3. 如果超限,返回适当的 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 层控制返回的内容,同时在拦截器中不直接返回响应。

更多推荐