跳转到内容
123xiao | 无名键客

《Java Web 开发中基于 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流实战》

字数: 0 阅读时长: 1 分钟

Java Web 开发中基于 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流实战

在做 Java Web 项目时,登录鉴权接口限流几乎是绕不过去的两件事。前者解决“你是谁、你能干什么”,后者解决“你不能无限打接口把服务打挂”。

很多项目一开始只有一个简单的登录接口,后面业务一多,就会慢慢暴露出这些问题:

  • 用户登录后如何保持会话,又不依赖服务器内存?
  • 多服务部署后,登录状态如何统一?
  • JWT 虽然无状态,但怎么做主动下线、踢人、续期?
  • 登录接口、短信接口、核心业务接口怎么限流?
  • 限流是按 IP、按用户,还是按接口维度?

这篇文章我就带你做一个能落地、可运行的方案:
Spring Boot + Redis + JWT 实现统一登录鉴权,并补上一套常用的接口限流能力。

这不是“只讲概念”的文章,我会从背景、原理、代码、排错到最佳实践完整走一遍。你可以直接把代码骨架抄到项目里,再按自己业务扩展。


一、背景与问题

先说结论:

  • JWT 适合做登录态载体,便于分布式部署;
  • Redis 适合做登录态缓存、黑名单、限流计数;
  • Spring Boot 拦截器/过滤器 适合统一做鉴权与限流切面。

但如果只是“JWT + 解析 token”,很快会遇到几个实际问题:

1. 纯 JWT 无法很好处理“主动失效”

JWT 自带过期时间,但如果你想实现:

  • 用户修改密码后,旧 token 失效
  • 管理员踢掉某个账号
  • 单端登录,新登录顶掉旧登录

只靠 JWT 本身就不够了。
这时通常会把 token 或 session 信息放入 Redis 做一层服务端校验

2. 登录态和权限控制不应散落在 Controller

如果每个接口都手写:

String token = request.getHeader("Authorization");

项目一大,维护成本会非常高。更好的方式是:

  • 登录态解析统一放在过滤器/拦截器
  • 用户上下文统一注入
  • 需要登录、需要角色权限的接口通过注解控制

3. 限流不能只靠网关,应用层也需要兜底

很多团队已经有 Nginx、Gateway 或云产品限流,但业务里依然经常需要:

  • 登录接口:按 IP 限流,防暴力破解
  • 短信验证码接口:按手机号 + IP 双重限流
  • 用户接口:按用户 ID 限流
  • 核心接口:按接口路径做细粒度限流

这类规则离业务很近,放在 Spring Boot 应用层做会更灵活。


二、前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.2.x
  • Spring Web
  • Spring Data Redis
  • jjwt 0.12.x
  • Redis 7.x
  • Maven

Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

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

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

application.yml

server:
  port: 8080

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 3000ms

security:
  jwt:
    secret: "ReplaceWithYourOwnSecretKeyAtLeast32BytesLong!"
    expire-seconds: 7200
    issuer: "demo-auth"

rate-limit:
  enabled: true

三、核心原理

这一套方案的核心思路可以概括成一句话:

JWT 负责身份声明,Redis 负责状态控制,过滤器负责统一拦截,Lua/原子计数负责限流。


四、整体架构图

flowchart LR
    A[客户端] --> B[Spring Boot API]
    B --> C[JWT过滤器]
    C --> D{Token合法?}
    D -- 否 --> E[返回401]
    D -- 是 --> F[Redis校验会话]
    F --> G{会话存在?}
    G -- 否 --> E
    G -- 是 --> H[注入用户上下文]
    H --> I[限流拦截器]
    I --> J{是否超限?}
    J -- 是 --> K[返回429]
    J -- 否 --> L[Controller业务处理]

这个架构里有两个关键链路:

  1. 登录链路

    • 用户输入账号密码
    • 服务端校验通过
    • 生成 JWT
    • Redis 记录当前 token/session
    • 返回 token 给客户端
  2. 请求链路

    • 客户端带 token 发请求
    • 服务端验签、验过期
    • 再去 Redis 校验会话是否仍有效
    • 校验通过后放行
    • 同时对接口做限流

五、鉴权流程时序图

sequenceDiagram
    participant U as 用户
    participant A as Spring Boot应用
    participant R as Redis

    U->>A: POST /auth/login 用户名密码
    A->>A: 校验账号密码
    A->>A: 生成JWT(token, userId, jti)
    A->>R: SET login:token:{token} userInfo EX 7200
    A-->>U: 返回token

    U->>A: GET /user/profile + Authorization: Bearer token
    A->>A: 解析JWT、校验签名/过期
    A->>R: GET login:token:{token}
    R-->>A: userInfo
    A->>A: 注入登录用户上下文
    A-->>U: 返回业务数据

六、登录与会话设计

1. 为什么不是“只用 JWT”

很多教程会说 JWT 是无状态的,服务端不用存 session。
这句话没错,但在真实项目里,完全无状态往往不够用

所以我们采用折中方案:

  • JWT 中保存必要身份信息:userIdusernamejti
  • Redis 中保存会话状态:token -> userInfo
  • 请求时必须同时满足:
    • JWT 合法
    • Redis 中存在该 token 对应会话

这样就具备了:

  • 可主动失效
  • 可单端登录
  • 可延长会话
  • 可做黑名单

2. Redis Key 设计建议

login:token:{token} -> 用户会话信息(JSON), TTL=7200
login:user:{userId} -> 当前有效token, TTL=7200
blacklist:token:{token} -> 1, TTL=剩余过期时间
rate_limit:{key} -> count, TTL=窗口秒数

如果你想实现“单端登录”,登录时可以:

  1. 先查 login:user:{userId} 是否存在旧 token
  2. 如果存在,删除旧 token 会话,或加入黑名单
  3. 再写入新的 token

七、实战代码(可运行)

下面给出一个精简但完整的版本。为了突出主线,用户校验先用内存模拟。

1. 启动类

package com.example.authdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthDemoApplication.class, args);
    }
}

2. 用户信息对象

package com.example.authdemo.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser {
    private Long userId;
    private String username;
    private String role;
}

3. JWT 工具类

package com.example.authdemo.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;

@Component
public class JwtUtil {

    private final SecretKey secretKey;
    private final long expireSeconds;
    private final String issuer;

    public JwtUtil(
            @Value("${security.jwt.secret}") String secret,
            @Value("${security.jwt.expire-seconds}") long expireSeconds,
            @Value("${security.jwt.issuer}") String issuer) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expireSeconds = expireSeconds;
        this.issuer = issuer;
    }

    public String generateToken(Long userId, String username, String role) {
        Date now = new Date();
        Date expireAt = new Date(now.getTime() + expireSeconds * 1000);
        String jti = UUID.randomUUID().toString();

        return Jwts.builder()
                .issuer(issuer)
                .subject(String.valueOf(userId))
                .claim("username", username)
                .claim("role", role)
                .id(jti)
                .issuedAt(now)
                .expiration(expireAt)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public Claims parse(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public long getExpireSeconds() {
        return expireSeconds;
    }
}

4. Redis 配置

package com.example.authdemo.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        RedisSerializer<String> stringSerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(jsonSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

5. 登录服务

package com.example.authdemo.service;

import com.example.authdemo.model.LoginUser;
import com.example.authdemo.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtUtil jwtUtil;
    private final RedisTemplate<String, Object> redisTemplate;

    public Map<String, Object> login(String username, String password) {
        // 演示用:模拟用户校验
        if (!"admin".equals(username) || !"123456".equals(password)) {
            throw new RuntimeException("用户名或密码错误");
        }

        Long userId = 1L;
        String role = "ADMIN";

        String newToken = jwtUtil.generateToken(userId, username, role);
        long expireSeconds = jwtUtil.getExpireSeconds();

        String userTokenKey = "login:user:" + userId;
        Object oldToken = redisTemplate.opsForValue().get(userTokenKey);

        // 单端登录:删除旧token会话
        if (oldToken != null) {
            redisTemplate.delete("login:token:" + oldToken);
        }

        LoginUser loginUser = new LoginUser(userId, username, role);

        redisTemplate.opsForValue().set("login:token:" + newToken, loginUser, expireSeconds, TimeUnit.SECONDS);
        redisTemplate.opsForValue().set(userTokenKey, newToken, expireSeconds, TimeUnit.SECONDS);

        return Map.of(
                "token", newToken,
                "expireSeconds", expireSeconds,
                "user", loginUser
        );
    }

    public void logout(String token) {
        Object session = redisTemplate.opsForValue().get("login:token:" + token);
        if (session instanceof LoginUser loginUser) {
            redisTemplate.delete("login:user:" + loginUser.getUserId());
        }
        redisTemplate.delete("login:token:" + token);
    }

    public LoginUser getLoginUser(String token) {
        Object obj = redisTemplate.opsForValue().get("login:token:" + token);
        if (obj == null) {
            return null;
        }
        return (LoginUser) obj;
    }

    public void refreshToken(String token) {
        Object obj = redisTemplate.opsForValue().get("login:token:" + token);
        if (obj == null) {
            return;
        }
        LoginUser loginUser = (LoginUser) obj;
        long expireSeconds = jwtUtil.getExpireSeconds();

        redisTemplate.opsForValue().set("login:token:" + token, loginUser, expireSeconds, TimeUnit.SECONDS);
        redisTemplate.opsForValue().set("login:user:" + loginUser.getUserId(), token, expireSeconds, TimeUnit.SECONDS);
    }
}

6. 用户上下文

package com.example.authdemo.context;

import com.example.authdemo.model.LoginUser;

public class UserContext {

    private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();

    public static void set(LoginUser loginUser) {
        HOLDER.set(loginUser);
    }

    public static LoginUser get() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();
    }
}

7. JWT 鉴权过滤器

package com.example.authdemo.filter;

import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import com.example.authdemo.service.AuthService;
import com.example.authdemo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final AuthService authService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/auth/login");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            String authHeader = request.getHeader("Authorization");
            if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                response.setStatus(401);
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token缺失\"}");
                return;
            }

            String token = authHeader.substring(7);
            Claims claims = jwtUtil.parse(token);

            LoginUser loginUser = authService.getLoginUser(token);
            if (loginUser == null) {
                response.setStatus(401);
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write("{\"code\":401,\"message\":\"登录已失效\"}");
                return;
            }

            // 可选:滑动续期
            authService.refreshToken(token);

            UserContext.set(loginUser);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            response.setStatus(401);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":401,\"message\":\"Token非法或已过期\"}");
        } finally {
            UserContext.clear();
        }
    }
}

8. 限流注解

package com.example.authdemo.ratelimit;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
    int seconds();
    int maxCount();
    LimitType type() default LimitType.IP;
    String key() default "";
}
package com.example.authdemo.ratelimit;

public enum LimitType {
    IP,
    USER,
    URI
}

9. 限流拦截器

这里用 Redis 原子自增实现一个固定窗口限流。不是最复杂的方案,但够稳定,也好理解。

package com.example.authdemo.ratelimit;

import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.concurrent.TimeUnit;

@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }

        RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
        if (rateLimit == null) {
            return true;
        }

        String limitKey = buildKey(request, rateLimit);
        Long count = stringRedisTemplate.opsForValue().increment(limitKey);

        if (count != null && count == 1) {
            stringRedisTemplate.expire(limitKey, rateLimit.seconds(), TimeUnit.SECONDS);
        }

        if (count != null && count > rateLimit.maxCount()) {
            response.setStatus(429);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
            return false;
        }

        return true;
    }

    private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
        return switch (rateLimit.type()) {
            case IP -> "rate_limit:ip:" + getClientIp(request) + ":" + request.getRequestURI();
            case USER -> {
                LoginUser user = UserContext.get();
                String userId = user == null ? "anonymous" : String.valueOf(user.getUserId());
                yield "rate_limit:user:" + userId + ":" + request.getRequestURI();
            }
            case URI -> "rate_limit:uri:" + request.getRequestURI();
        };
    }

    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip != null && !ip.isBlank()) {
            return ip.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

10. Web 配置注册拦截器

package com.example.authdemo.config;

import com.example.authdemo.ratelimit.RateLimitInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**");
    }
}

11. 控制器

package com.example.authdemo.controller;

import com.example.authdemo.context.UserContext;
import com.example.authdemo.model.LoginUser;
import com.example.authdemo.ratelimit.LimitType;
import com.example.authdemo.ratelimit.RateLimit;
import com.example.authdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/auth/login")
    @RateLimit(seconds = 60, maxCount = 5, type = LimitType.IP)
    public Map<String, Object> login(@RequestBody LoginRequest request) {
        return authService.login(request.getUsername(), request.getPassword());
    }

    @PostMapping("/auth/logout")
    public Map<String, Object> logout(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        String token = authHeader.substring(7);
        authService.logout(token);
        return Map.of("message", "退出成功");
    }

    @GetMapping("/user/profile")
    @RateLimit(seconds = 10, maxCount = 20, type = LimitType.USER)
    public Map<String, Object> profile() {
        LoginUser user = UserContext.get();
        return Map.of(
                "userId", user.getUserId(),
                "username", user.getUsername(),
                "role", user.getRole()
        );
    }

    @GetMapping("/public/ping")
    @RateLimit(seconds = 5, maxCount = 3, type = LimitType.URI)
    public Map<String, Object> ping() {
        return Map.of("message", "pong");
    }

    @Data
    public static class LoginRequest {
        private String username;
        private String password;
    }
}

八、接口调用示例

1. 登录

curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

返回示例:

{
  "token": "eyJhbGciOiJIUzI1NiJ9....",
  "expireSeconds": 7200,
  "user": {
    "userId": 1,
    "username": "admin",
    "role": "ADMIN"
  }
}

2. 访问受保护接口

curl http://localhost:8080/user/profile \
  -H "Authorization: Bearer 你的token"

3. 退出登录

curl -X POST http://localhost:8080/auth/logout \
  -H "Authorization: Bearer 你的token"

九、限流状态变化图

stateDiagram-v2
    [*] --> 正常请求
    正常请求 --> 计数加一
    计数加一 --> 未超限: count <= max
    计数加一 --> 已超限: count > max
    未超限 --> 放行业务
    已超限 --> 返回429
    放行业务 --> [*]
    返回429 --> [*]

十、逐步验证清单

如果你是边学边做,我建议按这个顺序验证,不容易乱。

第一步:只验证登录是否正常

  • Redis 启动
  • /auth/login
  • 拿到 token
  • 看 Redis 是否写入:
    • login:token:{token}
    • login:user:1

第二步:验证鉴权过滤器

  • 不带 token 请求 /user/profile,应返回 401
  • 带正确 token,应返回用户信息
  • Redis 中手动删掉 login:token:{token},再请求应返回 401

第三步:验证单端登录

  • 连续登录两次,拿到两个不同 token
  • 用第一个 token 调 /user/profile,应失效
  • 用第二个 token 调用,应成功

第四步:验证限流

  • 连续调用 /public/ping
  • 超过阈值后应返回 429

十一、常见坑与排查

这部分很重要。我自己做这类功能时,真正花时间的往往不是“写代码”,而是“为什么明明写了却不生效”。

1. Redis 里取出来的对象类型不对

现象

明明存的是 LoginUser,取出来后强转报错。

原因

Redis 序列化方式不一致,或者你一个地方用 RedisTemplate<String, Object>,另一个地方又用字符串模板写入。

排查建议

  • 确认 key/value serializer 是否统一
  • 开发期尽量固定 JSON 序列化
  • 若跨服务共享 Redis,建议显式定义 DTO 结构,不要依赖默认序列化细节

2. JWT secret 太短导致启动或签名失败

现象

启动时报签名 key 相关异常,或者生成 token 时报错。

原因

HS256 对密钥长度有要求,太短不安全也可能不被接受。

排查建议

  • secret 至少 32 字节
  • 不要把测试环境的短 secret 带到生产

3. 过滤器执行了,但接口还是拿不到用户信息

现象

UserContext.get() 返回 null。

原因

常见有两个:

  • 请求根本没经过该过滤器
  • ThreadLocal 被错误清理或异步线程中取不到

排查建议

  • 确认过滤器已被 Spring 托管
  • 确认请求路径没有被 shouldNotFilter 排除
  • 如果用了异步任务、线程池,ThreadLocal 不会自动传递

这一点我踩过坑:主线程里有登录用户,异步线程里直接 null。因为 ThreadLocal 是线程级别的,不是请求全局共享。


4. 限流在本地正常,上线后全乱了

现象

有些用户明明没怎么请求,却频繁被限;或者 IP 限流失效。

原因

大概率是 真实客户端 IP 没取对
线上一般有 Nginx 或网关转发,request.getRemoteAddr() 看到的可能只是代理层地址。

排查建议

  • 优先读取 X-Forwarded-For
  • 确保反向代理正确透传头部
  • 不要盲信客户端自己传来的 IP,生产中要配合可信代理链

5. 滑动续期导致 Redis 压力变大

现象

每次请求都刷新 token TTL,Redis QPS 上升明显。

原因

“每请求必续期”在高并发下会产生大量写操作。

优化建议

改成“临近过期才续期”,比如剩余 TTL 小于 20 分钟时再刷新一次,而不是每次都刷。


十二、安全/性能最佳实践

这一部分决定了你的方案能不能从“能跑”走到“能上线”。

1. Token 不要存太多敏感信息

JWT 虽然签名后防篡改,但默认不是加密的。
所以不要在 token 里放:

  • 明文手机号
  • 身份证号
  • 密码摘要
  • 复杂权限全量数据

推荐只放最小必要字段:

  • userId
  • username(可选)
  • role / scope(简化版)
  • jti

2. 登录密码必须加密存储

本文为了演示用了固定账号密码,但生产里必须:

  • 使用 BCrypt / Argon2
  • 不要自己手写 MD5
  • 登录失败次数过多要触发额外防护

3. 限流维度要按场景拆开

不要指望“一套限流参数打天下”。

常见建议

  • 登录接口:按 IP 限流
  • 验证码接口:按手机号 + IP 双限流
  • 用户中心接口:按用户 ID 限流
  • 公共查询接口:按 URI 或 appKey 限流

如果业务更复杂,可以扩展 LimitType

PHONE,
API_KEY,
TENANT

4. 固定窗口够用,但别神化它

本文使用的是固定窗口限流,优点是:

  • 简单
  • 好实现
  • 大多数后台接口够用

但它也有边界问题:

  • 窗口切换瞬间可能出现突刺
  • 无法像令牌桶那样更平滑

如果你面对的是高并发、强实时场景,可以升级到:

  • 滑动窗口
  • 漏桶
  • 令牌桶
  • Redis Lua 脚本原子限流

5. 对鉴权失败和限流失败做统一响应

别让系统返回一堆风格不一致的信息。建议统一成:

  • 401:未认证、token 无效、会话过期
  • 403:已登录但无权限
  • 429:请求过于频繁

这样前端和调用方更容易做兼容处理。


6. Redis Key 要有前缀和 TTL

这是很容易被忽略的点。
一个规范的 Redis key 会让你排障效率提升很多。

例如:

login:token:xxx
login:user:1
rate_limit:ip:127.0.0.1:/auth/login

并且:

  • 登录态必须设置 TTL
  • 限流计数必须设置 TTL
  • 黑名单也要根据 token 剩余时间设置 TTL

否则 Redis 会慢慢堆积无用 key。


7. 生产环境建议配合网关做“双层防护”

应用层限流很好用,但不应该成为唯一屏障。我的建议是:

  • 网关层:做粗粒度限流、IP 黑白名单、统一审计
  • 应用层:做细粒度业务限流、登录态校验、用户级别限制

这样既能抗流量,也能贴近业务。


十三、可以继续扩展的方向

如果你打算把这套方案继续做强,可以考虑这些增强点:

1. 权限注解

比如实现:

@RequireRole("ADMIN")

在拦截器或 AOP 中读取用户角色,做角色判断。

2. 黑名单机制

对于已签发但需要立刻失效的 token,可以将其加入:

blacklist:token:{token}

请求时额外校验是否在黑名单中。

3. 刷新 token 双 token 机制

常见做法:

  • access_token:短期有效
  • refresh_token:长期有效

这样既安全,又能提升用户体验。

4. Lua 脚本限流

INCR + EXPIRE 合并成 Lua,一次原子执行,更稳妥。


十四、总结

这套 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流方案,核心价值在于:

  • JWT 解决分布式下的身份传递
  • Redis 解决会话控制、主动失效、限流计数
  • 过滤器/拦截器 解决统一接入
  • 注解式限流 让业务接口更容易维护

如果你现在要落地,我建议按这个优先级推进:

  1. 先把 JWT + Redis 会话校验 做起来
  2. 再加 登录接口和核心接口限流
  3. 最后补 单端登录、滑动续期、黑名单、角色权限

边界上也要清楚:

  • 中小型后台系统:本文方案已经很实用
  • 高并发开放平台:建议再升级为 Lua 限流、双 token、权限中心、网关联动
  • 强安全场景:要进一步补充设备指纹、风控、异常行为审计

如果你把本文代码先跑通,再按自己的业务对象、权限模型和限流维度替换,基本就能形成一套靠谱的生产骨架。
说直白点:先别追求“最完整架构”,先把“登录能控、接口不炸”做扎实。


分享到:

上一篇
《大模型推理服务实战:从模型量化、KV Cache 优化到高并发部署的性能调优指南》
下一篇
《区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建》